diff --git a/src/govoplan_core/core/principal_cache.py b/src/govoplan_core/core/principal_cache.py new file mode 100644 index 0000000..19dce46 --- /dev/null +++ b/src/govoplan_core/core/principal_cache.py @@ -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", +] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 9c13237..3b473ca 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -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, diff --git a/tests/test_principal_cache.py b/tests/test_principal_cache.py new file mode 100644 index 0000000..ff0dd89 --- /dev/null +++ b/tests/test_principal_cache.py @@ -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()