from __future__ import annotations import hmac import re from dataclasses import dataclass from enum import StrEnum from typing import Annotated from fastapi import Depends, Header, HTTPException, Request from modelforge_api.settings import Settings, get_settings class PrincipalRole(StrEnum): """Closed operator-console roles, ordered from read-only to security administration.""" VIEWER = "viewer" OPERATOR = "operator" ADMIN = "admin" class AccessBoundary(StrEnum): """Mutually exclusive request authentication boundaries. The control-plane boundary is deliberately the default. New endpoints therefore fail closed behind the human operator credential until their narrower machine/public policy is explicitly recorded here and in the route inventory tests. """ PUBLIC = "public" SINGLE_USE_ENROLLMENT = "single_use_enrollment" NODE = "node" CAPABILITY_CLIENT = "capability_client" CONTROL_PLANE = "control_plane" _PUBLIC_REQUESTS = { ("GET", "/"), ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), ("GET", "/api/v1/version"), } _INTERACTIVE_API_PATHS = {"/docs", "/docs/oauth2-redirect", "/redoc", "/openapi.json"} _NODE_REQUESTS = { ("POST", "/api/v1/agent/heartbeat"), ("PUT", "/api/v1/agent/inventory"), ("PUT", "/api/v1/agent/telemetry"), ("GET", "/api/v1/agent/artifact-jobs/next"), ("POST", "/api/v1/agent/artifact-jobs/{job_id}/progress"), ("POST", "/api/v1/agent/artifact-jobs/{job_id}/complete"), ("POST", "/api/v1/agent/artifact-jobs/{job_id}/fail"), ("GET", "/api/v1/agent/runtime-probes/next"), ("POST", "/api/v1/agent/runtime-probes/{probe_id}/progress"), ("POST", "/api/v1/agent/runtime-probes/{probe_id}/complete"), ("POST", "/api/v1/agent/runtime-probes/{probe_id}/fail"), ("GET", "/api/v1/agent/serving-jobs/next"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/complete"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/fail"), ("POST", "/api/v1/agent/serving-state"), } _NODE_PARAMETERIZED_REQUESTS = ( ("POST", re.compile(r"^/api/v1/agent/artifact-jobs/[^/]+/(?:progress|complete|fail)$")), ("POST", re.compile(r"^/api/v1/agent/runtime-probes/[^/]+/(?:progress|complete|fail)$")), ("POST", re.compile(r"^/api/v1/agent/serving-jobs/[^/]+/(?:complete|fail)$")), ) _CAPABILITY_REQUIREMENTS = { ("POST", "/api/v1/capabilities/rag.embedding@1/invoke"): "rag.embedding@1", ("POST", "/api/v1/capabilities/rag.reranking@1/invoke"): "rag.reranking@1", ("POST", "/api/v1/capabilities/document.ocr@1/invoke"): "document.ocr@1", ("POST", "/api/v1/capabilities/vision.embedding@1/invoke"): "vision.embedding@1", ("POST", "/api/v1/capabilities/speech.transcription@1/invoke"): "speech.transcription@1", ("POST", "/api/v1/capability-experiments/{route_key}/invoke"): "rag.embedding@1", ("POST", "/v1/embeddings"): "rag.embedding@1", } _CAPABILITY_CLIENT_REQUESTS = set(_CAPABILITY_REQUIREMENTS) _CAPABILITY_EXPERIMENT_REQUEST = re.compile(r"^/api/v1/capability-experiments/[^/]+/invoke$") def access_boundary_for_request( method: str, path: str, ) -> AccessBoundary: """Classify an HTTP request without reading its body or accepting credential aliases.""" normalized_method = method.upper() if (normalized_method, path) in _PUBLIC_REQUESTS: return AccessBoundary.PUBLIC if path in _INTERACTIVE_API_PATHS: # These routes exist only when the FastAPI app is explicitly constructed for development. # In test/production they must reach routing unauthenticated so the disabled surface is a # genuine 404 rather than an operator-auth challenge that reveals a hidden endpoint. return AccessBoundary.PUBLIC if normalized_method == "POST" and path == "/api/v1/agent/enroll": return AccessBoundary.SINGLE_USE_ENROLLMENT if (normalized_method, path) in _NODE_REQUESTS or any( normalized_method == rule_method and pattern.fullmatch(path) for rule_method, pattern in _NODE_PARAMETERIZED_REQUESTS ): return AccessBoundary.NODE if (normalized_method, path) in _CAPABILITY_CLIENT_REQUESTS or ( normalized_method == "POST" and _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path) ): return AccessBoundary.CAPABILITY_CLIENT return AccessBoundary.CONTROL_PLANE def required_capability_for_request(method: str, path: str) -> str | None: """Return the exact capability scope for a registered project-facing route.""" normalized_method = method.upper() requirement = _CAPABILITY_REQUIREMENTS.get((normalized_method, path)) if requirement is not None: return requirement if normalized_method == "POST" and _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path): return "rag.embedding@1" return None def request_body_limit_bytes(settings: Settings, boundary: AccessBoundary) -> int: """Select an explicit pre-parser body limit for every request boundary.""" if boundary is AccessBoundary.CAPABILITY_CLIENT: return settings.gateway_max_payload_bytes if boundary in {AccessBoundary.NODE, AccessBoundary.SINGLE_USE_ENROLLMENT}: return settings.node_agent_max_payload_bytes return settings.control_plane_max_payload_bytes _ROLE_RANK = { PrincipalRole.VIEWER: 0, PrincipalRole.OPERATOR: 1, PrincipalRole.ADMIN: 2, } @dataclass(frozen=True, slots=True) class Principal: """An authenticated human/control-plane principal. Node and capability credentials deliberately never become a ``Principal``. Their separate dependencies remain the only way into node-agent and inference surfaces, preventing credential confusion at the type and dependency boundaries. """ subject: str role: PrincipalRole authentication_method: str def permits(self, required: PrincipalRole) -> bool: return _ROLE_RANK[self.role] >= _ROLE_RANK[required] def authenticate_operator_token(settings: Settings, token: str | None) -> Principal: """Authenticate the backward-compatible operator key as an admin principal. The legacy key is the sole human credential in this release. OIDC/session authentication can add another principal producer later without changing route authorization policy. """ configured = settings.operator_api_key if configured is None or not configured.get_secret_value(): raise HTTPException(status_code=503, detail="operator API authentication is not configured") if token is None or not hmac.compare_digest(token, configured.get_secret_value()): raise HTTPException(status_code=401, detail="invalid operator credential") return Principal( subject="legacy-operator", role=PrincipalRole.ADMIN, authentication_method="legacy_admin_token", ) def authenticate_operator_principal( request: Request, settings: Annotated[Settings, Depends(get_settings)], token: Annotated[str | None, Header(alias="X-ModelForge-Admin-Token")] = None, ) -> Principal: """Reuse the pre-body principal and retain a safe direct-dependency fallback.""" principal = getattr(request.state, "principal", None) if isinstance(principal, Principal): return principal return authenticate_operator_token(settings, token) AuthenticatedPrincipal = Annotated[Principal, Depends(authenticate_operator_principal)] def _require_role(principal: Principal, required: PrincipalRole) -> Principal: if not principal.permits(required): raise HTTPException(status_code=403, detail="operator role is not authorized") return principal def require_viewer(principal: AuthenticatedPrincipal) -> Principal: return _require_role(principal, PrincipalRole.VIEWER) def require_operator(principal: AuthenticatedPrincipal) -> Principal: return _require_role(principal, PrincipalRole.OPERATOR) def require_admin(principal: AuthenticatedPrincipal) -> Principal: return _require_role(principal, PrincipalRole.ADMIN) Viewer = Annotated[Principal, Depends(require_viewer)] Operator = Annotated[Principal, Depends(require_operator)] Admin = Annotated[Principal, Depends(require_admin)]