python-authz 0.5.0__py3-none-any.whl → 0.6.0__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,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
 
authz/conditions.py CHANGED
@@ -18,6 +18,18 @@ class PythonCallableEvaluator:
18
18
  def __init__(self) -> None:
19
19
  self._conditions: dict[str, ConditionFn] = {}
20
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
+
21
33
  def register(self, name: str, fn: ConditionFn) -> None:
22
34
  self._conditions[name] = fn
23
35
 
@@ -35,3 +47,67 @@ class PythonCallableEvaluator:
35
47
  if inspect.isawaitable(result):
36
48
  result = await result
37
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
+ }
authz/engine.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from collections import deque
2
2
  from dataclasses import dataclass
3
- from typing import Sequence
3
+ from typing import Any, Mapping, Sequence
4
4
 
5
5
  from authz.conditions import ConditionEvaluator, ConditionFn
6
6
  from authz.errors import (
@@ -27,6 +27,26 @@ from authz.storage.base import Storage, Token
27
27
  from authz.tuples import ObjectRef, RelationTuple, SubjectRef
28
28
 
29
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
+
30
50
  @dataclass(frozen=True)
31
51
  class UsersetTree:
32
52
  relation: str
@@ -35,6 +55,15 @@ class UsersetTree:
35
55
  children: tuple["UsersetTree", ...] = ()
36
56
 
37
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
+
38
67
  class Client:
39
68
  def __init__(
40
69
  self,
@@ -60,6 +89,14 @@ class Client:
60
89
  def register_condition(self, name: str, fn: ConditionFn) -> None:
61
90
  self._conditions.register(name, fn)
62
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
+
63
100
  def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
64
101
  ns = self._namespaces.get(namespace)
65
102
  if ns is None:
@@ -136,6 +173,28 @@ class Client:
136
173
  namespace, object_id=object_id, relation=relation, subject=subject
137
174
  )
138
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
+
139
198
  async def check(
140
199
  self,
141
200
  subject: str,
@@ -143,15 +202,163 @@ class Client:
143
202
  resource: str,
144
203
  *,
145
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,
146
207
  ) -> bool:
147
208
  if at_least_as_fresh_as is not None:
148
209
  await self._storage.wait_for_token(at_least_as_fresh_as)
149
210
  target = ObjectRef.parse(resource)
150
211
  self._get_relation(target.namespace, permission)
151
212
  return await self._check_relation(
152
- subject, target.namespace, target.object_id, permission, []
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,
153
271
  )
154
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
+
155
362
  async def _check_relation(
156
363
  self,
157
364
  subject: str,
@@ -159,58 +366,166 @@ class Client:
159
366
  object_id: str,
160
367
  relation: str,
161
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,
162
373
  ) -> bool:
163
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
+
164
379
  if node in path:
165
380
  trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
166
- trail.append(f"{namespace}:{object_id}#{relation}")
381
+ trail.append(node_str)
167
382
  raise CycleDetected(trail)
168
383
  if len(path) >= self._max_depth:
169
384
  raise DepthLimitExceeded(self._max_depth)
170
385
  rule = self._get_relation(namespace, relation)
171
386
  return await self._eval_rule(
172
- rule, subject, namespace, object_id, relation, path + [node]
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,
173
396
  )
174
397
 
175
398
  async def _eval_rule(
176
399
  self,
177
- rule,
400
+ rule: RewriteRule,
178
401
  subject: str,
179
402
  namespace: str,
180
403
  object_id: str,
181
404
  relation: str,
182
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,
183
410
  ) -> bool:
184
411
  if isinstance(rule, Direct):
185
- return await self._eval_direct(subject, namespace, object_id, relation, path)
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
+ )
186
422
  if isinstance(rule, Union):
187
423
  for child in rule.children:
188
- if await self._eval_rule(child, subject, namespace, object_id, relation, path):
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
+ ):
189
436
  return True
437
+ if collector is not None:
438
+ del collector.matched_tuples[mark:]
190
439
  return False
191
440
  if isinstance(rule, Intersection):
441
+ mark = len(collector.matched_tuples) if collector is not None else 0
192
442
  for child in rule.children:
193
- if not await self._eval_rule(child, subject, namespace, object_id, relation, path):
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:]
194
456
  return False
