from __future__ import annotations from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import ( CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, PermissionEvaluator, PrincipalRef, PrincipalResolver, ) from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.modules import AccessDecision from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.db.session import get_database, get_session from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.sessions import ( authenticate_session_token, collect_user_groups, collect_user_roles, collect_user_scopes, verify_auth_session_csrf, ) from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.permissions import expand_scopes, intersect_api_key_scopes from govoplan_core.settings import settings def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: if x_api_key: return x_api_key.strip(), "api_key" if authorization and authorization.lower().startswith("bearer "): return authorization[7:].strip(), "bearer" cookie_token = request.cookies.get(settings.auth_session_cookie_name) if cookie_token: return cookie_token.strip(), "cookie" return None, "none" def _requires_csrf(request: Request) -> bool: return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"} def _principal_group_ids(session: Session, user: User) -> frozenset[str]: return frozenset(group.id for group in collect_user_groups(session, user)) def _build_principal_ref( session: Session, *, account: Account, user: User, tenant_id: str, scopes: list[str], auth_method: str, api_key: ApiKey | None = None, auth_session: AuthSession | None = None, idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), extra_roles: tuple[Role, ...] = (), ) -> PrincipalRef: function_assignment_ids = list(collect_function_assignment_ids(session, user)) function_assignment_ids.extend(item.id for item in idm_assignments) roles = collect_user_roles(session, user) role_ids = [role.id for role in roles] role_ids.extend(role.id for role in extra_roles) return PrincipalRef( account_id=account.id, membership_id=user.id, tenant_id=tenant_id, identity_id=identity_id_for_account(session, account.id), scopes=frozenset(scopes), group_ids=_principal_group_ids(session, user), role_ids=frozenset(sorted(dict.fromkeys(role_ids))), function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))), delegation_ids=frozenset(collect_function_delegation_ids(session, user)), auth_method=auth_method, # type: ignore[arg-type] api_key_id=api_key.id if api_key else None, session_id=auth_session.id if auth_session else None, email=account.email, display_name=account.display_name or user.display_name, ) def _api_principal_from_ref( session: Session, principal: PrincipalRef, *, permission_evaluator: PermissionEvaluator | None = None, ) -> ApiPrincipal: account = session.get(Account, principal.account_id) user = session.get(User, principal.membership_id) if principal.membership_id else None tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None if ( not account or not user or not tenant or not account.is_active or not user.is_active or not tenant.is_active or user.account_id != account.id or user.tenant_id != tenant.id ): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API principal") api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None return ApiPrincipal( principal=principal, account=account, user=user, api_key=api_key, auth_session=auth_session, permission_evaluator=permission_evaluator, ) def _resolve_legacy_principal_ref( request: Request, session: Session, *, authorization: str | None, x_api_key: str | None, idm_directory: IdmDirectory | None = None, ) -> PrincipalRef: token, source = _extract_token(request, authorization, x_api_key) if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") # API keys remain supported for CLI/automation. Their permissions are the # intersection of the key grant and the owner's current tenant roles. api_key = authenticate_api_key(session, token) if api_key: user = session.get(User, api_key.user_id) account = session.get(Account, user.account_id) if user else None tenant = session.get(Tenant, api_key.tenant_id) if ( not user or not account or not tenant or not user.is_active or not account.is_active or not tenant.is_active or user.tenant_id != api_key.tenant_id ): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments)) user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) session.commit() return _build_principal_ref( session, api_key=api_key, account=account, user=user, tenant_id=api_key.tenant_id, scopes=effective_scopes, auth_method="api_key", idm_assignments=idm_assignments, extra_roles=idm_roles, ) auth_session = authenticate_session_token(session, token) if auth_session: user = session.get(User, auth_session.user_id) account = session.get(Account, auth_session.account_id) tenant = session.get(Tenant, auth_session.tenant_id) if ( not user or not account or not tenant or not user.is_active or not account.is_active or not tenant.is_active or user.account_id != account.id or user.tenant_id != tenant.id ): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") if source == "cookie" and _requires_csrf(request): header_token = request.headers.get("x-csrf-token") cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments)) scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) session.commit() return _build_principal_ref( session, auth_session=auth_session, account=account, user=user, tenant_id=user.tenant_id, scopes=scopes, auth_method="session", idm_assignments=idm_assignments, extra_roles=idm_roles, ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") def _idm_assignments_for_account( idm_directory: IdmDirectory | None, account_id: str, *, tenant_id: str, ) -> tuple[OrganizationFunctionAssignmentRef, ...]: if idm_directory is None: return () return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id)) def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]: merged = set(scopes) for role in roles: merged.update(role.permissions or []) return expand_scopes(merged) def _registry_from_request(request: Request) -> PlatformRegistry | None: registry = getattr(request.app.state, "govoplan_registry", None) return registry if isinstance(registry, PlatformRegistry) else None def _principal_resolver_from_request(request: Request) -> PrincipalResolver | None: registry = _registry_from_request(request) if registry is None: return None capability_name = ( CAPABILITY_AUTH_PRINCIPAL_RESOLVER if registry.has_capability(CAPABILITY_AUTH_PRINCIPAL_RESOLVER) else CAPABILITY_ACCESS_PRINCIPAL_RESOLVER ) if not registry.has_capability(capability_name): return None capability = registry.require_capability(capability_name) if not isinstance(capability, PrincipalResolver): raise HTTPException(status_code=500, detail=f"Invalid capability: {capability_name}") return capability def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator | None: registry = _registry_from_request(request) if registry is None: return None capability_name = ( CAPABILITY_AUTH_PERMISSION_EVALUATOR if registry.has_capability(CAPABILITY_AUTH_PERMISSION_EVALUATOR) else CAPABILITY_ACCESS_PERMISSION_EVALUATOR ) if not registry.has_capability(capability_name): return None capability = registry.require_capability(capability_name) if not isinstance(capability, PermissionEvaluator): raise HTTPException(status_code=500, detail=f"Invalid capability: {capability_name}") return capability class LegacyPrincipalResolver: def __init__(self, idm_directory: IdmDirectory | None = None) -> None: self._idm_directory = idm_directory def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: if not isinstance(request, Request): raise TypeError("LegacyPrincipalResolver requires a FastAPI request") authorization = request.headers.get("authorization") x_api_key = request.headers.get("x-api-key") if isinstance(session, Session): return _resolve_legacy_principal_ref( request, session, authorization=authorization, x_api_key=x_api_key, idm_directory=self._idm_directory, ) with get_database().session() as managed_session: return _resolve_legacy_principal_ref( request, managed_session, authorization=authorization, x_api_key=x_api_key, idm_directory=self._idm_directory, ) class LegacyPermissionEvaluator: def scopes_grant(self, scopes, required_scope: str) -> bool: return scopes_grant_compatible(scopes, required_scope) def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool: return self.scopes_grant(principal.scopes, required_scope) def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision: allowed = self.has_scope(principal, required_scope) return AccessDecision( allowed=allowed, reason=None if allowed else f"Missing scope: {required_scope}", requirements=(required_scope,), ) def resolve_api_principal( request: Request, session: Session, *, authorization: str | None = None, x_api_key: str | None = None, ) -> ApiPrincipal: resolver = _principal_resolver_from_request(request) permission_evaluator = _permission_evaluator_from_request(request) if resolver is not None: principal = resolver.resolve_request(request, session=session) api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) _enforce_maintenance_mode(session, api_principal) return api_principal principal = _resolve_legacy_principal_ref( request, session, authorization=authorization, x_api_key=x_api_key, ) api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) _enforce_maintenance_mode(session, api_principal) return api_principal class AccessApiPrincipalProvider: def resolve_api_principal( self, request: object, session: object, *, authorization: str | None = None, x_api_key: str | None = None, ) -> ApiPrincipal: if not isinstance(request, Request): raise TypeError("AccessApiPrincipalProvider requires a FastAPI request") if not isinstance(session, Session): raise TypeError("AccessApiPrincipalProvider requires a SQLAlchemy session") return resolve_api_principal( request, session, authorization=authorization, x_api_key=x_api_key, ) def get_api_principal( request: Request, session: Session = Depends(get_session), authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None, alias="X-API-Key"), ) -> ApiPrincipal: return resolve_api_principal( request, session, authorization=authorization, x_api_key=x_api_key, ) def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None: mode = saved_maintenance_mode(session) if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE): return raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=maintenance_response_detail(mode), ) def has_scope(principal: ApiPrincipal, required_scope: str) -> bool: return principal.has(required_scope) def require_scope(required_scope: str): def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: if not has_scope(principal, required_scope): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}") return principal return dependency def require_any_scope(*required_scopes: str): def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: if not any(has_scope(principal, required) for required in required_scopes): joined = ", ".join(required_scopes) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}") return principal return dependency