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.
- {python_authz-0.6.0 → python_authz-0.7.0}/PKG-INFO +35 -2
- {python_authz-0.6.0 → python_authz-0.7.0}/README.md +34 -1
- {python_authz-0.6.0 → python_authz-0.7.0}/pyproject.toml +1 -1
- {python_authz-0.6.0 → python_authz-0.7.0}/pyproject.toml.orig +1 -1
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/__init__.py +4 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/engine.py +27 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/errors.py +25 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/schema.py +51 -2
- {python_authz-0.6.0 → python_authz-0.7.0}/LICENSE +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/conditions.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/integrations/__init__.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/integrations/fastapi.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/storage/__init__.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/storage/base.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/storage/postgres.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/storage/sqlite.py +0 -0
- {python_authz-0.6.0 → python_authz-0.7.0}/src/authz/tuples.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-authz
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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
|
|
@@ -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__(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|