195
457
  return True
196
458
  if isinstance(rule, Exclusion):
197
- if not await self._eval_rule(rule.base, subject, namespace, object_id, relation, path):
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:]
198
473
  return False
199
- return not await self._eval_rule(
200
- rule.subtract, subject, namespace, object_id, relation, path
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,
201
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
202
493
  if isinstance(rule, From):
203
- return await self._check_relation(subject, namespace, object_id, rule.relation, path)
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
+ )
204
504
  if isinstance(rule, TupleToUserset):
205
- parents = await self._storage.read_tuples(
206
- namespace, object_id=object_id, relation=rule.tupleset_relation
505
+ parents = await self._read_tuples(
506
+ namespace,
507
+ object_id=object_id,
508
+ relation=rule.tupleset_relation,
509
+ read_cache=read_cache,
207
510
  )
208
511
  for pt in parents:
209
512
  pref = pt.subject_ref
513
+ mark = len(collector.matched_tuples) if collector is not None else 0
210
514
  if await self._check_relation(
211
- subject, pref.namespace, pref.object_id, rule.computed_relation, path
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,
212
523
  ):
524
+ if collector is not None:
525
+ collector.matched_tuples.append(pt)
213
526
  return True
527
+ if collector is not None:
528
+ del collector.matched_tuples[mark:]
214
529
  return False
215
530
  raise TypeError(f"unsupported rewrite rule: {rule!r}")
216
531
 
@@ -221,25 +536,48 @@ class Client:
221
536
  object_id: str,
222
537
  relation: str,
223
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,
224
543
  ) -> bool:
225
544
  subject_ref = SubjectRef.parse(subject)
226
- tuples = await self._storage.read_tuples(namespace, object_id=object_id, relation=relation)
545
+ tuples = await self._read_tuples(
546
+ namespace, object_id=object_id, relation=relation, read_cache=read_cache
547
+ )
227
548
  for t in tuples:
228
549
  if t.condition is not None:
550
+ merged_ctx = {**t.condition.context, **(context or {})}
229
551
  holds = await self._conditions.evaluate(
230
- t.condition.name, subject, f"{namespace}:{object_id}", t.condition.context
552
+ t.condition.name, subject, f"{namespace}:{object_id}", merged_ctx
231
553
  )
554
+ if collector is not None:
555
+ collector.condition_results.append((t.condition.name, holds))
232
556
  if not holds:
233
557
  continue
234
558
  tref = t.subject_ref
235
559
  if not tref.is_userset:
236
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)
237
563
  return True
238
564
  else:
565
+ mark = len(collector.matched_tuples) if collector is not None else 0
239
566
  if await self._check_relation(
240
- subject, tref.namespace, tref.object_id, tref.relation, path
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,
241
575
  ):
576
+ if collector is not None:
577
+ collector.matched_tuples.append(t)
242
578
  return True
579
+ if collector is not None:
580
+ del collector.matched_tuples[mark:]
243
581
  return False
244
582
 
245
583
  async def expand(self, permission: str, resource: str) -> "UsersetTree":
@@ -1,28 +1,51 @@
1
1
  import inspect
2
- from typing import Awaitable, Callable
2
+ from typing import Any, Awaitable, Callable, Mapping
3
3
 
4
4
  from fastapi import Depends, HTTPException, Request
5
5
 
6
- from authz.engine import Client
6
+ from authz.engine import Client, EvaluationDecision
7
7
 
8
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"]
9
12
 
10
13
 
11
14
  async def _resolve_resource(resource: str | ResourceFn, request: Request) -> str:
12
15
  if isinstance(resource, str):
13
- return resource.format(**request.path_params)
16
+ params = {
17
+ **dict(request.headers),
18
+ **dict(request.query_params),
19
+ **dict(request.path_params),
20
+ }
21
+ return resource.format(**params)
14
22
  result = resource(request)
15
23
  if inspect.isawaitable(result):
16
24
  result = await result
17
25
  return result
18
26
 
19
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
+
20
42
  def requires(
21
43
  permission: str,
22
44
  resource: str | ResourceFn,
23
45
  *,
24
46
  get_client: Callable[..., Client | Awaitable[Client]],
25
47
  get_subject: Callable[..., str | Awaitable[str]],
48
+ get_context: ContextFn | None = None,
26
49
  detail: str = "Forbidden",
27
50
  ) -> Callable:
