Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions migrations/versions/d4f8e2a1b3c7_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Add api_token table for scoped API token auth.

Revision ID: d4f8e2a1b3c7
Revises: c8f3a2b1d4e5
Create Date: 2026-06-11 03:00:00.000000

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = 'd4f8e2a1b3c7'
down_revision = 'c8f3a2b1d4e5'
branch_labels = None
depends_on = None


def upgrade():
"""Apply the migration."""
op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True))
op.create_table(
'api_token',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token_name', sa.String(length=50), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('token_prefix', sa.String(length=16), nullable=False),
sa.Column('scopes_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'),
sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'),
mysql_engine='InnoDB'
)
op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix'])


def downgrade():
"""Revert the migration."""
op.drop_index('ix_api_token_token_prefix', table_name='api_token')
op.drop_table('api_token')
op.drop_column('user', 'github_login')
23 changes: 23 additions & 0 deletions mod_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
mod_api: JSON REST API blueprint for the CCExtractor CI platform.

Registered at /api/v1. All endpoints return structured JSON, use scoped
Bearer token auth, and enforce per-client rate limiting.
"""

from flask import Blueprint

mod_api = Blueprint('api', __name__)

# Middleware (registers before_request hooks and error handlers)
from mod_api.middleware import auth # noqa: E402, F401
from mod_api.middleware import error_handler # noqa: E402, F401
from mod_api.middleware import rate_limit # noqa: E402, F401
from mod_api.middleware import security # noqa: E402, F401
# Route modules (registers endpoint functions on the blueprint)
from mod_api.routes import auth as auth_routes # noqa: E402, F401
from mod_api.routes import errors_logs # noqa: E402, F401
from mod_api.routes import results # noqa: E402, F401
from mod_api.routes import runs # noqa: E402, F401
from mod_api.routes import samples # noqa: E402, F401
from mod_api.routes import system # noqa: E402, F401
1 change: 1 addition & 0 deletions mod_api/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""mod_api.middleware: auth, rate limiting, validation, and error handling."""
131 changes: 131 additions & 0 deletions mod_api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Bearer token authentication and scope/role enforcement for API routes.

Runs as a before_request hook on the api blueprint. Public endpoints
(token creation, health check) are exempted. On success, the authenticated
user and token are stored in flask.g for downstream handlers.

