python-authz 0.5.0__tar.gz → 0.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-authz
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph
5
5
  Author: Anupam Gupta
6
6
  Author-email: Anupam Gupta <anupam@trois.in>
@@ -53,13 +53,15 @@ kept in agreement with each other.
53
53
 
54
54
  ## Status
55
55
 
56
- Early release, version 0.5:
56
+ Release, version 0.6.0:
57
57
 
58
- - the core engine, async SQLite and PostgreSQL storage backends, and the
59
- optional FastAPI `Depends()` integration (`authz.integrations.fastapi`,
60
- installed via `python-authz[fastapi]`)
61
- - a reference sample app (`examples/sample_app/`) and OAuth2/JWT auth
62
- integration recipes (`examples/auth_integrations/`)
58
+ - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
59
+ - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
60
+ - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
61
+ - **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
62
+ - **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
63
+ - **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
64
+ - Async SQLite and PostgreSQL storage backends.
63
65
 
64
66
  ## Roadmap
65
67
 
@@ -26,13 +26,15 @@ kept in agreement with each other.
26
26
 
27
27
  ## Status
28
28
 
29
- Early release, version 0.5:
30
-
31
- - the core engine, async SQLite and PostgreSQL storage backends, and the
32
- optional FastAPI `Depends()` integration (`authz.integrations.fastapi`,
33
- installed via `python-authz[fastapi]`)
34
- - a reference sample app (`examples/sample_app/`) and OAuth2/JWT auth
35
- integration recipes (`examples/auth_integrations/`)
29
+ Release, version 0.6.0:
30
+
31
+ - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
32
+ - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
33
+ - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
34
+ - **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
35
+ - **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
36
+ - **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
37
+ - Async SQLite and PostgreSQL storage backends.
36
38
 
37
39
  ## Roadmap
