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
22 changes: 22 additions & 0 deletions mod_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
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
# 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."""
123 changes: 123 additions & 0 deletions mod_api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
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."""
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(scope: str):
"""Reject the request if the token lacks ``scope``."""
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
token = getattr(g, 'api_token', None)
if token is None:
return _unauthorized()
if not token.has_scope(scope):
return make_error_response(
'forbidden',
'Token lacks the required scope for this operation.',
details={
'required_scope': scope,
'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
159 changes: 159 additions & 0 deletions mod_api/middleware/error_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
Structured JSON error responses for API routes.

Intercepts standard HTTP errors (400, 401, 403, 404, 405, 422, 429, 500),
Marshmallow validation errors, and SQLAlchemy errors so that nothing under
/api/v1/* ever returns an HTML error page.

Response shape: {"code": "...", "message": "...", "details": {...}}
"""

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


def _is_api_request():
"""Check whether the current request targets an API endpoint."""
return request.path.startswith(_API_PREFIX)


@mod_api.app_errorhandler(400)
def handle_400(error):
"""Bad request."""
if not _is_api_request():
raise error
return make_error_response(
'validation_error',
getattr(error, 'description', 'Bad request.'),
http_status=400,
)


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


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


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


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


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


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


@mod_api.app_errorhandler(500)
def handle_500(error):
"""Handle unexpected server errors for API routes."""
if not _is_api_request():
raise error
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 the real error, but never expose raw SQL details to the client."""
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