"""Caller authentication and least-privilege policy for trusted ingestion.""" from __future__ import annotations import asyncio import os import re from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from typing import Literal from uuid import UUID import httpx import jwt from services.control_api.auth.config import OIDCSettings from services.control_api.auth.errors import InvalidToken, ProviderUnavailable from services.control_api.auth.provider import OIDCProviderClient Capability = Literal["knowledge:read", "ingestion:write"] _CAPABILITIES = frozenset({"knowledge:read", "ingestion:write"}) _SCOPE = re.compile(r"^[A-Za-z][A-Za-z0-9:._-]{0,63}$") _SCOPES_CLAIM = re.compile(r"^[A-Za-z][A-Za-z0-9:._-]{0,63}(?: [A-Za-z][A-Za-z0-9:._-]{0,63})*$") _CORRELATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") _MAX_TOKEN_LENGTH = 65_536 _MAX_PROJECTS = 256 _MAX_SCOPES = 128 SAFE_AUDIT_KEYS = frozenset( { "tool", "result", "status", "latency_bucket", "correlation_id", "error_code", "input_reason", } ) _INGESTION_TOOLS = frozenset( { "search_knowledge", "get_knowledge_context", "analyze_knowledge_files", "extract_knowledge_files", "get_workspace_roots", "get_workspace_children", "get_workspace_page", "create_text_source_proposal", "submit_ingestion_proposal", "get_ingestion_status", } ) _SAFE_AUDIT_LABEL = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") _SAFE_INGESTION_ERROR_CODES = frozenset( { "invalid_input", "url_not_allowed", "source_limit", "download_failed", "download_limit", "download_timeout", "scan_unavailable", "scan_failed", "malware_detected", "unsupported_format", "mime_mismatch", "extraction_failed", "extraction_limit", "extraction_timeout", "configuration", } ) _SAFE_INGESTION_INPUT_REASONS = frozenset( { "actor_context_missing", "attachment_not_object", "attachment_unknown_fields", "attachment_required_fields_missing", "attachment_file_id_invalid", "attachment_file_id_empty", "attachment_file_id_too_long", "attachment_file_id_non_printable", "attachment_mime_type_invalid", "attachment_file_name_invalid", "files_container_invalid", "duplicate_file_ids", "acquired_file_id_mismatch", } ) @dataclass(frozen=True) class ActorContext: """Small, immutable projection of a validated caller token.""" subject: str projects: frozenset[str] scopes: frozenset[str] correlation_id: str connector_project: str | None = None connector_environment: str | None = None def __post_init__(self) -> None: if not isinstance(self.subject, str) or not 1 <= len(self.subject) <= 512: raise ValueError("actor subject is invalid") if not isinstance(self.projects, frozenset) or len(self.projects) > _MAX_PROJECTS: raise ValueError("actor projects are invalid") for project in self.projects: if not isinstance(project, str) or str(UUID(project)) != project.lower(): raise ValueError("actor projects are invalid") if not isinstance(self.scopes, frozenset) or len(self.scopes) > _MAX_SCOPES: raise ValueError("actor scopes are invalid") if any(not isinstance(scope, str) or not _SCOPE.fullmatch(scope) for scope in self.scopes): raise ValueError("actor scopes are invalid") if not isinstance(self.correlation_id, str) or not _CORRELATION_ID.fullmatch( self.correlation_id ): raise ValueError("actor correlation identifier is invalid") if self.connector_project is not None and ( not isinstance(self.connector_project, str) or not 1 <= len(self.connector_project) <= 128 or any( ord(character) < 0x21 or ord(character) > 0x7E for character in self.connector_project ) ): raise ValueError("actor connector project is invalid") if self.connector_environment is not None and ( not isinstance(self.connector_environment, str) or not 1 <= len(self.connector_environment) <= 128 or any( ord(character) < 0x21 or ord(character) > 0x7E for character in self.connector_environment ) ): raise ValueError("actor connector environment is invalid") def build_ingestion_oidc_provider( *, environ: Mapping[str, str] | None = None, transport: httpx.BaseTransport | None = None, ) -> OIDCProviderClient: """Build the validator for the dedicated MCP resource's configured issuer.""" values = environ if environ is not None else os.environ names = { "issuer": "HUB_MCP_ADDITIONAL_OIDC_ISSUER", "audience": "HUB_MCP_ADDITIONAL_OIDC_AUDIENCE", "client_id": "HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID", "redirect_uri": "HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI", } selected = {key: values.get(name, "").strip() for key, name in names.items()} if not all(selected.values()): raise ValueError("ingestion OIDC configuration is incomplete") settings = OIDCSettings.from_env( { "HUB_OIDC_ISSUER": selected["issuer"], "HUB_OIDC_AUDIENCE": selected["audience"], "HUB_OIDC_CLIENT_ID": selected["client_id"], "HUB_OIDC_REDIRECT_URI": selected["redirect_uri"], "HUB_OIDC_ALLOWED_ALGORITHMS": values.get("HUB_OIDC_ALLOWED_ALGORITHMS", "RS256"), "HUB_OIDC_CLOCK_SKEW_SECONDS": values.get("HUB_OIDC_CLOCK_SKEW_SECONDS", "60"), } ) return OIDCProviderClient(settings, transport=transport) async def create_actor_context( authorization_header: str | None, *, provider: OIDCProviderClient, correlation_id: str, now: datetime | None = None, ) -> ActorContext: """Validate the caller bearer before projecting delegated project/scope claims. The provider performs issuer, audience, time and asymmetric-signature validation. Additional signed claims are read only after that validation. """ if ( not isinstance(authorization_header, str) or len(authorization_header) > _MAX_TOKEN_LENGTH + 7 or not authorization_header.startswith("Bearer ") ): raise InvalidToken() token = authorization_header[7:].strip() if not token or len(token) > _MAX_TOKEN_LENGTH or " " in token: raise InvalidToken() if not isinstance(correlation_id, str) or not _CORRELATION_ID.fullmatch(correlation_id): raise InvalidToken() try: claims = await asyncio.to_thread(provider.validate_token, token, now=now) except ProviderUnavailable: # Avoid retaining provider URLs or transport details in exception chains. raise ProviderUnavailable() from None try: raw_claims = jwt.decode( token, options={ "verify_signature": False, "verify_exp": False, "verify_iat": False, "verify_nbf": False, "verify_iss": False, "verify_aud": False, }, algorithms=list(provider.settings.allowed_algorithms), ) except (jwt.PyJWTError, TypeError, ValueError) as exc: raise InvalidToken() from exc if not isinstance(raw_claims, dict): raise InvalidToken() projects_claim = raw_claims.get("projects") scopes_claim = raw_claims.get("scope") if ( not isinstance(projects_claim, list) or not 1 <= len(projects_claim) <= _MAX_PROJECTS or any(not isinstance(value, str) for value in projects_claim) or not isinstance(scopes_claim, str) or not _SCOPES_CLAIM.fullmatch(scopes_claim) or len(scopes_claim) > 4096 ): raise InvalidToken() try: projects = frozenset(str(UUID(value)) for value in projects_claim) except (ValueError, TypeError, AttributeError) as exc: raise InvalidToken() from exc if len(projects) != len(projects_claim): raise InvalidToken() scope_values = scopes_claim.split(" ") if not 1 <= len(scope_values) <= _MAX_SCOPES or any( not _SCOPE.fullmatch(scope) for scope in scope_values ): raise InvalidToken() try: return ActorContext( subject=claims.subject, projects=projects, scopes=frozenset(scope_values), correlation_id=correlation_id, ) except (TypeError, ValueError) as exc: raise InvalidToken() from exc def authorize_actor( actor: ActorContext, *, capability: Capability, project: str | None = None, ) -> None: """Fail before HTTP unless the validated actor has both required grants.""" if not isinstance(actor, ActorContext) or capability not in _CAPABILITIES: raise PermissionError("ingestion caller is not authorized") if capability not in actor.scopes: raise PermissionError("ingestion caller is not authorized") if project is not None: try: selected = str(UUID(project)) except (TypeError, ValueError, AttributeError) as exc: raise PermissionError("ingestion caller is not authorized") from exc if selected not in actor.projects: raise PermissionError("ingestion caller is not authorized") def authorize_workspace_actor(actor: ActorContext) -> None: """Allow workspace-wide reads only for the trusted production connector.""" if ( not isinstance(actor, ActorContext) or "knowledge:read" not in actor.scopes or actor.connector_project != "knowledge-hub" or actor.connector_environment != "prod" ): raise PermissionError("ingestion caller is not authorized") def sanitize_ingestion_audit(candidate: Mapping[str, object]) -> dict[str, object]: """Project telemetry onto a low-cardinality, content-free allow-list.""" safe: dict[str, object] = {} tool = candidate.get("tool") result = candidate.get("result") status = candidate.get("status") latency = candidate.get("latency_bucket") correlation = candidate.get("correlation_id") error_code = candidate.get("error_code") input_reason = candidate.get("input_reason") if isinstance(tool, str) and tool in _INGESTION_TOOLS: safe["tool"] = tool if isinstance(result, str) and _SAFE_AUDIT_LABEL.fullmatch(result): safe["result"] = result if isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599: safe["status"] = status if isinstance(latency, str) and _SAFE_AUDIT_LABEL.fullmatch(latency): safe["latency_bucket"] = latency if isinstance(correlation, str) and _CORRELATION_ID.fullmatch(correlation): safe["correlation_id"] = correlation if isinstance(error_code, str) and error_code in _SAFE_INGESTION_ERROR_CODES: safe["error_code"] = error_code if isinstance(input_reason, str) and input_reason in _SAFE_INGESTION_INPUT_REASONS: safe["input_reason"] = input_reason return safe