Skip to content

Commit 094f3af

Browse files
committed
Implement REST API Endpoints
1 parent a52f7f5 commit 094f3af

57 files changed

Lines changed: 11516 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Add api_token table for scoped API token auth.
2+
3+
Revision ID: d4f8e2a1b3c7
4+
Revises: c8f3a2b1d4e5
5+
Create Date: 2026-06-11 03:00:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = 'd4f8e2a1b3c7'
13+
down_revision = 'c8f3a2b1d4e5'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
"""Apply the migration."""
20+
op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True))
21+
op.create_table(
22+
'api_token',
23+
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
24+
sa.Column('user_id', sa.Integer(), nullable=False),
25+
sa.Column('token_name', sa.String(length=50), nullable=False),
26+
sa.Column('token_hash', sa.String(length=255), nullable=False),
27+
sa.Column('token_prefix', sa.String(length=16), nullable=False),
28+
sa.Column('scopes_json', sa.Text(), nullable=False),
29+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
30+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
31+
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
32+
sa.PrimaryKeyConstraint('id'),
33+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'),
34+
sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'),
35+
mysql_engine='InnoDB'
36+
)
37+
op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix'])
38+
39+
40+
def downgrade():
41+
"""Revert the migration."""
42+
op.drop_index('ix_api_token_token_prefix', table_name='api_token')
43+
op.drop_table('api_token')
44+
op.drop_column('user', 'github_login')

mod_api/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
mod_api: JSON REST API blueprint for the CCExtractor CI platform.
3+
4+
Registered at /api/v1. All endpoints return structured JSON, use scoped
5+
Bearer token auth, and enforce per-client rate limiting.
6+
"""
7+
8+
from flask import Blueprint
9+
10+
mod_api = Blueprint('api', __name__)
11+
12+
# Middleware (registers before_request hooks and error handlers)
13+
# WARNING: auth must be imported before rate_limit. The auth middleware
14+
# manually calls check_rate_limit() for unauthenticated paths. If
15+
# rate_limit is imported first, its before_request hook fires first and
16+
# the auth middleware's manual call would double-count requests.
17+
from mod_api.middleware import auth # noqa: E402, F401
18+
from mod_api.middleware import error_handler # noqa: E402, F401
19+
from mod_api.middleware import rate_limit # noqa: E402, F401
20+
from mod_api.middleware import security # noqa: E402, F401
21+
# Route modules (registers endpoint functions on the blueprint)
22+
from mod_api.routes import auth as auth_routes # noqa: E402, F401
23+
from mod_api.routes import errors_logs # noqa: E402, F401
24+
from mod_api.routes import results # noqa: E402, F401
25+
from mod_api.routes import runs # noqa: E402, F401
26+
from mod_api.routes import samples # noqa: E402, F401
27+
from mod_api.routes import system # noqa: E402, F401

mod_api/middleware/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""mod_api.middleware: auth, rate limiting, validation, and error handling."""

