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
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-only REST API for the CCExtractor CI/sample platform.

Blueprint registered at /api/v1. All endpoints return structured JSON,
use scoped Bearer token auth, and enforce rate limiting.
"""

from flask import Blueprint

mod_api = Blueprint('api', __name__)

# Import middleware (registers before_request, error handlers)
from mod_api.middleware import error_handler # noqa: E402, F401
from mod_api.middleware import auth # noqa: E402, F401
from mod_api.middleware import rate_limit # noqa: E402, F401

# Import routes (registers endpoint functions)
from mod_api.routes import auth as auth_routes # 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 results # noqa: E402, F401
from mod_api.routes import errors_logs # 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."""
147 changes: 147 additions & 0 deletions mod_api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
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

# 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)
])


@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 make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',

Check failure on line 41 in mod_api/middleware/auth.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 'Bearer token is missing, expired, or invalid.' 8 times.

See more on https://sonarcloud.io/project/issues?id=CCExtractor_sample-platform&issues=AZ6XLhcD4lgwc59G4KIk&open=AZ6XLhcD4lgwc59G4KIk&pullRequest=1117
http_status=401,
)

parts = auth_header.split(' ', 1)
if len(parts) != 2 or parts[0] != 'Bearer':
return make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)

token_value = parts[1].strip()
if not token_value or not token_value.startswith('spci_'):
return make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)

# 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 make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)

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 make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)

if not matched_token.is_valid:
return make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)

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


def require_scope(scope: str):
"""Decorator: 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 make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)
if not token.has_scope(scope):
return make_error_response(
'forbidden',
'Token does not have 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]):
"""Decorator: 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 make_error_response(
'unauthorized',
'Bearer token is missing, expired, or invalid.',
http_status=401,
)
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
149 changes: 149 additions & 0 deletions mod_api/middleware/error_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""
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


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.app_errorhandler(400)
def handle_400(error):
"""Bad request."""
if not request.path.startswith('/api/v1'):

Check failure on line 33 in mod_api/middleware/error_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal '/api/v1' 8 times.

See more on https://sonarcloud.io/project/issues?id=CCExtractor_sample-platform&issues=AZ6XLhZd4lgwc59G4KIj&open=AZ6XLhZd4lgwc59G4KIj&pullRequest=1117
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 request.path.startswith('/api/v1'):
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 request.path.startswith('/api/v1'):
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 request.path.startswith('/api/v1'):
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):
"""Method not allowed."""
if not request.path.startswith('/api/v1'):
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 request.path.startswith('/api/v1'):
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 request.path.startswith('/api/v1'):
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):
"""Internal server error."""
if not request.path.startswith('/api/v1'):
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