python-authz 0.5.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 +61 -0
- authz/conditions.py +37 -0
- authz/engine.py +297 -0
- authz/errors.py +50 -0
- authz/integrations/__init__.py +0 -0
- authz/integrations/fastapi.py +37 -0
- authz/schema.py +109 -0
- authz/storage/__init__.py +0 -0
- authz/storage/base.py +26 -0
- authz/storage/postgres.py +184 -0
- authz/storage/sqlite.py +154 -0
- authz/tuples.py +75 -0
- python_authz-0.5.0.dist-info/METADATA +166 -0
- python_authz-0.5.0.dist-info/RECORD +17 -0
- python_authz-0.5.0.dist-info/WHEEL +4 -0
- python_authz-0.5.0.dist-info/entry_points.txt +3 -0
- python_authz-0.5.0.dist-info/licenses/LICENSE +21 -0
authz/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from authz.conditions import ConditionEvaluator, PythonCallableEvaluator
|
|
2
|
+
from authz.engine import Client, UsersetTree
|
|
3
|
+
from authz.errors import (
|
|
4
|
+
AuthError,
|
|
5
|
+
ConditionNotFound,
|
|
6
|
+
CycleDetected,
|
|
7
|
+
DepthLimitExceeded,
|
|
8
|
+
NamespaceNotFound,
|
|
9
|
+
RelationNotFound,
|
|
10
|
+
SchemaCycleDetected,
|
|
11
|
+
WouldCreateCycle,
|
|
12
|
+
)
|
|
13
|
+
from authz.schema import (
|
|
14
|
+
Direct,
|
|
15
|
+
Exclusion,
|
|
16
|
+
From,
|
|
17
|
+
Intersection,
|
|
18
|
+
Namespace,
|
|
19
|
+
RewriteRule,
|
|
20
|
+
TupleToUserset,
|
|
21
|
+
Union,
|
|
22
|
+
)
|
|
23
|
+
from authz.storage.base import Storage, Token
|
|
24
|
+
from authz.storage.postgres import PostgresStorage
|
|
25
|
+
from authz.storage.sqlite import SqliteStorage
|
|
26
|
+
from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AuthError",
|
|
30
|
+
"Client",
|
|
31
|
+
"Condition",
|
|
32
|
+
"ConditionEvaluator",
|
|
33
|
+
"ConditionNotFound",
|
|
34
|
+
"CycleDetected",
|
|
35
|
+
"DepthLimitExceeded",
|
|
36
|
+
"Direct",
|
|
37
|
+
"Exclusion",
|
|
38
|
+
"From",
|
|
39
|
+
"Intersection",
|
|
40
|
+
"Namespace",
|
|
41
|
+
"NamespaceNotFound",
|
|
42
|
+
"ObjectRef",
|
|
43
|
+
"PostgresStorage",
|
|
44
|
+
"PythonCallableEvaluator",
|
|
45
|
+
"RelationNotFound",
|
|
46
|
+
"RelationTuple",
|
|
47
|
+
"RewriteRule",
|
|
48
|
+
"SchemaCycleDetected",
|
|
49
|
+
"SqliteStorage",
|
|
50
|
+
"Storage",
|
|
51
|
+
"SubjectRef",
|
|
52
|
+
"Token",
|
|
53
|
+
"TupleToUserset",
|
|
54
|
+
"Union",
|
|
55
|
+
"UsersetTree",
|
|
56
|
+
"WouldCreateCycle",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
print("Hello from authz!")
|
authz/conditions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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)
|
authz/engine.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
)
|
authz/errors.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class AuthError(Exception):
|
|
2
|
+
"""Base class for all authz errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NamespaceNotFound(AuthError):
|
|
6
|
+
def __init__(self, namespace: str):
|
|
7
|
+
super().__init__(f"namespace {namespace!r} is not registered")
|
|
8
|
+
self.namespace = namespace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RelationNotFound(AuthError):
|
|
12
|
+
def __init__(self, namespace: str, relation: str):
|
|
13
|
+
super().__init__(
|
|
14
|
+
f"relation {relation!r} is not defined on namespace {namespace!r}"
|
|
15
|
+
)
|
|
16
|
+
self.namespace = namespace
|
|
17
|
+
self.relation = relation
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConditionNotFound(AuthError):
|
|
21
|
+
def __init__(self, name: str):
|
|
22
|
+
super().__init__(f"condition {name!r} is not registered")
|
|
23
|
+
self.name = name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SchemaCycleDetected(AuthError):
|
|
27
|
+
def __init__(self, namespace: str, path: list[str]):
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"schema cycle in namespace {namespace!r}: {' -> '.join(path)}"
|
|
30
|
+
)
|
|
31
|
+
self.namespace = namespace
|
|
32
|
+
self.path = path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WouldCreateCycle(AuthError):
|
|
36
|
+
def __init__(self, tuple_, description: str):
|
|
37
|
+
super().__init__(f"writing {tuple_} would create a cycle: {description}")
|
|
38
|
+
self.tuple = tuple_
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CycleDetected(AuthError):
|
|
42
|
+
def __init__(self, path: list[str]):
|
|
43
|
+
super().__init__(f"cycle detected during check: {' -> '.join(path)}")
|
|
44
|
+
self.path = path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DepthLimitExceeded(AuthError):
|
|
48
|
+
def __init__(self, max_depth: int):
|
|
49
|
+
super().__init__(f"recursion exceeded max_depth={max_depth}")
|
|
50
|
+
self.max_depth = max_depth
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
authz/schema.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import Iterator
|
|
2
|
+
|
|
3
|
+
from authz.errors import SchemaCycleDetected
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RewriteRule:
|
|
7
|
+
"""Base class for relation-rewrite expressions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Direct(RewriteRule):
|
|
11
|
+
def __repr__(self) -> str:
|
|
12
|
+
return "Direct()"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Union(RewriteRule):
|
|
16
|
+
def __init__(self, *children: RewriteRule):
|
|
17
|
+
self.children = children
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return f"Union{self.children!r}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Intersection(RewriteRule):
|
|
24
|
+
def __init__(self, *children: RewriteRule):
|
|
25
|
+
self.children = children
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f"Intersection{self.children!r}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Exclusion(RewriteRule):
|
|
32
|
+
def __init__(self, base: RewriteRule, subtract: RewriteRule):
|
|
33
|
+
self.base = base
|
|
34
|
+
self.subtract = subtract
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"Exclusion({self.base!r}, {self.subtract!r})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class From(RewriteRule):
|
|
41
|
+
def __init__(self, relation: str):
|
|
42
|
+
self.relation = relation
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return f"From({self.relation!r})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TupleToUserset(RewriteRule):
|
|
49
|
+
def __init__(self, tupleset_relation: str, computed_relation: str):
|
|
50
|
+
self.tupleset_relation = tupleset_relation
|
|
51
|
+
self.computed_relation = computed_relation
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return f"TupleToUserset({self.tupleset_relation!r}, {self.computed_relation!r})"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Namespace:
|
|
58
|
+
def __init__(self, name: str, relations: dict[str, RewriteRule]):
|
|
59
|
+
self.name = name
|
|
60
|
+
self.relations = relations
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _from_edges(rule: RewriteRule) -> Iterator[str]:
|
|
64
|
+
if isinstance(rule, From):
|
|
65
|
+
yield rule.relation
|
|
66
|
+
elif isinstance(rule, (Union, Intersection)):
|
|
67
|
+
for child in rule.children:
|
|
68
|
+
yield from _from_edges(child)
|
|
69
|
+
elif isinstance(rule, Exclusion):
|
|
70
|
+
yield from _from_edges(rule.base)
|
|
71
|
+
yield from _from_edges(rule.subtract)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_schema_cycles(namespace: Namespace) -> None:
|
|
75
|
+
graph: dict[str, list[str]] = {
|
|
76
|
+
relation: list(_from_edges(rule))
|
|
77
|
+
for relation, rule in namespace.relations.items()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
visiting: set[str] = set()
|
|
81
|
+
visited: set[str] = set()
|
|
82
|
+
|
|
83
|
+
def dfs(relation: str, path: list[str]) -> None:
|
|
84
|
+
if relation in visiting:
|
|
85
|
+
raise SchemaCycleDetected(namespace.name, path + [relation])
|
|
86
|
+
if relation in visited or relation not in graph:
|
|
87
|
+
return
|
|
88
|
+
visiting.add(relation)
|
|
89
|
+
for neighbor in graph[relation]:
|
|
90
|
+
dfs(neighbor, path + [relation])
|
|
91
|
+
visiting.discard(relation)
|
|
92
|
+
visited.add(relation)
|
|
93
|
+
|
|
94
|
+
for relation in graph:
|
|
95
|
+
if relation not in visited:
|
|
96
|
+
dfs(relation, [])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tupleset_relations(rule: RewriteRule) -> set[str]:
|
|
100
|
+
if isinstance(rule, TupleToUserset):
|
|
101
|
+
return {rule.tupleset_relation}
|
|
102
|
+
if isinstance(rule, (Union, Intersection)):
|
|
103
|
+
result: set[str] = set()
|
|
104
|
+
for child in rule.children:
|
|
105
|
+
result |= tupleset_relations(child)
|
|
106
|
+
return result
|
|
107
|
+
if isinstance(rule, Exclusion):
|
|
108
|
+
return tupleset_relations(rule.base) | tupleset_relations(rule.subtract)
|
|
109
|
+
return set()
|
|
File without changes
|
authz/storage/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from contextlib import AbstractAsyncContextManager
|
|
2
|
+
from typing import Protocol, Sequence
|
|
3
|
+
|
|
4
|
+
from authz.tuples import RelationTuple
|
|
5
|
+
|
|
6
|
+
Token = str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Storage(Protocol):
|
|
10
|
+
async def write_tuples(self, tuples: Sequence[RelationTuple]) -> Token: ...
|
|
11
|
+
|
|
12
|
+
async def delete_tuples(self, tuples: Sequence[RelationTuple]) -> Token: ...
|
|
13
|
+
|
|
14
|
+
async def read_tuples(
|
|
15
|
+
self,
|
|
16
|
+
namespace: str,
|
|
17
|
+
object_id: str | None = None,
|
|
18
|
+
relation: str | None = None,
|
|
19
|
+
subject: str | None = None,
|
|
20
|
+
) -> list[RelationTuple]: ...
|
|
21
|
+
|
|
22
|
+
async def current_token(self) -> Token: ...
|
|
23
|
+
|
|
24
|
+
async def wait_for_token(self, token: Token) -> None: ...
|
|
25
|
+
|
|
26
|
+
def transaction(self) -> AbstractAsyncContextManager[None]: ...
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
import asyncpg
|
|
7
|
+
|
|
8
|
+
from authz.storage.base import Token
|
|
9
|
+
from authz.tuples import Condition, RelationTuple
|
|
10
|
+
|
|
11
|
+
_active_conn: ContextVar[asyncpg.Connection | None] = ContextVar(
|
|
12
|
+
"_active_conn", default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_SCHEMA_SQL = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS tuples (
|
|
17
|
+
change_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
18
|
+
namespace TEXT NOT NULL,
|
|
19
|
+
object_id TEXT NOT NULL,
|
|
20
|
+
relation TEXT NOT NULL,
|
|
21
|
+
subject TEXT NOT NULL,
|
|
22
|
+
condition_name TEXT,
|
|
23
|
+
condition_context JSONB,
|
|
24
|
+
UNIQUE(namespace, object_id, relation, subject)
|
|
25
|
+
);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_tuples_forward
|
|
27
|
+
ON tuples(namespace, object_id, relation);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_tuples_reverse
|
|
29
|
+
ON tuples(namespace, subject);
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_UPSERT_SQL = """
|
|
33
|
+
INSERT INTO tuples (namespace, object_id, relation, subject, condition_name, condition_context)
|
|
34
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
35
|
+
ON CONFLICT (namespace, object_id, relation, subject) DO UPDATE SET
|
|
36
|
+
condition_name = excluded.condition_name,
|
|
37
|
+
condition_context = excluded.condition_context
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_DELETE_SQL = """
|
|
41
|
+
DELETE FROM tuples WHERE namespace = $1 AND object_id = $2 AND relation = $3 AND subject = $4
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _row_to_tuple(row) -> RelationTuple:
|
|
46
|
+
condition = None
|
|
47
|
+
if row["condition_name"] is not None:
|
|
48
|
+
context = json.loads(row["condition_context"]) if row["condition_context"] else {}
|
|
49
|
+
condition = Condition(row["condition_name"], context)
|
|
50
|
+
return RelationTuple(
|
|
51
|
+
row["namespace"], row["object_id"], row["relation"], row["subject"],
|
|
52
|
+
condition=condition,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PostgresStorage:
|
|
57
|
+
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 10):
|
|
58
|
+
self._dsn = dsn
|
|
59
|
+
self._min_size = min_size
|
|
60
|
+
self._max_size = max_size
|
|
61
|
+
self._pool: asyncpg.Pool | None = None
|
|
62
|
+
|
|
63
|
+
async def connect(self) -> None:
|
|
64
|
+
self._pool = await asyncpg.create_pool(
|
|
65
|
+
self._dsn, min_size=self._min_size, max_size=self._max_size
|
|
66
|
+
)
|
|
67
|
+
async with self._pool.acquire() as conn:
|
|
68
|
+
await conn.execute(_SCHEMA_SQL)
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
if self._pool is not None:
|
|
72
|
+
await self._pool.close()
|
|
73
|
+
self._pool = None
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> "PostgresStorage":
|
|
76
|
+
await self.connect()
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
80
|
+
await self.close()
|
|
81
|
+
|
|
82
|
+
async def _connection(self) -> tuple[asyncpg.Connection, bool]:
|
|
83
|
+
conn = _active_conn.get()
|
|
84
|
+
if conn is not None:
|
|
85
|
+
return conn, False
|
|
86
|
+
return await self._pool.acquire(), True
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def transaction(self):
|
|
90
|
+
if _active_conn.get() is not None:
|
|
91
|
+
yield
|
|
92
|
+
return
|
|
93
|
+
async with self._pool.acquire() as conn:
|
|
94
|
+
token = _active_conn.set(conn)
|
|
95
|
+
try:
|
|
96
|
+
async with conn.transaction():
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
_active_conn.reset(token)
|
|
100
|
+
|
|
101
|
+
async def write_tuples(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
102
|
+
async with self.transaction():
|
|
103
|
+
conn, must_release = await self._connection()
|
|
104
|
+
try:
|
|
105
|
+
for t in tuples:
|
|
106
|
+
condition_name = t.condition.name if t.condition else None
|
|
107
|
+
condition_context = (
|
|
108
|
+
json.dumps(dict(t.condition.context)) if t.condition else None
|
|
109
|
+
)
|
|
110
|
+
await conn.execute(
|
|
111
|
+
_UPSERT_SQL,
|
|
112
|
+
t.namespace, t.object_id, t.relation, t.subject,
|
|
113
|
+
condition_name, condition_context,
|
|
114
|
+
)
|
|
115
|
+
finally:
|
|
116
|
+
if must_release:
|
|
117
|
+
await self._pool.release(conn)
|
|
118
|
+
return await self.current_token()
|
|
119
|
+
|
|
120
|
+
async def delete_tuples(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
121
|
+
async with self.transaction():
|
|
122
|
+
conn, must_release = await self._connection()
|
|
123
|
+
try:
|
|
124
|
+
for t in tuples:
|
|
125
|
+
await conn.execute(
|
|
126
|
+
_DELETE_SQL, t.namespace, t.object_id, t.relation, t.subject
|
|
127
|
+
)
|
|
128
|
+
finally:
|
|
129
|
+
if must_release:
|
|
130
|
+
await self._pool.release(conn)
|
|
131
|
+
return await self.current_token()
|
|
132
|
+
|
|
133
|
+
async def read_tuples(
|
|
134
|
+
self,
|
|
135
|
+
namespace: str,
|
|
136
|
+
object_id: str | None = None,
|
|
137
|
+
relation: str | None = None,
|
|
138
|
+
subject: str | None = None,
|
|
139
|
+
) -> list[RelationTuple]:
|
|
140
|
+
conn, must_release = await self._connection()
|
|
141
|
+
try:
|
|
142
|
+
query = (
|
|
143
|
+
"SELECT namespace, object_id, relation, subject, condition_name, "
|
|
144
|
+
"condition_context FROM tuples WHERE namespace = $1"
|
|
145
|
+
)
|
|
146
|
+
params: list[str] = [namespace]
|
|
147
|
+
if object_id is not None:
|
|
148
|
+
params.append(object_id)
|
|
149
|
+
query += f" AND object_id = ${len(params)}"
|
|
150
|
+
if relation is not None:
|
|
151
|
+
params.append(relation)
|
|
152
|
+
query += f" AND relation = ${len(params)}"
|
|
153
|
+
if subject is not None:
|
|
154
|
+
params.append(subject)
|
|
155
|
+
query += f" AND subject = ${len(params)}"
|
|
156
|
+
rows = await conn.fetch(query, *params)
|
|
157
|
+
return [_row_to_tuple(row) for row in rows]
|
|
158
|
+
finally:
|
|
159
|
+
if must_release:
|
|
160
|
+
await self._pool.release(conn)
|
|
161
|
+
|
|
162
|
+
async def current_token(self) -> Token:
|
|
163
|
+
conn, must_release = await self._connection()
|
|
164
|
+
try:
|
|
165
|
+
# last_value alone is misleading: Postgres pre-seeds a freshly
|
|
166
|
+
# created (or RESTART IDENTITY-reset) sequence's last_value to
|
|
167
|
+
# its start value even before anything has actually consumed
|
|
168
|
+
# it. is_called distinguishes "really advanced" from "just
|
|
169
|
+
# initialized" -- only count it once nextval() has actually run.
|
|
170
|
+
value = await conn.fetchval(
|
|
171
|
+
"SELECT CASE WHEN is_called THEN last_value ELSE 0 END "
|
|
172
|
+
"FROM tuples_change_id_seq"
|
|
173
|
+
)
|
|
174
|
+
return str(value)
|
|
175
|
+
finally:
|
|
176
|
+
if must_release:
|
|
177
|
+
await self._pool.release(conn)
|
|
178
|
+
|
|
179
|
+
async def wait_for_token(self, token: Token) -> None:
|
|
180
|
+
current = await self.current_token()
|
|
181
|
+
if int(current) < int(token):
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
f"storage is behind token {token!r} (current: {current!r})"
|
|
184
|
+
)
|
authz/storage/sqlite.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
import aiosqlite
|
|
6
|
+
|
|
7
|
+
from authz.storage.base import Token
|
|
8
|
+
from authz.tuples import Condition, RelationTuple
|
|
9
|
+
|
|
10
|
+
_SCHEMA_SQL = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS tuples (
|
|
12
|
+
change_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
namespace TEXT NOT NULL,
|
|
14
|
+
object_id TEXT NOT NULL,
|
|
15
|
+
relation TEXT NOT NULL,
|
|
16
|
+
subject TEXT NOT NULL,
|
|
17
|
+
condition_name TEXT,
|
|
18
|
+
condition_context TEXT,
|
|
19
|
+
UNIQUE(namespace, object_id, relation, subject)
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_tuples_forward
|
|
22
|
+
ON tuples(namespace, object_id, relation);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_tuples_reverse
|
|
24
|
+
ON tuples(namespace, subject);
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_UPSERT_SQL = """
|
|
28
|
+
INSERT INTO tuples (namespace, object_id, relation, subject, condition_name, condition_context)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT(namespace, object_id, relation, subject) DO UPDATE SET
|
|
31
|
+
condition_name = excluded.condition_name,
|
|
32
|
+
condition_context = excluded.condition_context
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_DELETE_SQL = """
|
|
36
|
+
DELETE FROM tuples WHERE namespace = ? AND object_id = ? AND relation = ? AND subject = ?
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _row_to_tuple(row: tuple) -> RelationTuple:
|
|
41
|
+
namespace, object_id, relation, subject, condition_name, condition_context = row
|
|
42
|
+
condition = None
|
|
43
|
+
if condition_name is not None:
|
|
44
|
+
condition = Condition(condition_name, json.loads(condition_context or "{}"))
|
|
45
|
+
return RelationTuple(namespace, object_id, relation, subject, condition=condition)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SqliteStorage:
|
|
49
|
+
def __init__(self, path: str):
|
|
50
|
+
self._path = path
|
|
51
|
+
self._conn: aiosqlite.Connection | None = None
|
|
52
|
+
self._in_transaction = False
|
|
53
|
+
|
|
54
|
+
async def connect(self) -> None:
|
|
55
|
+
self._conn = await aiosqlite.connect(self._path)
|
|
56
|
+
await self._conn.executescript(_SCHEMA_SQL)
|
|
57
|
+
await self._conn.commit()
|
|
58
|
+
|
|
59
|
+
async def close(self) -> None:
|
|
60
|
+
if self._conn is not None:
|
|
61
|
+
await self._conn.close()
|
|
62
|
+
self._conn = None
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> "SqliteStorage":
|
|
65
|
+
await self.connect()
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
69
|
+
await self.close()
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def transaction(self):
|
|
73
|
+
if self._in_transaction:
|
|
74
|
+
yield
|
|
75
|
+
return
|
|
76
|
+
self._in_transaction = True
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
await self._conn.commit()
|
|
80
|
+
except Exception:
|
|
81
|
+
await self._conn.rollback()
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
self._in_transaction = False
|
|
85
|
+
|
|
86
|
+
async def write_tuples(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
87
|
+
async with self.transaction():
|
|
88
|
+
for t in tuples:
|
|
89
|
+
condition_name = t.condition.name if t.condition else None
|
|
90
|
+
condition_context = (
|
|
91
|
+
json.dumps(dict(t.condition.context)) if t.condition else None
|
|
92
|
+
)
|
|
93
|
+
await self._conn.execute(
|
|
94
|
+
_UPSERT_SQL,
|
|
95
|
+
(
|
|
96
|
+
t.namespace,
|
|
97
|
+
t.object_id,
|
|
98
|
+
t.relation,
|
|
99
|
+
t.subject,
|
|
100
|
+
condition_name,
|
|
101
|
+
condition_context,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
return await self.current_token()
|
|
105
|
+
|
|
106
|
+
async def delete_tuples(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
107
|
+
async with self.transaction():
|
|
108
|
+
for t in tuples:
|
|
109
|
+
await self._conn.execute(
|
|
110
|
+
_DELETE_SQL, (t.namespace, t.object_id, t.relation, t.subject)
|
|
111
|
+
)
|
|
112
|
+
return await self.current_token()
|
|
113
|
+
|
|
114
|
+
async def read_tuples(
|
|
115
|
+
self,
|
|
116
|
+
namespace: str,
|
|
117
|
+
object_id: str | None = None,
|
|
118
|
+
relation: str | None = None,
|
|
119
|
+
subject: str | None = None,
|
|
120
|
+
) -> list[RelationTuple]:
|
|
121
|
+
query = (
|
|
122
|
+
"SELECT namespace, object_id, relation, subject, condition_name, "
|
|
123
|
+
"condition_context FROM tuples WHERE namespace = ?"
|
|
124
|
+
)
|
|
125
|
+
params: list[str] = [namespace]
|
|
126
|
+
if object_id is not None:
|
|
127
|
+
query += " AND object_id = ?"
|
|
128
|
+
params.append(object_id)
|
|
129
|
+
if relation is not None:
|
|
130
|
+
query += " AND relation = ?"
|
|
131
|
+
params.append(relation)
|
|
132
|
+
if subject is not None:
|
|
133
|
+
query += " AND subject = ?"
|
|
134
|
+
params.append(subject)
|
|
135
|
+
async with self._conn.execute(query, params) as cursor:
|
|
136
|
+
rows = await cursor.fetchall()
|
|
137
|
+
return [_row_to_tuple(row) for row in rows]
|
|
138
|
+
|
|
139
|
+
async def current_token(self) -> Token:
|
|
140
|
+
# MAX(change_id) over the live table would go backwards after a
|
|
141
|
+
# delete empties it; sqlite_sequence tracks the AUTOINCREMENT
|
|
142
|
+
# high-water mark, which only ever increases.
|
|
143
|
+
async with self._conn.execute(
|
|
144
|
+
"SELECT seq FROM sqlite_sequence WHERE name = 'tuples'"
|
|
145
|
+
) as cursor:
|
|
146
|
+
row = await cursor.fetchone()
|
|
147
|
+
return str(row[0] if row else 0)
|
|
148
|
+
|
|
149
|
+
async def wait_for_token(self, token: Token) -> None:
|
|
150
|
+
current = await self.current_token()
|
|
151
|
+
if int(current) < int(token):
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"storage is behind token {token!r} (current: {current!r})"
|
|
154
|
+
)
|
authz/tuples.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Mapping
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class ObjectRef:
|
|
7
|
+
namespace: str
|
|
8
|
+
object_id: str
|
|
9
|
+
|
|
10
|
+
def __str__(self) -> str:
|
|
11
|
+
return f"{self.namespace}:{self.object_id}"
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def parse(cls, raw: str) -> "ObjectRef":
|
|
15
|
+
namespace, colon, object_id = raw.partition(":")
|
|
16
|
+
if not colon:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"invalid object reference {raw!r}; expected 'namespace:object_id'"
|
|
19
|
+
)
|
|
20
|
+
return cls(namespace, object_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SubjectRef:
|
|
25
|
+
namespace: str
|
|
26
|
+
object_id: str
|
|
27
|
+
relation: str | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_userset(self) -> bool:
|
|
31
|
+
return self.relation is not None
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
base = f"{self.namespace}:{self.object_id}"
|
|
35
|
+
return f"{base}#{self.relation}" if self.relation else base
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def parse(cls, raw: str) -> "SubjectRef":
|
|
39
|
+
obj_part, sep, relation = raw.partition("#")
|
|
40
|
+
namespace, colon, object_id = obj_part.partition(":")
|
|
41
|
+
if not colon:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"invalid subject reference {raw!r}; expected 'namespace:object_id' "
|
|
44
|
+
"or 'namespace:object_id#relation'"
|
|
45
|
+
)
|
|
46
|
+
return cls(namespace, object_id, relation if sep else None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Condition:
|
|
51
|
+
name: str
|
|
52
|
+
context: Mapping[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RelationTuple:
|
|
57
|
+
namespace: str
|
|
58
|
+
object_id: str
|
|
59
|
+
relation: str
|
|
60
|
+
subject: str
|
|
61
|
+
condition: Condition | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def object(self) -> ObjectRef:
|
|
65
|
+
return ObjectRef(self.namespace, self.object_id)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def subject_ref(self) -> SubjectRef:
|
|
69
|
+
return SubjectRef.parse(self.subject)
|
|
70
|
+
|
|
71
|
+
def __str__(self) -> str:
|
|
72
|
+
return (
|
|
73
|
+
f"({self.namespace}, {self.object_id!r}, "
|
|
74
|
+
f"{self.relation!r}, {self.subject!r})"
|
|
75
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-authz
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph
|
|
5
|
+
Author: Anupam Gupta
|
|
6
|
+
Author-email: Anupam Gupta <anupam@trois.in>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Dist: duckdb>=1.5.5
|
|
18
|
+
Requires-Dist: aiosqlite>=0.21.0
|
|
19
|
+
Requires-Dist: asyncpg>=0.31.0
|
|
20
|
+
Requires-Dist: fastapi[all]>=0.141.1 ; extra == 'fastapi'
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://github.com/mapuna/python-authz
|
|
23
|
+
Project-URL: Repository, https://github.com/mapuna/python-authz
|
|
24
|
+
Project-URL: Issues, https://github.com/mapuna/python-authz/issues
|
|
25
|
+
Provides-Extra: fastapi
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# authz
|
|
29
|
+
|
|
30
|
+
A framework-agnostic ReBAC/RBAC/ABAC authorization engine.
|
|
31
|
+
|
|
32
|
+
## About
|
|
33
|
+
|
|
34
|
+
Most systems end up with three separate authorization mechanisms: roles
|
|
35
|
+
for admin/editor/viewer distinctions (RBAC), attribute rules for things
|
|
36
|
+
like "only during business hours" (ABAC), and some hand-rolled traversal
|
|
37
|
+
for hierarchies like folders or nested groups (ReBAC). `authz` implements
|
|
38
|
+
all three on one data structure instead: a graph of relationship facts,
|
|
39
|
+
each one a `(namespace, object, relation, subject)` tuple, in the style
|
|
40
|
+
of Google's Zanzibar paper.
|
|
41
|
+
|
|
42
|
+
A role is just a relation on an object, so "alice is an editor of
|
|
43
|
+
document:roadmap" is a tuple like any other. A folder hierarchy is a
|
|
44
|
+
traversal between tuples: "you can view a document if you can view its
|
|
45
|
+
parent folder" is one rule, applied uniformly no matter how deep the
|
|
46
|
+
folder tree goes. An attribute check is a condition attached to a
|
|
47
|
+
specific tuple, evaluated against the current subject and context at
|
|
48
|
+
check time. Because all three live in the same graph, a single `check()`
|
|
49
|
+
call can walk through a role, into a group, and across a parent/child
|
|
50
|
+
boundary in one pass, all using the same traversal logic, instead of
|
|
51
|
+
three separate mechanisms that would each need to be kept correct and
|
|
52
|
+
kept in agreement with each other.
|
|
53
|
+
|
|
54
|
+
## Status
|
|
55
|
+
|
|
56
|
+
Early release, version 0.5:
|
|
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/`)
|
|
63
|
+
|
|
64
|
+
## Roadmap
|
|
65
|
+
|
|
66
|
+
- DuckDB and MongoDB storage backends
|
|
67
|
+
- a standalone HTTP service mode beyond the sample app's admin routes
|
|
68
|
+
- a SAML auth integration recipe (OAuth2 and JWT are already covered)
|
|
69
|
+
- JSON-Logic and CEL `ConditionEvaluator` implementations, alongside the
|
|
70
|
+
existing Python-callable one
|
|
71
|
+
- Java, Kotlin, Rust, and C++ SDKs
|
|
72
|
+
- making `asyncpg`/`duckdb` and `pyjwt`/`httpx2` optional too, the way
|
|
73
|
+
`fastapi` already is
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv add python-authz
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quickstart
|
|
82
|
+
|
|
83
|
+
A schema declares, per namespace, how each relation is computed: `Direct()`
|
|
84
|
+
means "true when a matching tuple exists," `Union` combines several rules
|
|
85
|
+
with OR, and `TupleToUserset` re-checks a relation on a related object (the
|
|
86
|
+
mechanism behind "viewer of the parent folder implies viewer of the
|
|
87
|
+
document"). Once the schema is registered, `write()` adds facts to the
|
|
88
|
+
graph and `check()` answers a yes/no access question against it:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from authz import (
|
|
92
|
+
Client, Direct, From, Namespace, PythonCallableEvaluator,
|
|
93
|
+
RelationTuple, SqliteStorage, TupleToUserset, Union,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async with SqliteStorage(":memory:") as storage:
|
|
97
|
+
client = Client(storage=storage, condition_evaluator=PythonCallableEvaluator())
|
|
98
|
+
|
|
99
|
+
folder = Namespace("folder", {
|
|
100
|
+
"owner": Direct(),
|
|
101
|
+
"viewer": Union(Direct(), From("owner")),
|
|
102
|
+
})
|
|
103
|
+
document = Namespace("document", {
|
|
104
|
+
"parent": Direct(),
|
|
105
|
+
"viewer": Union(Direct(), TupleToUserset("parent", "viewer")),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
client.register_schema(folder, document)
|
|
109
|
+
|
|
110
|
+
await client.write([
|
|
111
|
+
RelationTuple("folder", "workspace", "owner", "user:alice"),
|
|
112
|
+
RelationTuple("document", "roadmap", "parent", "folder:workspace"),
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
await client.check("user:alice", "viewer", "document:roadmap") # True
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
See [`docs/quickstart.md`](docs/quickstart.md) for the full walkthrough
|
|
119
|
+
(groups, ABAC conditions, consistency tokens, `expand()`), and
|
|
120
|
+
[`examples/seed_data.py`](examples/seed_data.py) for a runnable version of
|
|
121
|
+
it: `python -m examples.seed_data`.
|
|
122
|
+
|
|
123
|
+
Before designing your own schema, read [`docs/pitfalls.md`](docs/pitfalls.md).
|
|
124
|
+
It covers schema vs. data cycles, which rewrite rule (`Union`,
|
|
125
|
+
`Intersection`, `Exclusion`, `From`, `TupleToUserset`) fits which
|
|
126
|
+
situation, and when a depth-limit error means your data is legitimately
|
|
127
|
+
deep versus something bypassed the guards.
|
|
128
|
+
|
|
129
|
+
## FastAPI Integration Example
|
|
130
|
+
|
|
131
|
+
A route needs one thing from an authorization library: block the request
|
|
132
|
+
before the handler runs if the caller lacks permission. `authz.integrations.fastapi`
|
|
133
|
+
does that as a single `Depends()`, so the check happens the same way
|
|
134
|
+
FastAPI already resolves any other dependency. It's an optional adapter
|
|
135
|
+
built as a proof of concept; a Django, Flask, or plain-script consumer
|
|
136
|
+
should call the same `Client` directly instead.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from fastapi import Depends
|
|
140
|
+
from authz.integrations.fastapi import requires
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.get("/documents/{document_id}")
|
|
144
|
+
async def read_document(
|
|
145
|
+
document_id: str,
|
|
146
|
+
_: None = Depends(requires(
|
|
147
|
+
"viewer", "document:{document_id}",
|
|
148
|
+
get_client=get_authz_client, get_subject=get_current_user,
|
|
149
|
+
)),
|
|
150
|
+
):
|
|
151
|
+
...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`requires()` makes the route depend on a `check()` call: when that call
|
|
155
|
+
returns `False`, the request never reaches the handler body, and the
|
|
156
|
+
caller receives a 403 automatically. See
|
|
157
|
+
[`docs/fastapi_integration.md`](docs/fastapi_integration.md) for the full
|
|
158
|
+
example, including how `get_client`/`get_subject` compose with your app's
|
|
159
|
+
own database and auth setup.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv sync --extra fastapi
|
|
165
|
+
uv run pytest
|
|
166
|
+
```
|
|
@@ -0,0 +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
|
|
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,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anupam Gupta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|