feat: cache immutable auth principal summaries
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
82
src/govoplan_access/backend/auth/principal_cache.py
Normal file
82
src/govoplan_access/backend/auth/principal_cache.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user