policy-pattern 0.4.0__tar.gz → 0.5.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.
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/PKG-INFO +8 -11
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/README.md +7 -10
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/adapters/memory.py +25 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/adapters/postgres.py +51 -10
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/evaluator.py +152 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/ports/tuple_store.py +21 -0
- policy_pattern-0.5.0/policy_pattern/sql/postgres/0002_add_subject_index.sql +5 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/pyproject.toml +2 -2
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/pyproject.toml.orig +2 -2
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/LICENSE.md +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/__init__.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/adapters/__init__.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/adapters/_db_protocols.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/adapters/_tuple_mapping.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/__init__.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/base_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/evaluation_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/identifier_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/index_entry_too_large_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/model_validation_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/mutation_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/relation_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/unknown_name_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/unknown_type_error.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/__init__.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/_schema_validation.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/compiled_model.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/direct_subject.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/identifier.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/model.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/mutation.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/object_reference.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/operators.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/reference.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/relation.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/subject.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/subject_set.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/tuple.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/tuple_to_userset.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/models/type.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/ports/__init__.py +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/py.typed +0 -0
- {policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/sql/postgres/0001_create_policy_tuples.sql +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: policy-pattern
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A minimal, embeddable ReBAC authorization engine for Python, inspired by Google Zanzibar.
|
|
5
5
|
Keywords: python,authorization,access-control,rebac,zanzibar,security
|
|
6
6
|
Author: Guillem Pozo Sebastián
|
|
@@ -45,8 +45,7 @@ Description-Content-Type: text/markdown
|
|
|
45
45
|
</a>
|
|
46
46
|
</p>
|
|
47
47
|
|
|
48
|
-
`policy-pattern` is a standalone Python library for relationship-based access control (ReBAC). It models
|
|
49
|
-
authorization as relationships between subjects and objects and evaluates those relationships through composable
|
|
48
|
+
`policy-pattern` is a standalone Python library for relationship-based access control (ReBAC). It models authorization as relationships between subjects and objects and evaluates those relationships through composable
|
|
50
49
|
authorization rules.
|
|
51
50
|
|
|
52
51
|
- **No authorization server**
|
|
@@ -131,18 +130,15 @@ evaluator = Evaluator(model=compiled, store=store)
|
|
|
131
130
|
evaluator.check(alice, "viewer", document_42) # True — alice owns the parent folder
|
|
132
131
|
```
|
|
133
132
|
|
|
134
|
-
`viewer` here means "the document's own owner, or the owner of whatever folder it lives in" — a document under
|
|
135
|
-
`folder:engineering` inherits access from that folder without copying a permission onto every document.
|
|
133
|
+
`viewer` here means "the document's own owner, or the owner of whatever folder it lives in" — a document under `folder:engineering` inherits access from that folder without copying a permission onto every document.
|
|
136
134
|
|
|
137
|
-
See the [Usage Guide](docs/usage/README.md) for the full, runnable walkthrough — every rewrite operator, nested
|
|
138
|
-
groups, `EvaluationBudget`, and error handling.
|
|
135
|
+
See the [Usage Guide](docs/usage/README.md) for the full, runnable walkthrough — every rewrite operator, nested groups, `EvaluationBudget`, and error handling.
|
|
139
136
|
|
|
140
137
|
---
|
|
141
138
|
|
|
142
139
|
## Documentation
|
|
143
140
|
|
|
144
|
-
The repository uses specifications and architecture documents as part of the implementation contract — they
|
|
145
|
-
describe **what must be true**, independent of any one adapter's implementation.
|
|
141
|
+
The repository uses specifications and architecture documents as part of the implementation contract — they describe **what must be true**, independent of any one adapter's implementation.
|
|
146
142
|
|
|
147
143
|
```text
|
|
148
144
|
docs/
|
|
@@ -153,8 +149,9 @@ docs/
|
|
|
153
149
|
└── errors.md — the PPxxx error code ranges and what each one means
|
|
154
150
|
```
|
|
155
151
|
|
|
156
|
-
Start with the [Usage Guide](docs/usage/README.md) to see the library working end to end, then
|
|
157
|
-
|
|
152
|
+
Start with the [Usage Guide](docs/usage/README.md) to see the library working end to end, then [Architecture](docs/architecture.md) to understand why the model is shaped the way it is.
|
|
153
|
+
|
|
154
|
+
See [`CODE_STYLE.md`](.github/CODE_STYLE.md) and [`TESTING.md`](TESTING.md) for contributor conventions no tool enforces.
|
|
158
155
|
|
|
159
156
|
---
|
|
160
157
|
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
</a>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
`policy-pattern` is a standalone Python library for relationship-based access control (ReBAC). It models
|
|
24
|
-
authorization as relationships between subjects and objects and evaluates those relationships through composable
|
|
23
|
+
`policy-pattern` is a standalone Python library for relationship-based access control (ReBAC). It models authorization as relationships between subjects and objects and evaluates those relationships through composable
|
|
25
24
|
authorization rules.
|
|
26
25
|
|
|
27
26
|
- **No authorization server**
|
|
@@ -106,18 +105,15 @@ evaluator = Evaluator(model=compiled, store=store)
|
|
|
106
105
|
evaluator.check(alice, "viewer", document_42) # True — alice owns the parent folder
|
|
107
106
|
```
|
|
108
107
|
|
|
109
|
-
`viewer` here means "the document's own owner, or the owner of whatever folder it lives in" — a document under
|
|
110
|
-
`folder:engineering` inherits access from that folder without copying a permission onto every document.
|
|
108
|
+
`viewer` here means "the document's own owner, or the owner of whatever folder it lives in" — a document under `folder:engineering` inherits access from that folder without copying a permission onto every document.
|
|
111
109
|
|
|
112
|
-
See the [Usage Guide](docs/usage/README.md) for the full, runnable walkthrough — every rewrite operator, nested
|
|
113
|
-
groups, `EvaluationBudget`, and error handling.
|
|
110
|
+
See the [Usage Guide](docs/usage/README.md) for the full, runnable walkthrough — every rewrite operator, nested groups, `EvaluationBudget`, and error handling.
|
|
114
111
|
|
|
115
112
|
---
|
|
116
113
|
|
|
117
114
|
## Documentation
|
|
118
115
|
|
|
119
|
-
The repository uses specifications and architecture documents as part of the implementation contract — they
|
|
120
|
-
describe **what must be true**, independent of any one adapter's implementation.
|
|
116
|
+
The repository uses specifications and architecture documents as part of the implementation contract — they describe **what must be true**, independent of any one adapter's implementation.
|
|
121
117
|
|
|
122
118
|
```text
|
|
123
119
|
docs/
|
|
@@ -128,8 +124,9 @@ docs/
|
|
|
128
124
|
└── errors.md — the PPxxx error code ranges and what each one means
|
|
129
125
|
```
|
|
130
126
|
|
|
131
|
-
Start with the [Usage Guide](docs/usage/README.md) to see the library working end to end, then
|
|
132
|
-
|
|
127
|
+
Start with the [Usage Guide](docs/usage/README.md) to see the library working end to end, then [Architecture](docs/architecture.md) to understand why the model is shaped the way it is.
|
|
128
|
+
|
|
129
|
+
See [`CODE_STYLE.md`](.github/CODE_STYLE.md) and [`TESTING.md`](TESTING.md) for contributor conventions no tool enforces.
|
|
133
130
|
|
|
134
131
|
---
|
|
135
132
|
|
|
@@ -133,3 +133,28 @@ class _MemoryView(View):
|
|
|
133
133
|
return ReadResult(tuples=frozenset(candidates[:limit]), complete=False)
|
|
134
134
|
|
|
135
135
|
return ReadResult(tuples=frozenset(candidates), complete=True)
|
|
136
|
+
|
|
137
|
+
@override
|
|
138
|
+
def read_tuples_for_subject(self, subject: Subject, limit: int) -> ReadResult:
|
|
139
|
+
"""Linearly scans the snapshot for tuples with this exact subject.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
subject (Subject): The exact subject to match.
|
|
143
|
+
limit (int): The maximum number of matching tuples to return.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
ReadResult: Matching tuples and whether the enumeration is complete.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
None: This method defines no exception of its own.
|
|
150
|
+
"""
|
|
151
|
+
candidates: list[Tuple] = nsmallest(
|
|
152
|
+
limit + 1,
|
|
153
|
+
(t for t in self.tuples if t.subject == subject),
|
|
154
|
+
key=repr,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if len(candidates) > limit:
|
|
158
|
+
return ReadResult(tuples=frozenset(candidates[:limit]), complete=False)
|
|
159
|
+
|
|
160
|
+
return ReadResult(tuples=frozenset(candidates), complete=True)
|
|
@@ -8,13 +8,16 @@ from importlib.resources import files
|
|
|
8
8
|
from typing import Any, override
|
|
9
9
|
|
|
10
10
|
from policy_pattern.errors import IndexEntryTooLargeError
|
|
11
|
-
from policy_pattern.models import Mutation, ObjectReference, Subject
|
|
11
|
+
from policy_pattern.models import Mutation, ObjectReference, Subject, Tuple
|
|
12
12
|
from policy_pattern.ports import ReadResult, TupleStore, View
|
|
13
13
|
|
|
14
14
|
from ._db_protocols import Connection, ConnectionPool, Cursor
|
|
15
15
|
from ._tuple_mapping import columns, compose, decompose
|
|
16
16
|
|
|
17
|
-
MIGRATION_RESOURCE: tuple[str, str, str] = (
|
|
17
|
+
MIGRATION_RESOURCE: tuple[tuple[str, str, str], ...] = (
|
|
18
|
+
("sql", "postgres", "0001_create_policy_tuples.sql"),
|
|
19
|
+
("sql", "postgres", "0002_add_subject_index.sql"),
|
|
20
|
+
)
|
|
18
21
|
|
|
19
22
|
# PostgreSQL's B-tree index entry limit for the default 8KB page size
|
|
20
23
|
# (BLCKSZ). Identifier bounds a value to 255 code points, any printable
|
|
@@ -68,6 +71,12 @@ _READ_RELATION_SQL: str = """
|
|
|
68
71
|
ORDER BY subject_type, subject_id, subject_relation LIMIT %s
|
|
69
72
|
"""
|
|
70
73
|
|
|
74
|
+
_READ_TUPLES_FOR_SUBJECT_SQL: str = """
|
|
75
|
+
SELECT object_type, object_id, relation FROM "authz_tuples"
|
|
76
|
+
WHERE subject_type=%s AND subject_id=%s AND subject_relation=%s
|
|
77
|
+
ORDER BY object_type, object_id, relation LIMIT %s
|
|
78
|
+
"""
|
|
79
|
+
|
|
71
80
|
|
|
72
81
|
class PostgresTupleStore(TupleStore):
|
|
73
82
|
"""
|
|
@@ -114,16 +123,17 @@ class PostgresTupleStore(TupleStore):
|
|
|
114
123
|
failed execute() would otherwise return an aborted transaction to
|
|
115
124
|
the pool for the next caller to trip over.
|
|
116
125
|
"""
|
|
117
|
-
migration_sql = files("policy_pattern").joinpath(*MIGRATION_RESOURCE).read_text(encoding="utf-8")
|
|
118
126
|
with self._pool.connection() as conn:
|
|
119
127
|
cursor = conn.cursor()
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
for resource in MIGRATION_RESOURCE:
|
|
129
|
+
migration_sql = files("policy_pattern").joinpath(*resource).read_text(encoding="utf-8")
|
|
130
|
+
try:
|
|
131
|
+
cursor.execute(migration_sql)
|
|
132
|
+
except Exception:
|
|
133
|
+
conn.rollback()
|
|
134
|
+
raise
|
|
135
|
+
else:
|
|
136
|
+
conn.commit()
|
|
127
137
|
|
|
128
138
|
@override
|
|
129
139
|
def write_tuples(self, mutation: Mutation) -> None:
|
|
@@ -255,3 +265,34 @@ class _PostgresView(View):
|
|
|
255
265
|
tuples = frozenset(compose(r, relation, object) for r in rows[:limit])
|
|
256
266
|
|
|
257
267
|
return ReadResult(tuples=tuples, complete=len(rows) <= limit)
|
|
268
|
+
|
|
269
|
+
@override
|
|
270
|
+
def read_tuples_for_subject(self, subject: Subject, limit: int) -> ReadResult:
|
|
271
|
+
"""Returns up to `limit` tuples whose subject exactly matches, in one query.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
subject (Subject): The exact subject to match.
|
|
275
|
+
limit (int): The maximum number of matching tuples to return.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
ReadResult: Matching tuples and whether the enumeration is complete.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
None: This method defines no exception of its own.
|
|
282
|
+
"""
|
|
283
|
+
subject_type, subject_id, subject_relation = decompose(subject)
|
|
284
|
+
cursor: Cursor = self.connection.cursor()
|
|
285
|
+
cursor.execute(
|
|
286
|
+
_READ_TUPLES_FOR_SUBJECT_SQL,
|
|
287
|
+
(subject_type, subject_id, subject_relation, limit + 1),
|
|
288
|
+
)
|
|
289
|
+
rows: list[tuple[Any, ...]] = cursor.fetchall()
|
|
290
|
+
tuples = frozenset(
|
|
291
|
+
Tuple(
|
|
292
|
+
subject=subject,
|
|
293
|
+
relation=row[2],
|
|
294
|
+
object=ObjectReference(type=row[0], id=row[1]),
|
|
295
|
+
)
|
|
296
|
+
for row in rows[:limit]
|
|
297
|
+
)
|
|
298
|
+
return ReadResult(tuples=tuples, complete=len(rows) <= limit)
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Evaluator: resolves check() over a CompiledModel and a TupleStore.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from collections import deque
|
|
5
6
|
from collections.abc import Sequence
|
|
6
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from functools import cached_property
|
|
7
9
|
|
|
8
10
|
from .errors import EvaluationError, UnknownNameError, UnknownTypeError
|
|
9
11
|
from .models import (
|
|
@@ -148,6 +150,156 @@ class Evaluator:
|
|
|
148
150
|
context = EvaluationContext(view=view, budget=budget)
|
|
149
151
|
return [self._check_name(subject, name, object, context) for subject, name, object in requests]
|
|
150
152
|
|
|
153
|
+
def list_objects(
|
|
154
|
+
self,
|
|
155
|
+
subject: Subject,
|
|
156
|
+
name: str,
|
|
157
|
+
object_type: str,
|
|
158
|
+
candidate_ids: frozenset[ObjectReference] | None = None,
|
|
159
|
+
budget: EvaluationBudget | None = None,
|
|
160
|
+
) -> frozenset[ObjectReference]:
|
|
161
|
+
"""Returns objects of `object_type` on which `subject` holds `name`.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
subject (Subject): The subject whose access is queried.
|
|
165
|
+
name (str): The relation or permission name to check.
|
|
166
|
+
object_type (str): The type of objects to return.
|
|
167
|
+
candidate_ids (frozenset[ObjectReference] | None): Optional objects to check instead of discovering them.
|
|
168
|
+
budget (EvaluationBudget | None): Resource limits for this evaluation.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
frozenset[ObjectReference]: Objects of `object_type` allowed by the forward check.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
UnknownTypeError: If `object_type` is not declared in the CompiledModel.
|
|
175
|
+
UnknownNameError: If `name` is not declared on `object_type`.
|
|
176
|
+
EvaluationError: If reverse candidate discovery or a candidate check exhausts the evaluation budget.
|
|
177
|
+
"""
|
|
178
|
+
if object_type not in self._model.types:
|
|
179
|
+
raise UnknownTypeError(
|
|
180
|
+
message=f"type {object_type!r} is not declared in this CompiledModel",
|
|
181
|
+
related=f"object_type={object_type!r}",
|
|
182
|
+
)
|
|
183
|
+
declaration = self._model.types[object_type]
|
|
184
|
+
if name not in declaration.relations and name not in declaration.permissions:
|
|
185
|
+
raise UnknownNameError(
|
|
186
|
+
message=f"{name!r} is not declared as a relation or permission on {object_type!r}",
|
|
187
|
+
related=f"object_type={object_type!r}",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if budget is None:
|
|
191
|
+
budget = EvaluationBudget()
|
|
192
|
+
|
|
193
|
+
with self._store.snapshot() as view:
|
|
194
|
+
context = EvaluationContext(view=view, budget=budget)
|
|
195
|
+
candidates = self._reverse_candidates(subject, context) if candidate_ids is None else candidate_ids
|
|
196
|
+
|
|
197
|
+
return frozenset(
|
|
198
|
+
object
|
|
199
|
+
for object in sorted(candidates, key=repr)
|
|
200
|
+
if object.type == object_type and self._check_name(subject, name, object, context)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _reverse_candidates(self, subject: Subject, context: EvaluationContext) -> frozenset[ObjectReference]:
|
|
204
|
+
"""Walk tuple subject edges to generate possible objects without interpreting permissions.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
subject (Subject): The starting subject for the reverse walk.
|
|
208
|
+
context (EvaluationContext): The shared view and resource-budget state.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
frozenset[ObjectReference]: All candidate objects reached by the tuple walk.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
EvaluationError: If `max_depth` or `max_nodes` is exceeded, or a subject-keyed enumeration is truncated.
|
|
215
|
+
"""
|
|
216
|
+
queue: deque[tuple[Subject, int]] = deque([(subject, 0)])
|
|
217
|
+
visited: set[Subject] = {subject}
|
|
218
|
+
candidates: set[ObjectReference] = set()
|
|
219
|
+
|
|
220
|
+
while queue:
|
|
221
|
+
current, depth = queue.popleft()
|
|
222
|
+
context.node_count += 1
|
|
223
|
+
if context.node_count > context.budget.max_nodes:
|
|
224
|
+
raise EvaluationError(
|
|
225
|
+
message=f"max_nodes={context.budget.max_nodes!r} exceeded during reverse lookup",
|
|
226
|
+
related=f"subject={current!r}",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self._charge_read(context, current.object, "list_objects")
|
|
230
|
+
result = context.view.read_tuples_for_subject(
|
|
231
|
+
current,
|
|
232
|
+
limit=context.budget.max_nodes - context.node_count,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if not result.complete:
|
|
236
|
+
raise EvaluationError(
|
|
237
|
+
message=(f"enumeration for subject {current!r} truncated by remaining max_nodes budget"),
|
|
238
|
+
related=f"subject={current!r}",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if result.tuples and depth >= context.budget.max_depth:
|
|
242
|
+
raise EvaluationError(
|
|
243
|
+
message=(f"max_depth={context.budget.max_depth!r} exceeded during reverse lookup from {current!r}"),
|
|
244
|
+
related=f"subject={current!r}",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
for tuple_ in sorted(result.tuples, key=repr):
|
|
248
|
+
candidates.add(tuple_.object)
|
|
249
|
+
for next_subject in self._reverse_frontier(tuple_.object):
|
|
250
|
+
if next_subject not in visited:
|
|
251
|
+
visited.add(next_subject)
|
|
252
|
+
queue.append((next_subject, depth + 1))
|
|
253
|
+
|
|
254
|
+
return frozenset(candidates)
|
|
255
|
+
|
|
256
|
+
@cached_property
|
|
257
|
+
def _referenceable_shapes(self) -> frozenset[str]:
|
|
258
|
+
"""Return the subject shapes referenced by any relation in the model.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
frozenset[str]: Subject type and type/relation shapes allowed by model relations.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
None.
|
|
265
|
+
"""
|
|
266
|
+
return frozenset(
|
|
267
|
+
allowed_shape
|
|
268
|
+
for type_declaration in self._model.types.values()
|
|
269
|
+
for relation in type_declaration.relations.values()
|
|
270
|
+
for allowed_shape in relation.allowed_subject_types
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _reverse_frontier(self, object: ObjectReference) -> tuple[Subject, ...]:
|
|
274
|
+
"""Return the declared subject identities that may reference `object`.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
object (ObjectReference): The object whose possible subject identities are generated.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
tuple[Subject, ...]: Direct and userset identities that model relations may reference.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
None.
|
|
284
|
+
"""
|
|
285
|
+
next_subjects: list[Subject] = []
|
|
286
|
+
if object.type in self._referenceable_shapes:
|
|
287
|
+
next_subjects.append(DirectSubject(object=object))
|
|
288
|
+
|
|
289
|
+
declaration = self._model.types.get(object.type)
|
|
290
|
+
if declaration is None:
|
|
291
|
+
return tuple(next_subjects)
|
|
292
|
+
|
|
293
|
+
names = (*declaration.relations, *declaration.permissions)
|
|
294
|
+
next_subjects.extend(
|
|
295
|
+
[
|
|
296
|
+
SubjectSet(object=object, relation=name)
|
|
297
|
+
for name in names
|
|
298
|
+
if f"{object.type}#{name}" in self._referenceable_shapes
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
return tuple(next_subjects)
|
|
302
|
+
|
|
151
303
|
def _check_name(self, subject: Subject, name: str, object: ObjectReference, context: EvaluationContext) -> bool:
|
|
152
304
|
"""
|
|
153
305
|
Dispatches (object, name) to a relation or permission check, with cycle/memo/budget bookkeeping.
|
|
@@ -116,3 +116,24 @@ class View(ABC):
|
|
|
116
116
|
`limit + 1` matching tuples before returning — that would violate the contract.
|
|
117
117
|
"""
|
|
118
118
|
raise NotImplementedError
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def read_tuples_for_subject(self, subject: Subject, limit: int) -> ReadResult:
|
|
122
|
+
"""
|
|
123
|
+
Enumerates every tuple whose subject exactly matches `subject`, bounded by `limit`.
|
|
124
|
+
|
|
125
|
+
Unlike `read_relation`, this is not filtered by relation name or
|
|
126
|
+
object; callers needing narrower results filter returned tuples.
|
|
127
|
+
See `ReadResult` for the `complete=False` contract.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
subject (Subject): The exact subject to match.
|
|
131
|
+
limit (int): The maximum number of matching tuples to return.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
ReadResult: Matching tuples and whether the enumeration is complete.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
None: The abstract contract defines no exception of its own.
|
|
138
|
+
"""
|
|
139
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Index supporting View.read_tuples_for_subject(). Without it, a query
|
|
2
|
+
-- filtered only by the subject columns has no index to use — the
|
|
3
|
+
-- primary key is ordered object-first.
|
|
4
|
+
CREATE INDEX IF NOT EXISTS authz_tuples_subject_idx
|
|
5
|
+
ON "authz_tuples" (subject_type, subject_id, subject_relation);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "policy-pattern"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "A minimal, embeddable ReBAC authorization engine for Python, inspired by Google Zanzibar."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -38,7 +38,7 @@ Repository = "https://github.com/guillem-ps/policy-pattern"
|
|
|
38
38
|
Issues = "https://github.com/guillem-ps/policy-pattern/issues"
|
|
39
39
|
|
|
40
40
|
[build-system]
|
|
41
|
-
requires = ["uv_build>=0.11.16,<0.
|
|
41
|
+
requires = ["uv_build>=0.11.16,<0.14.0"]
|
|
42
42
|
build-backend = "uv_build"
|
|
43
43
|
|
|
44
44
|
[tool.uv.build-backend]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "policy-pattern"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "A minimal, embeddable ReBAC authorization engine for Python, inspired by Google Zanzibar."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -37,7 +37,7 @@ Repository = "https://github.com/guillem-ps/policy-pattern"
|
|
|
37
37
|
Issues = "https://github.com/guillem-ps/policy-pattern/issues"
|
|
38
38
|
|
|
39
39
|
[build-system]
|
|
40
|
-
requires = ["uv_build>=0.11.16,<0.
|
|
40
|
+
requires = ["uv_build>=0.11.16,<0.14.0"]
|
|
41
41
|
build-backend = "uv_build"
|
|
42
42
|
|
|
43
43
|
[tool.uv.build-backend]
|
|
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
|
{policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/index_entry_too_large_error.py
RENAMED
|
File without changes
|
{policy_pattern-0.4.0 → policy_pattern-0.5.0}/policy_pattern/errors/model_validation_error.py
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|