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.
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/PKG-INFO +1 -1
- policy_pattern-0.4.0/policy_pattern/adapters/__init__.py +7 -0
- policy_pattern-0.4.0/policy_pattern/adapters/_db_protocols.py +90 -0
- policy_pattern-0.4.0/policy_pattern/adapters/_tuple_mapping.py +44 -0
- policy_pattern-0.4.0/policy_pattern/adapters/postgres.py +257 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/__init__.py +2 -0
- policy_pattern-0.4.0/policy_pattern/errors/index_entry_too_large_error.py +16 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/evaluator.py +37 -9
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/identifier.py +1 -1
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/type.py +2 -1
- policy_pattern-0.4.0/policy_pattern/sql/postgres/0001_create_policy_tuples.sql +16 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/pyproject.toml +7 -4
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/pyproject.toml.orig +12 -4
- policy_pattern-0.2.5/policy_pattern/adapters/__init__.py +0 -3
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/LICENSE.md +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/README.md +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/__init__.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/adapters/memory.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/base_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/evaluation_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/identifier_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/model_validation_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/mutation_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/relation_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/unknown_name_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/errors/unknown_type_error.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/__init__.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/_schema_validation.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/compiled_model.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/direct_subject.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/model.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/mutation.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/object_reference.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/operators.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/reference.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/relation.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/subject.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/subject_set.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/tuple.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/models/tuple_to_userset.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/ports/__init__.py +0 -0
- {policy_pattern-0.2.5 → policy_pattern-0.4.0}/policy_pattern/ports/tuple_store.py +0 -0
- {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.
|
|
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,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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
@@ -7,7 +7,8 @@ from dataclasses import dataclass
|
|
|
7
7
|
from types import MappingProxyType
|
|
8
8
|
from typing import ClassVar
|
|
9
9
|
|
|
10
|
-
from
|
|
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.
|
|
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:
|
|
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
|
-
"
|
|
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.
|
|
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"
|
|
52
|
+
lint = ["ruff>=0.16.7", "ty>=0.0.80"]
|
|
53
53
|
release = ["python-semantic-release>=9.15.0"]
|
|
54
|
-
test = [
|
|
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:
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{policy_pattern-0.2.5 → policy_pattern-0.4.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
|