from dataclasses import dataclass from secrets import compare_digest from typing import Any from fastapi import Header, HTTPException, status from app.core.config import get_settings from app.core.constants import HttpHeader, SecurityErrorDetail @dataclass(frozen=True) class ApiPrincipal: """Authenticated service principal derived from server-side configuration.""" actor: str def require_api_key( x_api_key: str | None = Header(default=None, alias=HttpHeader.X_API_KEY), ) -> ApiPrincipal: """Validate the internal API key header and return its service principal.""" settings = get_settings() if not settings.api_key and not _has_enabled_keys(settings.api_keys): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=SecurityErrorDetail.API_KEY_REQUIRED, ) principal = _match_service_key( x_api_key, settings.api_key, settings.api_actor, settings.api_keys, ) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=SecurityErrorDetail.INVALID_API_KEY, ) return principal def require_approval_api_key( x_approval_api_key: str | None = Header( default=None, alias=HttpHeader.X_APPROVAL_API_KEY, ), ) -> ApiPrincipal: """Validate the approval API key and return the approval principal.""" settings = get_settings() if not settings.approval_api_key and not _has_enabled_keys(settings.approval_api_keys): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=SecurityErrorDetail.APPROVAL_API_KEY_REQUIRED, ) principal = _match_service_key( x_approval_api_key, settings.approval_api_key, settings.approval_api_actor, settings.approval_api_keys, ) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=SecurityErrorDetail.INVALID_APPROVAL_API_KEY, ) return principal def require_audit_api_key( x_audit_api_key: str | None = Header( default=None, alias=HttpHeader.X_AUDIT_API_KEY, ), ) -> ApiPrincipal: """Validate the audit API key and return the audit principal.""" settings = get_settings() if not settings.audit_api_key and not _has_enabled_keys(settings.audit_api_keys): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=SecurityErrorDetail.AUDIT_API_KEY_REQUIRED, ) principal = _match_service_key( x_audit_api_key, settings.audit_api_key, settings.audit_api_actor, settings.audit_api_keys, ) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=SecurityErrorDetail.INVALID_AUDIT_API_KEY, ) return principal def _has_enabled_keys(configured_keys: list[dict[str, Any]]) -> bool: return any(_key_enabled(item) and item.get("key") for item in configured_keys) def _match_service_key( provided_key: str | None, legacy_key: str | None, legacy_actor: str, configured_keys: list[dict[str, Any]], ) -> ApiPrincipal | None: if not provided_key: return None if legacy_key and compare_digest(provided_key, legacy_key): return ApiPrincipal(actor=legacy_actor) for item in configured_keys: key = item.get("key") if not key or not _key_enabled(item): continue if compare_digest(provided_key, str(key)): return ApiPrincipal(actor=str(item.get("actor") or legacy_actor)) return None def _key_enabled(item: dict[str, Any]) -> bool: value = item.get("enabled", True) if isinstance(value, bool): return value return str(value).strip().lower() not in {"0", "false", "no", "off", "disabled"}