Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pycodestylerc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[pycodestyle]
count = True
max-line-length = 120
exclude=test_diff.py,migrations,venv*,parse.py,config.py
exclude=test_diff.py,migrations,venv*,.venv*,parse.py,config.py
ignore = E701
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')
39 changes: 39 additions & 0 deletions mod_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
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 imports
from mod_api.middleware import auth # noqa: E402
from mod_api.middleware import error_handler # noqa: E402
from mod_api.middleware import rate_limit # noqa: E402
from mod_api.middleware import security # noqa: E402

# Explicitly register before_request hooks in the exact order they should run
mod_api.before_request(auth.authenticate_request)
mod_api.before_request(rate_limit.check_rate_limit)
mod_api.before_request(auth.enforce_auth_error)

# Explicitly register after_request hooks.
# NOTE: Flask executes after_request hooks in REVERSE registration order.
# Registration: security -> rate_limit -> (convert is app-level, see below)
# Execution: rate_limit -> security
# This means rate-limit headers are added first, then security headers layer
# on top - both on the same response object.
mod_api.after_request(security.add_security_headers)
mod_api.after_request(rate_limit.add_rate_limit_headers)

# Registered as after_app_request so it fires for ALL requests (including
# routing-level 404s/405s that never enter the blueprint).
mod_api.after_app_request(error_handler.convert_api_errors_to_json)

# Route modules
from mod_api.routes import auth as auth_routes # noqa: E402, F401
from mod_api.routes import runs as runs_routes # noqa: E402, F401
from mod_api.routes import system as system_routes # 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."""
144 changes: 144 additions & 0 deletions mod_api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
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.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)


def authenticate_request():
"""Validate Bearer token and attach user context to the request.

If auth fails, sets g.auth_error instead of returning immediately,
so that subsequent hooks (like rate limiting) still run.
"""
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:
g.auth_error = _unauthorized()
return

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

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

# 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:
# Dummy verification to prevent timing attacks on non-existent tokens
ApiToken.verify_token(
'dummy',
'$argon2id$v=19$m=65536,t=3,p=4$ZHVtbXlfc2FsdF9mb3JfdGltaW5n$A1H8jT2lJ1t5fX9gK0rX4M')
g.auth_error = _unauthorized()
return

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:
g.auth_error = _unauthorized()
return

if not matched_token.is_valid:
g.auth_error = _unauthorized()
return

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


def enforce_auth_error():
"""Return any stored auth errors after rate limiting."""
if hasattr(g, 'auth_error') and g.auth_error is not None:
return g.auth_error


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
Loading
Loading