209 lines
8.0 KiB
Python
209 lines
8.0 KiB
Python
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)]
|