38
40
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-authz"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-authz"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,5 +1,9 @@
1
- from authz.conditions import ConditionEvaluator, PythonCallableEvaluator
2
- from authz.engine import Client, UsersetTree
1
+ from authz.conditions import (
2
+ ConditionEvaluator,
3
+ PythonCallableEvaluator,
4
+ builtin_conditions,
5
+ )
6
+ from authz.engine import CheckItem, Client, EvaluationDecision, UsersetTree
3
7
  from authz.errors import (
4
8
  AuthError,
5
9
  ConditionNotFound,
@@ -27,6 +31,7 @@ from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
27
31
 
28
32
  __all__ = [
29
33
  "AuthError",
34
+ "CheckItem",
30
35
  "Client",
31
36
  "Condition",
32
37
  "ConditionEvaluator",
@@ -34,6 +39,7 @@ __all__ = [
34
39
  "CycleDetected",
35
40
  "DepthLimitExceeded",
36
41
  "Direct",
42
+ "EvaluationDecision",
37
43
  "Exclusion",
38
44
  "From",
39
45
  "Intersection",
@@ -54,6 +60,7 @@ __all__ = [
54
60
  "Union",
55
61
  "UsersetTree",
56
62
  "WouldCreateCycle",
63
+ "builtin_conditions",
57
64
  ]
58
65
 
59
66
 
@@ -0,0 +1,113 @@
1
+ import inspect
2
+ from typing import Any, Awaitable, Callable, Mapping, Protocol, Union
3
+
4
+ from authz.errors import ConditionNotFound
5
+
6
+ ConditionFn = Callable[..., Union[bool, Awaitable[bool]]]
7
+
8
+
9
+ class ConditionEvaluator(Protocol):
10
+ def is_registered(self, name: str) -> bool: ...
11
+
12
+ async def evaluate(
13
+ self, name: str, subject: str, resource: str, context: Mapping[str, Any]
14
+ ) -> bool: ...
15
+
16
+
17
+ class PythonCallableEvaluator:
18
+ def __init__(self) -> None:
19
+ self._conditions: dict[str, ConditionFn] = {}
20
+
21
+ @classmethod
22
+ def with_builtins(
23
+ cls, extra: Mapping[str, ConditionFn] | None = None
24
+ ) -> "PythonCallableEvaluator":
25
+ evaluator = cls()
26
+ for name, fn in builtin_conditions().items():
27
+ evaluator.register(name, fn)
28
+ if extra:
29
+ for name, fn in extra.items():
30
+ evaluator.register(name, fn)
31
+ return evaluator
32
+
33
+ def register(self, name: str, fn: ConditionFn) -> None:
34
+ self._conditions[name] = fn
35
+
36
+ def is_registered(self, name: str) -> bool:
37
+ return name in self._conditions
38
+
39
+ async def evaluate(
40
+ self, name: str, subject: str, resource: str, context: Mapping[str, Any]
41
+ ) -> bool:
42
+ try:
43
+ fn = self._conditions[name]
44
+ except KeyError:
45
+ raise ConditionNotFound(name) from None
46
+ result = fn(subject=subject, resource=resource, **context)
47
+ if inspect.isawaitable(result):
48
+ result = await result
49
+ return bool(result)
50
+
51
+
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
57
+ 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
+
63
+
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
+
75
+
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
+
80
+
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
+
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")
103
+
104
+
105
+ def builtin_conditions() -> dict[str, ConditionFn]:
106
+ return {
107
+ "max_amount": _max_amount,
108
+ "min_amount": _min_amount,
109
+ "equals_attr": _equals_attr,
110
+ "in_set": _in_set,
111
+ "jurisdiction_match": _jurisdiction_match,
112
+ "not_same_subject": _not_same_subject,
113
+ }
@@ -0,0 +1,635 @@
1
+ from collections import deque
2
+ from dataclasses import dataclass
3
+ from typing import Any, Mapping, Sequence
4
+
5
+ from authz.conditions import ConditionEvaluator, ConditionFn
6
+ from authz.errors import (
7
+ ConditionNotFound,
8
+ CycleDetected,
9
+ DepthLimitExceeded,
10
+ NamespaceNotFound,
11
+ RelationNotFound,
12
+ WouldCreateCycle,
13
+ )
14
+ from authz.schema import (
15
+ Direct,
16
+ Exclusion,
17
+ From,
18
+ Intersection,
19
+ Namespace,
20
+ RewriteRule,
21
+ TupleToUserset,
22
+ Union,
23
+ check_schema_cycles,
24
+ tupleset_relations,
25
+ )
26
+ from authz.storage.base import Storage, Token
27
+ from authz.tuples import ObjectRef, RelationTuple, SubjectRef
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CheckItem:
32
+ subject: str
33
+ permission: str
34
+ resource: str
35
+ context: Mapping[str, Any] | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EvaluationDecision:
40
+ allowed: bool
41
+ subject: str
42
+ permission: str
43
+ resource: str
44
+ matched_tuples: tuple[RelationTuple, ...] = ()
45
+ visited_path: tuple[str, ...] = ()
46
+ condition_results: tuple[tuple[str, bool], ...] = ()
47
+ reason: str = ""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class UsersetTree:
52
+ relation: str
53
+ object: ObjectRef
54
+ direct_subjects: tuple[str, ...] = ()
55
+ children: tuple["UsersetTree", ...] = ()
56
+
57
+
58
+ class _TraceCollector:
59
+ __slots__ = ("visited_path", "matched_tuples", "condition_results")
60
+
61
+ def __init__(self) -> None:
62
+ self.visited_path: list[str] = []
63
+ self.matched_tuples: list[RelationTuple] = []
64
+ self.condition_results: list[tuple[str, bool]] = []
65
+
66
+
67
+ class Client:
68
+ def __init__(
69
+ self,
70
+ storage: Storage,
71
+ condition_evaluator: ConditionEvaluator,
72
+ max_depth: int = 25,
73
+ ):
74
+ self._storage = storage
75
+ self._conditions = condition_evaluator
76
+ self._max_depth = max_depth
77
+ self._namespaces: dict[str, Namespace] = {}
78
+ self._traversal_relations: dict[str, set[str]] = {}
79
+
80
+ def register_schema(self, *namespaces: Namespace) -> None:
81
+ for ns in namespaces:
82
+ check_schema_cycles(ns)
83
+ self._namespaces[ns.name] = ns
84
+ traversal: set[str] = set()
85
+ for rule in ns.relations.values():
86
+ traversal |= tupleset_relations(rule)
87
+ self._traversal_relations[ns.name] = traversal
88
+
89
+ def register_condition(self, name: str, fn: ConditionFn) -> None:
90
+ self._conditions.register(name, fn)
91
+
92
+ @property
93
+ def schema(self) -> dict[str, Namespace]:
94
+ return self._namespaces
95
+
96
+ @property
97
+ def _schema(self) -> dict[str, Namespace]:
98
+ return self._namespaces
99
+
100
+ def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
101
+ ns = self._namespaces.get(namespace)
102
+ if ns is None:
103
+ raise NamespaceNotFound(namespace)
104
+ rule = ns.relations.get(relation)
105
+ if rule is None:
106
+ raise RelationNotFound(namespace, relation)
107
+ return rule
108
+
109
+ def _validate_tuple(self, t: RelationTuple) -> None:
110
+ self._get_relation(t.namespace, t.relation)
111
+ if t.condition is not None and not self._conditions.is_registered(t.condition.name):
112
+ raise ConditionNotFound(t.condition.name)
113
+
114
+ def _is_graph_edge(self, t: RelationTuple) -> bool:
115
+ if t.relation in self._traversal_relations.get(t.namespace, set()):
116
+ return True
117
+ return t.subject_ref.is_userset
118
+
119
+ async def _guard_against_cycle(self, t: RelationTuple) -> None:
120
+ source = (t.namespace, t.object_id)
121
+ subject_ref = t.subject_ref
122
+ if subject_ref.is_userset:
123
+ start = (subject_ref.namespace, subject_ref.object_id, subject_ref.relation)
124
+ else:
125
+ start = (subject_ref.namespace, subject_ref.object_id, t.relation)
126
+
127
+ visited: set[tuple[str, str]] = set()
128
+ queue: deque[tuple[str, str, str]] = deque([start])
129
+ while queue:
130
+ namespace, object_id, relation = queue.popleft()
131
+ if (namespace, object_id) == source:
132
+ raise WouldCreateCycle(t, f"{t.object} -> {namespace}:{object_id}")
133
+ if (namespace, object_id) in visited:
134
+ continue
135
+ visited.add((namespace, object_id))
136
+ existing = await self._storage.read_tuples(
137
+ namespace, object_id=object_id, relation=relation
138
+ )
139
+ for et in existing:
140
+ eref = et.subject_ref
141
+ if eref.is_userset:
142
+ queue.append((eref.namespace, eref.object_id, eref.relation))
143
+ elif relation in self._traversal_relations.get(namespace, set()):
144
+ queue.append((eref.namespace, eref.object_id, relation))
145
+
146
+ async def write(self, tuples: Sequence[RelationTuple]) -> Token:
147
+ for t in tuples:
148
+ self._validate_tuple(t)
149
+ async with self._storage.transaction():
150
+ for t in tuples:
151
+ if self._is_graph_edge(t):
152
+ await self._guard_against_cycle(t)
153
+ await self._storage.write_tuples([t])
154
+ return await self._storage.current_token()
155
+
156
+ async def delete(self, tuples: Sequence[RelationTuple]) -> Token:
157
+ for t in tuples:
158
+ self._get_relation(t.namespace, t.relation)
159
+ return await self._storage.delete_tuples(tuples)
160
+
161
+ async def read(
162
+ self,
163
+ namespace: str,
164
+ object_id: str | None = None,
165
+ relation: str | None = None,
166
+ subject: str | None = None,
167
+ ) -> list[RelationTuple]:
168
+ if namespace not in self._namespaces:
169
+ raise NamespaceNotFound(namespace)
170
+ if relation is not None:
171
+ self._get_relation(namespace, relation)
172
+ return await self._storage.read_tuples(
173
+ namespace, object_id=object_id, relation=relation, subject=subject
174
+ )
175
+
176
+ async def _read_tuples(
177
+ self,
178
+ namespace: str,
179
+ object_id: str | None = None,
180
+ relation: str | None = None,
181
+ subject: str | None = None,
182
+ *,
183
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
184
+ ) -> list[RelationTuple]:
185
+ if read_cache is not None:
186
+ key = (namespace, object_id, relation, subject)
187
+ if key in read_cache:
188
+ return read_cache[key]
189
+ result = await self._storage.read_tuples(
190
+ namespace, object_id=object_id, relation=relation, subject=subject
191
+ )
192
+ read_cache[key] = result
193
+ return result
194
+ return await self._storage.read_tuples(
195
+ namespace, object_id=object_id, relation=relation, subject=subject
196
+ )
197
+
198
+ async def check(
199
+ self,
200
+ subject: str,
201
+ permission: str,
202
+ resource: str,
203
+ *,
204
+ at_least_as_fresh_as: Token | None = None,
205
+ context: Mapping[str, Any] | None = None,
206
+ _read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
207
+ ) -> bool:
208
+ if at_least_as_fresh_as is not None:
209
+ await self._storage.wait_for_token(at_least_as_fresh_as)
210
+ target = ObjectRef.parse(resource)
211
+ self._get_relation(target.namespace, permission)
212
+ return await self._check_relation(
213
+ subject,
214
+ target.namespace,
215
+ target.object_id,
216
+ permission,
217
+ [],
218
+ context=context,
219
+ read_cache=_read_cache,
220
+ )
221
+
222
+ async def explain(
223
+ self,
224
+ subject: str,
225
+ permission: str,
226
+ resource: str,
227
+ *,
228
+ at_least_as_fresh_as: Token | None = None,
229
+ context: Mapping[str, Any] | None = None,
230
+ ) -> EvaluationDecision:
231
+ if at_least_as_fresh_as is not None:
232
+ await self._storage.wait_for_token(at_least_as_fresh_as)
233
+ target = ObjectRef.parse(resource)
234
+ self._get_relation(target.namespace, permission)
235
+
236
+ collector = _TraceCollector()
237
+ allowed = await self._check_relation(
238
+ subject,
239
+ target.namespace,
240
+ target.object_id,
241
+ permission,
242
+ [],
243
+ context=context,
244
+ collector=collector,
245
+ )
246
+
247
+ target_node = f"{resource}#{permission}"
248
+ if allowed:
249
+ matched = tuple(collector.matched_tuples)
250
+ chain = [subject]
251
+ for t in matched:
252
+ node = f"{t.namespace}:{t.object_id}#{t.relation}"
253
+ if node not in chain:
254
+ chain.append(node)
255
+ if target_node not in chain:
256
+ chain.append(target_node)
257
+ reason = f"Allowed via {' -> '.join(chain)}"
258
+ else:
259
+ matched = ()
260
+ reason = f"Denied: no matching relation or condition failed for {subject} on {target_node}"
261
+
262
+ return EvaluationDecision(
263
+ allowed=allowed,
264
+ subject=subject,
265
+ permission=permission,
266
+ resource=resource,
267
+ matched_tuples=matched,
268
+ visited_path=tuple(collector.visited_path),
269
+ condition_results=tuple(collector.condition_results),
270
+ reason=reason,
271
+ )
272
+
273
+ async def check_batch(
274
+ self,
275
+ checks: Sequence[CheckItem],
276
+ *,
277
+ at_least_as_fresh_as: Token | None = None,
278
+ ) -> list[bool]:
279
+ if at_least_as_fresh_as is not None:
280
+ await self._storage.wait_for_token(at_least_as_fresh_as)
281
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] = {}
282
+ results: list[bool] = []
283
+ for item in checks:
284
+ res = await self.check(
285
+ item.subject,
286
+ item.permission,
287
+ item.resource,
288
+ context=item.context,
289
+ _read_cache=read_cache,
290
+ )
291
+ results.append(res)
292
+ return results
293
+
294
+ async def list_permissions(
295
+ self,
296
+ subject: str,
297
+ resource: str,
298
+ permissions: Sequence[str] | None = None,
299
+ *,
300
+ at_least_as_fresh_as: Token | None = None,
301
+ context: Mapping[str, Any] | None = None,
302
+ ) -> list[str]:
303
+ target = ObjectRef.parse(resource)
304
+ ns = self._namespaces.get(target.namespace)
305
+ if ns is None:
306
+ raise NamespaceNotFound(target.namespace)
307
+ if permissions is None:
308
+ candidate_perms = list(ns.relations.keys())
309
+ else:
310
+ candidate_perms = list(permissions)
311
+ checks = [
312
+ CheckItem(
313
+ subject=subject,
314
+ permission=perm,
315
+ resource=resource,
316
+ context=context,
317
+ )
318
+ for perm in candidate_perms
319
+ ]
320
+ results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
321
+ return [perm for perm, allowed in zip(candidate_perms, results) if allowed]
322
+
323
+ async def list_objects(
324
+ self,
325
+ subject: str,
326
+ permission: str,
327
+ namespace: str,
328
+ candidate_ids: Sequence[str] | None = None,
329
+ *,
330
+ at_least_as_fresh_as: Token | None = None,
331
+ context: Mapping[str, Any] | None = None,
332
+ ) -> list[str]:
333
+ self._get_relation(namespace, permission)
334
+ if candidate_ids is None:
335
+ if at_least_as_fresh_as is not None:
336
+ await self._storage.wait_for_token(at_least_as_fresh_as)
337
+ at_least_as_fresh_as = None
338
+ tuples = await self._storage.read_tuples(namespace)
339
+ seen: set[str] = set()
340
+ discovered: list[str] = []
341
+ for t in tuples:
342
+ if t.object_id not in seen:
343
+ seen.add(t.object_id)
344
+ discovered.append(t.object_id)
345
+ candidate_ids = discovered
346
+
347
+ if not candidate_ids:
348
+ return []
349
+
350
+ checks = [
351
+ CheckItem(
352
+ subject=subject,
353
+ permission=permission,
354
+ resource=f"{namespace}:{oid}",
355
+ context=context,
356
+ )
357
+ for oid in candidate_ids
358
+ ]
359
+ results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
360
+ return [oid for oid, allowed in zip(candidate_ids, results) if allowed]
361
+
362
+ async def _check_relation(
363
+ self,
364
+ subject: str,
365
+ namespace: str,
366
+ object_id: str,
367
+ relation: str,
368
+ path: list[tuple[str, str, str]],
369
+ *,
370
+ context: Mapping[str, Any] | None = None,
371
+ collector: _TraceCollector | None = None,
372
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
373
+ ) -> bool:
374
+ node = (namespace, object_id, relation)
375
+ node_str = f"{namespace}:{object_id}#{relation}"
376
+ if collector is not None and node_str not in collector.visited_path:
377
+ collector.visited_path.append(node_str)
378
+
379
+ if node in path:
380
+ trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
381
+ trail.append(node_str)
382
+ raise CycleDetected(trail)
383
+ if len(path) >= self._max_depth:
384
+ raise DepthLimitExceeded(self._max_depth)
385
+ rule = self._get_relation(namespace, relation)
386
+ return await self._eval_rule(
387
+ rule,
388
+ subject,
389
+ namespace,
390
+ object_id,
391
+ relation,
392
+ path + [node],
393
+ context=context,
394
+ collector=collector,
395
+ read_cache=read_cache,
396
+ )
397
+
398
+ async def _eval_rule(
399
+ self,
400
+ rule: RewriteRule,
401
+ subject: str,
402
+ namespace: str,
403
+ object_id: str,
404
+ relation: str,
405
+ path: list[tuple[str, str, str]],
406
+ *,
407
+ context: Mapping[str, Any] | None = None,
408
+ collector: _TraceCollector | None = None,
409
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
410
+ ) -> bool:
411
+ if isinstance(rule, Direct):
412
+ return await self._eval_direct(
413
+ subject,
414
+ namespace,
415
+ object_id,
416
+ relation,
417
+ path,
418
+ context=context,
419
+ collector=collector,
420
+ read_cache=read_cache,
421
+ )
422
+ if isinstance(rule, Union):
423
+ for child in rule.children:
424
+ mark = len(collector.matched_tuples) if collector is not None else 0
425
+ if await self._eval_rule(
426
+ child,
427
+ subject,
428
+ namespace,
429
+ object_id,
430
+ relation,
431
+ path,
432
+ context=context,
433
+ collector=collector,
434
+ read_cache=read_cache,
435
+ ):
436
+ return True
437
+ if collector is not None:
438
+ del collector.matched_tuples[mark:]
439
+ return False
440
+ if isinstance(rule, Intersection):
441
+ mark = len(collector.matched_tuples) if collector is not None else 0
442
+ for child in rule.children:
443
+ if not await self._eval_rule(
444
+ child,
445
+ subject,
446
+ namespace,
447
+ object_id,
448
+ relation,
449
+ path,
450
+ context=context,
451
+ collector=collector,
452
+ read_cache=read_cache,
453
+ ):
454
+ if collector is not None:
455
+ del collector.matched_tuples[mark:]
456
+ return False
457
+ return True
458
+ if isinstance(rule, Exclusion):
459
+ mark = len(collector.matched_tuples) if collector is not None else 0
460
+ if not await self._eval_rule(
461
+ rule.base,
462
+ subject,
463
+ namespace,
464
+ object_id,
465
+ relation,
466
+ path,
467
+ context=context,
468
+ collector=collector,
469
+ read_cache=read_cache,
470
+ ):
471
+ if collector is not None:
472
+ del collector.matched_tuples[mark:]
473
+ return False
474
+ sub_mark = len(collector.matched_tuples) if collector is not None else 0
475
+ sub_result = await self._eval_rule(
476
+ rule.subtract,
477
+ subject,
478
+ namespace,
479
+ object_id,
480
+ relation,
481
+ path,
482
+ context=context,
483
+ collector=collector,
484
+ read_cache=read_cache,
485
+ )
486
+ if collector is not None:
487
+ del collector.matched_tuples[sub_mark:]
488
+ if sub_result:
489
+ if collector is not None:
490
+ del collector.matched_tuples[mark:]
491
+ return False
492
+ return True
493
+ if isinstance(rule, From):
494
+ return await self._check_relation(
495
+ subject,
496
+ namespace,
497
+ object_id,
498
+ rule.relation,
499
+ path,
500
+ context=context,
501
+ collector=collector,
502
+ read_cache=read_cache,
503
+ )
504
+ if isinstance(rule, TupleToUserset):
505
+ parents = await self._read_tuples(
506
+ namespace,
507
+ object_id=object_id,
508
+ relation=rule.tupleset_relation,
509
+ read_cache=read_cache,
510
+ )
511
+ for pt in parents:
512
+ pref = pt.subject_ref
513
+ mark = len(collector.matched_tuples) if collector is not None else 0
514
+ if await self._check_relation(
515
+ subject,
516
+ pref.namespace,
517
+ pref.object_id,
518
+ rule.computed_relation,
519
+ path,
520
+ context=context,
521
+ collector=collector,
522
+ read_cache=read_cache,
523
+ ):
524
+ if collector is not None:
525
+ collector.matched_tuples.append(pt)
526
+ return True
527
+ if collector is not None:
528
+ del collector.matched_tuples[mark:]
529
+ return False
530
+ raise TypeError(f"unsupported rewrite rule: {rule!r}")
531
+
532
+ async def _eval_direct(
533
+ self,
534
+ subject: str,
535
+ namespace: str,
536
+ object_id: str,
537
+ relation: str,
538
+ path: list[tuple[str, str, str]],
539
+ *,
540
+ context: Mapping[str, Any] | None = None,
541
+ collector: _TraceCollector | None = None,
542
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
543
+ ) -> bool:
544
+ subject_ref = SubjectRef.parse(subject)
545
+ tuples = await self._read_tuples(
546
+ namespace, object_id=object_id, relation=relation, read_cache=read_cache
547
+ )
548
+ for t in tuples:
549
+ if t.condition is not None:
550
+ merged_ctx = {**t.condition.context, **(context or {})}
551
+ holds = await self._conditions.evaluate(
552
+ t.condition.name, subject, f"{namespace}:{object_id}", merged_ctx
553
+ )
554
+ if collector is not None:
555
+ collector.condition_results.append((t.condition.name, holds))
556
+ if not holds:
557
+ continue
558
+ tref = t.subject_ref
559
+ if not tref.is_userset:
560
+ if tref.namespace == subject_ref.namespace and tref.object_id == subject_ref.object_id:
561
+ if collector is not None:
562
+ collector.matched_tuples.append(t)
563
+ return True
564
+ else:
565
+ mark = len(collector.matched_tuples) if collector is not None else 0
566
+ if await self._check_relation(
567
+ subject,
568
+ tref.namespace,
569
+ tref.object_id,
570
+ tref.relation,
571
+ path,
572
+ context=context,
573
+ collector=collector,
574
+ read_cache=read_cache,
575
+ ):
576
+ if collector is not None:
577
+ collector.matched_tuples.append(t)
578
+ return True
579
+ if collector is not None:
580
+ del collector.matched_tuples[mark:]
581
+ return False
582
+
583
+ async def expand(self, permission: str, resource: str) -> "UsersetTree":
584
+ target = ObjectRef.parse(resource)
585
+ rule = self._get_relation(target.namespace, permission)
586
+ return await self._expand_rule(rule, target.namespace, target.object_id, permission)
587
+
588
+ async def _expand_rule(
589
+ self, rule, namespace: str, object_id: str, relation: str
590
+ ) -> "UsersetTree":
591
+ direct_subjects: list[str] = []
592
+ children: list[UsersetTree] = []
593
+
594
+ async def walk(r) -> None:
595
+ if isinstance(r, Direct):
596
+ tuples = await self._storage.read_tuples(
597
+ namespace, object_id=object_id, relation=relation
598
+ )
599
+ direct_subjects.extend(t.subject for t in tuples)
600
+ elif isinstance(r, (Union, Intersection)):
601
+ for child in r.children:
602
+ await walk(child)
603
+ elif isinstance(r, Exclusion):
604
+ await walk(r.base)
605
+ elif isinstance(r, From):
606
+ children.append(
607
+ await self._expand_rule(
608
+ self._get_relation(namespace, r.relation),
609
+ namespace,
610
+ object_id,
611
+ r.relation,
612
+ )
613
+ )
614
+ elif isinstance(r, TupleToUserset):
615
+ parents = await self._storage.read_tuples(
616
+ namespace, object_id=object_id, relation=r.tupleset_relation
617
+ )
618
+ for pt in parents:
619
+ pref = pt.subject_ref
620
+ children.append(
621
+ await self._expand_rule(
622
+ self._get_relation(pref.namespace, r.computed_relation),
623
+ pref.namespace,
624
+ pref.object_id,
625
+ r.computed_relation,
626
+ )
627
+ )
628
+
629
+ await walk(rule)
630
+ return UsersetTree(
631
+ relation=relation,
632
+ object=ObjectRef(namespace, object_id),
633
+ direct_subjects=tuple(direct_subjects),
634
+ children=tuple(children),
635
+ )
@@ -0,0 +1,86 @@
1
+ import inspect
2
+ from typing import Any, Awaitable, Callable, Mapping
3
+
4
+ from fastapi import Depends, HTTPException, Request
5
+
6
+ from authz.engine import Client, EvaluationDecision
7
+
8
+ ResourceFn = Callable[[Request], str | Awaitable[str]]
9
+ ContextFn = Callable[[Request], Mapping[str, Any] | Awaitable[Mapping[str, Any]]]
10
+
11
+ __all__ = ["ContextFn", "ResourceFn", "explain_access", "requires"]
12
+
13
+
14
+ async def _resolve_resource(resource: str | ResourceFn, request: Request) -> str:
15
+ if isinstance(resource, str):
16
+ params = {
17
+ **dict(request.headers),
18
+ **dict(request.query_params),
19
+ **dict(request.path_params),
20
+ }
21
+ return resource.format(**params)
22
+ result = resource(request)
23
+ if inspect.isawaitable(result):
24
+ result = await result
25
+ return result
26
+
27
+
28
+ async def _resolve_context(
29
+ get_context: ContextFn | None, request: Request
30
+ ) -> Mapping[str, Any] | None:
31
+ if get_context is None:
32
+ return None
33
+ try:
34
+ result = get_context(request)
35
+ except TypeError:
36
+ result = get_context() # type: ignore[call-arg]
37
+ if inspect.isawaitable(result):
38
+ result = await result
39
+ return result
40
+
41
+
42
+ def requires(
43
+ permission: str,
44
+ resource: str | ResourceFn,
45
+ *,
46
+ get_client: Callable[..., Client | Awaitable[Client]],
47
+ get_subject: Callable[..., str | Awaitable[str]],
48
+ get_context: ContextFn | None = None,
49
+ detail: str = "Forbidden",
50
+ ) -> Callable:
51
+ async def dependency(
52
+ request: Request,
53
+ client: Client = Depends(get_client),
54
+ subject: str = Depends(get_subject),
55
+ ) -> None:
56
+ resolved = await _resolve_resource(resource, request)
57
+ ctx = await _resolve_context(get_context, request)
58
+ if not await client.check(subject, permission, resolved, context=ctx):
59
+ raise HTTPException(status_code=403, detail=detail)
60
+
61
+ return dependency
62
+
63
+
64
+ def explain_access(
65
+ permission: str,
66
+ resource: str | ResourceFn,
67
+ *,
68
+ get_client: Callable[..., Client | Awaitable[Client]],
69
+ get_subject: Callable[..., str | Awaitable[str]],
70
+ get_context: ContextFn | None = None,
71
+ detail: str = "Forbidden",
72
+ ) -> Callable:
73
+ async def dependency(
74
+ request: Request,
75
+ client: Client = Depends(get_client),
76
+ subject: str = Depends(get_subject),
77
+ ) -> EvaluationDecision:
78
+ resolved = await _resolve_resource(resource, request)
79
+ ctx = await _resolve_context(get_context, request)
80
+ decision = await client.explain(subject, permission, resolved, context=ctx)
81
+ request.state.authz_decision = decision
82
+ if not decision.allowed:
83
+ raise HTTPException(status_code=403, detail=detail)
84
+ return decision
85
+
86
+ return dependency
@@ -1,37 +0,0 @@
1
- import inspect
2
- from typing import Any, Awaitable, Callable, Mapping, Protocol, Union
3
-
4
- from authz.errors import ConditionNotFound
5
-
6
- ConditionFn = Callable[..., Union[bool, Awaitable[bool]]]
7
-
8
-
9
- class ConditionEvaluator(Protocol):
10
- def is_registered(self, name: str) -> bool: ...
11
-
12
- async def evaluate(
13
- self, name: str, subject: str, resource: str, context: Mapping[str, Any]
14
- ) -> bool: ...
15
-
16
-
17
- class PythonCallableEvaluator:
18
- def __init__(self) -> None:
19
- self._conditions: dict[str, ConditionFn] = {}
20
-
21
- def register(self, name: str, fn: ConditionFn) -> None:
22
- self._conditions[name] = fn
23
-
24
- def is_registered(self, name: str) -> bool:
25
- return name in self._conditions
26
-
27
- async def evaluate(
28
- self, name: str, subject: str, resource: str, context: Mapping[str, Any]
29
- ) -> bool:
30
- try:
31
- fn = self._conditions[name]
32
- except KeyError:
33
- raise ConditionNotFound(name) from None
34
- result = fn(subject=subject, resource=resource, **context)
35
- if inspect.isawaitable(result):
36
- result = await result
37
- return bool(result)
@@ -1,297 +0,0 @@
1
- from collections import deque
2
- from dataclasses import dataclass
3
- from typing import Sequence
4
-
5
- from authz.conditions import ConditionEvaluator, ConditionFn
6
- from authz.errors import (
7
- ConditionNotFound,
8
- CycleDetected,
9
- DepthLimitExceeded,
10
- NamespaceNotFound,
11
- RelationNotFound,
12
- WouldCreateCycle,
13
- )
14
- from authz.schema import (
15
- Direct,
16
- Exclusion,
17
- From,
18
- Intersection,
19
- Namespace,
20
- RewriteRule,
21
- TupleToUserset,
22
- Union,
23
- check_schema_cycles,
24
- tupleset_relations,
25
- )
26
- from authz.storage.base import Storage, Token
27
- from authz.tuples import ObjectRef, RelationTuple, SubjectRef
28
-
29
-
30
- @dataclass(frozen=True)
31
- class UsersetTree:
32
- relation: str
33
- object: ObjectRef
34
- direct_subjects: tuple[str, ...] = ()
35
- children: tuple["UsersetTree", ...] = ()
36
-
37
-
38
- class Client:
39
- def __init__(
40
- self,
41
- storage: Storage,
42
- condition_evaluator: ConditionEvaluator,
43
- max_depth: int = 25,
44
- ):
45
- self._storage = storage
46
- self._conditions = condition_evaluator
47
- self._max_depth = max_depth
48
- self._namespaces: dict[str, Namespace] = {}
49
- self._traversal_relations: dict[str, set[str]] = {}
50
-
51
- def register_schema(self, *namespaces: Namespace) -> None:
52
- for ns in namespaces:
53
- check_schema_cycles(ns)
54
- self._namespaces[ns.name] = ns
55
- traversal: set[str] = set()
56
- for rule in ns.relations.values():
57
- traversal |= tupleset_relations(rule)
58
- self._traversal_relations[ns.name] = traversal
59
-
60
- def register_condition(self, name: str, fn: ConditionFn) -> None:
61
- self._conditions.register(name, fn)
62
-
63
- def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
64
- ns = self._namespaces.get(namespace)
65
- if ns is None:
66
- raise NamespaceNotFound(namespace)
67
- rule = ns.relations.get(relation)
68
- if rule is None:
69
- raise RelationNotFound(namespace, relation)
70
- return rule
71
-
72
- def _validate_tuple(self, t: RelationTuple) -> None:
73
- self._get_relation(t.namespace, t.relation)
74
- if t.condition is not None and not self._conditions.is_registered(t.condition.name):
75
- raise ConditionNotFound(t.condition.name)
76
-
77
- def _is_graph_edge(self, t: RelationTuple) -> bool:
78
- if t.relation in self._traversal_relations.get(t.namespace, set()):
79
- return True
80
- return t.subject_ref.is_userset
81
-
82
- async def _guard_against_cycle(self, t: RelationTuple) -> None:
83
- source = (t.namespace, t.object_id)
84
- subject_ref = t.subject_ref
85
- if subject_ref.is_userset:
86
- start = (subject_ref.namespace, subject_ref.object_id, subject_ref.relation)
87
- else:
88
- start = (subject_ref.namespace, subject_ref.object_id, t.relation)
89
-
90
- visited: set[tuple[str, str]] = set()
91
- queue: deque[tuple[str, str, str]] = deque([start])
92
- while queue:
93
- namespace, object_id, relation = queue.popleft()
94
- if (namespace, object_id) == source:
95
- raise WouldCreateCycle(t, f"{t.object} -> {namespace}:{object_id}")
96
- if (namespace, object_id) in visited:
97
- continue
98
- visited.add((namespace, object_id))
99
- existing = await self._storage.read_tuples(
100
- namespace, object_id=object_id, relation=relation
101
- )
102
- for et in existing:
103
- eref = et.subject_ref
104
- if eref.is_userset:
105
- queue.append((eref.namespace, eref.object_id, eref.relation))
106
- elif relation in self._traversal_relations.get(namespace, set()):
107
- queue.append((eref.namespace, eref.object_id, relation))
108
-
109
- async def write(self, tuples: Sequence[RelationTuple]) -> Token:
110
- for t in tuples:
111
- self._validate_tuple(t)
112
- async with self._storage.transaction():
113
- for t in tuples:
114
- if self._is_graph_edge(t):
115
- await self._guard_against_cycle(t)
116
- await self._storage.write_tuples([t])
117
- return await self._storage.current_token()
118
-
119
- async def delete(self, tuples: Sequence[RelationTuple]) -> Token:
120
- for t in tuples:
121
- self._get_relation(t.namespace, t.relation)
122
- return await self._storage.delete_tuples(tuples)
123
-
124
- async def read(
125
- self,
126
- namespace: str,
127
- object_id: str | None = None,
128
- relation: str | None = None,
129
- subject: str | None = None,
130
- ) -> list[RelationTuple]:
131
- if namespace not in self._namespaces:
132
- raise NamespaceNotFound(namespace)
133
- if relation is not None:
134
- self._get_relation(namespace, relation)
135
- return await self._storage.read_tuples(
136
- namespace, object_id=object_id, relation=relation, subject=subject
137
- )
138
-
139
- async def check(
140
- self,
141
- subject: str,
142
- permission: str,
143
- resource: str,
144
- *,
145
- at_least_as_fresh_as: Token | None = None,
146
- ) -> bool:
147
- if at_least_as_fresh_as is not None:
148
- await self._storage.wait_for_token(at_least_as_fresh_as)
149
- target = ObjectRef.parse(resource)
150
- self._get_relation(target.namespace, permission)
151
- return await self._check_relation(
152
- subject, target.namespace, target.object_id, permission, []
153
- )
154
-
155
- async def _check_relation(
156
- self,
157
- subject: str,
158
- namespace: str,
159
- object_id: str,
160
- relation: str,
161
- path: list[tuple[str, str, str]],
162
- ) -> bool:
163
- node = (namespace, object_id, relation)
164
- if node in path:
165
- trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
166
- trail.append(f"{namespace}:{object_id}#{relation}")
167
- raise CycleDetected(trail)
168
- if len(path) >= self._max_depth:
169
- raise DepthLimitExceeded(self._max_depth)
170
- rule = self._get_relation(namespace, relation)
171
- return await self._eval_rule(
172
- rule, subject, namespace, object_id, relation, path + [node]
173
- )
174
-
175
- async def _eval_rule(
176
- self,
177
- rule,
178
- subject: str,
179
- namespace: str,
180
- object_id: str,
181
- relation: str,
182
- path: list[tuple[str, str, str]],
183
- ) -> bool:
184
- if isinstance(rule, Direct):
185
- return await self._eval_direct(subject, namespace, object_id, relation, path)
186
- if isinstance(rule, Union):
187
- for child in rule.children:
188
- if await self._eval_rule(child, subject, namespace, object_id, relation, path):
189
- return True
190
- return False
191
- if isinstance(rule, Intersection):
192
- for child in rule.children:
193
- if not await self._eval_rule(child, subject, namespace, object_id, relation, path):
194
- return False
195
- return True
196
- if isinstance(rule, Exclusion):
197
- if not await self._eval_rule(rule.base, subject, namespace, object_id, relation, path):
198
- return False
199
- return not await self._eval_rule(
200
- rule.subtract, subject, namespace, object_id, relation, path
201
- )
202
- if isinstance(rule, From):
203
- return await self._check_relation(subject, namespace, object_id, rule.relation, path)
204
- if isinstance(rule, TupleToUserset):
205
- parents = await self._storage.read_tuples(
206
- namespace, object_id=object_id, relation=rule.tupleset_relation
207
- )
208
- for pt in parents:
209
- pref = pt.subject_ref
210
- if await self._check_relation(
211
- subject, pref.namespace, pref.object_id, rule.computed_relation, path
212
- ):
213
- return True
214
- return False
215
- raise TypeError(f"unsupported rewrite rule: {rule!r}")
216
-
217
- async def _eval_direct(
218
- self,
219
- subject: str,
220
- namespace: str,
221
- object_id: str,
222
- relation: str,
223
- path: list[tuple[str, str, str]],
224
- ) -> bool:
225
- subject_ref = SubjectRef.parse(subject)
226
- tuples = await self._storage.read_tuples(namespace, object_id=object_id, relation=relation)
227
- for t in tuples:
228
- if t.condition is not None:
229
- holds = await self._conditions.evaluate(
230
- t.condition.name, subject, f"{namespace}:{object_id}", t.condition.context
231
- )
232
- if not holds:
233
- continue
234
- tref = t.subject_ref
235
- if not tref.is_userset:
236
- if tref.namespace == subject_ref.namespace and tref.object_id == subject_ref.object_id:
237
- return True
238
- else:
239
- if await self._check_relation(
240
- subject, tref.namespace, tref.object_id, tref.relation, path
241
- ):
242
- return True
243
- return False
244
-
245
- async def expand(self, permission: str, resource: str) -> "UsersetTree":
246
- target = ObjectRef.parse(resource)
247
- rule = self._get_relation(target.namespace, permission)
248
- return await self._expand_rule(rule, target.namespace, target.object_id, permission)
249
-
250
- async def _expand_rule(
251
- self, rule, namespace: str, object_id: str, relation: str
252
- ) -> "UsersetTree":
253
- direct_subjects: list[str] = []
254
- children: list[UsersetTree] = []
255
-
256
- async def walk(r) -> None:
257
- if isinstance(r, Direct):
258
- tuples = await self._storage.read_tuples(
259
- namespace, object_id=object_id, relation=relation
260
- )
261
- direct_subjects.extend(t.subject for t in tuples)
262
- elif isinstance(r, (Union, Intersection)):
263
- for child in r.children:
264
- await walk(child)
265
- elif isinstance(r, Exclusion):
266
- await walk(r.base)
267
- elif isinstance(r, From):
268
- children.append(
269
- await self._expand_rule(
270
- self._get_relation(namespace, r.relation),
271
- namespace,
272
- object_id,
273
- r.relation,
274
- )
275
- )
276
- elif isinstance(r, TupleToUserset):
277
- parents = await self._storage.read_tuples(
278
- namespace, object_id=object_id, relation=r.tupleset_relation
279
- )
280
- for pt in parents:
281
- pref = pt.subject_ref
282
- children.append(
283
- await self._expand_rule(
284
- self._get_relation(pref.namespace, r.computed_relation),
285
- pref.namespace,
286
- pref.object_id,
287
- r.computed_relation,
288
- )
289
- )
290
-
291
- await walk(rule)
292
- return UsersetTree(
293
- relation=relation,
294
- object=ObjectRef(namespace, object_id),
295
- direct_subjects=tuple(direct_subjects),
296
- children=tuple(children),
297
- )
@@ -1,37 +0,0 @@
1
- import inspect
2
- from typing import Awaitable, Callable
3
-
4
- from fastapi import Depends, HTTPException, Request
5
-
6
- from authz.engine import Client
7
-
8
- ResourceFn = Callable[[Request], str | Awaitable[str]]
9
-
10
-
11
- async def _resolve_resource(resource: str | ResourceFn, request: Request) -> str:
12
- if isinstance(resource, str):
13
- return resource.format(**request.path_params)
14
- result = resource(request)
15
- if inspect.isawaitable(result):
16
- result = await result
17
- return result
18
-
19
-
20
- def requires(
21
- permission: str,
22
- resource: str | ResourceFn,
23
- *,
24
- get_client: Callable[..., Client | Awaitable[Client]],
25
- get_subject: Callable[..., str | Awaitable[str]],
26
- detail: str = "Forbidden",
27
- ) -> Callable:
28
- async def dependency(
29
- request: Request,
30
- client: Client = Depends(get_client),
31
- subject: str = Depends(get_subject),
32
- ) -> None:
33
- resolved = await _resolve_resource(resource, request)
34
- if not await client.check(subject, permission, resolved):
35
- raise HTTPException(status_code=403, detail=detail)
36
-
37
- return dependency
File without changes