Sync GovOPlaN module state
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
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,
|
||||
@@ -14,12 +13,13 @@ from govoplan_core.core.access import (
|
||||
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, Tenant, User
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
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,
|
||||
@@ -29,99 +29,10 @@ from govoplan_access.backend.security.sessions import (
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import intersect_api_key_scopes
|
||||
from govoplan_core.security.permissions import expand_scopes, intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ApiPrincipal:
|
||||
"""Compatibility wrapper around the platform Principal DTO.
|
||||
|
||||
Existing routers still expect ORM ``account`` and ``user`` objects. New
|
||||
platform/module code should use the stable ID fields or
|
||||
``to_platform_principal()`` instead.
|
||||
"""
|
||||
|
||||
principal: PrincipalRef
|
||||
account: Account
|
||||
user: User
|
||||
api_key: ApiKey | None = None
|
||||
auth_session: AuthSession | None = None
|
||||
permission_evaluator: PermissionEvaluator | None = None
|
||||
|
||||
@property
|
||||
def account_id(self) -> str:
|
||||
return self.principal.account_id
|
||||
|
||||
@property
|
||||
def membership_id(self) -> str | None:
|
||||
return self.principal.membership_id
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
if self.principal.tenant_id is None:
|
||||
raise RuntimeError("Tenant principal has no active tenant id.")
|
||||
return self.principal.tenant_id
|
||||
|
||||
@property
|
||||
def scopes(self) -> frozenset[str]:
|
||||
return self.principal.scopes
|
||||
|
||||
@property
|
||||
def group_ids(self) -> frozenset[str]:
|
||||
return self.principal.group_ids
|
||||
|
||||
@property
|
||||
def role_ids(self) -> frozenset[str]:
|
||||
return self.principal.role_ids
|
||||
|
||||
@property
|
||||
def function_assignment_ids(self) -> frozenset[str]:
|
||||
return self.principal.function_assignment_ids
|
||||
|
||||
@property
|
||||
def delegation_ids(self) -> frozenset[str]:
|
||||
return self.principal.delegation_ids
|
||||
|
||||
@property
|
||||
def identity_id(self) -> str | None:
|
||||
return self.principal.identity_id
|
||||
|
||||
@property
|
||||
def acting_for_account_id(self) -> str | None:
|
||||
return self.principal.acting_for_account_id
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return self.principal.auth_method
|
||||
|
||||
@property
|
||||
def api_key_id(self) -> str | None:
|
||||
return self.principal.api_key_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> str | None:
|
||||
return self.principal.session_id
|
||||
|
||||
@property
|
||||
def email(self) -> str | None:
|
||||
return self.principal.email
|
||||
|
||||
@property
|
||||
def display_name(self) -> str | None:
|
||||
return self.principal.display_name
|
||||
|
||||
def has(self, required_scope: str) -> bool:
|
||||
if self.permission_evaluator is not None:
|
||||
return self.permission_evaluator.has_scope(self.principal, required_scope)
|
||||
# Keep legacy scope aliases alive while current routers still use the
|
||||
# pre-platform permission catalogue.
|
||||
return scopes_grant_compatible(self.scopes, required_scope)
|
||||
|
||||
def to_platform_principal(self) -> PrincipalRef:
|
||||
return self.principal
|
||||
|
||||
|
||||
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"
|
||||
@@ -151,7 +62,14 @@ def _build_principal_ref(
|
||||
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,
|
||||
@@ -159,8 +77,8 @@ def _build_principal_ref(
|
||||
identity_id=identity_id_for_account(session, account.id),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=_principal_group_ids(session, user),
|
||||
role_ids=frozenset(role.id for role in collect_user_roles(session, user)),
|
||||
function_assignment_ids=frozenset(collect_function_assignment_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,
|
||||
@@ -209,6 +127,7 @@ def _resolve_legacy_principal_ref(
|
||||
*,
|
||||
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:
|
||||
@@ -227,7 +146,9 @@ def _resolve_legacy_principal_ref(
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
user_scopes = collect_user_scopes(session, user, include_system=False)
|
||||
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(
|
||||
@@ -238,6 +159,8 @@ def _resolve_legacy_principal_ref(
|
||||
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)
|
||||
@@ -257,7 +180,9 @@ def _resolve_legacy_principal_ref(
|
||||
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")
|
||||
scopes = collect_user_scopes(session, user, include_system=True)
|
||||
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,
|
||||
@@ -267,11 +192,31 @@ def _resolve_legacy_principal_ref(
|
||||
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
|
||||
@@ -312,15 +257,30 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
return _resolve_legacy_principal_ref(
|
||||
request,
|
||||
managed_session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=self._idm_directory,
|
||||
)
|
||||
|
||||
|
||||
class LegacyPermissionEvaluator:
|
||||
@@ -339,11 +299,12 @@ class LegacyPermissionEvaluator:
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
def resolve_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"),
|
||||
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)
|
||||
@@ -364,6 +325,41 @@ def get_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):
|
||||
|
||||
26
src/govoplan_access/backend/auth/tenant_context.py
Normal file
26
src/govoplan_access/backend/auth/tenant_context.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import TenantContextSwitchRef
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_access.backend.security.sessions import switch_auth_session_tenant
|
||||
|
||||
|
||||
class AccessTenantContextSwitcher:
|
||||
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("AccessTenantContextSwitcher requires a SQLAlchemy session")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("AccessTenantContextSwitcher requires an API principal")
|
||||
if not isinstance(principal.auth_session, AuthSession):
|
||||
raise ValueError("API keys cannot switch tenant context")
|
||||
membership = switch_auth_session_tenant(session, principal.auth_session, tenant_id)
|
||||
return TenantContextSwitchRef(
|
||||
account_id=principal.account_id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
session_id=principal.session_id,
|
||||
)
|
||||
Reference in New Issue
Block a user