python-authz 0.7.0__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
authz/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
1
3
  from authz.conditions import (
2
4
  ConditionEvaluator,
3
5
  PythonCallableEvaluator,
@@ -10,6 +12,7 @@ from authz.errors import (
10
12
  ConditionNotFound,
11
13
  CycleDetected,
12
14
  DepthLimitExceeded,
15
+ EvaluationBudgetExceeded,
13
16
  NamespaceNotFound,
14
17
  RelationNotFound,
15
18
  SchemaCycleDetected,
@@ -27,10 +30,12 @@ from authz.schema import (
27
30
  build_role_hierarchy,
28
31
  )
29
32
  from authz.storage.base import Storage, Token
30
- from authz.storage.postgres import PostgresStorage
31
33
  from authz.storage.sqlite import SqliteStorage
32
34
  from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
33
35
 
36
+ if TYPE_CHECKING:
37
+ from authz.storage.postgres import PostgresStorage
38
+
34
39
  __all__ = [
35
40
  "AuthError",
36
41
  "CardinalityViolationError",
@@ -42,6 +47,7 @@ __all__ = [
42
47
  "CycleDetected",
43
48
  "DepthLimitExceeded",
44
49
  "Direct",
50
+ "EvaluationBudgetExceeded",
45
51
  "EvaluationDecision",
46
52
  "Exclusion",
47
53
  "From",
@@ -49,7 +55,6 @@ __all__ = [
49
55
  "Namespace",
50
56
  "NamespaceNotFound",
51
57
  "ObjectRef",
52
- "PostgresStorage",
53
58
  "PythonCallableEvaluator",
54
59
  "RelationNotFound",
55
60
  "RelationTuple",
@@ -68,5 +73,14 @@ __all__ = [
68
73
  ]
69
74
 
70
75
 
71
- def main() -> None:
72
- print("Hello from authz!")
76
+ def __getattr__(name: str) -> Any:
77
+ if name == "PostgresStorage":
78
+ try:
79
+ from authz.storage.postgres import PostgresStorage
80
+ except ImportError as exc:
81
+ raise ImportError(
82
+ "PostgresStorage requires asyncpg; install it with "
83
+ "'pip install python-authz[postgres]'"
84
+ ) from exc
85
+ return PostgresStorage
86
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
authz/conditions.py CHANGED
@@ -1,9 +1,13 @@
1
1
  import inspect
2
+ import math
2
3
  from typing import Any, Awaitable, Callable, Mapping, Protocol, Union
3
4
 
4
5
  from authz.errors import ConditionNotFound
5
6
 
6
- ConditionFn = Callable[..., Union[bool, Awaitable[bool]]]
7
+ ConditionResult = Union[bool, None]
8
+ ConditionFn = Callable[..., Union[ConditionResult, Awaitable[ConditionResult]]]
9
+
10
+ _RESERVED_KEYS = frozenset({"subject", "resource"})
7
11
 
8
12
 
9
13
  class ConditionEvaluator(Protocol):
@@ -11,7 +15,7 @@ class ConditionEvaluator(Protocol):
11
15
 
12
16
  async def evaluate(
13
17
  self, name: str, subject: str, resource: str, context: Mapping[str, Any]
14
- ) -> bool: ...
18
+ ) -> bool | None: ... # None: required context missing or invalid
15
19
 
16
20
 
17
21
  class PythonCallableEvaluator:
@@ -38,68 +42,110 @@ class PythonCallableEvaluator:
38
42
 
39
43
  async def evaluate(
40
44
  self, name: str, subject: str, resource: str, context: Mapping[str, Any]
41
- ) -> bool:
45
+ ) -> bool | None:
42
46
  try:
43
47
  fn = self._conditions[name]
44
48
  except KeyError:
45
49
  raise ConditionNotFound(name) from None
50
+ for key in context:
51
+ if not isinstance(key, str):
52
+ raise ValueError(
53
+ f"condition {name!r}: context keys must be strings, got {key!r}"
54
+ )
55
+ if key in _RESERVED_KEYS:
56
+ raise ValueError(
57
+ f"condition {name!r}: context key {key!r} is reserved"
58
+ )
46
59
  result = fn(subject=subject, resource=resource, **context)
47
60
  if inspect.isawaitable(result):
48
61
  result = await result
49
- return bool(result)
62
+ if result is None or isinstance(result, bool):
63
+ return result
64
+ raise TypeError(
65
+ f"condition {name!r} must return True, False or None, "
66
+ f"got {type(result).__name__}"
67
+ )
50
68
 
51
69
 
52
- def _max_amount(*, subject: str, resource: str, **ctx: Any) -> bool:
53
- try:
54
- amount = float(ctx.get("amount", 0))
55
- except (ValueError, TypeError):
56
- amount = 0.0
70
+ def _number(value: Any) -> float | None:
71
+ if value is None or isinstance(value, bool):
72
+ return None
57
73
  try:
58
- max_amount = float(ctx.get("max_amount", float("inf")))
59
- except (ValueError, TypeError):
60
- max_amount = float("inf")
61
- return amount <= max_amount
62
-
74
+ number = float(value)
75
+ except (ValueError, TypeError, OverflowError):
76
+ return None
77
+ return number if math.isfinite(number) else None
63
78
 
64
- def _min_amount(*, subject: str, resource: str, **ctx: Any) -> bool:
65
- try:
66
- amount = float(ctx.get("amount", 0))
67
- except (ValueError, TypeError):
68
- amount = 0.0
69
- try:
70
- min_amount = float(ctx.get("min_amount", 0))
71
- except (ValueError, TypeError):
72
- min_amount = 0.0
73
- return amount >= min_amount
74
79
 
80
+ def _collection(value: Any) -> Any:
81
+ return value if isinstance(value, (list, tuple, set, frozenset)) else None
75
82
 
76
- def _equals_attr(*, subject: str, resource: str, **ctx: Any) -> bool:
77
- attr_name = str(ctx.get("attribute", ""))
78
- return ctx.get(attr_name) == ctx.get("expected")
79
83
 
84
+ def _attribute(ctx: Mapping[str, Any]) -> Any:
85
+ name = ctx.get("attribute")
86
+ if not isinstance(name, str) or not name:
87
+ return None
88
+ return ctx.get(name)
80
89
 
81
- def _in_set(*, subject: str, resource: str, **ctx: Any) -> bool:
82
- attr_name = str(ctx.get("attribute", ""))
83
- allowed = ctx.get("allowed_values")
84
- if allowed is None:
85
- allowed = ()
86
- return ctx.get(attr_name) in allowed
87
90
 
88
-
89
- def _jurisdiction_match(*, subject: str, resource: str, **ctx: Any) -> bool:
90
- jur = ctx.get("jurisdiction")
91
- if "allowed_jurisdictions" in ctx:
92
- allowed = ctx.get("allowed_jurisdictions")
93
- if allowed is None:
94
- allowed = ()
95
- return jur in allowed
96
- if "required_jurisdiction" in ctx:
97
- return jur == ctx.get("required_jurisdiction")
98
- return True
99
-
100
-
101
- def _not_same_subject(*, subject: str, resource: str, **ctx: Any) -> bool:
102
- return subject != ctx.get("excluded_subject")
91
+ def _member(value: Any, allowed: Any) -> bool | None:
92
+ try:
93
+ return value in allowed
94
+ except TypeError:
95
+ return None
96
+
97
+
98
+ def _max_amount(*, subject: str, resource: str, **ctx: Any) -> bool | None:
99
+ amount, limit = _number(ctx.get("amount")), _number(ctx.get("max_amount"))
100
+ if amount is None or limit is None:
101
+ return None
102
+ return amount <= limit
103
+
104
+
105
+ def _min_amount(*, subject: str, resource: str, **ctx: Any) -> bool | None:
106
+ amount, limit = _number(ctx.get("amount")), _number(ctx.get("min_amount"))
107
+ if amount is None or limit is None:
108
+ return None
109
+ return amount >= limit
110
+
111
+
112
+ def _equals_attr(*, subject: str, resource: str, **ctx: Any) -> bool | None:
113
+ actual, expected = _attribute(ctx), ctx.get("expected")
114
+ if actual is None or expected is None:
115
+ return None
116
+ return actual == expected
117
+
118
+
119
+ def _in_set(*, subject: str, resource: str, **ctx: Any) -> bool | None:
120
+ actual, allowed = _attribute(ctx), _collection(ctx.get("allowed_values"))
121
+ if actual is None or allowed is None:
122
+ return None
123
+ return _member(actual, allowed)
124
+
125
+
126
+ def _jurisdiction_match(*, subject: str, resource: str, **ctx: Any) -> bool | None:
127
+ jurisdiction = ctx.get("jurisdiction")
128
+ if jurisdiction is None:
129
+ return None
130
+ allowed, required = ctx.get("allowed_jurisdictions"), ctx.get("required_jurisdiction")
131
+ if allowed is None and required is None:
132
+ return None
133
+ results = []
134
+ if allowed is not None:
135
+ allowed = _collection(allowed)
136
+ results.append(None if allowed is None else _member(jurisdiction, allowed))
137
+ if required is not None:
138
+ results.append(jurisdiction == required)
139
+ if False in results:
140
+ return False
141
+ return None if None in results else True
142
+
143
+
144
+ def _not_same_subject(*, subject: str, resource: str, **ctx: Any) -> bool | None:
145
+ excluded = ctx.get("excluded_subject")
146
+ if not isinstance(excluded, str) or not excluded:
147
+ return None
148
+ return subject != excluded
103
149
 
104
150
 
105
151
  def builtin_conditions() -> dict[str, ConditionFn]: