python-authz 0.5.0__py3-none-any.whl → 0.7.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,7 +1,12 @@
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,
9
+ CardinalityViolationError,
5
10
  ConditionNotFound,
6
11
  CycleDetected,
7
12
  DepthLimitExceeded,
@@ -19,6 +24,7 @@ from authz.schema import (
19
24
  RewriteRule,
20
25
  TupleToUserset,
21
26
  Union,
27
+ build_role_hierarchy,
22
28
  )
23
29
  from authz.storage.base import Storage, Token
24
30
  from authz.storage.postgres import PostgresStorage
@@ -27,6 +33,8 @@ from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
27
33
 
28
34
  __all__ = [
29
35
  "AuthError",
36
+ "CardinalityViolationError",
37
+ "CheckItem",
30
38
  "Client",
31
39
  "Condition",
32
40
  "ConditionEvaluator",
@@ -34,6 +42,7 @@ __all__ = [
34
42
  "CycleDetected",
35
43
  "DepthLimitExceeded",
36
44
  "Direct",
45
+ "EvaluationDecision",
37
46
  "Exclusion",
38
47
  "From",
39
48
  "Intersection",
@@ -54,6 +63,8 @@ __all__ = [
54
63
  "Union",
55
64
  "UsersetTree",
56
65
  "WouldCreateCycle",
66
+ "build_role_hierarchy",
67
+ "builtin_conditions",
57
68
  ]
58
69
 
59
70
 
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,9 +1,10 @@
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 (
7
+ CardinalityViolationError,
7
8
  ConditionNotFound,
8
9
  CycleDetected,
9
10
  DepthLimitExceeded,
@@ -27,6 +28,26 @@ from authz.storage.base import Storage, Token
27
28
  from authz.tuples import ObjectRef, RelationTuple, SubjectRef
28
29
 
29
30
 
31
+ @dataclass(frozen=True)
32
+ class CheckItem:
33
+ subject: str
34
+ permission: str
35
+ resource: str
36
+ context: Mapping[str, Any] | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EvaluationDecision:
41
+ allowed: bool
42
+ subject: str
43
+ permission: str
44
+ resource: str
45
+ matched_tuples: tuple[RelationTuple, ...] = ()
46
+ visited_path: tuple[str, ...] = ()
47
+ condition_results: tuple[tuple[str, bool], ...] = ()
48
+ reason: str = ""
49
+
50
+
30
51
  @dataclass(frozen=True)
31
52
  class UsersetTree:
32
53
  relation: str
@@ -35,6 +56,15 @@ class UsersetTree:
35
56
  children: tuple["UsersetTree", ...] = ()
36
57
 
37
58
 
59
+ class _TraceCollector:
60
+ __slots__ = ("visited_path", "matched_tuples", "condition_results")
61
+
62
+ def __init__(self) -> None:
63
+ self.visited_path: list[str] = []
64
+ self.matched_tuples: list[RelationTuple] = []
65
+ self.condition_results: list[tuple[str, bool]] = []
66
+
67
+
38
68
  class Client:
39
69
  def __init__(
40
70
  self,
@@ -60,6 +90,14 @@ class Client:
60
90
  def register_condition(self, name: str, fn: ConditionFn) -> None:
61
91
  self._conditions.register(name, fn)
62
92
 
93
+ @property
94
+ def schema(self) -> dict[str, Namespace]:
95
+ return self._namespaces
96
+
97
+ @property
98
+ def _schema(self) -> dict[str, Namespace]:
99
+ return self._namespaces
100
+
63
101
  def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
64
102
  ns = self._namespaces.get(namespace)
65
103
  if ns is None:
@@ -110,6 +148,32 @@ class Client:
110
148
  for t in tuples:
111
149
  self._validate_tuple(t)
112
150
  async with self._storage.transaction():
151
+ target_subjects: dict[tuple[str, str, str], list[str]] = {}
152
+ for t in tuples:
153
+ ns = self._namespaces[t.namespace]
154
+ max_limit = ns.max_subjects_for(t.relation)
155
+ if max_limit is not None:
156
+ target_key = (t.namespace, t.object_id, t.relation)
157
+ if target_key not in target_subjects:
158
+ existing = await self._storage.read_tuples(
159
+ t.namespace, object_id=t.object_id, relation=t.relation
160
+ )
161
+ target_subjects[target_key] = list(
162
+ dict.fromkeys(et.subject for et in existing)
163
+ )
164
+ current = target_subjects[target_key]
165
+ if t.subject not in current:
166
+ if len(current) >= max_limit:
167
+ raise CardinalityViolationError(
168
+ namespace=t.namespace,
169
+ object_id=t.object_id,
170
+ relation=t.relation,
171
+ max_subjects=max_limit,
172
+ existing_subjects=tuple(current),
173
+ attempted_subject=t.subject,
174
+ )
175
+ current.append(t.subject)
176
+
113
177
  for t in tuples:
114
178
  if self._is_graph_edge(t):
115
179
  await self._guard_against_cycle(t)
@@ -136,6 +200,28 @@ class Client:
136
200
  namespace, object_id=object_id, relation=relation, subject=subject
137
201
  )
138
202
 
203
+ async def _read_tuples(
204
+ self,
205
+ namespace: str,
206
+ object_id: str | None = None,
207
+ relation: str | None = None,
208
+ subject: str | None = None,
209
+ *,
210
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
211
+ ) -> list[RelationTuple]:
212
+ if read_cache is not None:
213
+ key = (namespace, object_id, relation, subject)
214
+ if key in read_cache:
215
+ return read_cache[key]
216
+ result = await self._storage.read_tuples(
217
+ namespace, object_id=object_id, relation=relation, subject=subject
218
+ )
219
+ read_cache[key] = result
220
+ return result
221
+ return await self._storage.read_tuples(
222
+ namespace, object_id=object_id, relation=relation, subject=subject
223
+ )
224
+
139
225
  async def check(
140
226
  self,
141
227
  subject: str,
@@ -143,15 +229,163 @@ class Client:
143
229
  resource: str,
144
230
  *,
145
231
  at_least_as_fresh_as: Token | None = None,
232
+ context: Mapping[str, Any] | None = None,
233
+ _read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
146
234
  ) -> bool:
147
235
  if at_least_as_fresh_as is not None:
148
236
  await self._storage.wait_for_token(at_least_as_fresh_as)
149
237
  target = ObjectRef.parse(resource)
150
238
  self._get_relation(target.namespace, permission)
151
239
  return await self._check_relation(
152
- subject, target.namespace, target.object_id, permission, []
240
+ subject,
241
+ target.namespace,
242
+ target.object_id,
243
+ permission,
244
+ [],
245
+ context=context,
246
+ read_cache=_read_cache,
153
247
  )
154
248
 
249
+ async def explain(
250
+ self,
251
+ subject: str,
252
+ permission: str,
253
+ resource: str,
254
+ *,
255
+ at_least_as_fresh_as: Token | None = None,
256
+ context: Mapping[str, Any] | None = None,
257
+ ) -> EvaluationDecision:
258
+ if at_least_as_fresh_as is not None:
259
+ await self._storage.wait_for_token(at_least_as_fresh_as)
260
+ target = ObjectRef.parse(resource)
261
+ self._get_relation(target.namespace, permission)
262
+
263
+ collector = _TraceCollector()
264
+ allowed = await self._check_relation(
265
+ subject,
266
+ target.namespace,
267
+ target.object_id,
268
+ permission,
269
+ [],
270
+ context=context,
271
+ collector=collector,
272
+ )
273
+
274
+ target_node = f"{resource}#{permission}"
275
+ if allowed:
276
+ matched = tuple(collector.matched_tuples)
277
+ chain = [subject]
278
+ for t in matched:
279
+ node = f"{t.namespace}:{t.object_id}#{t.relation}"
280
+ if node not in chain:
281
+ chain.append(node)
282
+ if target_node not in chain:
283
+ chain.append(target_node)
284
+ reason = f"Allowed via {' -> '.join(chain)}"
285
+ else:
286
+ matched = ()
287
+ reason = f"Denied: no matching relation or condition failed for {subject} on {target_node}"
288
+
289
+ return EvaluationDecision(
290
+ allowed=allowed,
291
+ subject=subject,
292
+ permission=permission,
293
+ resource=resource,
294
+ matched_tuples=matched,
295
+ visited_path=tuple(collector.visited_path),
296
+ condition_results=tuple(collector.condition_results),
297
+ reason=reason,
298
+ )
299
+
300
+ async def check_batch(
301
+ self,
302
+ checks: Sequence[CheckItem],
303
+ *,
304
+ at_least_as_fresh_as: Token | None = None,
305
+ ) -> list[bool]:
306
+ if at_least_as_fresh_as is not None:
307
+ await self._storage.wait_for_token(at_least_as_fresh_as)
308
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] = {}
309
+ results: list[bool] = []
310
+ for item in checks:
311
+ res = await self.check(
312
+ item.subject,
313
+ item.permission,
314
+ item.resource,
315
+ context=item.context,
316
+ _read_cache=read_cache,
317
+ )
318
+ results.append(res)
319
+ return results
320
+
321
+ async def list_permissions(
322
+ self,
323
+ subject: str,
324
+ resource: str,
325
+ permissions: Sequence[str] | None = None,
326
+ *,
327
+ at_least_as_fresh_as: Token | None = None,
328
+ context: Mapping[str, Any] | None = None,
329
+ ) -> list[str]:
330
+ target = ObjectRef.parse(resource)
331
+ ns = self._namespaces.get(target.namespace)
332
+ if ns is None:
333
+ raise NamespaceNotFound(target.namespace)
334
+ if permissions is None:
335
+ candidate_perms = list(ns.relations.keys())
336
+ else:
337
+ candidate_perms = list(permissions)
338
+ checks = [
339
+ CheckItem(
340
+ subject=subject,
341
+ permission=perm,
342
+ resource=resource,
343
+ context=context,
344
+ )
345
+ for perm in candidate_perms
346
+ ]
347
+ results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
348
+ return [perm for perm, allowed in zip(candidate_perms, results) if allowed]
349
+
350
+ async def list_objects(
351
+ self,
352
+ subject: str,
353
+ permission: str,
354
+ namespace: str,
355
+ candidate_ids: Sequence[str] | None = None,
356
+ *,
357
+ at_least_as_fresh_as: Token | None = None,
358
+ context: Mapping[str, Any] | None = None,
359
+ ) -> list[str]:
360
+ self._get_relation(namespace, permission)
361
+ if candidate_ids is None:
362
+ if at_least_as_fresh_as is not None:
363
+ await self._storage.wait_for_token(at_least_as_fresh_as)
364
+ at_least_as_fresh_as = None
365
+ tuples = await self._storage.read_tuples(namespace)
366
+ seen: set[str] = set()
367
+ discovered: list[str] = []
368
+ for t in tuples:
369
+ if t.object_id not in seen:
370
+ seen.add(t.object_id)
371
+ discovered.append(t.object_id)
372
+ candidate_ids = discovered
373
+
374
+ if not candidate_ids:
375
+ return []
376
+
377
+ checks = [
378
+ CheckItem(
379
+ subject=subject,
380
+ permission=permission,
381
+ resource=f"{namespace}:{oid}",
382
+ context=context,
383
+ )
384
+ for oid in candidate_ids
385
+ ]
386
+ results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
387
+ return [oid for oid, allowed in zip(candidate_ids, results) if allowed]
388
+
155
389
  async def _check_relation(
156
390
  self,
157
391
  subject: str,
@@ -159,58 +393,166 @@ class Client:
159
393
  object_id: str,
160
394
  relation: str,
161
395
  path: list[tuple[str, str, str]],
396
+ *,
397
+ context: Mapping[str, Any] | None = None,
398
+ collector: _TraceCollector | None = None,
399
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
162
400
  ) -> bool:
163
401
  node = (namespace, object_id, relation)
402
+ node_str = f"{namespace}:{object_id}#{relation}"
403
+ if collector is not None and node_str not in collector.visited_path:
404
+ collector.visited_path.append(node_str)
405
+
164
406
  if node in path:
165
407
  trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
166
- trail.append(f"{namespace}:{object_id}#{relation}")
408
+ trail.append(node_str)
167
409
  raise CycleDetected(trail)
168
410
  if len(path) >= self._max_depth:
169
411
  raise DepthLimitExceeded(self._max_depth)
170
412
  rule = self._get_relation(namespace, relation)
171
413
  return await self._eval_rule(
172
- rule, subject, namespace, object_id, relation, path + [node]
414
+ rule,
415
+ subject,
416
+ namespace,
417
+ object_id,
418
+ relation,
419
+ path + [node],
420
+ context=context,
421
+ collector=collector,
422
+ read_cache=read_cache,
173
423
  )
174
424
 
175
425
  async def _eval_rule(
176
426
  self,
177
- rule,
427
+ rule: RewriteRule,
178
428
  subject: str,
179
429
  namespace: str,
180
430
  object_id: str,
181
431
  relation: str,
182
432
  path: list[tuple[str, str, str]],
433
+ *,
434
+ context: Mapping[str, Any] | None = None,
435
+ collector: _TraceCollector | None = None,
436
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
183
437
  ) -> bool:
184
438
  if isinstance(rule, Direct):
185
- return await self._eval_direct(subject, namespace, object_id, relation, path)
439
+ return await self._eval_direct(
440
+ subject,
441
+ namespace,
442
+ object_id,
443
+ relation,
444
+ path,
445
+ context=context,
446
+ collector=collector,
447
+ read_cache=read_cache,
448
+ )
186
449
  if isinstance(rule, Union):
187
450
  for child in rule.children:
188
- if await self._eval_rule(child, subject, namespace, object_id, relation, path):
451
+ mark = len(collector.matched_tuples) if collector is not None else 0
452
+ if await self._eval_rule(
453
+ child,
454
+ subject,
455
+ namespace,
456
+ object_id,
457
+ relation,
458
+ path,
459
+ context=context,
460
+ collector=collector,
461
+ read_cache=read_cache,
462
+ ):
189
463
  return True
464
+ if collector is not None:
465
+ del collector.matched_tuples[mark:]
190
466
  return False
191
467
  if isinstance(rule, Intersection):
468
+ mark = len(collector.matched_tuples) if collector is not None else 0
192
469
  for child in rule.children:
193
- if not await self._eval_rule(child, subject, namespace, object_id, relation, path):
470
+ if not await self._eval_rule(
471
+ child,
472
+ subject,
473
+ namespace,
474
+ object_id,
475
+ relation,
476
+ path,
477
+ context=context,
478
+ collector=collector,
479
+ read_cache=read_cache,
480
+ ):
481
+ if collector is not None:
482
+ del collector.matched_tuples[mark:]
194
483
  return False
195
484
  return True
196
485
  if isinstance(rule, Exclusion):
197
- if not await self._eval_rule(rule.base, subject, namespace, object_id, relation, path):
486
+ mark = len(collector.matched_tuples) if collector is not None else 0
487
+ if not await self._eval_rule(
488
+ rule.base,
489
+ subject,
490
+ namespace,
491
+ object_id,
492
+ relation,
493
+ path,
494
+ context=context,
495
+ collector=collector,
496
+ read_cache=read_cache,
497
+ ):
498
+ if collector is not None:
499
+ del collector.matched_tuples[mark:]
198
500
  return False
199
- return not await self._eval_rule(
200
- rule.subtract, subject, namespace, object_id, relation, path
501
+ sub_mark = len(collector.matched_tuples) if collector is not None else 0
502
+ sub_result = await self._eval_rule(
503
+ rule.subtract,
504
+ subject,
505
+ namespace,
506
+ object_id,
507
+ relation,
508
+ path,
509
+ context=context,
510
+ collector=collector,
511
+ read_cache=read_cache,
201
512
  )
513
+ if collector is not None:
514
+ del collector.matched_tuples[sub_mark:]
515
+ if sub_result:
516
+ if collector is not None:
517
+ del collector.matched_tuples[mark:]
518
+ return False
519
+ return True
202
520
  if isinstance(rule, From):
203
- return await self._check_relation(subject, namespace, object_id, rule.relation, path)
521
+ return await self._check_relation(
522
+ subject,
523
+ namespace,
524
+ object_id,
525
+ rule.relation,
526
+ path,
527
+ context=context,
528
+ collector=collector,
529
+ read_cache=read_cache,
530
+ )
204
531
  if isinstance(rule, TupleToUserset):
205
- parents = await self._storage.read_tuples(
206
- namespace, object_id=object_id, relation=rule.tupleset_relation
532
+ parents = await self._read_tuples(
533
+ namespace,
534
+ object_id=object_id,
535
+ relation=rule.tupleset_relation,
536
+ read_cache=read_cache,
207
537
  )
208
538
  for pt in parents:
209
539
  pref = pt.subject_ref
540
+ mark = len(collector.matched_tuples) if collector is not None else 0
210
541
  if await self._check_relation(
211
- subject, pref.namespace, pref.object_id, rule.computed_relation, path
542
+ subject,
543
+ pref.namespace,
544
+ pref.object_id,
545
+ rule.computed_relation,
546
+ path,
547
+ context=context,
548
+ collector=collector,
549
+ read_cache=read_cache,
212
550
  ):
551
+ if collector is not None:
552
+ collector.matched_tuples.append(pt)
213
553
  return True
554
+ if collector is not None:
555
+ del collector.matched_tuples[mark:]
214
556
  return False
215
557
  raise TypeError(f"unsupported rewrite rule: {rule!r}")
216
558
 
@@ -221,25 +563,48 @@ class Client:
221
563
  object_id: str,
222
564
  relation: str,
223
565
  path: list[tuple[str, str, str]],
566
+ *,
567
+ context: Mapping[str, Any] | None = None,
568
+ collector: _TraceCollector | None = None,
569
+ read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
224
570
  ) -> bool:
225
571
  subject_ref = SubjectRef.parse(subject)
226
- tuples = await self._storage.read_tuples(namespace, object_id=object_id, relation=relation)
572
+ tuples = await self._read_tuples(
573
+ namespace, object_id=object_id, relation=relation, read_cache=read_cache
574
+ )
227
575
  for t in tuples:
228
576
  if t.condition is not None:
577
+ merged_ctx = {**t.condition.context, **(context or {})}
229
578
  holds = await self._conditions.evaluate(
230
- t.condition.name, subject, f"{namespace}:{object_id}", t.condition.context
579
+ t.condition.name, subject, f"{namespace}:{object_id}", merged_ctx
231
580
  )
581
+ if collector is not None:
582
+ collector.condition_results.append((t.condition.name, holds))
232
583
  if not holds:
233
584
  continue
234
585
  tref = t.subject_ref
235
586
  if not tref.is_userset:
236
587
  if tref.namespace == subject_ref.namespace and tref.object_id == subject_ref.object_id:
588
+ if collector is not None:
589
+ collector.matched_tuples.append(t)
237
590
  return True
238
591
  else:
592
+ mark = len(collector.matched_tuples) if collector is not None else 0
239
593
  if await self._check_relation(
240
- subject, tref.namespace, tref.object_id, tref.relation, path
594
+ subject,
595
+ tref.namespace,
596
+ tref.object_id,
597
+ tref.relation,
598
+ path,
599
+ context=context,
600
+ collector=collector,
601
+ read_cache=read_cache,
241
602
  ):
603
+ if collector is not None:
604
+ collector.matched_tuples.append(t)
242
605
  return True
606
+ if collector is not None:
607
+ del collector.matched_tuples[mark:]
243
608
  return False
244
609
 
245
610
  async def expand(self, permission: str, resource: str) -> "UsersetTree":
authz/errors.py CHANGED
@@ -48,3 +48,28 @@ class DepthLimitExceeded(AuthError):
48
48
  def __init__(self, max_depth: int):
49
49
  super().__init__(f"recursion exceeded max_depth={max_depth}")
50
50
  self.max_depth = max_depth
51
+
52
+
53
+ class CardinalityViolationError(AuthError):
54
+ """Raised when writing a tuple would exceed a relation's max_subjects limit (e.g. singleton org_admin)."""
55
+
56
+ def __init__(
57
+ self,
58
+ namespace: str,
59
+ object_id: str,
60
+ relation: str,
61
+ max_subjects: int,
62
+ existing_subjects: tuple[str, ...],
63
+ attempted_subject: str,
64
+ ) -> None:
65
+ self.namespace = namespace
66
+ self.object_id = object_id
67
+ self.relation = relation
68
+ self.max_subjects = max_subjects
69
+ self.existing_subjects = existing_subjects
70
+ self.attempted_subject = attempted_subject
71
+ super().__init__(
72
+ f"Cardinality violation on {namespace}:{object_id}#{relation}: "
73
+ f"max_subjects={max_subjects}, existing={list(existing_subjects)}, "
74
+ f"attempted={attempted_subject!r}"
75
+ )
@@ -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
authz/schema.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections.abc import Iterable, Mapping, Sequence
1
2
  from typing import Iterator
2
3
 
3
4
  from authz.errors import SchemaCycleDetected
@@ -55,9 +56,57 @@ class TupleToUserset(RewriteRule):
55
56
 
56
57
 
57
58
  class Namespace:
58
- def __init__(self, name: str, relations: dict[str, RewriteRule]):
59
+ def __init__(
60
+ self,
61
+ name: str,
62
+ relations: Mapping[str, RewriteRule],
63
+ singleton_relations: Iterable[str] | None = None,
64
+ max_subjects: Mapping[str, int] | None = None,
65
+ ) -> None:
59
66
  self.name = name
60
- self.relations = relations
67
+ self.relations = relations # type: ignore[assignment]
68
+ self.singleton_relations: frozenset[str] = (
69
+ frozenset(singleton_relations) if singleton_relations is not None else frozenset()
70
+ )
71
+ self.max_subjects: Mapping[str, int] = (
72
+ dict(max_subjects) if max_subjects is not None else {}
73
+ )
74
+
75
+ def max_subjects_for(self, relation: str) -> int | None:
76
+ if relation in self.singleton_relations:
77
+ return 1
78
+ return self.max_subjects.get(relation)
79
+
80
+
81
+ def build_role_hierarchy(
82
+ roles_high_to_low: Sequence[str],
83
+ *,
84
+ extra_relations: Mapping[str, RewriteRule] | None = None,
85
+ ) -> dict[str, RewriteRule]:
86
+ """Build a role hierarchy with transitive downward inheritance and strict upward override relations.
87
+
88
+ Given an ordered sequence of roles from highest to lowest (e.g. ['org_admin', 'org_manager', ...]):
89
+ - Index 0 (top role): Direct() and override_<top_role> = From(<top_role>)
90
+ - Index i > 0: Union(Direct(), From(<superior_role>)) and override_<role> = From(<superior_role>)
91
+ - Merges any extra_relations provided.
92
+ """
93
+ if len(roles_high_to_low) != len(set(roles_high_to_low)):
94
+ raise ValueError("Role hierarchy must not contain duplicate roles")
95
+
96
+ relations: dict[str, RewriteRule] = {}
97
+ for i, role in enumerate(roles_high_to_low):
98
+ if i == 0:
99
+ relations[role] = Direct()
100
+ relations[f"override_{role}"] = From(role)
101
+ else:
102
+ superior_role = roles_high_to_low[i - 1]
103
+ relations[role] = Union(Direct(), From(superior_role))
104
+ relations[f"override_{role}"] = From(superior_role)
105
+
106
+ if extra_relations is not None:
107
+ relations.update(extra_relations)
108
+
109
+ return relations
61
110
 
62
111
 
63
112
  def _from_edges(rule: RewriteRule) -> Iterator[str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-authz
3
- Version: 0.5.0
3
+ Version: 0.7.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,17 @@ kept in agreement with each other.
53
53
 
54
54
  ## Status
55
55
 
56
- Early release, version 0.5:
56
+ Release, version 0.7.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
+ - **Singleton & Cardinality Constraints**: Enforce subject limits on relations (e.g. at most 1 `org_admin`) via `singleton_relations` and `max_subjects` on `Namespace`, raising `CardinalityViolationError` on violations while maintaining idempotent rewrites.
59
+ - **Declarative Role Hierarchies & Strict Upward Overrides**: `build_role_hierarchy(roles_high_to_low, extra_relations=...)` creates multi-tier role hierarchies with transitive downward inheritance and strict upward override relations (`override_<role>`).
60
+ - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
61
+ - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
62
+ - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
63
+ - **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
64
+ - **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
65
+ - **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
66
+ - Async SQLite and PostgreSQL storage backends.
63
67
 
64
68
  ## Roadmap
65
69
 
@@ -126,6 +130,37 @@ It covers schema vs. data cycles, which rewrite rule (`Union`,
126
130
  situation, and when a depth-limit error means your data is legitimately
127
131
  deep versus something bypassed the guards.
128
132
 
133
+ ## Role Hierarchies & Cardinality Constraints
134
+
135
+ v0.7.0 adds first-class support for ordered role tiers with upward override relations and singleton limits:
136
+
137
+ ```python
138
+ from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
139
+
140
+ # Build 5-tier role relations with downward inheritance and override_<role> relations
141
+ roles = build_role_hierarchy([
142
+ "org_admin",
143
+ "org_manager",
144
+ "tpa_coordinator",
145
+ "org_user",
146
+ "diagnostic_billing_staff",
147
+ ])
148
+
149
+ # Enforce singleton constraint: an organization can have at most ONE org_admin
150
+ org_ns = Namespace("org", roles, singleton_relations={"org_admin"})
151
+ client.register_schema(org_ns)
152
+
153
+ # Alice is assigned org_admin -> inherits org_manager, tpa_coordinator, etc.
154
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:alice")])
155
+ assert await client.check("user:alice", "org_user", "org:acme") is True
156
+
157
+ # Bob cannot also be assigned org_admin on the same org
158
+ try:
159
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:bob")])
160
+ except CardinalityViolationError as e:
161
+ print(f"Singleton violation: {e}")
162
+ ```
163
+
129
164
  ## FastAPI Integration Example
130
165
 
131
166
  A route needs one thing from an authorization library: block the request
@@ -0,0 +1,17 @@
1
+ authz/__init__.py,sha256=yGOq69ryEECmnSAVOgvGC5f5E2oLs6Xr2Z3TgkqBTFw,1565
2
+ authz/conditions.py,sha256=3MxE524bsdwjkCR-9yTyri_OstWpsHFFrjNr9n5cQQ4,3449
3
+ authz/engine.py,sha256=TcJycywjSXN5zcMn-WKt0IXpAD00VdkSlUQf52AVo8c,23995
4
+ authz/errors.py,sha256=gzYBGjnn7bSU81S-13LMVVTBBmDeCVzTykM0QUtppSw,2388
5
+ authz/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ authz/integrations/fastapi.py,sha256=PR0RLH0Xs5vg9c2ZZJXKh0N8nBTyNjPj4I0DLQejk8U,2749
7
+ authz/schema.py,sha256=WBRMhEE63l7fXZBfNO-_1lvcTeP8urgekImOcqqrywg,5068
8
+ authz/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ authz/storage/base.py,sha256=I0My-ByzNnVjH_UH37YnRfVQl8_EtCurA-UtJfcviL8,723
10
+ authz/storage/postgres.py,sha256=x_9OxEKkD1Glp-luaFfHWzq4KQfc-H92z2ggBOOCgR8,6595
11
+ authz/storage/sqlite.py,sha256=Ua4vWSvsOtbfdEEv4UDfB8NVJaaZ4T9dKU3DkmUi6Cc,5248
12
+ authz/tuples.py,sha256=NR4WpFxMj_9u2bPMyrVC8ajxbqoMUGLYIq3Rt8HZmM0,2005
13
+ python_authz-0.7.0.dist-info/licenses/LICENSE,sha256=1e3AR9C0E8RKy14RPFwHKLoYHryFj4Gwiio0vyQtDVQ,1069
14
+ python_authz-0.7.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
15
+ python_authz-0.7.0.dist-info/entry_points.txt,sha256=74k2ityJQ4AAFVFhVZddedlmWS4P4ZLAqrj8XTMD9Nk,38
16
+ python_authz-0.7.0.dist-info/METADATA,sha256=spvE3nRZRDIzTz-p5Q5vXB2bc63wNl6yTs1pZRmvPfQ,8585
17
+ python_authz-0.7.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
@@ -1,17 +0,0 @@
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
4
- authz/errors.py,sha256=6DXssJp_KvCWY-DwKEtS4gnHXBl4Tbg1io84eLhL-SQ,1522
5
- authz/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- authz/integrations/fastapi.py,sha256=SB3IAQ1yQDyAh5NW9hFfXx_psBWFZ7HSABJ5FJ7iYew,1091
7
- authz/schema.py,sha256=ALRdTg2CuUa7mjwN30zgAN3V2iYPUMq6ICv6IppUUeg,3118
8
- authz/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- authz/storage/base.py,sha256=I0My-ByzNnVjH_UH37YnRfVQl8_EtCurA-UtJfcviL8,723
10
- authz/storage/postgres.py,sha256=x_9OxEKkD1Glp-luaFfHWzq4KQfc-H92z2ggBOOCgR8,6595
11
- authz/storage/sqlite.py,sha256=Ua4vWSvsOtbfdEEv4UDfB8NVJaaZ4T9dKU3DkmUi6Cc,5248
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,,