policy-pattern 0.2.5__tar.gz → 0.4.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.
Files changed (43) hide show
  1. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/PKG-INFO +1 -1
  2. policy_pattern-0.4.0/policy_pattern/adapters/__init__.py +7 -0
  3. policy_pattern-0.4.0/policy_pattern/adapters/_db_protocols.py +90 -0
  4. policy_pattern-0.4.0/policy_pattern/adapters/_tuple_mapping.py +44 -0
  5. policy_pattern-0.4.0/policy_pattern/adapters/postgres.py +257 -0
  6. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/__init__.py +2 -0
  7. policy_pattern-0.4.0/policy_pattern/errors/index_entry_too_large_error.py +16 -0
  8. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/evaluator.py +37 -9
  9. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/identifier.py +1 -1
  10. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/type.py +2 -1
  11. policy_pattern-0.4.0/policy_pattern/sql/postgres/0001_create_policy_tuples.sql +16 -0
  12. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/pyproject.toml +7 -4
  13. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/pyproject.toml.orig +12 -4
  14. policy_pattern-0.2.5/policy_pattern/adapters/__init__.py +0 -3
  15. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/LICENSE.md +0 -0
  16. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/README.md +0 -0
  17. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/__init__.py +0 -0
  18. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/adapters/memory.py +0 -0
  19. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/base_error.py +0 -0
  20. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/evaluation_error.py +0 -0
  21. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/identifier_error.py +0 -0
  22. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/model_validation_error.py +0 -0
  23. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/mutation_error.py +0 -0
  24. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/relation_error.py +0 -0
  25. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/unknown_name_error.py +0 -0
  26. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/unknown_type_error.py +0 -0
  27. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/__init__.py +0 -0
  28. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/_schema_validation.py +0 -0
  29. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/compiled_model.py +0 -0
  30. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/direct_subject.py +0 -0
  31. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/model.py +0 -0
  32. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/mutation.py +0 -0
  33. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/object_reference.py +0 -0
  34. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/operators.py +0 -0
  35. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/reference.py +0 -0
  36. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/relation.py +0 -0
  37. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/subject.py +0 -0
  38. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/subject_set.py +0 -0
  39. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/tuple.py +0 -0
  40. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/tuple_to_userset.py +0 -0
  41. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/ports/__init__.py +0 -0
  42. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/ports/tuple_store.py +0 -0
  43. {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: policy-pattern
3
- Version: 0.2.5
3
+ Version: 0.4.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
@@ -0,0 +1,7 @@
1
+ from .memory import MemoryTupleStore
2
+ from .postgres import PostgresTupleStore
3
+
4
+ __all__ = [
5
+ "MemoryTupleStore",
6
+ "PostgresTupleStore",
7
+ ]
@@ -0,0 +1,90 @@
1
+ """
2
+ Common interface shapes for DB-API 2.0 (https://peps.python.org/pep-0249/) connections, cursors, and connection pools.
3
+ """
4
+
5
+ from abc import abstractmethod
6
+ from collections.abc import Sequence
7
+ from contextlib import AbstractContextManager
8
+ from typing import Any, Protocol
9
+
10
+
11
+ class Cursor(Protocol):
12
+ """
13
+ Structural shape of a DB-API 2.0 cursor — satisfied by psycopg's real Cursor, with no import or inheritance.
14
+ """
15
+
16
+ @abstractmethod
17
+ def execute(self, query: str, params: Sequence[Any] = ()) -> object:
18
+ """
19
+ Executes a SQL statement with optional parameters.
20
+ """
21
+ raise NotImplementedError
22
+
23
+ @abstractmethod
24
+ def executemany(self, query: str, params_seq: Sequence[Sequence[Any]]) -> None:
25
+ """
26
+ Executes the same SQL statement for each sequence of parameters in the sequence.
27
+ """
28
+ raise NotImplementedError
29
+
30
+ @abstractmethod
31
+ def fetchone(self) -> tuple[Any, ...] | None:
32
+ """
33
+ Fetches the next row of a query result, returning a single tuple, or None when
34
+ no more rows are available.
35
+ """
36
+ raise NotImplementedError
37
+
38
+ @abstractmethod
39
+ def fetchall(self) -> list[tuple[Any, ...]]:
40
+ """
41
+ Fetches all remaining rows of a query result, returning a list of tuples.
42
+ """
43
+ raise NotImplementedError
44
+
45
+
46
+ class Connection(Protocol):
47
+ """
48
+ Structural shape of a DB-API 2.0 connection.
49
+
50
+ Deliberately plain commit()/rollback(), not psycopg3's own
51
+ transaction() context-manager convenience — that sugar is
52
+ psycopg3-specific and would narrow this Protocol to one driver.
53
+ """
54
+
55
+ @abstractmethod
56
+ def cursor(self) -> Cursor:
57
+ """
58
+ Returns a new Cursor object using the connection.
59
+ """
60
+ raise NotImplementedError
61
+
62
+ @abstractmethod
63
+ def commit(self) -> None:
64
+ """
65
+ Commits the current transaction.
66
+ """
67
+ raise NotImplementedError
68
+
69
+ @abstractmethod
70
+ def rollback(self) -> None:
71
+ """
72
+ Rolls back the current transaction.
73
+ """
74
+ raise NotImplementedError
75
+
76
+
77
+ class ConnectionPool(Protocol):
78
+ """
79
+ Structural shape of a connection pool — satisfied by psycopg_pool.ConnectionPool, with no import.
80
+ """
81
+
82
+ @abstractmethod
83
+ def connection(self) -> AbstractContextManager[Connection]:
84
+ """
85
+ Returns a context manager that yields a Connection from the pool.
86
+
87
+ returns:
88
+ An AbstractContextManager that yields a Connection object.
89
+ """
90
+ raise NotImplementedError
@@ -0,0 +1,44 @@
1
+ """
2
+ Data decomposition and recomposition functions for the TupleStore adapters.
3
+ """
4
+
5
+ from policy_pattern.models import DirectSubject, ObjectReference, Subject, SubjectSet, Tuple
6
+
7
+
8
+ def columns(t: Tuple) -> tuple[str, str, str, str, str, str]:
9
+ """
10
+ Decomposes a Tuple into the six column values this adapter stores.
11
+ """
12
+ if isinstance(t.subject, DirectSubject):
13
+ subject_type, subject_id, subject_relation = t.subject.object.type, t.subject.object.id, ""
14
+ else:
15
+ subject_type, subject_id, subject_relation = (
16
+ t.subject.object.type,
17
+ t.subject.object.id,
18
+ t.subject.relation,
19
+ )
20
+ return (t.object.type, t.object.id, t.relation, subject_type, subject_id, subject_relation)
21
+
22
+
23
+ def decompose(subject: Subject) -> tuple[str, str, str]:
24
+ """
25
+ Decomposes a Subject into (subject_type, subject_id, subject_relation).
26
+ """
27
+ if isinstance(subject, DirectSubject):
28
+ return (subject.object.type, subject.object.id, "")
29
+
30
+ return (subject.object.type, subject.object.id, subject.relation)
31
+
32
+
33
+ def compose(row: tuple[str, str, str], relation: str, object: ObjectReference) -> Tuple:
34
+ """
35
+ Reconstructs a Tuple from one (subject_type, subject_id, subject_relation) row.
36
+ """
37
+ subject_type, subject_id, subject_relation = row
38
+ subject: Subject
39
+ if subject_relation == "":
40
+ subject = DirectSubject(object=ObjectReference(type=subject_type, id=subject_id))
41
+ else:
42
+ subject = SubjectSet(object=ObjectReference(type=subject_type, id=subject_id), relation=subject_relation)
43
+
44
+ return Tuple(subject=subject, relation=relation, object=object)
@@ -0,0 +1,257 @@
1
+ """
2
+ PostgreSQL TupleStore adapter — typed against a stdlib Protocol, no database driver import.
3
+ """
4
+
5
+ from collections.abc import Generator
6
+ from contextlib import contextmanager
7
+ from importlib.resources import files
8
+ from typing import Any, override
9
+
10
+ from policy_pattern.errors import IndexEntryTooLargeError
11
+ from policy_pattern.models import Mutation, ObjectReference, Subject
12
+ from policy_pattern.ports import ReadResult, TupleStore, View
13
+
14
+ from ._db_protocols import Connection, ConnectionPool, Cursor
15
+ from ._tuple_mapping import columns, compose, decompose
16
+
17
+ MIGRATION_RESOURCE: tuple[str, str, str] = ("sql", "postgres", "0001_create_policy_tuples.sql")
18
+
19
+ # PostgreSQL's B-tree index entry limit for the default 8KB page size
20
+ # (BLCKSZ). Identifier bounds a value to 255 code points, any printable
21
+ # Unicode — not encoded bytes — so a value valid by its own contract can
22
+ # still make the composite primary key exceed this limit once UTF-8
23
+ # encoded.
24
+ #
25
+ # BLCKSZ is a compile-time option (--with-blocksize, 1KB-32KB); no
26
+ # packaged distribution (apt/yum/Docker/RDS/Cloud SQL/...) ever changes
27
+ # it from 8KB, only a from-source build with a non-default flag would.
28
+ # This adapter does not detect that case — it would need to query
29
+ # `block_size` at runtime to compute the real limit, which this constant
30
+ # deliberately skips. If it ever happens: a *smaller* BLCKSZ makes this
31
+ # check too permissive (the real Postgres limit drops below 2704, so
32
+ # some additions this check lets through still fail server-side with
33
+ # the driver's own cryptic error); a *larger* BLCKSZ makes it too
34
+ # strict (the real limit rises above 2704, so some additions that would
35
+ # have succeeded get rejected here instead).
36
+ #
37
+ # The check below sums only the six columns' raw UTF-8 byte length — it
38
+ # does not add PostgreSQL's own per-entry overhead (an IndexTupleData
39
+ # header plus a varlena length header per long TEXT column, roughly
40
+ # 30-40 bytes total for six columns this long). The margin subtracted
41
+ # here covers that, so this check triggers slightly before the real
42
+ # server-side limit rather than slightly after.
43
+ _BTREE_MAX_INDEX_ENTRY_BYTES: int = 2640 # 2704 minus a 64-byte margin for overhead not counted above
44
+
45
+ _INSERT_SQL: str = """
46
+ INSERT INTO "authz_tuples"
47
+ (object_type, object_id, relation, subject_type, subject_id, subject_relation)
48
+ VALUES (%s, %s, %s, %s, %s, %s)
49
+ ON CONFLICT (object_type, object_id, relation, subject_type, subject_id, subject_relation)
50
+ DO NOTHING
51
+ """
52
+
53
+ _DELETE_SQL: str = """
54
+ DELETE FROM "authz_tuples"
55
+ WHERE object_type=%s AND object_id=%s AND relation=%s
56
+ AND subject_type=%s AND subject_id=%s AND subject_relation=%s
57
+ """
58
+
59
+ _READ_TUPLE_SQL: str = """
60
+ SELECT 1 FROM "authz_tuples"
61
+ WHERE object_type=%s AND object_id=%s AND relation=%s
62
+ AND subject_type=%s AND subject_id=%s AND subject_relation=%s
63
+ """
64
+
65
+ _READ_RELATION_SQL: str = """
66
+ SELECT subject_type, subject_id, subject_relation FROM "authz_tuples"
67
+ WHERE object_type=%s AND object_id=%s AND relation=%s
68
+ ORDER BY subject_type, subject_id, subject_relation LIMIT %s
69
+ """
70
+
71
+
72
+ class PostgresTupleStore(TupleStore):
73
+ """
74
+ PostgreSQL-backed TupleStore — typed against a structural Protocol, no database driver import.
75
+
76
+ Stores tuples in the fixed table `authz_tuples`. The matching schema ships as a versioned SQL
77
+ resource (`policy_pattern/sql/postgres/0001_create_policy_tuples.sql`)
78
+ for the consuming service's own migration tooling to apply — this
79
+ adapter never creates or migrates schema itself, unless `auto_create_schema=True`.
80
+
81
+ Precondition (documented, not runtime-checked): every connection
82
+ `pool` hands out must have `autocommit=False` (DB-API 2.0's own
83
+ required default). If violated, write_tuples() loses atomicity and
84
+ snapshot()'s isolation-level statement has no transaction to apply to.
85
+
86
+ `auto_create_schema` (default False): when True, runs the packaged
87
+ migration (`CREATE TABLE IF NOT EXISTS`) once at construction time,
88
+ using a connection from `pool`. Requires that connection's role to
89
+ have `CREATE` privilege — stricter than the default
90
+ (`SELECT`/`INSERT`/`DELETE` only). Not a substitute for a real
91
+ migration history: concurrent process startup may run this
92
+ concurrently across instances — Postgres serializes the DDL
93
+ internally so this does not corrupt the schema, but it is not
94
+ tracked by any migration tool either. See `docs/usage/README.md`
95
+ for when this trade-off is worth taking.
96
+ """
97
+
98
+ def __init__(self, pool: ConnectionPool, *, auto_create_schema: bool = False) -> None:
99
+ """
100
+ Stores the pool for later use. Never opens a connection or touches the
101
+ database, unless `auto_create_schema=True`.
102
+ """
103
+ self._pool = pool
104
+ if auto_create_schema:
105
+ self._create_schema()
106
+
107
+ def _create_schema(self) -> None:
108
+ """
109
+ Applies the packaged migration directly. Only reachable when
110
+ `auto_create_schema=True` — never part of the default construction path.
111
+
112
+ try/except/rollback, same reason as write_tuples(): the pool's
113
+ connection() context manager does not roll back on its own, so a
114
+ failed execute() would otherwise return an aborted transaction to
115
+ the pool for the next caller to trip over.
116
+ """
117
+ migration_sql = files("policy_pattern").joinpath(*MIGRATION_RESOURCE).read_text(encoding="utf-8")
118
+ with self._pool.connection() as conn:
119
+ cursor = conn.cursor()
120
+ try:
121
+ cursor.execute(migration_sql)
122
+ except Exception:
123
+ conn.rollback()
124
+ raise
125
+ else:
126
+ conn.commit()
127
+
128
+ @override
129
+ def write_tuples(self, mutation: Mutation) -> None:
130
+ """
131
+ Atomically applies additions/deletions; commit() is the visibility point.
132
+
133
+ Empty mutations short-circuit before acquiring a connection. Uses
134
+ cursor()/commit()/rollback() explicitly — plain DB-API 2.0, not
135
+ psycopg3's own transaction() convenience, so this stays portable
136
+ to any driver satisfying Connection.
137
+
138
+ Raises:
139
+ IndexEntryTooLargeError: An addition's encoded primary key
140
+ would exceed PostgreSQL's B-tree index entry limit —
141
+ checked before acquiring any connection, turning the
142
+ driver's own cryptic "index row size exceeds btree ...
143
+ maximum" error into one naming the offending tuple.
144
+ """
145
+ if not mutation.additions and not mutation.deletions:
146
+ return
147
+
148
+ for t in mutation.additions:
149
+ encoded_size = sum(len(column.encode("utf-8")) for column in columns(t))
150
+ if encoded_size > _BTREE_MAX_INDEX_ENTRY_BYTES:
151
+ raise IndexEntryTooLargeError(
152
+ message=(
153
+ f"tuple's encoded primary key is {encoded_size!r} bytes, exceeding "
154
+ f"PostgreSQL's {_BTREE_MAX_INDEX_ENTRY_BYTES!r}-byte B-tree index entry limit"
155
+ ),
156
+ related=f"tuple={t!r}",
157
+ )
158
+
159
+ with self._pool.connection() as conn:
160
+ cursor = conn.cursor()
161
+ try:
162
+ if mutation.deletions:
163
+ cursor.executemany(_DELETE_SQL, [columns(t) for t in mutation.deletions])
164
+
165
+ if mutation.additions:
166
+ cursor.executemany(_INSERT_SQL, [columns(t) for t in mutation.additions])
167
+ except Exception:
168
+ conn.rollback()
169
+ raise
170
+ else:
171
+ conn.commit()
172
+
173
+ @override
174
+ @contextmanager
175
+ def snapshot(self) -> Generator[View, None, None]:
176
+ """
177
+ Opens a REPEATABLE READ, READ ONLY transaction held for the view's lifetime.
178
+
179
+ SET TRANSACTION, not a literal BEGIN — with autocommit=False (the
180
+ documented pool precondition), psycopg already opens a
181
+ transaction implicitly before the first statement on a fresh
182
+ connection; a literal BEGIN here would be a redundant, nested
183
+ BEGIN, which Postgres warns about and simply ignores outright —
184
+ its isolation-level clause never runs, leaving the already-open
185
+ transaction at the default READ COMMITTED. SET TRANSACTION sets
186
+ the characteristics of the already-open transaction directly
187
+ instead, which Postgres requires as the first statement inside
188
+ it — satisfied here since this is literally the first
189
+ cursor.execute() call after checkout. The pool's own
190
+ connection() context manager returns the connection to the pool
191
+ on __exit__ regardless of exception; the inner try/finally
192
+ guarantees rollback() runs on both normal completion and an
193
+ exception raised while the view is open.
194
+ """
195
+ with self._pool.connection() as conn:
196
+ cursor = conn.cursor()
197
+ cursor.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
198
+ try:
199
+ yield _PostgresView(conn)
200
+ finally:
201
+ conn.rollback()
202
+
203
+
204
+ class _PostgresView(View):
205
+ """
206
+ Read-only view over one REPEATABLE READ Postgres transaction.
207
+
208
+ Private: nothing outside PostgresTupleStore.snapshot() ever
209
+ constructs one directly — same convention as _MemoryView.
210
+ """
211
+
212
+ def __init__(self, connection: Connection) -> None:
213
+ """
214
+ Wraps the already-open, already-in-transaction connection.
215
+ """
216
+ self._connection = connection
217
+
218
+ @property
219
+ def connection(self) -> Connection:
220
+ """
221
+ Returns the already-open, already-in-transaction connection.
222
+
223
+ Private: nothing outside PostgresTupleStore.snapshot() ever
224
+ needs to see this.
225
+ """
226
+ return self._connection
227
+
228
+ @override
229
+ def read_tuple(self, subject: Subject, relation: str, object: ObjectReference) -> bool:
230
+ """
231
+ Returns whether the exact tuple `(subject, relation, object)` exists in this view.
232
+ """
233
+ subject_type, subject_id, subject_relation = decompose(subject)
234
+ cursor: Cursor = self.connection.cursor()
235
+
236
+ cursor.execute(
237
+ _READ_TUPLE_SQL,
238
+ (object.type, object.id, relation, subject_type, subject_id, subject_relation),
239
+ )
240
+
241
+ return cursor.fetchone() is not None
242
+
243
+ @override
244
+ def read_relation(self, object: ObjectReference, relation: str, limit: int) -> ReadResult:
245
+ """
246
+ Returns up to `limit` tuples matching the given object and relation, in a single query.
247
+ """
248
+ cursor: Cursor = self.connection.cursor()
249
+ cursor.execute(
250
+ _READ_RELATION_SQL,
251
+ (object.type, object.id, relation, limit + 1),
252
+ )
253
+
254
+ rows: list[tuple[Any, ...]] = cursor.fetchall()
255
+ tuples = frozenset(compose(r, relation, object) for r in rows[:limit])
256
+
257
+ return ReadResult(tuples=tuples, complete=len(rows) <= limit)
@@ -1,6 +1,7 @@
1
1
  from .base_error import BaseError
2
2
  from .evaluation_error import EvaluationError
3
3
  from .identifier_error import InvalidIdentifierError
4
+ from .index_entry_too_large_error import IndexEntryTooLargeError
4
5
  from .model_validation_error import ModelValidationError
5
6
  from .mutation_error import InvalidMutationError
6
7
  from .relation_error import InvalidRelationError
@@ -10,6 +11,7 @@ from .unknown_type_error import UnknownTypeError
10
11
  __all__ = [
11
12
  "BaseError",
12
13
  "EvaluationError",
14
+ "IndexEntryTooLargeError",
13
15
  "InvalidIdentifierError",
14
16
  "InvalidMutationError",
15
17
  "InvalidRelationError",
@@ -0,0 +1,16 @@
1
+ """
2
+ Set of errors related to storage adapters rejecting an oversized index entry.
3
+ """
4
+
5
+ from .base_error import BaseError
6
+
7
+
8
+ class IndexEntryTooLargeError(BaseError):
9
+ """
10
+ Error raised when a tuple's encoded primary key would exceed a storage
11
+ backend's index entry size limit.
12
+ Attributes:
13
+ code (ClassVar[str]): A unique error code for the specific error type.
14
+ """
15
+
16
+ code = "PP900"
@@ -2,6 +2,7 @@
2
2
  Evaluator: resolves check() over a CompiledModel and a TupleStore.
3
3
  """
4
4
 
5
+ from collections.abc import Sequence
5
6
  from dataclasses import dataclass, field
6
7
 
7
8
  from .errors import EvaluationError, UnknownNameError, UnknownTypeError
@@ -24,7 +25,11 @@ from .ports import TupleStore, View
24
25
  @dataclass(frozen=True, slots=True)
25
26
  class EvaluationBudget:
26
27
  """
27
- Per-`check()` resource limits.
28
+ Resource limits for one `check()` or `check_many()` call.
29
+
30
+ `max_nodes` and `max_store_reads` accumulate across a `check_many()` call.
31
+ `max_depth` bounds the live `context.visited` frontier and resets as each
32
+ request finishes, so it does not accumulate across the batch.
28
33
 
29
34
  No `__post_init__` validation — a degenerate value (e.g. `max_depth=0`)
30
35
  is well-defined without a guard: it simply fails on the first node
@@ -35,16 +40,16 @@ class EvaluationBudget:
35
40
  EvaluationBudget(max_depth=10, max_nodes=500, max_store_reads=100)
36
41
  """
37
42
 
38
- # Maximum recursion depth for a single `check()` call. Each recursive call
43
+ # Maximum recursion depth for one call. Each recursive call
39
44
  # to `_check_name()` adds one to the depth; exceeding this limit raises an
40
45
  # `EvaluationError`.
41
46
  max_depth: int = 25
42
47
 
43
- # Maximum number of nodes visited during a single `check()` call. Each
48
+ # Maximum number of nodes visited during one call. Each
44
49
  # call to `_check_name()` increments this count; exceeding this limit raises an `EvaluationError`.
45
50
  max_nodes: int = 10_000
46
51
 
47
- # Maximum number of store reads during a single `check()` call. Each call
52
+ # Maximum number of store reads during one call. Each call
48
53
  # to `_charge_read()` increments this count; exceeding this limit raises an `EvaluationError`.
49
54
  max_store_reads: int = 1_000
50
55
 
@@ -52,7 +57,7 @@ class EvaluationBudget:
52
57
  @dataclass(slots=True)
53
58
  class EvaluationContext:
54
59
  """
55
- Live bookkeeping for exactly one `check()` call.
60
+ Live bookkeeping for exactly one `check()` or `check_many()` call.
56
61
 
57
62
  Deliberately mutable, unlike almost everything else in this package —
58
63
  this is not a domain value, it is the in-flight state of one
@@ -63,13 +68,19 @@ class EvaluationContext:
63
68
  (added before recursing, discarded after returning) — anything
64
69
  permanently resolved lives in `memo` instead, so the two never overlap
65
70
  in meaning.
71
+
72
+ The key carries the subject because one `check()` has a constant subject,
73
+ while `check_many()` can mix subjects; a narrower key could reuse one
74
+ subject's cached decision for another and cause a false allow.
66
75
  """
67
76
 
68
77
  view: View
69
78
  budget: EvaluationBudget
70
- visited: set[tuple[ObjectReference, str]] = field(default_factory=lambda: set[tuple[ObjectReference, str]]())
71
- memo: dict[tuple[ObjectReference, str], bool] = field(
72
- default_factory=lambda: dict[tuple[ObjectReference, str], bool]()
79
+ visited: set[tuple[Subject, ObjectReference, str]] = field(
80
+ default_factory=lambda: set[tuple[Subject, ObjectReference, str]]()
81
+ )
82
+ memo: dict[tuple[Subject, ObjectReference, str], bool] = field(
83
+ default_factory=lambda: dict[tuple[Subject, ObjectReference, str], bool]()
73
84
  )
74
85
  node_count: int = 0
75
86
  store_read_count: int = 0
@@ -120,11 +131,28 @@ class Evaluator:
120
131
  context = EvaluationContext(view=view, budget=budget)
121
132
  return self._check_name(subject, name, object, context)
122
133
 
134
+ def check_many(
135
+ self,
136
+ requests: Sequence[tuple[Subject, str, ObjectReference]],
137
+ budget: EvaluationBudget | None = None,
138
+ ) -> list[bool]:
139
+ """
140
+ Returns one decision per request, preserving input order and duplicates.
141
+
142
+ The whole batch shares one snapshot, context, and budget. An error on
143
+ any request aborts the call and propagates without partial results.
144
+ """
145
+ if budget is None:
146
+ budget = EvaluationBudget()
147
+ with self._store.snapshot() as view:
148
+ context = EvaluationContext(view=view, budget=budget)
149
+ return [self._check_name(subject, name, object, context) for subject, name, object in requests]
150
+
123
151
  def _check_name(self, subject: Subject, name: str, object: ObjectReference, context: EvaluationContext) -> bool:
124
152
  """
125
153
  Dispatches (object, name) to a relation or permission check, with cycle/memo/budget bookkeeping.
126
154
  """
127
- key = (object, name)
155
+ key = (subject, object, name)
128
156
  if key in context.memo:
129
157
  return context.memo[key]
130
158
  if key in context.visited:
@@ -5,7 +5,7 @@ Value Object representing an entity identifier.
5
5
  from dataclasses import dataclass
6
6
  from typing import ClassVar
7
7
 
8
- from ..errors import InvalidIdentifierError
8
+ from policy_pattern.errors import InvalidIdentifierError
9
9
 
10
10
 
11
11
  @dataclass(frozen=True, slots=True)
@@ -7,7 +7,8 @@ from dataclasses import dataclass
7
7
  from types import MappingProxyType
8
8
  from typing import ClassVar
9
9
 
10
- from ..errors import InvalidIdentifierError
10
+ from policy_pattern.errors import InvalidIdentifierError
11
+
11
12
  from .identifier import Identifier
12
13
  from .operators import PermissionExpression
13
14
  from .relation import Relation
@@ -0,0 +1,16 @@
1
+ -- Initial schema for PostgresTupleStore (policy_pattern/adapters/postgres.py).
2
+ --
3
+ -- By default, apply this through your own migration tooling (Alembic, a
4
+ -- raw-SQL migration step, etc.). PostgresTupleStore can optionally run
5
+ -- this statement at construction with auto_create_schema=True. See
6
+ -- docs/architecture.md's "Database Adapters" section.
7
+
8
+ CREATE TABLE IF NOT EXISTS "authz_tuples" (
9
+ object_type TEXT NOT NULL COLLATE "C",
10
+ object_id TEXT NOT NULL COLLATE "C",
11
+ relation TEXT NOT NULL COLLATE "C",
12
+ subject_type TEXT NOT NULL COLLATE "C",
13
+ subject_id TEXT NOT NULL COLLATE "C",
14
+ subject_relation TEXT NOT NULL DEFAULT '' COLLATE "C",
15
+ PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_relation)
16
+ );
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "policy-pattern"
3
- version = "0.2.5"
3
+ version = "0.4.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"
@@ -52,9 +52,10 @@ addopts = [
52
52
  "--color=yes",
53
53
  ]
54
54
  markers = [
55
- "unit: relation rewrite / evaluator logic, no storage",
55
+ "unit: fast, deterministic tests with no live external service (no real database, no network call)",
56
56
  "contract: shared TupleStore contract, runs against every adapter",
57
57
  "integration: real PostgreSQL via testcontainers",
58
+ "slow: performance-sensitive tests that may take longer to run",
58
59
  ]
59
60
  xfail_strict = true
60
61
 
@@ -196,12 +197,14 @@ format = ["ruff>=0.16.7"]
196
197
  lint = [
197
198
  "ruff>=0.16.7",
198
199
  "ty>=0.0.80",
199
- { include-group = "test" },
200
200
  ]
201
201
  release = ["python-semantic-release>=9.15.0"]
202
202
  test = [
203
+ "psycopg-pool>=3.3.3",
204
+ "psycopg[binary]>=3.3.6",
203
205
  "pytest>=9.1.1",
204
- "testcontainers>=4.15.0",
206
+ "pytest-randomly>=5.0.0",
207
+ "testcontainers[postgres]>=4.15.0",
205
208
  { include-group = "coverage" },
206
209
  ]
207
210
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "policy-pattern"
3
- version = "0.2.5"
3
+ version = "0.4.0"
4
4
  description = "A minimal, embeddable ReBAC authorization engine for Python, inspired by Google Zanzibar."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -49,9 +49,16 @@ module-root = "."
49
49
  coverage = ["coverage[toml]>=7.0.0"]
50
50
  develop = ["pre-commit>=4.0.0"]
51
51
  format = ["ruff>=0.16.7"]
52
- lint = ["ruff>=0.16.7", "ty>=0.0.80", { include-group = "test" }]
52
+ lint = ["ruff>=0.16.7", "ty>=0.0.80"]
53
53
  release = ["python-semantic-release>=9.15.0"]
54
- test = ["pytest>=9.1.1", "testcontainers>=4.15.0", { include-group = "coverage" }]
54
+ test = [
55
+ "psycopg-pool>=3.3.3",
56
+ "psycopg[binary]>=3.3.6",
57
+ "pytest>=9.1.1",
58
+ "pytest-randomly>=5.0.0",
59
+ "testcontainers[postgres]>=4.15.0",
60
+ { include-group = "coverage" },
61
+ ]
55
62
  all = [
56
63
  { include-group = "coverage" },
57
64
  { include-group = "develop" },
@@ -73,9 +80,10 @@ addopts = [
73
80
  "--color=yes", # color the output
74
81
  ]
75
82
  markers = [
76
- "unit: relation rewrite / evaluator logic, no storage",
83
+ "unit: fast, deterministic tests with no live external service (no real database, no network call)",
77
84
  "contract: shared TupleStore contract, runs against every adapter",
78
85
  "integration: real PostgreSQL via testcontainers",
86
+ "slow: performance-sensitive tests that may take longer to run",
79
87
  ]
80
88
  xfail_strict = true
81
89
 
@@ -1,3 +0,0 @@
1
- from .memory import MemoryTupleStore
2
-
3
- __all__ = ["MemoryTupleStore"]
File without changes