feat: add durable auth principal cache revisions
This commit is contained in:
111
src/govoplan_core/core/principal_cache.py
Normal file
111
src/govoplan_core/core/principal_cache.py
Normal 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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
95
tests/test_principal_cache.py
Normal file
95
tests/test_principal_cache.py
Normal 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()
|
||||
Reference in New Issue
Block a user