Files
govoplan-access/src/govoplan_access/backend/auth/dependencies.py

394 lines
16 KiB
Python

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