feat: add durable auth principal cache revisions

This commit is contained in:
2026-07-29 19:23:52 +02:00
parent 4bde0495f7
commit 48fb953b93
3 changed files with 228 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
retained_sequence_floor,
record_change,
)
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
@dataclass(frozen=True, slots=True)
class AuthPrincipalRevision:
system: int
tenant: int
def auth_principal_revision(
session: Session,
*,
tenant_id: str | None,
) -> AuthPrincipalRevision:
"""Return the durable global and tenant authorization revision.
The sequence rows make invalidation visible across processes. Retention
floors keep revisions monotonic if old change rows are pruned.
"""
rows = (
session.query(
ChangeSequenceEntry.tenant_id,
func.max(ChangeSequenceEntry.id),
)
.filter(
ChangeSequenceEntry.module_id == "core",
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
or_(
ChangeSequenceEntry.tenant_id.is_(None),
ChangeSequenceEntry.tenant_id == tenant_id,
),
)
.group_by(ChangeSequenceEntry.tenant_id)
.all()
)
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
system = max(
revisions.get(None, 0),
retained_sequence_floor(
session,
tenant_id=None,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
tenant = 0
if tenant_id is not None:
tenant = max(
revisions.get(tenant_id, 0),
retained_sequence_floor(
session,
tenant_id=tenant_id,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
return AuthPrincipalRevision(system=system, tenant=tenant)
def invalidate_auth_principals(
session: Session,
*,
tenant_id: str | None,
source_module: str,
resource_type: str,
resource_id: str,
actor_type: str | None = None,
actor_id: str | None = None,
reason: str | None = None,
) -> ChangeSequenceEntry:
"""Advance the authorization revision in the caller's transaction."""
return record_change(
session,
tenant_id=tenant_id,
module_id="core",
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
resource_type="auth_principal_revision",
resource_id=tenant_id or "system",
operation="invalidated",
actor_type=actor_type,
actor_id=actor_id,
payload={
"source_module": source_module,
"resource_type": resource_type,
"resource_id": resource_id,
**({"reason": reason} if reason else {}),
},
)
__all__ = [
"AUTH_PRINCIPAL_REVISION_COLLECTION",
"AuthPrincipalRevision",
"auth_principal_revision",
"invalidate_auth_principals",
]

View File

@@ -88,6 +88,28 @@ class Settings(BaseSettings):
ge=0,
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
)
auth_principal_cache_enabled: bool = Field(
default=True,
alias="AUTH_PRINCIPAL_CACHE_ENABLED",
)
auth_principal_cache_session_ttl_seconds: int = Field(
default=30,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_SESSION_TTL_SECONDS",
)
auth_principal_cache_api_key_ttl_seconds: int = Field(
default=10,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_API_KEY_TTL_SECONDS",
)
auth_principal_cache_max_entries: int = Field(
default=2048,
ge=1,
le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
ChangeSequenceRetentionFloor,
prune_sequence_entries,
)
from govoplan_core.core.principal_cache import (
auth_principal_revision,
invalidate_auth_principals,
)
from govoplan_core.db.base import Base
class AuthPrincipalRevisionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
bind=self.engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
self.SessionLocal = sessionmaker(bind=self.engine)
def tearDown(self) -> None:
self.engine.dispose()
def test_revisions_are_tenant_scoped_and_include_system_changes(self) -> None:
with self.SessionLocal() as session:
initial = auth_principal_revision(session, tenant_id="tenant-1")
self.assertEqual(initial.system, 0)
self.assertEqual(initial.tenant, 0)
system_entry = invalidate_auth_principals(
session,
tenant_id=None,
source_module="access",
resource_type="system_role",
resource_id="role-1",
)
tenant_entry = invalidate_auth_principals(
session,
tenant_id="tenant-1",
source_module="idm",
resource_type="function_assignment",
resource_id="assignment-1",
)
invalidate_auth_principals(
session,
tenant_id="tenant-2",
source_module="access",
resource_type="role",
resource_id="role-2",
)
session.commit()
revision = auth_principal_revision(session, tenant_id="tenant-1")
self.assertEqual(revision.system, system_entry.id)
self.assertEqual(revision.tenant, tenant_entry.id)
def test_retention_floor_keeps_revision_monotonic_after_pruning(self) -> None:
with self.SessionLocal() as session:
entry = invalidate_auth_principals(
session,
tenant_id="tenant-1",
source_module="access",
resource_type="role",
resource_id="role-1",
)
session.flush()
expected_revision = entry.id
prune_sequence_entries(
session,
before_or_equal_id=expected_revision,
tenant_id="tenant-1",
module_id="core",
collections=("core.auth-principal-revisions",),
)
session.commit()
self.assertEqual(
auth_principal_revision(session, tenant_id="tenant-1").tenant,
expected_revision,
)
if __name__ == "__main__":
unittest.main()