mod_api/middleware/auth.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
Bearer token authentication and scope/role enforcement for API routes.
3+
4+
Runs as a before_request hook on the api blueprint. Public endpoints
5+
(token creation, health check) are exempted. On success, the authenticated
6+
user and token are stored in flask.g for downstream handlers.
7+
8+
HTTP semantics:
9+
401 = token missing, expired, revoked, or invalid
10+
403 = valid token but insufficient scope or role
11+
"""
12+
13+
import functools
14+
from typing import List
15+
16+
from flask import g, request
17+
18+
from mod_api import mod_api
19+
from mod_api.middleware.error_handler import make_error_response
20+
from mod_api.models.api_token import ApiToken
21+
22+
_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.'
23+
24+
# These endpoints bypass auth entirely.
25+
_PUBLIC_ENDPOINTS = frozenset([
26+
'api.create_token', # POST /auth/tokens (uses email/password body)
27+
'api.system_health', # GET /system/health (uptime monitoring)
28+
])
29+
30+
31+
def _unauthorized():
32+
"""Shorthand for a 401 response with the standard auth failure message."""
33+
from mod_api.middleware.rate_limit import check_rate_limit
34+
rate_limit_resp = check_rate_limit()
35+
if rate_limit_resp:
36+
return rate_limit_resp
37+
38+
return make_error_response(
39+
'unauthorized', _AUTH_FAILED_MSG, http_status=401)
40+
41+
42+
@mod_api.before_request
43+
def authenticate_request():
44+
"""Validate Bearer token and attach user context to the request."""
45+
if request.endpoint in _PUBLIC_ENDPOINTS:
46+
g.api_user = None
47+
g.api_token = None
48+
return
49+
50+
auth_header = request.headers.get('Authorization', '')
51+
if not auth_header:
52+
return _unauthorized()
53+
54+
parts = auth_header.split(' ', 1)
55+
if len(parts) != 2 or parts[0] != 'Bearer':
56+
return _unauthorized()
57+
58+
token_value = parts[1].strip()
59+
if not token_value or not token_value.startswith('spci_'):
60+
return _unauthorized()
61+
62+
# Look up by prefix, then verify the full hash against each candidate.
63+
prefix = ApiToken.extract_prefix(token_value)
64+
candidates = ApiToken.query.filter_by(token_prefix=prefix).all()
65+
66+
if not candidates:
67+
return _unauthorized()
68+
69+
matched_token = None
70+
for candidate in candidates:
71+
if ApiToken.verify_token(token_value, candidate.token_hash):
72+
matched_token = candidate
73+
break
74+
75+
if matched_token is None:
76+
return _unauthorized()
77+
78+
if not matched_token.is_valid:
79+
return _unauthorized()
80+
81+
g.api_token = matched_token
82+
g.api_user = matched_token.user
83+
84+
85+
def require_scope(*scopes: str):
86+
"""Reject the request if the token lacks any of the ``scopes``."""
87+
def decorator(f):
88+
@functools.wraps(f)
89+
def decorated_function(*args, **kwargs):
90+
token = getattr(g, 'api_token', None)
91+
if token is None:
92+
return _unauthorized()
93+
94+
missing_scopes = [s for s in scopes if not token.has_scope(s)]
95+
if missing_scopes:
96+
return make_error_response(
97+
'forbidden',
98+
'Token lacks the required scopes for this operation.',
99+
details={
100+
'required_scopes': list(scopes),
101+
'missing_scopes': missing_scopes,
102+
'token_scopes': token.scopes,
103+
},
104+
http_status=403,
105+
)
106+
return f(*args, **kwargs)
107+
return decorated_function
108+
return decorator
109+
110+
111+
def require_roles(roles: List[str]):
112+
"""Reject the request if the user's role is not in ``roles``."""
113+
def decorator(f):
114+
@functools.wraps(f)
115+
def decorated_function(*args, **kwargs):
116+
user = getattr(g, 'api_user', None)
117+
if user is None:
118+
return _unauthorized()
119+
if user.role.value not in roles:
120+
return make_error_response(
121+
'forbidden',
122+
'Your role does not have permission for this operation.',
123+
details={
124+
'required_roles': roles,
125+
'user_role': user.role.value,
126+
},
127+
http_status=403,
128+
)
129+
return f(*args, **kwargs)
130+
return decorated_function
131+
return decorator
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Structured JSON error responses for API routes."""
2+
3+
from flask import jsonify, make_response, request
4+
from marshmallow import ValidationError as MarshmallowValidationError
5+
from sqlalchemy.exc import SQLAlchemyError
6+
7+
from mod_api import mod_api
8+
9+
_API_PREFIX = '/api/v1'
10+
11+
12+
def make_error_response(code, message, details=None, http_status=400):
13+
"""Build a JSON error response conforming to the ErrorResponse schema."""
14+
body = {
15+
'code': code,
16+
'message': str(message)[:500],
17+
'details': details if details is not None else {},
18+
}
19+
response = jsonify(body)
20+
response.status_code = http_status
21+
return response
22+
23+
24+
@mod_api.errorhandler(400)
25+
def handle_400(error):
26+
"""Bad request."""
27+
return make_error_response(
28+
'validation_error',
29+
getattr(error, 'description', 'Bad request.'),
30+
http_status=400,
31+
)
32+
33+
34+
@mod_api.errorhandler(401)
35+
def handle_401(error):
36+
"""Unauthorized."""
37+
return make_error_response(
38+
'unauthorized',
39+
'Bearer token is missing, expired, or invalid.',
40+
http_status=401,
41+
)
42+
43+
44+
@mod_api.errorhandler(403)
45+
def handle_403(error):
46+
"""Forbidden."""
47+
return make_error_response(
48+
'forbidden',
49+
'Token does not have the required scope for this operation.',
50+
http_status=403,
51+
)
52+
53+
54+
@mod_api.errorhandler(404)
55+
def handle_404(error):
56+
"""Not found."""
57+
return make_error_response(
58+
'not_found',
59+
getattr(error, 'description', 'Resource not found.'),
60+
http_status=404,
61+
)
62+
63+
64+
@mod_api.errorhandler(405)
65+
def handle_405(error):
66+
"""Handle method-not-allowed errors for API routes."""
67+
resp = make_error_response(
68+
'method_not_allowed',
69+
'Method not allowed.',
70+
http_status=405,
71+
)
72+
if hasattr(error, 'valid_methods') and error.valid_methods:
73+
resp.headers['Allow'] = ', '.join(error.valid_methods)
74+
return resp
75+
76+
77+
@mod_api.errorhandler(422)
78+
def handle_422(error):
79+
"""Unprocessable entity."""
80+
return make_error_response(
81+
'unprocessable',
82+
getattr(
83+
error,
84+
'description',
85+
'Request is valid JSON but semantically invalid.'),
86+
http_status=422,
87+
)
88+
89+
90+
@mod_api.errorhandler(429)
91+
def handle_429(error):
92+
"""Rate limited."""
93+
return make_error_response(
94+
'rate_limited',
95+
'Rate limit exceeded.',
96+
details={'retry_after': 30, 'limit': 120, 'window': '60s'},
97+
http_status=429,
98+
)
99+
100+
101+
@mod_api.errorhandler(500)
102+
def handle_500(error):
103+
"""Handle unexpected server errors for API routes."""
104+
return make_error_response(
105+
'internal_error',
106+
'An unexpected error occurred.',
107+
http_status=500,
108+
)
109+
110+
111+
@mod_api.errorhandler(MarshmallowValidationError)
112+
def handle_marshmallow_validation_error(error):
113+
"""Catch schema validation failures and return them as 400."""
114+
return make_error_response(
115+
'validation_error',
116+
'Request failed schema validation.',
117+
details={'fields': error.messages},
118+
http_status=400,
119+
)
120+
121+
122+
@mod_api.errorhandler(SQLAlchemyError)
123+
def handle_sqlalchemy_error(error):
124+
"""Log database errors."""
125+
from flask import g
126+
log = getattr(g, 'log', None)
127+
if log:
128+
log.error(f'Database error in API: {type(error).__name__}')
129+
return make_error_response(
130+
'internal_error',
131+
'An unexpected database error occurred.',
132+
http_status=500,
133+
)
134+
135+
136+
@mod_api.after_app_request
137+
def convert_api_errors_to_json(response):
138+
"""Catch routing errors that were handled by global app handlers and convert them to JSON."""
139+
if request.path.startswith(_API_PREFIX):
140+
if response.status_code == 404:
141+
return make_error_response('not_found', 'Resource not found.', http_status=404)
142+
if response.status_code == 405:
143+
return make_error_response('method_not_allowed', 'Method not allowed.', http_status=405)
144+
return response

0 commit comments

Comments
 (0)