feat: cache immutable auth principal summaries

This commit is contained in:
2026-07-29 19:24:00 +02:00
parent 6b0dd8beab
commit b6c2c89adf
4 changed files with 446 additions and 3 deletions

View File

@@ -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:

View File

@@ -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,
*,

View File

@@ -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",
]

View File

@@ -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()