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