HTTP semantics:
401 = token missing, expired, revoked, or invalid
403 = valid token but insufficient scope or role
"""

import functools
from typing import List

from flask import g, request

from mod_api import mod_api
from mod_api.middleware.error_handler import make_error_response
from mod_api.models.api_token import ApiToken

_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.'

# These endpoints bypass auth entirely.
_PUBLIC_ENDPOINTS = frozenset([
'api.create_token', # POST /auth/tokens (uses email/password body)
'api.system_health', # GET /system/health (uptime monitoring)
])


def _unauthorized():
"""Shorthand for a 401 response with the standard auth failure message."""
from mod_api.middleware.rate_limit import check_rate_limit
rate_limit_resp = check_rate_limit()
if rate_limit_resp:
return rate_limit_resp

return make_error_response(
'unauthorized', _AUTH_FAILED_MSG, http_status=401)


@mod_api.before_request
def authenticate_request():
"""Validate Bearer token and attach user context to the request."""
if request.endpoint in _PUBLIC_ENDPOINTS:
g.api_user = None
g.api_token = None
return

auth_header = request.headers.get('Authorization', '')
if not auth_header:
return _unauthorized()

parts = auth_header.split(' ', 1)
if len(parts) != 2 or parts[0] != 'Bearer':
return _unauthorized()

token_value = parts[1].strip()
if not token_value or not token_value.startswith('spci_'):
return _unauthorized()

# Look up by prefix, then verify the full hash against each candidate.
prefix = ApiToken.extract_prefix(token_value)
candidates = ApiToken.query.filter_by(token_prefix=prefix).all()

if not candidates:
return _unauthorized()

matched_token = None
for candidate in candidates:
if ApiToken.verify_token(token_value, candidate.token_hash):
matched_token = candidate
break

if matched_token is None:
return _unauthorized()

if not matched_token.is_valid:
return _unauthorized()

g.api_token = matched_token
g.api_user = matched_token.user


def require_scope(*scopes: str):
"""Reject the request if the token lacks any of the ``scopes``."""
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
token = getattr(g, 'api_token', None)
if token is None:
return _unauthorized()

missing_scopes = [s for s in scopes if not token.has_scope(s)]
if missing_scopes:
return make_error_response(
'forbidden',
'Token lacks the required scopes for this operation.',
details={
'required_scopes': list(scopes),
'missing_scopes': missing_scopes,
'token_scopes': token.scopes,
},
http_status=403,
)
return f(*args, **kwargs)
return decorated_function
return decorator


def require_roles(roles: List[str]):
"""Reject the request if the user's role is not in ``roles``."""
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
user = getattr(g, 'api_user', None)
if user is None:
return _unauthorized()
if user.role.value not in roles:
return make_error_response(
'forbidden',
'Your role does not have permission for this operation.',
details={
'required_roles': roles,
'user_role': user.role.value,
},
http_status=403,
)
return f(*args, **kwargs)
return decorated_function
return decorator
139 changes: 139 additions & 0 deletions mod_api/middleware/error_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Structured JSON error responses for API routes."""

from flask import jsonify, request
from marshmallow import ValidationError as MarshmallowValidationError
from sqlalchemy.exc import SQLAlchemyError

from mod_api import mod_api

_API_PREFIX = '/api/v1'


def make_error_response(code, message, details=None, http_status=400):
"""Build a JSON error response conforming to the ErrorResponse schema."""
body = {
'code': code,
'message': str(message)[:500],
'details': details if details is not None else {},
}
response = jsonify(body)
response.status_code = http_status
return response


@mod_api.errorhandler(400)
def handle_400(error):
"""Bad request."""
return make_error_response(
'validation_error',
getattr(error, 'description', 'Bad request.'),
http_status=400,
)


@mod_api.errorhandler(401)
def handle_401(error):
"""Unauthorized."""
return make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)


@mod_api.errorhandler(403)
def handle_403(error):
"""Forbidden."""
return make_error_response(
'forbidden',
'Token does not have the required scope for this operation.',
http_status=403,
)


def _is_api_request():
return request.path.startswith('/api/')


@mod_api.app_errorhandler(404)
def handle_404(error):
"""Not found."""
if _is_api_request():
return make_error_response(
'not_found',
getattr(error, 'description', 'Resource not found.'),
http_status=404,
)
from run import not_found
return not_found(error)


@mod_api.app_errorhandler(405)
def handle_405(error):
"""Handle method-not-allowed errors for API routes."""
if _is_api_request():
return make_error_response(
'method_not_allowed',
'Method not allowed.',
http_status=405,
)
return "Method not allowed", 405


@mod_api.errorhandler(422)
def handle_422(error):
"""Unprocessable entity."""
return make_error_response(
'unprocessable',
getattr(
error,
'description',
'Request is valid JSON but semantically invalid.'),
http_status=422,
)


@mod_api.errorhandler(429)
def handle_429(error):
"""Rate limited."""
return make_error_response(
'rate_limited',
'Rate limit exceeded.',
details={'retry_after': 30, 'limit': 120, 'window': '60s'},
http_status=429,
)


@mod_api.errorhandler(500)
def handle_500(error):
"""Handle unexpected server errors for API routes."""
return make_error_response(
'internal_error',
'An unexpected error occurred.',
http_status=500,
)


@mod_api.errorhandler(MarshmallowValidationError)
def handle_marshmallow_validation_error(error):
"""Catch schema validation failures and return them as 400."""
return make_error_response(
'validation_error',
'Request failed schema validation.',
details={'fields': error.messages},
http_status=400,
)


@mod_api.errorhandler(SQLAlchemyError)
def handle_sqlalchemy_error(error):
"""Log database errors."""
from flask import g
log = getattr(g, 'log', None)
if log:
log.error(f'Database error in API: {error}')
return make_error_response(
'internal_error',
'An unexpected database error occurred.',
http_status=500,
)
Loading
Loading