28
51
  async def dependency(
@@ -31,7 +54,33 @@ def requires(
31
54
  subject: str = Depends(get_subject),
32
55
  ) -> None:
33
56
  resolved = await _resolve_resource(resource, request)
34
- if not await client.check(subject, permission, resolved):
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:
35
83
  raise HTTPException(status_code=403, detail=detail)
84
+ return decision
36
85
 
37
86
  return dependency
@@ -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
 
@@ -1,17 +1,17 @@
1
- authz/__init__.py,sha256=X_-rK75ArWRx48dc7MMa9pHUua-S8_n-bCPfahOews0,1310
2
- authz/conditions.py,sha256=5_LPWmtLQtf7S6wwSq0sp7ndJYHWvSmv3CsDNZCktmM,1128
3
- authz/engine.py,sha256=x2jQmWLBh51DLKFxEs2b2N3G1T1Rtq7lBE3Q7roUDas,11137
1
+ authz/__init__.py,sha256=OOWzf0pVpNNS5zA-A5h6appIYWzQmttTTkVMrqzyeCQ,1447
2
+ authz/conditions.py,sha256=3MxE524bsdwjkCR-9yTyri_OstWpsHFFrjNr9n5cQQ4,3449
3
+ authz/engine.py,sha256=Dx3gjd555K4ncUKkEtJYBnBJ9mZfYBWNZEMqHaJOltM,22594
4
4
  authz/errors.py,sha256=6DXssJp_KvCWY-DwKEtS4gnHXBl4Tbg1io84eLhL-SQ,1522
5
5
  authz/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- authz/integrations/fastapi.py,sha256=SB3IAQ1yQDyAh5NW9hFfXx_psBWFZ7HSABJ5FJ7iYew,1091
6
+ authz/integrations/fastapi.py,sha256=PR0RLH0Xs5vg9c2ZZJXKh0N8nBTyNjPj4I0DLQejk8U,2749
7
7
  authz/schema.py,sha256=ALRdTg2CuUa7mjwN30zgAN3V2iYPUMq6ICv6IppUUeg,3118
8
8
  authz/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  authz/storage/base.py,sha256=I0My-ByzNnVjH_UH37YnRfVQl8_EtCurA-UtJfcviL8,723
10
10
  authz/storage/postgres.py,sha256=x_9OxEKkD1Glp-luaFfHWzq4KQfc-H92z2ggBOOCgR8,6595
11
11
  authz/storage/sqlite.py,sha256=Ua4vWSvsOtbfdEEv4UDfB8NVJaaZ4T9dKU3DkmUi6Cc,5248
12
12
  authz/tuples.py,sha256=NR4WpFxMj_9u2bPMyrVC8ajxbqoMUGLYIq3Rt8HZmM0,2005
13
- python_authz-0.5.0.dist-info/licenses/LICENSE,sha256=1e3AR9C0E8RKy14RPFwHKLoYHryFj4Gwiio0vyQtDVQ,1069
14
- python_authz-0.5.0.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
15
- python_authz-0.5.0.dist-info/entry_points.txt,sha256=74k2ityJQ4AAFVFhVZddedlmWS4P4ZLAqrj8XTMD9Nk,38
16
- python_authz-0.5.0.dist-info/METADATA,sha256=MswIj_E9TjAb5UvhxRAA2nj1ZDW3T9jNZhgd5TTvlSo,6287
17
- python_authz-0.5.0.dist-info/RECORD,,
13
+ python_authz-0.6.0.dist-info/licenses/LICENSE,sha256=1e3AR9C0E8RKy14RPFwHKLoYHryFj4Gwiio0vyQtDVQ,1069
14
+ python_authz-0.6.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
15
+ python_authz-0.6.0.dist-info/entry_points.txt,sha256=74k2ityJQ4AAFVFhVZddedlmWS4P4ZLAqrj8XTMD9Nk,38
16
+ python_authz-0.6.0.dist-info/METADATA,sha256=NsY5C09shEZGLDlOVGWpX3ET0nNVArqnJjQCt4n7oY4,6953
17
+ python_authz-0.6.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.12.5
2
+ Generator: uv 0.13.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any