Coverage for src/endow/policy.py: 100%
33 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 13:36 +0200
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 13:36 +0200
1"""Framework-agnostic policy primitives for authorization-aware domains."""
3from __future__ import annotations
5import typing as t
6from abc import ABC, abstractmethod
7from dataclasses import dataclass
9from .base import Injectable
12class AuthorizationError(Exception):
13 """Base class for framework-agnostic authorization failures."""
16class AuthenticationRequired(AuthorizationError):
17 """Raised when an anonymous actor must authenticate first."""
20class PermissionDenied(AuthorizationError):
21 """Raised when the current actor is not allowed to perform an action."""
24TValue = t.TypeVar("TValue")
27class BasePolicy(Injectable):
28 """Base class for reusable, injectable authorization policies."""
30 @staticmethod
31 def require_allowed(is_allowed: bool, reason: str | None = None) -> None:
32 """Raise when the provided decision denies the action."""
33 if not is_allowed:
34 raise PermissionDenied(reason or "Permission denied")
36 @staticmethod
37 def require_authenticated(
38 is_authenticated: bool,
39 reason: str | None = None,
40 ) -> None:
41 """Raise when an action requires an authenticated actor."""
42 if not is_authenticated:
43 raise AuthenticationRequired(reason or "Authentication required")
46class AuthorizationResult(ABC, t.Generic[TValue]):
47 """Base class for allowed-or-denied authorization results."""
49 @abstractmethod
50 def require(self) -> Allow[TValue]:
51 """Return the allowed result or raise if denied."""
54@dataclass(frozen=True, slots=True)
55class Allow(AuthorizationResult[TValue]):
56 """An allowed authorization result with a query-transform callback."""
58 apply: t.Callable[[TValue], TValue]
60 def require(self) -> Allow[TValue]:
61 """Return the allowed result unchanged."""
62 return self
64 def __call__(self, value: TValue) -> TValue:
65 """Apply the stored callback to a value."""
66 return self.apply(value)
69@dataclass(frozen=True, slots=True)
70class Deny(AuthorizationResult[TValue]):
71 """A denied authorization result with a rejection reason."""
73 reason: str
75 def require(self) -> t.NoReturn:
76 """Raise when the result denies the requested action."""
77 raise PermissionDenied(self.reason)