python-authz 0.6.0__tar.gz → 0.7.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.6.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,8 +53,10 @@ kept in agreement with each other.
53
53
 
54
54
  ## Status
55
55
 
56
- Release, version 0.6.0:
56
+ Release, version 0.7.0:
57
57
 
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>`).
58
60
  - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
59
61
  - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
60
62
  - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
@@ -128,6 +130,37 @@ It covers schema vs. data cycles, which rewrite rule (`Union`,
128
130
  situation, and when a depth-limit error means your data is legitimately
129
131
  deep versus something bypassed the guards.
130
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
+
131
164
  ## FastAPI Integration Example
132
165
 
133
166
  A route needs one thing from an authorization library: block the request
@@ -26,8 +26,10 @@ kept in agreement with each other.
26
26
 
27
27
  ## Status
28
28
 
29
- Release, version 0.6.0:
29
+ Release, version 0.7.0:
30
30
 
31
+ - **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.
32
+ - **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>`).
31
33
  - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
32
34
  - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
33
35
  - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
@@ -101,6 +103,37 @@ It covers schema vs. data cycles, which rewrite rule (`Union`,
101
103
  situation, and when a depth-limit error means your data is legitimately
102
104
  deep versus something bypassed the guards.
103
105
 
106
+ ## Role Hierarchies & Cardinality Constraints
107
+
108
+ v0.7.0 adds first-class support for ordered role tiers with upward override relations and singleton limits:
109
+
110
+ ```python
111
+ from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
112
+
113
+ # Build 5-tier role relations with downward inheritance and override_<role> relations
114
+ roles = build_role_hierarchy([
115
+ "org_admin",
116
+ "org_manager",
117
+ "tpa_coordinator",
118
+ "org_user",
119
+ "diagnostic_billing_staff",
120
+ ])
121
+
122
+ # Enforce singleton constraint: an organization can have at most ONE org_admin
123
+ org_ns = Namespace("org", roles, singleton_relations={"org_admin"})
124
+ client.register_schema(org_ns)
125
+
126
+ # Alice is assigned org_admin -> inherits org_manager, tpa_coordinator, etc.
127
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:alice")])
128
+ assert await client.check("user:alice", "org_user", "org:acme") is True
129
+
130
+ # Bob cannot also be assigned org_admin on the same org
131
+ try:
132
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:bob")])
133
+ except CardinalityViolationError as e:
134
+ print(f"Singleton violation: {e}")
135
+ ```
136
+
104
137
  ## FastAPI Integration Example
105
138
 
106
139
  A route needs one thing from an authorization library: block the request
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-authz"
3
- version = "0.6.0"
3
+ version = "0.7.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.6.0"
3
+ version = "0.7.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"
@@ -6,6 +6,7 @@ from authz.conditions import (
6
6
  from authz.engine import CheckItem, Client, EvaluationDecision, UsersetTree
7
7
  from authz.errors import (
8
8
  AuthError,
9
+ CardinalityViolationError,
9
10
  ConditionNotFound,
10
11
  CycleDetected,
11
12
  DepthLimitExceeded,
@@ -23,6 +24,7 @@ from authz.schema import (
23
24
  RewriteRule,
24
25
  TupleToUserset,
25
26
  Union,
27
+ build_role_hierarchy,
26
28
  )
27
29
  from authz.storage.base import Storage, Token
28
30
  from authz.storage.postgres import PostgresStorage
@@ -31,6 +33,7 @@ from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
31
33
 
32
34
  __all__ = [
33
35
  "AuthError",
36
+ "CardinalityViolationError",
34
37
  "CheckItem",
35
38
  "Client",
36
39
  "Condition",
@@ -60,6 +63,7 @@ __all__ = [
60
63
  "Union",
61
64
  "UsersetTree",
62
65
  "WouldCreateCycle",
66
+ "build_role_hierarchy",
63
67
  "builtin_conditions",
64
68
  ]
65
69
 
@@ -4,6 +4,7 @@ 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,
@@ -147,6 +148,32 @@ class Client:
147
148
  for t in tuples:
148
149
  self._validate_tuple(t)
149
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
+
150
177
  for t in tuples:
151
178
  if self._is_graph_edge(t):
152
179
  await self._guard_against_cycle(t)
@@ -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,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]:
File without changes