Initial public ModelForge release
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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)]
|
||||
Reference in New Issue
Block a user