Source code for loom.core.authz.elevation

"""Elevation frames: which scopes an execution may write beyond its own rows.

A frame lives exactly as long as one use-case execution. ``elevate_scope``
records a scope in the open frame after checking the decision that justifies
it, and asks the sink to flag the open transaction when there is one; the
session-settings provider reads ``elevated_scopes`` for every later
transaction. Tasks spawned inside the execution share the frame object and
lose the elevation when it closes. Nothing here knows a database.
"""

from __future__ import annotations

import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Protocol

from loom.core.authz._decide import Decision
from loom.core.authz._roles import Permission, RoleCatalog
from loom.core.authz._scope import Scope

_logger = logging.getLogger(__name__)


[docs] class ElevationSink(Protocol): """How the frame reaches the transaction that is open right now.""" def in_transaction(self) -> bool: ... async def set_flag(self, scope: str) -> None: ... async def clear_flags(self, scopes: frozenset[str]) -> None: ... async def invalidate(self) -> None: ...
@dataclass(slots=True) class _Frame: owns_transaction: bool sink: ElevationSink | None parent: _Frame | None scopes: set[str] = field(default_factory=set) is_open: bool = True _elevation: ContextVar[_Frame | None] = ContextVar("_elevation", default=None)
[docs] def in_execution_frame() -> bool: """Whether an execution frame is open in this context.""" return _open_frame() is not None
[docs] def elevated_scopes() -> frozenset[str]: """Scopes elevated by the execution running in this context; empty outside one.""" frame = _open_frame() return frozenset(frame.scopes) if frame is not None else frozenset()
[docs] @asynccontextmanager async def elevation_scope( *, owns_transaction: bool, sink: ElevationSink | None = None ) -> AsyncIterator[None]: """Open a frame for one execution; nested frames inherit and never mutate their parent.""" parent = _open_frame() inherited = parent.sink if parent is not None else None frame = _Frame( owns_transaction=owns_transaction, sink=sink or inherited, parent=parent, scopes=set(parent.scopes) if parent else set(), ) token = _elevation.set(frame) try: yield finally: try: await _clear_added(frame) finally: frame.is_open = False _elevation.reset(token)
[docs] async def elevate_scope( scope: str, decision: Decision, *, at: Scope, permission: Permission, catalog: RoleCatalog, ) -> None: """Record ``scope`` as elevated for the rest of the execution. Raises: RuntimeError: Outside an execution frame. PermissionError: When the decision is denied, its grant's role lacks ``permission``, or its grant does not cover ``at``. """ frame = _open_frame() if frame is None: raise RuntimeError("elevate requires an open execution frame") _check(decision, scope, at, permission, catalog) if scope in frame.scopes: return frame.scopes.add(scope) if frame.sink is not None and frame.sink.in_transaction(): try: await frame.sink.set_flag(scope) except BaseException: frame.scopes.discard(scope) raise
def _check( decision: Decision, scope: str, at: Scope, permission: Permission, catalog: RoleCatalog ) -> None: grant = decision.grant if not decision or grant is None: raise PermissionError(f"scope {scope!r} cannot be elevated on a denied decision") if grant.role not in catalog.roles_with(permission): raise PermissionError( f"role {grant.role!r} lacks {permission} and cannot elevate scope {scope!r}" ) if not grant.scope.covers(at): raise PermissionError( f"grant on {grant.scope} does not cover {at}; scope {scope!r} stays unelevated" ) async def _clear_added(frame: _Frame) -> None: if frame.owns_transaction or frame.sink is None or not frame.sink.in_transaction(): return inherited = frame.parent.scopes if frame.parent is not None else set() added = frozenset(frame.scopes - inherited) if not added: return try: await asyncio.shield(frame.sink.clear_flags(added)) except Exception: _logger.exception( "elevation flags could not be cleared for %s; invalidating the session", sorted(added) ) await _invalidate(frame.sink) async def _invalidate(sink: ElevationSink) -> None: try: await sink.invalidate() except Exception: _logger.exception("the session could not be invalidated after a failed clearing") def _open_frame() -> _Frame | None: frame = _elevation.get() return frame if frame is not None and frame.is_open else None