python-authz 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_authz-0.5.0 → python_authz-0.6.0}/PKG-INFO +9 -7
- {python_authz-0.5.0 → python_authz-0.6.0}/README.md +9 -7
- {python_authz-0.5.0 → python_authz-0.6.0}/pyproject.toml +1 -1
- {python_authz-0.5.0 → python_authz-0.6.0}/pyproject.toml.orig +1 -1
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/__init__.py +9 -2
- python_authz-0.6.0/src/authz/conditions.py +113 -0
- python_authz-0.6.0/src/authz/engine.py +635 -0
- python_authz-0.6.0/src/authz/integrations/fastapi.py +86 -0
- python_authz-0.5.0/src/authz/conditions.py +0 -37
- python_authz-0.5.0/src/authz/engine.py +0 -297
- python_authz-0.5.0/src/authz/integrations/fastapi.py +0 -37
- {python_authz-0.5.0 → python_authz-0.6.0}/LICENSE +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/errors.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/integrations/__init__.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/schema.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/storage/__init__.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/storage/base.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/storage/postgres.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/storage/sqlite.py +0 -0
- {python_authz-0.5.0 → python_authz-0.6.0}/src/authz/tuples.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-authz
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
|
|
@@ -26,13 +26,15 @@ kept in agreement with each other.
|
|
|
26
26
|
|
|
27
27
|
## Status
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
29
|
+
Release, version 0.6.0:
|
|
30
|
+
|
|
31
|
+
- **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
|
|
32
|
+
- **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
|
|
33
|
+
- **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
|
|
34
|
+
- **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
|
|
35
|
+
- **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
|
|
36
|
+
- **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
|
|
37
|
+
- Async SQLite and PostgreSQL storage backends.
|
|
36
38
|
|
|
37
39
|
## Roadmap
|
|
38
40
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
@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
|
+
|
|
33
|
+
def register(self, name: str, fn: ConditionFn) -> None:
|
|
34
|
+
self._conditions[name] = fn
|
|
35
|
+
|
|
36
|
+
def is_registered(self, name: str) -> bool:
|
|
37
|
+
return name in self._conditions
|
|
38
|
+
|
|
39
|
+
async def evaluate(
|
|
40
|
+
self, name: str, subject: str, resource: str, context: Mapping[str, Any]
|
|
41
|
+
) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
fn = self._conditions[name]
|
|
44
|
+
except KeyError:
|
|
45
|
+
raise ConditionNotFound(name) from None
|
|
46
|
+
result = fn(subject=subject, resource=resource, **context)
|
|
47
|
+
if inspect.isawaitable(result):
|
|
48
|
+
result = await result
|
|
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
|
+
}
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Mapping, 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 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
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class UsersetTree:
|
|
52
|
+
relation: str
|
|
53
|
+
object: ObjectRef
|
|
54
|
+
direct_subjects: tuple[str, ...] = ()
|
|
55
|
+
children: tuple["UsersetTree", ...] = ()
|
|
56
|
+
|
|
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
|
+
|
|
67
|
+
class Client:
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
storage: Storage,
|
|
71
|
+
condition_evaluator: ConditionEvaluator,
|
|
72
|
+
max_depth: int = 25,
|
|
73
|
+
):
|
|
74
|
+
self._storage = storage
|
|
75
|
+
self._conditions = condition_evaluator
|
|
76
|
+
self._max_depth = max_depth
|
|
77
|
+
self._namespaces: dict[str, Namespace] = {}
|
|
78
|
+
self._traversal_relations: dict[str, set[str]] = {}
|
|
79
|
+
|
|
80
|
+
def register_schema(self, *namespaces: Namespace) -> None:
|
|
81
|
+
for ns in namespaces:
|
|
82
|
+
check_schema_cycles(ns)
|
|
83
|
+
self._namespaces[ns.name] = ns
|
|
84
|
+
traversal: set[str] = set()
|
|
85
|
+
for rule in ns.relations.values():
|
|
86
|
+
traversal |= tupleset_relations(rule)
|
|
87
|
+
self._traversal_relations[ns.name] = traversal
|
|
88
|
+
|
|
89
|
+
def register_condition(self, name: str, fn: ConditionFn) -> None:
|
|
90
|
+
self._conditions.register(name, fn)
|
|
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
|
+
|
|
100
|
+
def _get_relation(self, namespace: str, relation: str) -> RewriteRule:
|
|
101
|
+
ns = self._namespaces.get(namespace)
|
|
102
|
+
if ns is None:
|
|
103
|
+
raise NamespaceNotFound(namespace)
|
|
104
|
+
rule = ns.relations.get(relation)
|
|
105
|
+
if rule is None:
|
|
106
|
+
raise RelationNotFound(namespace, relation)
|
|
107
|
+
return rule
|
|
108
|
+
|
|
109
|
+
def _validate_tuple(self, t: RelationTuple) -> None:
|
|
110
|
+
self._get_relation(t.namespace, t.relation)
|
|
111
|
+
if t.condition is not None and not self._conditions.is_registered(t.condition.name):
|
|
112
|
+
raise ConditionNotFound(t.condition.name)
|
|
113
|
+
|
|
114
|
+
def _is_graph_edge(self, t: RelationTuple) -> bool:
|
|
115
|
+
if t.relation in self._traversal_relations.get(t.namespace, set()):
|
|
116
|
+
return True
|
|
117
|
+
return t.subject_ref.is_userset
|
|
118
|
+
|
|
119
|
+
async def _guard_against_cycle(self, t: RelationTuple) -> None:
|
|
120
|
+
source = (t.namespace, t.object_id)
|
|
121
|
+
subject_ref = t.subject_ref
|
|
122
|
+
if subject_ref.is_userset:
|
|
123
|
+
start = (subject_ref.namespace, subject_ref.object_id, subject_ref.relation)
|
|
124
|
+
else:
|
|
125
|
+
start = (subject_ref.namespace, subject_ref.object_id, t.relation)
|
|
126
|
+
|
|
127
|
+
visited: set[tuple[str, str]] = set()
|
|
128
|
+
queue: deque[tuple[str, str, str]] = deque([start])
|
|
129
|
+
while queue:
|
|
130
|
+
namespace, object_id, relation = queue.popleft()
|
|
131
|
+
if (namespace, object_id) == source:
|
|
132
|
+
raise WouldCreateCycle(t, f"{t.object} -> {namespace}:{object_id}")
|
|
133
|
+
if (namespace, object_id) in visited:
|
|
134
|
+
continue
|
|
135
|
+
visited.add((namespace, object_id))
|
|
136
|
+
existing = await self._storage.read_tuples(
|
|
137
|
+
namespace, object_id=object_id, relation=relation
|
|
138
|
+
)
|
|
139
|
+
for et in existing:
|
|
140
|
+
eref = et.subject_ref
|
|
141
|
+
if eref.is_userset:
|
|
142
|
+
queue.append((eref.namespace, eref.object_id, eref.relation))
|
|
143
|
+
elif relation in self._traversal_relations.get(namespace, set()):
|
|
144
|
+
queue.append((eref.namespace, eref.object_id, relation))
|
|
145
|
+
|
|
146
|
+
async def write(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
147
|
+
for t in tuples:
|
|
148
|
+
self._validate_tuple(t)
|
|
149
|
+
async with self._storage.transaction():
|
|
150
|
+
for t in tuples:
|
|
151
|
+
if self._is_graph_edge(t):
|
|
152
|
+
await self._guard_against_cycle(t)
|
|
153
|
+
await self._storage.write_tuples([t])
|
|
154
|
+
return await self._storage.current_token()
|
|
155
|
+
|
|
156
|
+
async def delete(self, tuples: Sequence[RelationTuple]) -> Token:
|
|
157
|
+
for t in tuples:
|
|
158
|
+
self._get_relation(t.namespace, t.relation)
|
|
159
|
+
return await self._storage.delete_tuples(tuples)
|
|
160
|
+
|
|
161
|
+
async def read(
|
|
162
|
+
self,
|
|
163
|
+
namespace: str,
|
|
164
|
+
object_id: str | None = None,
|
|
165
|
+
relation: str | None = None,
|
|
166
|
+
subject: str | None = None,
|
|
167
|
+
) -> list[RelationTuple]:
|
|
168
|
+
if namespace not in self._namespaces:
|
|
169
|
+
raise NamespaceNotFound(namespace)
|
|
170
|
+
if relation is not None:
|
|
171
|
+
self._get_relation(namespace, relation)
|
|
172
|
+
return await self._storage.read_tuples(
|
|
173
|
+
namespace, object_id=object_id, relation=relation, subject=subject
|
|
174
|
+
)
|
|
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
|
+
|
|
198
|
+
async def check(
|
|
199
|
+
self,
|
|
200
|
+
subject: str,
|
|
201
|
+
permission: str,
|
|
202
|
+
resource: str,
|
|
203
|
+
*,
|
|
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,
|
|
207
|
+
) -> bool:
|
|
208
|
+
if at_least_as_fresh_as is not None:
|
|
209
|
+
await self._storage.wait_for_token(at_least_as_fresh_as)
|
|
210
|
+
target = ObjectRef.parse(resource)
|
|
211
|
+
self._get_relation(target.namespace, permission)
|
|
212
|
+
return await self._check_relation(
|
|
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,
|
|
271
|
+
)
|
|
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
|
+
|
|
362
|
+
async def _check_relation(
|
|
363
|
+
self,
|
|
364
|
+
subject: str,
|
|
365
|
+
namespace: str,
|
|
366
|
+
object_id: str,
|
|
367
|
+
relation: str,
|
|
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,
|
|
373
|
+
) -> bool:
|
|
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
|
+
|
|
379
|
+
if node in path:
|
|
380
|
+
trail = [f"{ns}:{oid}#{rel}" for ns, oid, rel in path]
|
|
381
|
+
trail.append(node_str)
|
|
382
|
+
raise CycleDetected(trail)
|
|
383
|
+
if len(path) >= self._max_depth:
|
|
384
|
+
raise DepthLimitExceeded(self._max_depth)
|
|
385
|
+
rule = self._get_relation(namespace, relation)
|
|
386
|
+
return await self._eval_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,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def _eval_rule(
|
|
399
|
+
self,
|
|
400
|
+
rule: RewriteRule,
|
|
401
|
+
subject: str,
|
|
402
|
+
namespace: str,
|
|
403
|
+
object_id: str,
|
|
404
|
+
relation: str,
|
|
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,
|
|
410
|
+
) -> bool:
|
|
411
|
+
if isinstance(rule, 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
|
+
)
|
|
422
|
+
if isinstance(rule, Union):
|
|
423
|
+
for child in rule.children:
|
|
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
|
+
):
|
|
436
|
+
return True
|
|
437
|
+
if collector is not None:
|
|
438
|
+
del collector.matched_tuples[mark:]
|
|
439
|
+
return False
|
|
440
|
+
if isinstance(rule, Intersection):
|
|
441
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
442
|
+
for child in rule.children:
|
|
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:]
|
|
456
|
+
return False
|
|
457
|
+
return True
|
|
458
|
+
if isinstance(rule, Exclusion):
|
|
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:]
|
|
473
|
+
return False
|
|
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,
|
|
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
|
|
493
|
+
if isinstance(rule, From):
|
|
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
|
+
)
|
|
504
|
+
if isinstance(rule, TupleToUserset):
|
|
505
|
+
parents = await self._read_tuples(
|
|
506
|
+
namespace,
|
|
507
|
+
object_id=object_id,
|
|
508
|
+
relation=rule.tupleset_relation,
|
|
509
|
+
read_cache=read_cache,
|
|
510
|
+
)
|
|
511
|
+
for pt in parents:
|
|
512
|
+
pref = pt.subject_ref
|
|
513
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
514
|
+
if await self._check_relation(
|
|
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,
|
|
523
|
+
):
|
|
524
|
+
if collector is not None:
|
|
525
|
+
collector.matched_tuples.append(pt)
|
|
526
|
+
return True
|
|
527
|
+
if collector is not None:
|
|
528
|
+
del collector.matched_tuples[mark:]
|
|
529
|
+
return False
|
|
530
|
+
raise TypeError(f"unsupported rewrite rule: {rule!r}")
|
|
531
|
+
|
|
532
|
+
async def _eval_direct(
|
|
533
|
+
self,
|
|
534
|
+
subject: str,
|
|
535
|
+
namespace: str,
|
|
536
|
+
object_id: str,
|
|
537
|
+
relation: str,
|
|
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,
|
|
543
|
+
) -> bool:
|
|
544
|
+
subject_ref = SubjectRef.parse(subject)
|
|
545
|
+
tuples = await self._read_tuples(
|
|
546
|
+
namespace, object_id=object_id, relation=relation, read_cache=read_cache
|
|
547
|
+
)
|
|
548
|
+
for t in tuples:
|
|
549
|
+
if t.condition is not None:
|
|
550
|
+
merged_ctx = {**t.condition.context, **(context or {})}
|
|
551
|
+
holds = await self._conditions.evaluate(
|
|
552
|
+
t.condition.name, subject, f"{namespace}:{object_id}", merged_ctx
|
|
553
|
+
)
|
|
554
|
+
if collector is not None:
|
|
555
|
+
collector.condition_results.append((t.condition.name, holds))
|
|
556
|
+
if not holds:
|
|
557
|
+
continue
|
|
558
|
+
tref = t.subject_ref
|
|
559
|
+
if not tref.is_userset:
|
|
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)
|
|
563
|
+
return True
|
|
564
|
+
else:
|
|
565
|
+
mark = len(collector.matched_tuples) if collector is not None else 0
|
|
566
|
+
if await self._check_relation(
|
|
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,
|
|
575
|
+
):
|
|
576
|
+
if collector is not None:
|
|
577
|
+
collector.matched_tuples.append(t)
|
|
578
|
+
return True
|
|
579
|
+
if collector is not None:
|
|
580
|
+
del collector.matched_tuples[mark:]
|
|
581
|
+
return False
|
|
582
|
+
|
|
583
|
+
async def expand(self, permission: str, resource: str) -> "UsersetTree":
|
|
584
|
+
target = ObjectRef.parse(resource)
|
|
585
|
+
rule = self._get_relation(target.namespace, permission)
|
|
586
|
+
return await self._expand_rule(rule, target.namespace, target.object_id, permission)
|
|
587
|
+
|
|
588
|
+
async def _expand_rule(
|
|
589
|
+
self, rule, namespace: str, object_id: str, relation: str
|
|
590
|
+
) -> "UsersetTree":
|
|
591
|
+
direct_subjects: list[str] = []
|
|
592
|
+
children: list[UsersetTree] = []
|
|
593
|
+
|
|
594
|
+
async def walk(r) -> None:
|
|
595
|
+
if isinstance(r, Direct):
|
|
596
|
+
tuples = await self._storage.read_tuples(
|
|
597
|
+
namespace, object_id=object_id, relation=relation
|
|
598
|
+
)
|
|
599
|
+
direct_subjects.extend(t.subject for t in tuples)
|
|
600
|
+
elif isinstance(r, (Union, Intersection)):
|
|
601
|
+
for child in r.children:
|
|
602
|
+
await walk(child)
|
|
603
|
+
elif isinstance(r, Exclusion):
|
|
604
|
+
await walk(r.base)
|
|
605
|
+
elif isinstance(r, From):
|
|
606
|
+
children.append(
|
|
607
|
+
await self._expand_rule(
|
|
608
|
+
self._get_relation(namespace, r.relation),
|
|
609
|
+
namespace,
|
|
610
|
+
object_id,
|
|
611
|
+
r.relation,
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
elif isinstance(r, TupleToUserset):
|
|
615
|
+
parents = await self._storage.read_tuples(
|
|
616
|
+
namespace, object_id=object_id, relation=r.tupleset_relation
|
|
617
|
+
)
|
|
618
|
+
for pt in parents:
|
|
619
|
+
pref = pt.subject_ref
|
|
620
|
+
children.append(
|
|
621
|
+
await self._expand_rule(
|
|
622
|
+
self._get_relation(pref.namespace, r.computed_relation),
|
|
623
|
+
pref.namespace,
|
|
624
|
+
pref.object_id,
|
|
625
|
+
r.computed_relation,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
await walk(rule)
|
|
630
|
+
return UsersetTree(
|
|
631
|
+
relation=relation,
|
|
632
|
+
object=ObjectRef(namespace, object_id),
|
|
633
|
+
direct_subjects=tuple(direct_subjects),
|
|
634
|
+
children=tuple(children),
|
|
635
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Awaitable, Callable, Mapping
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, HTTPException, Request
|
|
5
|
+
|
|
6
|
+
from authz.engine import Client, EvaluationDecision
|
|
7
|
+
|
|
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"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _resolve_resource(resource: str | ResourceFn, request: Request) -> str:
|
|
15
|
+
if isinstance(resource, str):
|
|
16
|
+
params = {
|
|
17
|
+
**dict(request.headers),
|
|
18
|
+
**dict(request.query_params),
|
|
19
|
+
**dict(request.path_params),
|
|
20
|
+
}
|
|
21
|
+
return resource.format(**params)
|
|
22
|
+
result = resource(request)
|
|
23
|
+
if inspect.isawaitable(result):
|
|
24
|
+
result = await result
|
|
25
|
+
return result
|
|
26
|
+
|
|
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
|
+
|
|
42
|
+
def requires(
|
|
43
|
+
permission: str,
|
|
44
|
+
resource: str | ResourceFn,
|
|
45
|
+
*,
|
|
46
|
+
get_client: Callable[..., Client | Awaitable[Client]],
|
|
47
|
+
get_subject: Callable[..., str | Awaitable[str]],
|
|
48
|
+
get_context: ContextFn | None = None,
|
|
49
|
+
detail: str = "Forbidden",
|
|
50
|
+
) -> Callable:
|
|
51
|
+
async def dependency(
|
|
52
|
+
request: Request,
|
|
53
|
+
client: Client = Depends(get_client),
|
|
54
|
+
subject: str = Depends(get_subject),
|
|
55
|
+
) -> None:
|
|
56
|
+
resolved = await _resolve_resource(resource, request)
|
|
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:
|
|
83
|
+
raise HTTPException(status_code=403, detail=detail)
|
|
84
|
+
return decision
|
|
85
|
+
|
|
86
|
+
return dependency
|
|
@@ -1,37 +0,0 @@
|
|
|
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)
|
|
@@ -1,297 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|