python-authz 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authz/__init__.py +9 -2
- authz/conditions.py +76 -0
- authz/engine.py +356 -18
- authz/integrations/fastapi.py +53 -4
- {python_authz-0.5.0.dist-info → python_authz-0.6.0.dist-info}/METADATA +9 -7
- {python_authz-0.5.0.dist-info → python_authz-0.6.0.dist-info}/RECORD +9 -9
- {python_authz-0.5.0.dist-info → python_authz-0.6.0.dist-info}/WHEEL +1 -1
- {python_authz-0.5.0.dist-info → python_authz-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_authz-0.5.0.dist-info → python_authz-0.6.0.dist-info}/licenses/LICENSE +0 -0
authz/__init__.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
from authz.conditions import
|
|
2
|
-
|
|
1
|
+
from authz.conditions import (
|
|
2
|
+
ConditionEvaluator,
|
|
3
|
+
PythonCallableEvaluator,
|
|
4
|
+
builtin_conditions,
|
|
5
|
+
)
|
|
6
|
+
from authz.engine import CheckItem, Client, EvaluationDecision, UsersetTree
|
|
3
7
|
from authz.errors import (
|
|
4
8
|
AuthError,
|
|
5
9
|
ConditionNotFound,
|
|
@@ -27,6 +31,7 @@ from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
|
|
|
27
31
|
|
|
28
32
|
__all__ = [
|
|
29
33
|
"AuthError",
|
|
34
|
+
"CheckItem",
|
|
30
35
|
"Client",
|
|
31
36
|
"Condition",
|
|
32
37
|
"ConditionEvaluator",
|
|
@@ -34,6 +39,7 @@ __all__ = [
|
|
|
34
39
|
"CycleDetected",
|
|
35
40
|
"DepthLimitExceeded",
|
|
36
41
|
"Direct",
|
|
42
|
+
"EvaluationDecision",
|
|
37
43
|
"Exclusion",
|
|
38
44
|
"From",
|
|
39
45
|
"Intersection",
|
|
@@ -54,6 +60,7 @@ __all__ = [
|
|
|
54
60
|
"Union",
|
|
55
61
|
"UsersetTree",
|
|
56
62
|
"WouldCreateCycle",
|
|
63
|
+
"builtin_conditions",
|
|
57
64
|
]
|
|
58
65
|
|
|
59
66
|
|
authz/conditions.py
CHANGED
|
@@ -18,6 +18,18 @@ class PythonCallableEvaluator:
|
|
|
18
18
|
def __init__(self) -> None:
|
|
19
19
|
self._conditions: dict[str, ConditionFn] = {}
|
|
20
20
|
|
|
21
|
+
@classmethod
|
|
22
|
+
def with_builtins(
|
|
23
|
+
cls, extra: Mapping[str, ConditionFn] | None = None
|
|
24
|
+
) -> "PythonCallableEvaluator":
|
|
25
|
+
evaluator = cls()
|
|
26
|
+
for name, fn in builtin_conditions().items():
|
|
27
|
+
evaluator.register(name, fn)
|
|
28
|
+
if extra:
|
|
29
|
+
for name, fn in extra.items():
|
|
30
|
+
evaluator.register(name, fn)
|
|
31
|
+
return evaluator
|
|
32
|
+
|
|
21
33
|
def register(self, name: str, fn: ConditionFn) -> None:
|
|
22
34
|
self._conditions[name] = fn
|
|
23
35
|
|
|
@@ -35,3 +47,67 @@ class PythonCallableEvaluator:
|
|
|
35
47
|
if inspect.isawaitable(result):
|
|
36
48
|
result = await result
|
|
37
49
|
return bool(result)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _max_amount(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
amount = float(ctx.get("amount", 0))
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
amount = 0.0
|
|
57
|
+
try:
|
|
58
|
+
max_amount = float(ctx.get("max_amount", float("inf")))
|
|
59
|
+
except (ValueError, TypeError):
|
|
60
|
+
max_amount = float("inf")
|
|
61
|
+
return amount <= max_amount
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _min_amount(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
amount = float(ctx.get("amount", 0))
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
amount = 0.0
|
|
69
|
+
try:
|
|
70
|
+
min_amount = float(ctx.get("min_amount", 0))
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
min_amount = 0.0
|
|
73
|
+
return amount >= min_amount
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _equals_attr(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
77
|
+
attr_name = str(ctx.get("attribute", ""))
|
|
78
|
+
return ctx.get(attr_name) == ctx.get("expected")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _in_set(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
82
|
+
attr_name = str(ctx.get("attribute", ""))
|
|
83
|
+
allowed = ctx.get("allowed_values")
|
|
84
|
+
if allowed is None:
|
|
85
|
+
allowed = ()
|
|
86
|
+
return ctx.get(attr_name) in allowed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _jurisdiction_match(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
90
|
+
jur = ctx.get("jurisdiction")
|
|
91
|
+
if "allowed_jurisdictions" in ctx:
|
|
92
|
+
allowed = ctx.get("allowed_jurisdictions")
|
|
93
|
+
if allowed is None:
|
|
94
|
+
allowed = ()
|
|
95
|
+
return jur in allowed
|
|
96
|
+
if "required_jurisdiction" in ctx:
|
|
97
|
+
return jur == ctx.get("required_jurisdiction")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _not_same_subject(*, subject: str, resource: str, **ctx: Any) -> bool:
|
|
102
|
+
return subject != ctx.get("excluded_subject")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def builtin_conditions() -> dict[str, ConditionFn]:
|
|
106
|
+
return {
|
|
107
|
+
"max_amount": _max_amount,
|
|
108
|
+
"min_amount": _min_amount,
|
|
109
|
+
"equals_attr": _equals_attr,
|
|
110
|
+
"in_set": _in_set,
|
|
111
|
+
"jurisdiction_match": _jurisdiction_match,
|
|
112
|
+
"not_same_subject": _not_same_subject,
|
|
113
|
+
}
|
authz/engine.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from collections import deque
|
|
2
2
|
from dataclasses import dataclass
|
|
3
|
-
from typing import Sequence
|
|
3
|
+
from typing import Any, Mapping, Sequence
|
|
4
4
|
|
|
5
5
|
from authz.conditions import ConditionEvaluator, ConditionFn
|
|
6
6
|
from authz.errors import (
|
|
@@ -27,6 +27,26 @@ from authz.storage.base import Storage, Token
|
|
|
27
27
|
from authz.tuples import ObjectRef, RelationTuple, SubjectRef
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CheckItem:
|
|
32
|
+
subject: str
|
|
33
|
+
permission: str
|
|
34
|
+
resource: str
|
|
35
|
+
context: Mapping[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EvaluationDecision:
|
|
40
|
+
allowed: bool
|
|
41
|
+
subject: str
|
|
42
|
+
permission: str
|
|
43
|
+
resource: str
|
|
44
|
+
matched_tuples: tuple[RelationTuple, ...] = ()
|
|
45
|
+
visited_path: tuple[str, ...] = ()
|
|
46
|
+
condition_results: tuple[tuple[str, bool], ...] = ()
|
|
47
|
+
reason: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
30
50
|
@dataclass(frozen=True)
|
|
31
51
|
class UsersetTree:
|
|
32
52
|
relation: str
|
|
@@ -35,6 +55,15 @@ class UsersetTree:
|
|
|
35
55
|
children: tuple["UsersetTree", ...] = ()
|
|
36
56
|
|
|
37
57
|
|
|
58
|
+
class _TraceCollector:
|
|
59
|
+
__slots__ = ("visited_path", "matched_tuples", "condition_results")
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.visited_path: list[str] = []
|
|
63
|
+
self.matched_tuples: list[RelationTuple] = []
|
|
64
|
+
self.condition_results: list[tuple[str, bool]] = []
|
|
65
|
+
|
|
66
|
+
|
|
38
67
|
class Client:
|
|
39
68
|
def __init__(
|
|
40
69
|
self,
|
|
@@ -60,6 +89,14 @@ class Client:
|
|
|
60
89
|
def register_condition(self, name: str, fn: ConditionFn) -> None:
|
|
61
90
|
self._conditions.register(name, fn)
|
|
62
91
|
|
|
92
|
+
@property
|
|
93
|
+
def schema(self) -> dict[str, Namespace]:
|
|
94
|
+
return self._namespaces
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def _schema(self) -> dict[str, Namespace]:
|
|
98
|
+
return self._namespaces
|
|
99
|
+
|
|
63
100
|
def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
|
|
64
101
|
ns = self._namespaces.get(namespace)
|
|
65
102
|
if ns is None:
|
|
@@ -136,6 +173,28 @@ class Client:
|
|
|
136
173
|
namespace, object_id=object_id, relation=relation, subject=subject
|
|
137
174
|
)
|
|
138
175
|
|
|
176
|
+
async def _read_tuples(
|
|
177
|
+
self,
|
|
178
|
+
namespace: str,
|
|
179
|
+
object_id: str | None = None,
|
|
180
|
+
relation: str | None = None,
|
|
181
|
+
subject: str | None = None,
|
|
182
|
+
*,
|
|
183
|
+
read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
|
|
184
|
+
) -> list[RelationTuple]:
|
|
185
|
+
if read_cache is not None:
|
|
186
|
+
key = (namespace, object_id, relation, subject)
|
|
187
|
+
if key in read_cache:
|
|
188
|
+
return read_cache[key]
|
|
189
|
+
result = await self._storage.read_tuples(
|
|
190
|
+
namespace, object_id=object_id, relation=relation, subject=subject
|
|
191
|
+
)
|
|
192
|
+
read_cache[key] = result
|
|
193
|
+
return result
|
|
194
|
+
return await self._storage.read_tuples(
|
|
195
|
+
namespace, object_id=object_id, relation=relation, subject=subject
|
|
196
|
+
)
|
|
197
|
+
|
|
139
198
|
async def check(
|
|
140
199
|
self,
|
|
141
200
|
subject: str,
|
|
@@ -143,15 +202,163 @@ class Client:
|
|
|
143
202
|
resource: str,
|
|
144
203
|
*,
|
|
145
204
|
at_least_as_fresh_as: Token | None = None,
|
|
205
|
+
context: Mapping[str, Any] | None = None,
|
|
206
|
+
_read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
|
|
146
207
|
) -> bool:
|
|
147
208
|
if at_least_as_fresh_as is not None:
|
|
148
209
|
await self._storage.wait_for_token(at_least_as_fresh_as)
|
|
149
210
|
target = ObjectRef.parse(resource)
|
|
150
211
|
self._get_relation(target.namespace, permission)
|
|
151
212
|
return await self._check_relation(
|
|
152
|
-
subject,
|
|
213
|
+
subject,
|
|
214
|
+
target.namespace,
|
|
215
|
+
target.object_id,
|
|
216
|
+
permission,
|
|
217
|
+
[],
|
|
218
|
+
context=context,
|
|
219
|
+
read_cache=_read_cache,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def explain(
|
|
223
|
+
self,
|
|
224
|
+
subject: str,
|
|
225
|
+
permission: str,
|
|
226
|
+
resource: str,
|
|
227
|
+
*,
|
|
228
|
+
at_least_as_fresh_as: Token | None = None,
|
|
229
|
+
context: Mapping[str, Any] | None = None,
|
|
230
|
+
) -> EvaluationDecision:
|
|
231
|
+
if at_least_as_fresh_as is not None:
|
|
232
|
+
await self._storage.wait_for_token(at_least_as_fresh_as)
|
|
233
|
+
target = ObjectRef.parse(resource)
|
|
234
|
+
self._get_relation(target.namespace, permission)
|
|
235
|
+
|
|
236
|
+
collector = _TraceCollector()
|
|
237
|
+
allowed = await self._check_relation(
|
|
238
|
+
subject,
|
|
239
|
+
target.namespace,
|
|
240
|
+
target.object_id,
|
|
241
|
+
permission,
|
|
242
|
+
[],
|
|
243
|
+
context=context,
|
|
244
|
+
collector=collector,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
target_node = f"{resource}#{permission}"
|
|
248
|
+
if allowed:
|
|
249
|
+
matched = tuple(collector.matched_tuples)
|
|
250
|
+
chain = [subject]
|
|
251
|
+
for t in matched:
|
|
252
|
+
node = f"{t.namespace}:{t.object_id}#{t.relation}"
|
|
253
|
+
if node not in chain:
|
|
254
|
+
chain.append(node)
|
|
255
|
+
if target_node not in chain:
|
|
256
|
+
chain.append(target_node)
|
|
257
|
+
reason = f"Allowed via {' -> '.join(chain)}"
|
|
258
|
+
else:
|
|
259
|
+
matched = ()
|
|
260
|
+
reason = f"Denied: no matching relation or condition failed for {subject} on {target_node}"
|
|
261
|
+
|
|
262
|
+
return EvaluationDecision(
|
|
263
|
+
allowed=allowed,
|
|
264
|
+
subject=subject,
|
|
265
|
+
permission=permission,
|
|
266
|
+
resource=resource,
|
|
267
|
+
matched_tuples=matched,
|
|
268
|
+
visited_path=tuple(collector.visited_path),
|
|
269
|
+
condition_results=tuple(collector.condition_results),
|
|
270
|
+
reason=reason,
|
|
153
271
|
)
|
|
154
272
|
|
|
273
|
+
async def check_batch(
|
|
274
|
+
self,
|
|
275
|
+
checks: Sequence[CheckItem],
|
|
276
|
+
*,
|
|
277
|
+
at_least_as_fresh_as: Token | None = None,
|
|
278
|
+
) -> list[bool]:
|
|
279
|
+
if at_least_as_fresh_as is not None:
|
|
280
|
+
await self._storage.wait_for_token(at_least_as_fresh_as)
|
|
281
|
+
read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] = {}
|
|
282
|
+
results: list[bool] = []
|
|
283
|
+
for item in checks:
|
|
284
|
+
res = await self.check(
|
|
285
|
+
item.subject,
|
|
286
|
+
item.permission,
|
|
287
|
+
item.resource,
|
|
288
|
+
context=item.context,
|
|
289
|
+
_read_cache=read_cache,
|
|
290
|
+
)
|
|
291
|
+
results.append(res)
|
|
292
|
+
return results
|
|
293
|
+
|
|
294
|
+
async def list_permissions(
|
|
295
|
+
self,
|
|
296
|
+
subject: str,
|
|
297
|
+
resource: str,
|
|
298
|
+
permissions: Sequence[str] | None = None,
|
|
299
|
+
*,
|
|
300
|
+
at_least_as_fresh_as: Token | None = None,
|
|
301
|
+
context: Mapping[str, Any] | None = None,
|
|
302
|
+
) -> list[str]:
|
|
303
|
+
target = ObjectRef.parse(resource)
|
|
304
|
+
ns = self._namespaces.get(target.namespace)
|
|
305
|
+
if ns is None:
|
|
306
|
+
raise NamespaceNotFound(target.namespace)
|
|
307
|
+
if permissions is None:
|
|
308
|
+
candidate_perms = list(ns.relations.keys())
|
|
309
|
+
else:
|
|
310
|
+
candidate_perms = list(permissions)
|
|
311
|
+
checks = [
|
|
312
|
+
CheckItem(
|
|
313
|
+
subject=subject,
|
|
314
|
+
permission=perm,
|
|
315
|
+
resource=resource,
|
|
316
|
+
context=context,
|
|
317
|
+
)
|
|
318
|
+
for perm in candidate_perms
|
|
319
|
+
]
|
|
320
|
+
results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
|
|
321
|
+
return [perm for perm, allowed in zip(candidate_perms, results) if allowed]
|
|
322
|
+
|
|
323
|
+
async def list_objects(
|
|
324
|
+
self,
|
|
325
|
+
subject: str,
|
|
326
|
+
permission: str,
|
|
327
|
+
namespace: str,
|
|
328
|
+
candidate_ids: Sequence[str] | None = None,
|
|
329
|
+
*,
|
|
330
|
+
at_least_as_fresh_as: Token | None = None,
|
|
331
|
+
context: Mapping[str, Any] | None = None,
|
|
332
|
+
) -> list[str]:
|
|
333
|
+
self._get_relation(namespace, permission)
|
|
334
|
+
if candidate_ids is None:
|
|
335
|
+
if at_least_as_fresh_as is not None:
|
|
336
|
+
await self._storage.wait_for_token(at_least_as_fresh_as)
|
|
337
|
+
at_least_as_fresh_as = None
|
|
338
|
+
tuples = await self._storage.read_tuples(namespace)
|
|
339
|
+
seen: set[str] = set()
|
|
340
|
+
discovered: list[str] = []
|
|
341
|
+
for t in tuples:
|
|
342
|
+
if t.object_id not in seen:
|
|
343
|
+
seen.add(t.object_id)
|
|
344
|
+
discovered.append(t.object_id)
|
|
345
|
+
candidate_ids = discovered
|
|
346
|
+
|
|
347
|
+
if not candidate_ids:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
checks = [
|
|
351
|
+
CheckItem(
|
|
352
|
+
subject=subject,
|
|
353
|
+
permission=permission,
|
|
354
|
+
resource=f"{namespace}:{oid}",
|
|
355
|
+
context=context,
|
|
356
|
+
)
|
|
357
|
+
for oid in candidate_ids
|
|
358
|
+
]
|
|
359
|
+
results = await self.check_batch(checks, at_least_as_fresh_as=at_least_as_fresh_as)
|
|
360
|
+
return [oid for oid, allowed in zip(candidate_ids, results) if allowed]
|
|
361
|
+
|
|
155
362
|
async def _check_relation(
|
|
156
363
|
self,
|
|
157
364
|
subject: str,
|
|
@@ -159,58 +366,166 @@ class Client:
|
|
|
159
366
|
object_id: str,
|
|
160
367
|
relation: str,
|
|
161
368
|
path: list[tuple[str, str, str]],
|
|
369
|
+
*,
|
|
370
|
+
context: Mapping[str, Any] | None = None,
|
|
371
|
+
collector: _TraceCollector | None = None,
|
|
372
|
+
read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
|
|
162
373
|
) -> bool:
|
|
163
374
|
node = (namespace, object_id, relation)
|
|
375
|
+
node_str = f"{namespace}:{object_id}#{relation}"
|
|
376
|
+
if collector is not None and node_str not in collector.visited_path:
|
|
377
|
+
collector.visited_path.append(node_str)
|
|
378
|
+
|
|
164
379
|
if node in path:
|
|
165
380
|
trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
|
|
166
|
-
trail.append(
|
|
381
|
+
trail.append(node_str)
|
|
167
382
|
raise CycleDetected(trail)
|
|
168
383
|
if len(path) >= self._max_depth:
|
|
169
384
|
raise DepthLimitExceeded(self._max_depth)
|
|
170
385
|
rule = self._get_relation(namespace, relation)
|
|
171
386
|
return await self._eval_rule(
|
|
172
|
-
rule,
|
|
387
|
+
rule,
|
|
388
|
+
subject,
|
|
389
|
+
namespace,
|
|
390
|
+
object_id,
|
|
391
|
+
relation,
|
|
392
|
+
path + [node],
|
|
393
|
+
context=context,
|
|
394
|
+
collector=collector,
|
|
395
|
+
read_cache=read_cache,
|
|
173
396
|
)
|
|
174
397
|
|
|
175
398
|
async def _eval_rule(
|
|
176
399
|
self,
|
|
177
|
-
rule,
|
|
400
|
+
rule: RewriteRule,
|
|
178
401
|
subject: str,
|
|
179
402
|
namespace: str,
|
|
180
403
|
object_id: str,
|
|
181
404
|
relation: str,
|
|
182
405
|
path: list[tuple[str, str, str]],
|
|
406
|
+
*,
|
|
407
|
+
context: Mapping[str, Any] | None = None,
|
|
408
|
+
collector: _TraceCollector | None = None,
|
|
409
|
+
read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
|
|
183
410
|
) -> bool:
|
|
184
411
|
if isinstance(rule, Direct):
|
|
185
|
-
return await self._eval_direct(
|
|
412
|
+
return await self._eval_direct(
|
|
413
|
+
subject,
|
|
414
|
+
namespace,
|
|
415
|
+
object_id,
|
|
416
|
+
relation,
|
|
417
|
+
path,
|
|
418
|
+
context=context,
|
|
419
|
+
collector=collector,
|
|
420
|
+
read_cache=read_cache,
|
|
421
|
+
)
|
|
186
422
|
if isinstance(rule, Union):
|
|
187
423
|
for child in rule.children:
|
|
188
|
-
|
|
424
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
425
|
+
if await self._eval_rule(
|
|
426
|
+
child,
|
|
427
|
+
subject,
|
|
428
|
+
namespace,
|
|
429
|
+
object_id,
|
|
430
|
+
relation,
|
|
431
|
+
path,
|
|
432
|
+
context=context,
|
|
433
|
+
collector=collector,
|
|
434
|
+
read_cache=read_cache,
|
|
435
|
+
):
|
|
189
436
|
return True
|
|
437
|
+
if collector is not None:
|
|
438
|
+
del collector.matched_tuples[mark:]
|
|
190
439
|
return False
|
|
191
440
|
if isinstance(rule, Intersection):
|
|
441
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
192
442
|
for child in rule.children:
|
|
193
|
-
if not await self._eval_rule(
|
|
443
|
+
if not await self._eval_rule(
|
|
444
|
+
child,
|
|
445
|
+
subject,
|
|
446
|
+
namespace,
|
|
447
|
+
object_id,
|
|
448
|
+
relation,
|
|
449
|
+
path,
|
|
450
|
+
context=context,
|
|
451
|
+
collector=collector,
|
|
452
|
+
read_cache=read_cache,
|
|
453
|
+
):
|
|
454
|
+
if collector is not None:
|
|
455
|
+
del collector.matched_tuples[mark:]
|
|
194
456
|
return False
|
|
195
457
|
return True
|
|
196
458
|
if isinstance(rule, Exclusion):
|
|
197
|
-
|
|
459
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
460
|
+
if not await self._eval_rule(
|
|
461
|
+
rule.base,
|
|
462
|
+
subject,
|
|
463
|
+
namespace,
|
|
464
|
+
object_id,
|
|
465
|
+
relation,
|
|
466
|
+
path,
|
|
467
|
+
context=context,
|
|
468
|
+
collector=collector,
|
|
469
|
+
read_cache=read_cache,
|
|
470
|
+
):
|
|
471
|
+
if collector is not None:
|
|
472
|
+
del collector.matched_tuples[mark:]
|
|
198
473
|
return False
|
|
199
|
-
|
|
200
|
-
|
|
474
|
+
sub_mark = len(collector.matched_tuples) if collector is not None else 0
|
|
475
|
+
sub_result = await self._eval_rule(
|
|
476
|
+
rule.subtract,
|
|
477
|
+
subject,
|
|
478
|
+
namespace,
|
|
479
|
+
object_id,
|
|
480
|
+
relation,
|
|
481
|
+
path,
|
|
482
|
+
context=context,
|
|
483
|
+
collector=collector,
|
|
484
|
+
read_cache=read_cache,
|
|
201
485
|
)
|
|
486
|
+
if collector is not None:
|
|
487
|
+
del collector.matched_tuples[sub_mark:]
|
|
488
|
+
if sub_result:
|
|
489
|
+
if collector is not None:
|
|
490
|
+
del collector.matched_tuples[mark:]
|
|
491
|
+
return False
|
|
492
|
+
return True
|
|
202
493
|
if isinstance(rule, From):
|
|
203
|
-
return await self._check_relation(
|
|
494
|
+
return await self._check_relation(
|
|
495
|
+
subject,
|
|
496
|
+
namespace,
|
|
497
|
+
object_id,
|
|
498
|
+
rule.relation,
|
|
499
|
+
path,
|
|
500
|
+
context=context,
|
|
501
|
+
collector=collector,
|
|
502
|
+
read_cache=read_cache,
|
|
503
|
+
)
|
|
204
504
|
if isinstance(rule, TupleToUserset):
|
|
205
|
-
parents = await self.
|
|
206
|
-
namespace,
|
|
505
|
+
parents = await self._read_tuples(
|
|
506
|
+
namespace,
|
|
507
|
+
object_id=object_id,
|
|
508
|
+
relation=rule.tupleset_relation,
|
|
509
|
+
read_cache=read_cache,
|
|
207
510
|
)
|
|
208
511
|
for pt in parents:
|
|
209
512
|
pref = pt.subject_ref
|
|
513
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
210
514
|
if await self._check_relation(
|
|
211
|
-
subject,
|
|
515
|
+
subject,
|
|
516
|
+
pref.namespace,
|
|
517
|
+
pref.object_id,
|
|
518
|
+
rule.computed_relation,
|
|
519
|
+
path,
|
|
520
|
+
context=context,
|
|
521
|
+
collector=collector,
|
|
522
|
+
read_cache=read_cache,
|
|
212
523
|
):
|
|
524
|
+
if collector is not None:
|
|
525
|
+
collector.matched_tuples.append(pt)
|
|
213
526
|
return True
|
|
527
|
+
if collector is not None:
|
|
528
|
+
del collector.matched_tuples[mark:]
|
|
214
529
|
return False
|
|
215
530
|
raise TypeError(f"unsupported rewrite rule: {rule!r}")
|
|
216
531
|
|
|
@@ -221,25 +536,48 @@ class Client:
|
|
|
221
536
|
object_id: str,
|
|
222
537
|
relation: str,
|
|
223
538
|
path: list[tuple[str, str, str]],
|
|
539
|
+
*,
|
|
540
|
+
context: Mapping[str, Any] | None = None,
|
|
541
|
+
collector: _TraceCollector | None = None,
|
|
542
|
+
read_cache: dict[tuple[str, str | None, str | None, str | None], list[RelationTuple]] | None = None,
|
|
224
543
|
) -> bool:
|
|
225
544
|
subject_ref = SubjectRef.parse(subject)
|
|
226
|
-
tuples = await self.
|
|
545
|
+
tuples = await self._read_tuples(
|
|
546
|
+
namespace, object_id=object_id, relation=relation, read_cache=read_cache
|
|
547
|
+
)
|
|
227
548
|
for t in tuples:
|
|
228
549
|
if t.condition is not None:
|
|
550
|
+
merged_ctx = {**t.condition.context, **(context or {})}
|
|
229
551
|
holds = await self._conditions.evaluate(
|
|
230
|
-
t.condition.name, subject, f"{namespace}:{object_id}",
|
|
552
|
+
t.condition.name, subject, f"{namespace}:{object_id}", merged_ctx
|
|
231
553
|
)
|
|
554
|
+
if collector is not None:
|
|
555
|
+
collector.condition_results.append((t.condition.name, holds))
|
|
232
556
|
if not holds:
|
|
233
557
|
continue
|
|
234
558
|
tref = t.subject_ref
|
|
235
559
|
if not tref.is_userset:
|
|
236
560
|
if tref.namespace == subject_ref.namespace and tref.object_id == subject_ref.object_id:
|
|
561
|
+
if collector is not None:
|
|
562
|
+
collector.matched_tuples.append(t)
|
|
237
563
|
return True
|
|
238
564
|
else:
|
|
565
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
239
566
|
if await self._check_relation(
|
|
240
|
-
subject,
|
|
567
|
+
subject,
|
|
568
|
+
tref.namespace,
|
|
569
|
+
tref.object_id,
|
|
570
|
+
tref.relation,
|
|
571
|
+
path,
|
|
572
|
+
context=context,
|
|
573
|
+
collector=collector,
|
|
574
|
+
read_cache=read_cache,
|
|
241
575
|
):
|
|
576
|
+
if collector is not None:
|
|
577
|
+
collector.matched_tuples.append(t)
|
|
242
578
|
return True
|
|
579
|
+
if collector is not None:
|
|
580
|
+
del collector.matched_tuples[mark:]
|
|
243
581
|
return False
|
|
244
582
|
|
|
245
583
|
async def expand(self, permission: str, resource: str) -> "UsersetTree":
|
authz/integrations/fastapi.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
57
|
+
ctx = await _resolve_context(get_context, request)
|
|
58
|
+
if not await client.check(subject, permission, resolved, context=ctx):
|
|
59
|
+
raise HTTPException(status_code=403, detail=detail)
|
|
60
|
+
|
|
61
|
+
return dependency
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def explain_access(
|
|
65
|
+
permission: str,
|
|
66
|
+
resource: str | ResourceFn,
|
|
67
|
+
*,
|
|
68
|
+
get_client: Callable[..., Client | Awaitable[Client]],
|
|
69
|
+
get_subject: Callable[..., str | Awaitable[str]],
|
|
70
|
+
get_context: ContextFn | None = None,
|
|
71
|
+
detail: str = "Forbidden",
|
|
72
|
+
) -> Callable:
|
|
73
|
+
async def dependency(
|
|
74
|
+
request: Request,
|
|
75
|
+
client: Client = Depends(get_client),
|
|
76
|
+
subject: str = Depends(get_subject),
|
|
77
|
+
) -> EvaluationDecision:
|
|
78
|
+
resolved = await _resolve_resource(resource, request)
|
|
79
|
+
ctx = await _resolve_context(get_context, request)
|
|
80
|
+
decision = await client.explain(subject, permission, resolved, context=ctx)
|
|
81
|
+
request.state.authz_decision = decision
|
|
82
|
+
if not decision.allowed:
|
|
35
83
|
raise HTTPException(status_code=403, detail=detail)
|
|
84
|
+
return decision
|
|
36
85
|
|
|
37
86
|
return dependency
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-authz
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph
|
|
5
5
|
Author: Anupam Gupta
|
|
6
6
|
Author-email: Anupam Gupta <anupam@trois.in>
|
|
@@ -53,13 +53,15 @@ kept in agreement with each other.
|
|
|
53
53
|
|
|
54
54
|
## Status
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Release, version 0.6.0:
|
|
57
57
|
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
58
|
+
- **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
|
|
59
|
+
- **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
|
|
60
|
+
- **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
|
|
61
|
+
- **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
|
|
62
|
+
- **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
|
|
63
|
+
- **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
|
|
64
|
+
- Async SQLite and PostgreSQL storage backends.
|
|
63
65
|
|
|
64
66
|
## Roadmap
|
|
65
67
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
authz/__init__.py,sha256=
|
|
2
|
-
authz/conditions.py,sha256=
|
|
3
|
-
authz/engine.py,sha256=
|
|
1
|
+
authz/__init__.py,sha256=OOWzf0pVpNNS5zA-A5h6appIYWzQmttTTkVMrqzyeCQ,1447
|
|
2
|
+
authz/conditions.py,sha256=3MxE524bsdwjkCR-9yTyri_OstWpsHFFrjNr9n5cQQ4,3449
|
|
3
|
+
authz/engine.py,sha256=Dx3gjd555K4ncUKkEtJYBnBJ9mZfYBWNZEMqHaJOltM,22594
|
|
4
4
|
authz/errors.py,sha256=6DXssJp_KvCWY-DwKEtS4gnHXBl4Tbg1io84eLhL-SQ,1522
|
|
5
5
|
authz/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
authz/integrations/fastapi.py,sha256=
|
|
6
|
+
authz/integrations/fastapi.py,sha256=PR0RLH0Xs5vg9c2ZZJXKh0N8nBTyNjPj4I0DLQejk8U,2749
|
|
7
7
|
authz/schema.py,sha256=ALRdTg2CuUa7mjwN30zgAN3V2iYPUMq6ICv6IppUUeg,3118
|
|
8
8
|
authz/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
authz/storage/base.py,sha256=I0My-ByzNnVjH_UH37YnRfVQl8_EtCurA-UtJfcviL8,723
|
|
10
10
|
authz/storage/postgres.py,sha256=x_9OxEKkD1Glp-luaFfHWzq4KQfc-H92z2ggBOOCgR8,6595
|
|
11
11
|
authz/storage/sqlite.py,sha256=Ua4vWSvsOtbfdEEv4UDfB8NVJaaZ4T9dKU3DkmUi6Cc,5248
|
|
12
12
|
authz/tuples.py,sha256=NR4WpFxMj_9u2bPMyrVC8ajxbqoMUGLYIq3Rt8HZmM0,2005
|
|
13
|
-
python_authz-0.
|
|
14
|
-
python_authz-0.
|
|
15
|
-
python_authz-0.
|
|
16
|
-
python_authz-0.
|
|
17
|
-
python_authz-0.
|
|
13
|
+
python_authz-0.6.0.dist-info/licenses/LICENSE,sha256=1e3AR9C0E8RKy14RPFwHKLoYHryFj4Gwiio0vyQtDVQ,1069
|
|
14
|
+
python_authz-0.6.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
|
|
15
|
+
python_authz-0.6.0.dist-info/entry_points.txt,sha256=74k2ityJQ4AAFVFhVZddedlmWS4P4ZLAqrj8XTMD9Nk,38
|
|
16
|
+
python_authz-0.6.0.dist-info/METADATA,sha256=NsY5C09shEZGLDlOVGWpX3ET0nNVArqnJjQCt4n7oY4,6953
|
|
17
|
+
python_authz-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|