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_entries_since,
|
||||||
sequence_watermark_is_expired,
|
sequence_watermark_is_expired,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -358,6 +359,16 @@ def _record_access_change(
|
|||||||
actor_id=principal.user.id,
|
actor_id=principal.user.id,
|
||||||
payload=payload or {},
|
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:
|
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
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.identity import IdentityDirectory
|
||||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
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.modules import AccessDecision
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
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,
|
User,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
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.api_keys import authenticate_api_key
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
@@ -45,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
verify_auth_session_csrf,
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
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_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -204,6 +211,15 @@ def _resolve_legacy_principal_context(
|
|||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session 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":
|
if source != "cookie":
|
||||||
context = _resolve_api_key_principal_context(
|
context = _resolve_api_key_principal_context(
|
||||||
session,
|
session,
|
||||||
@@ -213,7 +229,14 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
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(
|
context = _resolve_session_principal_context(
|
||||||
request,
|
request,
|
||||||
@@ -225,11 +248,226 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
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")
|
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(
|
def _resolve_api_key_principal_ref(
|
||||||
session: Session,
|
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",
|
||||||
|
]
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
from starlette.requests import Request
|
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.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:
|
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):
|
class AuthDependencyTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
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.status_code, 401)
|
||||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user