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

1"""Framework-agnostic policy primitives for authorization-aware domains.""" 

2 

3from __future__ import annotations 

4 

5import typing as t 

6from abc import ABC, abstractmethod 

7from dataclasses import dataclass 

8 

9from .base import Injectable 

10 

11 

12class AuthorizationError(Exception): 

13 """Base class for framework-agnostic authorization failures.""" 

14 

15 

16class AuthenticationRequired(AuthorizationError): 

17 """Raised when an anonymous actor must authenticate first.""" 

18 

19 

20class PermissionDenied(AuthorizationError): 

21 """Raised when the current actor is not allowed to perform an action.""" 

22 

23 

24TValue = t.TypeVar("TValue") 

25 

26 

27class BasePolicy(Injectable): 

28 """Base class for reusable, injectable authorization policies.""" 

29 

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") 

35 

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") 

44 

45 

46class AuthorizationResult(ABC, t.Generic[TValue]): 

47 """Base class for allowed-or-denied authorization results.""" 

48 

49 @abstractmethod 

50 def require(self) -> Allow[TValue]: 

51 """Return the allowed result or raise if denied.""" 

52 

53 

54@dataclass(frozen=True, slots=True) 

55class Allow(AuthorizationResult[TValue]): 

56 """An allowed authorization result with a query-transform callback.""" 

57 

58 apply: t.Callable[[TValue], TValue] 

59 

60 def require(self) -> Allow[TValue]: 

61 """Return the allowed result unchanged.""" 

62 return self 

63 

64 def __call__(self, value: TValue) -> TValue: 

65 """Apply the stored callback to a value.""" 

66 return self.apply(value) 

67 

68 

69@dataclass(frozen=True, slots=True) 

70class Deny(AuthorizationResult[TValue]): 

71 """A denied authorization result with a rejection reason.""" 

72 

73 reason: str 

74 

75 def require(self) -> t.NoReturn: 

76 """Raise when the result denies the requested action.""" 

77 raise PermissionDenied(self.reason)