From b6c2c89adf7b65d76a89590293751b850698942e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 19:24:00 +0200 Subject: [PATCH] feat: cache immutable auth principal summaries --- src/govoplan_access/backend/api/v1/routes.py | 11 + .../backend/auth/dependencies.py | 242 +++++++++++++++++- .../backend/auth/principal_cache.py | 82 ++++++ tests/test_auth_dependencies.py | 114 ++++++++- 4 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 src/govoplan_access/backend/auth/principal_cache.py diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index 75da56f..92749b8 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -177,6 +177,7 @@ from govoplan_core.core.change_sequence import ( sequence_entries_since, sequence_watermark_is_expired, ) +from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_access.backend.db.models import ( Account, ApiKey, @@ -358,6 +359,16 @@ def _record_access_change( actor_id=principal.user.id, payload=payload or {}, ) + invalidate_auth_principals( + session, + tenant_id=tenant_id, + source_module=ACCESS_MODULE_ID, + resource_type=resource_type, + resource_id=resource_id, + actor_type="user", + actor_id=principal.user.id, + reason=operation, + ) def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None: diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index b765286..e37d1de 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, replace +from datetime import timedelta from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.orm import Session @@ -23,6 +24,9 @@ from govoplan_core.core.automation import ( from govoplan_core.core.identity import IdentityDirectory from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.organizations import OrganizationDirectory +from govoplan_core.core.principal_cache import ( + auth_principal_revision, +) 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 @@ -37,6 +41,8 @@ from govoplan_access.backend.db.models import ( User, ) from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account +from govoplan_access.backend.auth.principal_cache import principal_summary_cache +from govoplan_access.backend.auth.tokens import hash_secret from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.sessions import ( authenticate_session_token, @@ -45,6 +51,7 @@ 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.time import ensure_aware_utc, utc_now from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes from govoplan_core.settings import settings @@ -204,6 +211,15 @@ def _resolve_legacy_principal_context( if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") + cached = _cached_principal_context( + request, + session, + token=token, + source=source, + ) + if cached is not None: + return cached + if source != "cookie": context = _resolve_api_key_principal_context( session, @@ -213,7 +229,14 @@ def _resolve_legacy_principal_context( organization_directory=organization_directory, ) if context is not None: - return context + return _cache_resolved_principal_context( + session, + token=token, + context=context, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) context = _resolve_session_principal_context( request, @@ -225,11 +248,226 @@ def _resolve_legacy_principal_context( organization_directory=organization_directory, ) if context is not None: - return context + return _cache_resolved_principal_context( + session, + token=token, + context=context, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") +def _cached_principal_context( + request: Request, + session: Session, + *, + token: str, + source: str, +) -> ResolvedPrincipalContext | None: + if not settings.auth_principal_cache_enabled: + return None + token_digest = hash_secret(token) + entry = principal_summary_cache.get( + token_digest, + session_ttl_seconds=settings.auth_principal_cache_session_ttl_seconds, + api_key_ttl_seconds=settings.auth_principal_cache_api_key_ttl_seconds, + ) + if entry is None: + return None + before = auth_principal_revision(session, tenant_id=entry.principal.tenant_id) + if before != entry.revision: + principal_summary_cache.discard(token_digest) + return None + context = _rehydrate_cached_principal( + request, + session, + token_digest=token_digest, + source=source, + principal=entry.principal, + ) + after = auth_principal_revision(session, tenant_id=entry.principal.tenant_id) + if context is None or after != before: + principal_summary_cache.discard(token_digest) + return None + return context + + +def _rehydrate_cached_principal( + request: Request, + session: Session, + *, + token_digest: str, + source: str, + principal: PrincipalRef, +) -> ResolvedPrincipalContext | None: + 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 + ): + return None + + if principal.auth_method == "api_key": + api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None + if ( + api_key is None + or api_key.key_hash != token_digest + or api_key.revoked_at is not None + or api_key.user_id != user.id + or api_key.tenant_id != tenant.id + or _is_expired(api_key.expires_at, allow_none=True) + ): + return None + _touch_auth_activity( + session, + api_key, + field="last_used_at", + ) + return ResolvedPrincipalContext( + principal=principal, + account=account, + user=user, + tenant=tenant, + api_key=api_key, + ) + + auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None + if ( + auth_session is None + or auth_session.token_hash != token_digest + or auth_session.revoked_at is not None + or auth_session.user_id != user.id + or auth_session.account_id != account.id + or auth_session.tenant_id != tenant.id + or _is_expired(auth_session.expires_at) + ): + return None + if source == "cookie": + _verify_session_csrf(request, auth_session) + _touch_auth_activity( + session, + auth_session, + field="last_seen_at", + ) + return ResolvedPrincipalContext( + principal=principal, + account=account, + user=user, + tenant=tenant, + auth_session=auth_session, + ) + + +def _is_expired(value, *, allow_none: bool = False) -> bool: + expires_at = ensure_aware_utc(value) + return (not allow_none and expires_at is None) or ( + expires_at is not None and expires_at < utc_now() + ) + + +def _touch_auth_activity( + session: Session, + model: ApiKey | AuthSession, + *, + field: str, +) -> None: + now = utc_now() + previous = ensure_aware_utc(getattr(model, field)) + interval = settings.auth_activity_touch_interval_seconds + if interval <= 0 or previous is None or now - previous >= timedelta(seconds=interval): + setattr(model, field, now) + session.add(model) + session.commit() + + +def _cache_resolved_principal_context( + session: Session, + *, + token: str, + context: ResolvedPrincipalContext, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> ResolvedPrincipalContext: + if not settings.auth_principal_cache_enabled: + return context + before = auth_principal_revision(session, tenant_id=context.principal.tenant_id) + refreshed = _refresh_principal_context( + session, + context=context, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) + after = auth_principal_revision(session, tenant_id=context.principal.tenant_id) + if before == after: + principal_summary_cache.put( + hash_secret(token), + principal=refreshed.principal, + revision=after, + max_entries=settings.auth_principal_cache_max_entries, + ) + return refreshed + + +def _refresh_principal_context( + session: Session, + *, + context: ResolvedPrincipalContext, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> ResolvedPrincipalContext: + idm_assignments, idm_roles = _principal_idm_context( + session, + user=context.user, + account=context.account, + tenant_id=context.tenant.id, + idm_directory=idm_directory, + organization_directory=organization_directory, + ) + include_system = context.auth_session is not None + authorization_context = collect_user_authorization_context( + session, + context.user, + account=context.account, + include_system=include_system, + extra_roles=idm_roles, + ) + scopes = authorization_context.scopes + auth_method = "session" + if context.api_key is not None: + scopes = intersect_api_key_scopes(scopes, context.api_key.scopes or []) + auth_method = "api_key" + principal = _build_principal_ref( + session, + account=context.account, + user=context.user, + tenant_id=context.tenant.id, + scopes=scopes, + auth_method=auth_method, + api_key=context.api_key, + auth_session=context.auth_session, + idm_assignments=idm_assignments, + identity_directory=identity_directory, + extra_roles=idm_roles, + authorization_context=authorization_context, + include_system_roles=include_system, + ) + return replace(context, principal=principal) + + def _resolve_api_key_principal_ref( session: Session, *, diff --git a/src/govoplan_access/backend/auth/principal_cache.py b/src/govoplan_access/backend/auth/principal_cache.py new file mode 100644 index 0000000..56f8066 --- /dev/null +++ b/src/govoplan_access/backend/auth/principal_cache.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +from threading import Lock +from time import monotonic + +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.principal_cache import AuthPrincipalRevision + + +@dataclass(frozen=True, slots=True) +class CachedPrincipal: + principal: PrincipalRef + revision: AuthPrincipalRevision + stored_at: float + + +class PrincipalSummaryCache: + """A bounded process-local cache containing no ORM or secret objects.""" + + def __init__(self) -> None: + self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict() + self._lock = Lock() + + def get( + self, + token_digest: str, + *, + session_ttl_seconds: int, + api_key_ttl_seconds: int, + ) -> CachedPrincipal | None: + with self._lock: + entry = self._entries.get(token_digest) + if entry is None: + return None + ttl = ( + api_key_ttl_seconds + if entry.principal.auth_method == "api_key" + else session_ttl_seconds + ) + if ttl <= 0 or monotonic() - entry.stored_at > ttl: + self._entries.pop(token_digest, None) + return None + self._entries.move_to_end(token_digest) + return entry + + def put( + self, + token_digest: str, + *, + principal: PrincipalRef, + revision: AuthPrincipalRevision, + max_entries: int, + ) -> None: + with self._lock: + self._entries[token_digest] = CachedPrincipal( + principal=principal, + revision=revision, + stored_at=monotonic(), + ) + self._entries.move_to_end(token_digest) + while len(self._entries) > max(1, max_entries): + self._entries.popitem(last=False) + + def discard(self, token_digest: str) -> None: + with self._lock: + self._entries.pop(token_digest, None) + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + +principal_summary_cache = PrincipalSummaryCache() + + +__all__ = [ + "CachedPrincipal", + "PrincipalSummaryCache", + "principal_summary_cache", +] diff --git a/tests/test_auth_dependencies.py b/tests/test_auth_dependencies.py index 8c39904..f8bc4aa 100644 --- a/tests/test_auth_dependencies.py +++ b/tests/test_auth_dependencies.py @@ -1,13 +1,30 @@ from __future__ import annotations import unittest +from datetime import timedelta from typing import Iterable from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from starlette.requests import Request -from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref +from govoplan_access.backend.auth.dependencies import ( + _extract_token, + _requires_csrf, + _resolve_legacy_principal_context, + _resolve_legacy_principal_ref, +) +from govoplan_access.backend.auth.principal_cache import principal_summary_cache +from govoplan_access.backend.auth.tokens import hash_secret +from govoplan_access.backend.db.base import AccessBase +from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment +from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor +from govoplan_core.core.principal_cache import invalidate_auth_principals +from govoplan_core.db.base import Base +from govoplan_core.security.time import utc_now from govoplan_core.settings import settings +from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request: @@ -22,6 +39,9 @@ def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) class AuthDependencyTests(unittest.TestCase): + def tearDown(self) -> None: + principal_summary_cache.clear() + def test_extract_token_prefers_explicit_api_key(self) -> None: request = request_for(headers=[("authorization", "Bearer session-token")]) @@ -44,6 +64,98 @@ class AuthDependencyTests(unittest.TestCase): self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.detail, "Missing API key or session token") + def test_permission_revision_invalidates_cached_principal(self) -> None: + engine = create_engine("sqlite:///:memory:") + create_scope_tables(engine) + AccessBase.metadata.create_all(bind=engine) + Base.metadata.create_all( + bind=engine, + tables=[ + ChangeSequenceEntry.__table__, + ChangeSequenceRetentionFloor.__table__, + ], + ) + SessionLocal = sessionmaker(bind=engine) + try: + with SessionLocal() as session: + tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1") + account = Account( + id="account-1", + email="owner@example.test", + normalized_email="owner@example.test", + ) + user = User( + id="user-1", + tenant_id=tenant.id, + account_id=account.id, + email=account.email, + ) + role = Role( + id="role-1", + tenant_id=tenant.id, + slug="reader", + name="Reader", + permissions=["files:file:read"], + ) + assignment = UserRoleAssignment( + tenant_id=tenant.id, + user_id=user.id, + role_id=role.id, + ) + token = "ms_test-session-token" + auth_session = AuthSession( + id="session-1", + tenant_id=tenant.id, + user_id=user.id, + account_id=account.id, + token_hash=hash_secret(token), + expires_at=utc_now() + timedelta(hours=1), + ) + session.add_all( + [tenant, account, user, role, assignment, auth_session] + ) + session.commit() + + request = request_for() + first = _resolve_legacy_principal_context( + request, + session, + authorization=f"Bearer {token}", + x_api_key=None, + ) + self.assertIn("files:file:read", first.principal.scopes) + + role.permissions = ["files:file:write"] + session.add(role) + invalidate_auth_principals( + session, + tenant_id=tenant.id, + source_module="access", + resource_type="role", + resource_id=role.id, + ) + session.commit() + + second = _resolve_legacy_principal_context( + request, + session, + authorization=f"Bearer {token}", + x_api_key=None, + ) + self.assertNotIn("files:file:read", second.principal.scopes) + self.assertIn("files:file:write", second.principal.scopes) + finally: + AccessBase.metadata.drop_all(bind=engine) + scope_registry.metadata.drop_all(bind=engine) + Base.metadata.drop_all( + bind=engine, + tables=[ + ChangeSequenceEntry.__table__, + ChangeSequenceRetentionFloor.__table__, + ], + ) + engine.dispose() + if __name__ == "__main__": unittest.main()