rowstile 0.1.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rowstile/__init__.py +335 -0
- rowstile/__main__.py +3 -0
- rowstile/_command/authzlib/__init__.py +33 -0
- rowstile/_command/authzlib/base.py +185 -0
- rowstile/_command/authzlib/compiler.py +1682 -0
- rowstile/_command/authzlib/conditions.py +297 -0
- rowstile/_command/authzlib/connection.py +84 -0
- rowstile/_command/authzlib/coverage.py +103 -0
- rowstile/_command/authzlib/database.py +802 -0
- rowstile/_command/authzlib/devtools.py +613 -0
- rowstile/_command/authzlib/draft.py +378 -0
- rowstile/_command/authzlib/errors.py +672 -0
- rowstile/_command/authzlib/evaluate.py +735 -0
- rowstile/_command/authzlib/fmt.py +315 -0
- rowstile/_command/authzlib/governance.py +655 -0
- rowstile/_command/authzlib/grant.py +316 -0
- rowstile/_command/authzlib/hardening.py +489 -0
- rowstile/_command/authzlib/identity.py +460 -0
- rowstile/_command/authzlib/insight.py +537 -0
- rowstile/_command/authzlib/migrate.py +672 -0
- rowstile/_command/authzlib/output.py +1230 -0
- rowstile/_command/authzlib/parse.py +1000 -0
- rowstile/_command/authzlib/perf.py +325 -0
- rowstile/_command/authzlib/prove.py +97 -0
- rowstile/_command/authzlib/refusals.py +259 -0
- rowstile/_command/authzlib/review.py +1032 -0
- rowstile/_command/authzlib/sqlutil.py +240 -0
- rowstile/_command/authzlib/statements.py +249 -0
- rowstile/_command/authzlib/testing.py +273 -0
- rowstile/_command/authzlib/trees.py +938 -0
- rowstile/_command/cli/init.py +96 -0
- rowstile/_command/cli/lsp.py +645 -0
- rowstile/_command/cli/mcp.py +245 -0
- rowstile/_command/cli/migrations.py +242 -0
- rowstile/_command/cli/pgwire.py +627 -0
- rowstile/_command/cli/rowstile_cli.py +1320 -0
- rowstile/_command/cli/stack.py +230 -0
- rowstile/_command/cli/studio/app.js +406 -0
- rowstile/_command/cli/studio/index.html +58 -0
- rowstile/_command/cli/studio/style.css +62 -0
- rowstile/_command/cli/studio.py +507 -0
- rowstile/_version.py +15 -0
- rowstile/alembic.py +37 -0
- rowstile/asyncpg.py +79 -0
- rowstile/command.py +30 -0
- rowstile/fastapi.py +142 -0
- rowstile/psycopg.py +121 -0
- rowstile/py.typed +0 -0
- rowstile/sqlalchemy.py +236 -0
- rowstile/testing.py +124 -0
- rowstile-0.1.0a2.dist-info/METADATA +70 -0
- rowstile-0.1.0a2.dist-info/RECORD +55 -0
- rowstile-0.1.0a2.dist-info/WHEEL +4 -0
- rowstile-0.1.0a2.dist-info/entry_points.txt +5 -0
- rowstile-0.1.0a2.dist-info/licenses/LICENSE +202 -0
rowstile/__init__.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""rowstile for Python apps.
|
|
2
|
+
|
|
3
|
+
Every transaction signs in as whoever the request (or the job) acts for, with `authz.act_as()`; the database's
|
|
4
|
+
refusals become `Refused` (a 403 with the reason), rows the user can't see become `NotFound` (a 404). The
|
|
5
|
+
package holds no rowstile logic: it calls the `authz.*` functions the policy made, and translates their answers.
|
|
6
|
+
|
|
7
|
+
import rowstile
|
|
8
|
+
with rowstile.acting_as(42): # or ("service", 3), or None for nobody
|
|
9
|
+
... # every transaction in here signs in as user 42
|
|
10
|
+
|
|
11
|
+
The integrations: `rowstile.fastapi` (one line: Rowstile(app, engine, user=...)), `rowstile.sqlalchemy` (sync
|
|
12
|
+
and async engines, SQLModel too), `rowstile.psycopg`, `rowstile.asyncpg`, `rowstile.alembic` (autogenerate
|
|
13
|
+
leaves rowstile's objects alone) and `rowstile.testing` (pytest fixtures).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import contextvars
|
|
19
|
+
import enum
|
|
20
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, LiteralString, ParamSpec, TypeAlias, TypeVar, cast
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from sqlalchemy.engine import Engine
|
|
28
|
+
|
|
29
|
+
Id: TypeAlias = "int | str"
|
|
30
|
+
# who a transaction acts for: 42, '42' (a user, whatever the id holds), ('service', 3), a Principal, or None (nobody)
|
|
31
|
+
Who: TypeAlias = "Principal | Id | tuple[str, Id | None] | None"
|
|
32
|
+
Problem: TypeAlias = "dict[str, str | int | list[str] | None]" # an RFC 9457 problem body
|
|
33
|
+
P = ParamSpec("P")
|
|
34
|
+
R = TypeVar("R")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Principal:
|
|
39
|
+
"""Who a transaction acts for: a user (type 'user'), another principal type the policy declares
|
|
40
|
+
(a service, a bot), or nobody (id None: only what `anyone` may see)."""
|
|
41
|
+
type: str = "user"
|
|
42
|
+
id: str | None = None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def of(cls, who: Who) -> Principal:
|
|
46
|
+
"""42, '42', ('service', 3), a Principal, or None (nobody). A plain id is a user's, whatever it holds:
|
|
47
|
+
'service:3' is the user whose id that is (ids often come from outside: a username, an identity
|
|
48
|
+
provider's subject), never service 3. Another principal type is named: ('service', 3)."""
|
|
49
|
+
if isinstance(who, Principal):
|
|
50
|
+
return who
|
|
51
|
+
if who is None:
|
|
52
|
+
return NOBODY
|
|
53
|
+
if isinstance(who, tuple):
|
|
54
|
+
kind, pid = who
|
|
55
|
+
return cls(str(kind), None if pid is None else str(pid))
|
|
56
|
+
return cls("user", str(who))
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def parse(cls, text: str) -> Principal:
|
|
60
|
+
"""What str() wrote, read back: 'service:3' is service 3, '42' user 42, 'nobody' nobody. For text your
|
|
61
|
+
own code wrote (a job's argument, the audit trail's `by`), not for an id that came from outside."""
|
|
62
|
+
if text == "nobody":
|
|
63
|
+
return NOBODY
|
|
64
|
+
if ":" in text and not text.startswith("("):
|
|
65
|
+
kind, pid = text.split(":", 1)
|
|
66
|
+
return cls(kind, pid)
|
|
67
|
+
return cls("user", text)
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return "nobody" if self.id is None else (self.id if self.type == "user" else f"{self.type}:{self.id}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
NOBODY = Principal("user", None)
|
|
74
|
+
_current: contextvars.ContextVar[Principal | None] = contextvars.ContextVar("rowstile_principal", default=None)
|
|
75
|
+
# the rows the ORM updated or deleted lately (sqlalchemy.why_stale): a list each request or acting_as block
|
|
76
|
+
# makes, which the ORM's hooks add to (they may run in another greenlet, which sees the same list)
|
|
77
|
+
# (the engine, the table, update or delete, the row's key)
|
|
78
|
+
_Write: TypeAlias = "tuple[Engine, str, str, tuple[object, ...]]"
|
|
79
|
+
_writes: contextvars.ContextVar[list[_Write] | None] = contextvars.ContextVar("rowstile_writes", default=None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Unset(enum.Enum):
|
|
83
|
+
UNSET = "unset" # an argument left out: whoever the code acts for now
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_UNSET = _Unset.UNSET
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def current() -> Principal | None:
|
|
90
|
+
"""Who the code running now acts for (set by acting_as, the web integration or a job), or None if unset."""
|
|
91
|
+
return _current.get()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextlib.contextmanager
|
|
95
|
+
def acting_as(who: Who) -> Iterator[Principal | None]:
|
|
96
|
+
"""Everything in the block (every transaction the integrations begin) acts for `who`."""
|
|
97
|
+
token, writes = _current.set(Principal.of(who)), _writes.set([])
|
|
98
|
+
try:
|
|
99
|
+
yield _current.get()
|
|
100
|
+
finally:
|
|
101
|
+
_writes.reset(writes)
|
|
102
|
+
_current.reset(token)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def job(who: Who) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
106
|
+
"""A decorator for background jobs (Celery, RQ, FastAPI's BackgroundTasks): the job acts for `who`, e.g.
|
|
107
|
+
@rowstile.job(("service", 3)). Async functions work too."""
|
|
108
|
+
import functools
|
|
109
|
+
import inspect
|
|
110
|
+
|
|
111
|
+
def wrap(fn: Callable[P, R]) -> Callable[P, R]:
|
|
112
|
+
if inspect.iscoroutinefunction(fn):
|
|
113
|
+
coroutine = fn
|
|
114
|
+
|
|
115
|
+
@functools.wraps(fn)
|
|
116
|
+
async def run_async(*a: P.args, **kw: P.kwargs) -> object:
|
|
117
|
+
with acting_as(who):
|
|
118
|
+
return await coroutine(*a, **kw)
|
|
119
|
+
return cast("Callable[P, R]", run_async) # R is the coroutine run_async returns too
|
|
120
|
+
|
|
121
|
+
@functools.wraps(fn)
|
|
122
|
+
def run(*a: P.args, **kw: P.kwargs) -> R:
|
|
123
|
+
with acting_as(who):
|
|
124
|
+
return fn(*a, **kw)
|
|
125
|
+
return run
|
|
126
|
+
return wrap
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def literal(value: object) -> str:
|
|
130
|
+
"""A SQL string literal (standard_conforming_strings, as every supported Postgres has it)."""
|
|
131
|
+
return "NULL" if value is None else "'" + str(value).replace("'", "''") + "'"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def act_as_args(who: Who | _Unset = _UNSET) -> tuple[str | None, str | None]:
|
|
135
|
+
"""authz.act_as's arguments, (type, id): ('user', '42'), or (None, None) for nobody (who is None). With
|
|
136
|
+
no argument, as whoever the code acts for now (nobody if unset)."""
|
|
137
|
+
p = (current() or NOBODY) if isinstance(who, _Unset) else Principal.of(who)
|
|
138
|
+
return (None, None) if p.id is None else (p.type, p.id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def act_as_sql(who: Who | _Unset = _UNSET) -> str:
|
|
142
|
+
"""The statement that signs a transaction in: SELECT authz.act_as('user', '42'); None is nobody. With no
|
|
143
|
+
argument, as whoever the code acts for now (nobody if unset). The values are written into the statement:
|
|
144
|
+
where the driver takes parameters, pass act_as_args() instead (psycopg reads % in a statement)."""
|
|
145
|
+
kind, pid = act_as_args(who)
|
|
146
|
+
return f"SELECT authz.act_as({literal(kind)}, {literal(pid)})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- errors -------------------------------------------------------------------------------------------
|
|
150
|
+
class Refused(Exception):
|
|
151
|
+
"""The database refused a write, and said why: the rule (table and command), and the explanation."""
|
|
152
|
+
code = "AZ709" # rowstile help AZ709
|
|
153
|
+
|
|
154
|
+
def __init__(self, message: str, table: str | None = None, command: str | None = None,
|
|
155
|
+
why: Sequence[str] = ()) -> None:
|
|
156
|
+
super().__init__(message)
|
|
157
|
+
self.message, self.table, self.command, self.why = message, table, command, list(why)
|
|
158
|
+
|
|
159
|
+
def problem(self) -> Problem:
|
|
160
|
+
"""An RFC 9457 problem body for a 403."""
|
|
161
|
+
return {"type": "https://rowstile.dev/problems/refused", "title": "Forbidden", "status": 403,
|
|
162
|
+
"detail": self.message, "table": self.table, "command": self.command, "why": self.why,
|
|
163
|
+
"code": self.code}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class NotFound(Exception):
|
|
167
|
+
"""The row isn't there, or whoever the transaction acts for can't see it."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, table: str | None = None, id_: object = None) -> None:
|
|
170
|
+
super().__init__(f"{table or 'the row'} {'' if id_ is None else id_} not found".replace(" ", " "))
|
|
171
|
+
self.table, self.id = table, None if id_ is None else str(id_)
|
|
172
|
+
|
|
173
|
+
def problem(self) -> Problem:
|
|
174
|
+
return {"type": "https://rowstile.dev/problems/not-found", "title": "Not Found", "status": 404,
|
|
175
|
+
"detail": str(self)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class NotSignedIn(Exception):
|
|
179
|
+
"""A query that needs to know who is asking ran in a transaction nobody signed in to (strict sign-in)."""
|
|
180
|
+
code = "AZ701" # rowstile help AZ701
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ConnectionProblem(RuntimeError):
|
|
184
|
+
"""authz.connection_check() found that the app's connection skips row-level security (or worse)."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, problems: Iterable[str]) -> None:
|
|
187
|
+
self.problems = list(problems)
|
|
188
|
+
super().__init__("the app's database connection can't be used with rowstile: " + "; ".join(self.problems))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _db_error(exc: BaseException) -> list[BaseException]:
|
|
192
|
+
"""The error and the driver's errors inside it: SQLAlchemy keeps the driver's in .orig, and its asyncpg
|
|
193
|
+
adapter keeps asyncpg's own as the cause of that."""
|
|
194
|
+
out: list[BaseException] = []
|
|
195
|
+
seen: set[int] = set()
|
|
196
|
+
todo: list[BaseException | None] = [exc]
|
|
197
|
+
while todo:
|
|
198
|
+
e = todo.pop(0)
|
|
199
|
+
if e is None or id(e) in seen:
|
|
200
|
+
continue
|
|
201
|
+
seen.add(id(e))
|
|
202
|
+
out.append(e)
|
|
203
|
+
todo += [getattr(e, "orig", None), e.__cause__]
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _field(errs: list[BaseException], *names: str) -> str | None:
|
|
208
|
+
for err in errs:
|
|
209
|
+
for n in names:
|
|
210
|
+
v = getattr(err, n, None)
|
|
211
|
+
if v and isinstance(v, str):
|
|
212
|
+
return v
|
|
213
|
+
diag = getattr(err, "diag", None)
|
|
214
|
+
v = getattr(diag, n, None) if diag is not None else None
|
|
215
|
+
if v:
|
|
216
|
+
return str(v)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def error_code(exc: BaseException) -> str | None:
|
|
221
|
+
"""rowstile's code for a database error (AZ709; `rowstile help AZ709` says what it means): from its HINT,
|
|
222
|
+
where the runtime puts it, or its message. None for an error rowstile didn't raise."""
|
|
223
|
+
import re
|
|
224
|
+
errs = _db_error(exc)
|
|
225
|
+
for text in (_field(errs, "message_hint", "hint"), _field(errs, "message_primary", "message") or str(errs[-1])):
|
|
226
|
+
m = re.search(r"(?:rowstile|rowfence) help (AZ\d{3})|\[(AZ\d{3})\]", text or "")
|
|
227
|
+
if m:
|
|
228
|
+
return m.group(1) or m.group(2)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def sqlstate(exc: BaseException) -> str | None:
|
|
233
|
+
err = _db_error(exc)
|
|
234
|
+
return _field(err, "sqlstate", "pgcode")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def refusal(exc: BaseException) -> Refused | None:
|
|
238
|
+
"""A Refused for an error that is the database refusing a write (SQLSTATE 42501, raised by rowstile's
|
|
239
|
+
policies with the rule and why), else None. psycopg, psycopg2 and asyncpg, bare or in SQLAlchemy's.
|
|
240
|
+
Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: None."""
|
|
241
|
+
err = _db_error(exc)
|
|
242
|
+
if _field(err, "sqlstate", "pgcode") != "42501":
|
|
243
|
+
return None
|
|
244
|
+
message = _field(err, "message_primary", "message") or str(err[-1]).split("\n")[0]
|
|
245
|
+
if error_code(exc) is None and not message.startswith("new row violates row-level security policy"):
|
|
246
|
+
return None
|
|
247
|
+
detail = _field(err, "message_detail", "detail") or ""
|
|
248
|
+
schema, table = _field(err, "schema_name"), _field(err, "table_name")
|
|
249
|
+
constraint = _field(err, "constraint_name") or ""
|
|
250
|
+
if message.startswith("new row violates row-level security policy"):
|
|
251
|
+
# Postgres's own words, not rowstile's: the row was allowed in, but may not be read back
|
|
252
|
+
# (INSERT ... RETURNING, or an ORM that reads the new row), which the select rule decides
|
|
253
|
+
import re
|
|
254
|
+
m = re.search(r'for table "([^"]+)"', message)
|
|
255
|
+
return Refused(f"{message}: the write was allowed, but the select rule doesn't let this user read the row "
|
|
256
|
+
f"back (RETURNING); read it back only if the select rule allows it", m.group(1) if m else table,
|
|
257
|
+
"select", [])
|
|
258
|
+
return Refused(message, f"{schema}.{table}" if schema and table else table,
|
|
259
|
+
constraint[6:] if constraint.startswith("authz_") else None,
|
|
260
|
+
detail.split("\n") if detail else [])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def not_signed_in(exc: BaseException) -> bool:
|
|
264
|
+
return sqlstate(exc) == "28000"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_problems(rows: Iterable[tuple[str, str]]) -> list[str]:
|
|
268
|
+
"""The errors among authz.connection_check()'s rows (severity, problem)."""
|
|
269
|
+
return [problem for severity, problem in rows if severity == "error"]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _field_text(value: object) -> str:
|
|
273
|
+
"""One field of a row as Postgres writes it, quoted: a comma, a quote or a space in it stays in its field."""
|
|
274
|
+
if value is None:
|
|
275
|
+
return ""
|
|
276
|
+
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def key_text(key: object) -> str | None:
|
|
280
|
+
"""A row's key as the database compares it: one value, or a composite key as Postgres writes a row."""
|
|
281
|
+
if isinstance(key, (tuple, list)):
|
|
282
|
+
if len(key) != 1:
|
|
283
|
+
return "(" + ",".join(_field_text(v) for v in key) + ")"
|
|
284
|
+
key = key[0]
|
|
285
|
+
return None if key is None else str(key)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def explain_rule_args(table: str, command: str, key: object) -> tuple[str, str, str | None]:
|
|
289
|
+
"""authz.explain_rule's arguments for a row's key: one value, or several (a composite key's row text)."""
|
|
290
|
+
return table, command, key_text(key)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def explain_rule_sql(table: LiteralString, command: LiteralString, key: LiteralString) -> LiteralString:
|
|
294
|
+
"""SELECT authz.explain_rule($1, $2, $3, NULL) with the driver's placeholders for the table, the command and
|
|
295
|
+
the key (':t', '%(t)s', '$1'). The policy names tables with their schema; a table named without one (a
|
|
296
|
+
model that names no schema) is looked up on the search_path."""
|
|
297
|
+
found = ("SELECT n.nspname || '.' || c.relname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
|
|
298
|
+
f"ON n.oid = c.relnamespace WHERE position('.' in {table}) = 0 "
|
|
299
|
+
f"AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident({table}))")
|
|
300
|
+
return f"SELECT authz.explain_rule(coalesce(({found}), {table}), {command}, {key}, NULL)"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def verdict(table: str, command: str, key: object, lines: Sequence[str] | None) -> NotFound | Refused:
|
|
304
|
+
"""What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user
|
|
305
|
+
(authz.explain_rule answered NULL), or when the rule allows the write (its first line says yes: the
|
|
306
|
+
statement matched nothing for another reason, such as a WHERE with more than the key); Refused, with
|
|
307
|
+
why, otherwise."""
|
|
308
|
+
key = key[0] if isinstance(key, (tuple, list)) and len(key) == 1 else key
|
|
309
|
+
if lines is None or (lines and lines[0].lstrip().startswith("yes")):
|
|
310
|
+
return NotFound(table, key)
|
|
311
|
+
return Refused(f"permission denied: may not {command} {table} {key}", table, command, lines)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
__all__ = [
|
|
315
|
+
"NOBODY",
|
|
316
|
+
"ConnectionProblem",
|
|
317
|
+
"Id",
|
|
318
|
+
"NotFound",
|
|
319
|
+
"NotSignedIn",
|
|
320
|
+
"Principal",
|
|
321
|
+
"Problem",
|
|
322
|
+
"Refused",
|
|
323
|
+
"Who",
|
|
324
|
+
"__version__",
|
|
325
|
+
"act_as_args",
|
|
326
|
+
"act_as_sql",
|
|
327
|
+
"acting_as",
|
|
328
|
+
"current",
|
|
329
|
+
"error_code",
|
|
330
|
+
"job",
|
|
331
|
+
"not_signed_in",
|
|
332
|
+
"refusal",
|
|
333
|
+
"sqlstate",
|
|
334
|
+
"verdict",
|
|
335
|
+
]
|
rowstile/__main__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""authzlib: compile a .authz policy file into PostgreSQL (views, closure tables,
|
|
2
|
+
triggers, row-level security policies and an API for app code)."""
|
|
3
|
+
from .devtools import DevMixin
|
|
4
|
+
from .output import OutputMixin
|
|
5
|
+
from .parse import Loc, PolicyError, parse_policy
|
|
6
|
+
from .testing import TestMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Compiler(TestMixin, DevMixin, OutputMixin):
|
|
10
|
+
"""A policy compiled: each part of the output is one of its bases (OutputMixin assembles them)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0-alpha.2" # set with packaging/version.py (the packages copy it); authz.policy_versions records it on each apply
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build() -> str:
|
|
17
|
+
"""The version apply records and trusts: a release's own, and for a build of the next one (X.Y.Z-dev)
|
|
18
|
+
the version with a hash of the compiler's sources, since two such builds may make different SQL."""
|
|
19
|
+
if not __version__.endswith("-dev"):
|
|
20
|
+
return __version__
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
here, h = os.path.dirname(os.path.abspath(__file__)), hashlib.sha256()
|
|
24
|
+
for name in sorted(os.listdir(here)):
|
|
25
|
+
if name.endswith(".py"):
|
|
26
|
+
with open(os.path.join(here, name), "rb") as fh:
|
|
27
|
+
h.update(name.encode() + b"\0" + fh.read().replace(b"\r\n", b"\n") + b"\0")
|
|
28
|
+
return f"{__version__}+{h.hexdigest()[:12]}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
BUILD = _build()
|
|
32
|
+
|
|
33
|
+
__all__ = ["BUILD", "Compiler", "Loc", "PolicyError", "__version__", "parse_policy"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""SQL kept across re-applies: the tables people's data lives in, and migrations."""
|
|
2
|
+
|
|
3
|
+
BASE_SQL = r"""-- Base objects (kept across re-applies)
|
|
4
|
+
CREATE SCHEMA IF NOT EXISTS authz;
|
|
5
|
+
REVOKE ALL ON SCHEMA authz FROM PUBLIC;
|
|
6
|
+
|
|
7
|
+
-- 0.1.0 renamed authz.grants to authz.shares
|
|
8
|
+
DO $mg$
|
|
9
|
+
BEGIN
|
|
10
|
+
IF to_regclass('authz.grants') IS NOT NULL AND to_regclass('authz.shares') IS NULL THEN
|
|
11
|
+
ALTER TABLE authz.grants RENAME TO shares;
|
|
12
|
+
ALTER TABLE authz.shares RENAME CONSTRAINT grants_pkey TO shares_pkey;
|
|
13
|
+
ALTER INDEX IF EXISTS authz.grants_by_subject RENAME TO shares_by_subject;
|
|
14
|
+
END IF;
|
|
15
|
+
END $mg$;
|
|
16
|
+
|
|
17
|
+
-- Shares people create in the app, for relations marked 'shared' (and custom role assignments)
|
|
18
|
+
CREATE TABLE IF NOT EXISTS authz.shares (
|
|
19
|
+
object_type text NOT NULL,
|
|
20
|
+
object_id text NOT NULL,
|
|
21
|
+
relation text NOT NULL,
|
|
22
|
+
subject_type text NOT NULL,
|
|
23
|
+
subject_id text NOT NULL,
|
|
24
|
+
subject_relation text NOT NULL DEFAULT '',
|
|
25
|
+
expires_at timestamptz,
|
|
26
|
+
created_by text,
|
|
27
|
+
starts_at timestamptz,
|
|
28
|
+
caveat text,
|
|
29
|
+
caveat_args jsonb,
|
|
30
|
+
created_at timestamptz DEFAULT now(),
|
|
31
|
+
PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_relation));
|
|
32
|
+
-- earlier versions stored integer ids
|
|
33
|
+
DO $m$
|
|
34
|
+
BEGIN
|
|
35
|
+
IF (SELECT atttypid FROM pg_attribute WHERE attrelid = 'authz.shares'::regclass AND attname = 'object_id') <> 'text'::regtype THEN
|
|
36
|
+
ALTER TABLE authz.shares ALTER COLUMN object_id TYPE text USING object_id::text,
|
|
37
|
+
ALTER COLUMN subject_id TYPE text USING subject_id::text,
|
|
38
|
+
ALTER COLUMN created_by TYPE text USING created_by::text;
|
|
39
|
+
END IF;
|
|
40
|
+
END $m$;
|
|
41
|
+
ALTER TABLE authz.shares ADD COLUMN IF NOT EXISTS starts_at timestamptz;
|
|
42
|
+
ALTER TABLE authz.shares ADD COLUMN IF NOT EXISTS caveat text;
|
|
43
|
+
ALTER TABLE authz.shares ADD COLUMN IF NOT EXISTS caveat_args jsonb;
|
|
44
|
+
ALTER TABLE authz.shares ADD COLUMN IF NOT EXISTS created_at timestamptz DEFAULT now();
|
|
45
|
+
CREATE INDEX IF NOT EXISTS shares_by_subject
|
|
46
|
+
ON authz.shares (subject_type, subject_id, subject_relation, object_type, relation);
|
|
47
|
+
REVOKE ALL ON authz.shares FROM PUBLIC;
|
|
48
|
+
|
|
49
|
+
-- Custom roles: sets of permissions defined as data, assigned like shares ('role:<id>')
|
|
50
|
+
CREATE TABLE IF NOT EXISTS authz.roles (
|
|
51
|
+
id bigserial PRIMARY KEY,
|
|
52
|
+
owner_type text NOT NULL,
|
|
53
|
+
owner_id text NOT NULL,
|
|
54
|
+
object_type text NOT NULL,
|
|
55
|
+
name text NOT NULL,
|
|
56
|
+
created_by text,
|
|
57
|
+
created_at timestamptz DEFAULT now(),
|
|
58
|
+
UNIQUE (owner_type, owner_id, object_type, name));
|
|
59
|
+
CREATE TABLE IF NOT EXISTS authz.role_permissions (
|
|
60
|
+
role_id bigint NOT NULL REFERENCES authz.roles ON DELETE CASCADE,
|
|
61
|
+
permission text NOT NULL,
|
|
62
|
+
PRIMARY KEY (role_id, permission));
|
|
63
|
+
REVOKE ALL ON authz.roles, authz.role_permissions FROM PUBLIC;
|
|
64
|
+
|
|
65
|
+
-- API keys (only a hash of each is kept)
|
|
66
|
+
CREATE TABLE IF NOT EXISTS authz.api_keys (
|
|
67
|
+
id bigserial PRIMARY KEY,
|
|
68
|
+
user_id text NOT NULL,
|
|
69
|
+
name text NOT NULL,
|
|
70
|
+
prefix text NOT NULL,
|
|
71
|
+
hash text NOT NULL UNIQUE,
|
|
72
|
+
scopes text NOT NULL DEFAULT '',
|
|
73
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
74
|
+
expires_at timestamptz,
|
|
75
|
+
last_used_at timestamptz,
|
|
76
|
+
revoked_at timestamptz);
|
|
77
|
+
-- whose key: user_id is the principal's id, of this type
|
|
78
|
+
ALTER TABLE authz.api_keys ADD COLUMN IF NOT EXISTS principal_type text NOT NULL DEFAULT 'user';
|
|
79
|
+
-- Settings for the functions (jwt_secret, jwt_issuer, jwt_audience, jwt_user_claim, jwt_type_claim, ...)
|
|
80
|
+
CREATE TABLE IF NOT EXISTS authz.settings (key text PRIMARY KEY, value text NOT NULL);
|
|
81
|
+
-- The audit trail: every share, role, key, request and impersonation, and relationship changes
|
|
82
|
+
CREATE TABLE IF NOT EXISTS authz.audit (
|
|
83
|
+
id bigserial PRIMARY KEY,
|
|
84
|
+
at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
85
|
+
txid bigint NOT NULL DEFAULT txid_current(),
|
|
86
|
+
db_role text,
|
|
87
|
+
user_id text,
|
|
88
|
+
acting_user text,
|
|
89
|
+
action text NOT NULL,
|
|
90
|
+
object_type text,
|
|
91
|
+
object_id text,
|
|
92
|
+
relation text,
|
|
93
|
+
subject_type text,
|
|
94
|
+
subject_id text,
|
|
95
|
+
subject_relation text,
|
|
96
|
+
detail jsonb,
|
|
97
|
+
reason text);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS audit_by_object ON authz.audit (object_type, object_id, at);
|
|
99
|
+
REVOKE ALL ON authz.api_keys, authz.settings, authz.audit FROM PUBLIC;
|
|
100
|
+
|
|
101
|
+
-- Change feed: objects whose access may have changed, for caches, search indexes, file stores
|
|
102
|
+
CREATE TABLE IF NOT EXISTS authz.changes (
|
|
103
|
+
pos bigserial PRIMARY KEY,
|
|
104
|
+
at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
105
|
+
txid bigint NOT NULL DEFAULT txid_current(),
|
|
106
|
+
object_type text NOT NULL,
|
|
107
|
+
object_ids text[] NOT NULL,
|
|
108
|
+
cause text NOT NULL);
|
|
109
|
+
-- Access requests, approvals, and emergency access
|
|
110
|
+
CREATE TABLE IF NOT EXISTS authz.requests (
|
|
111
|
+
id bigserial PRIMARY KEY,
|
|
112
|
+
object_type text NOT NULL,
|
|
113
|
+
object_id text NOT NULL,
|
|
114
|
+
relation text NOT NULL,
|
|
115
|
+
requester text NOT NULL,
|
|
116
|
+
reason text,
|
|
117
|
+
duration interval,
|
|
118
|
+
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied', 'cancelled')),
|
|
119
|
+
decided_by text,
|
|
120
|
+
decided_at timestamptz,
|
|
121
|
+
note text,
|
|
122
|
+
created_at timestamptz NOT NULL DEFAULT now());
|
|
123
|
+
-- Access reviews: a snapshot of the shares on an object, each kept or revoked by a reviewer
|
|
124
|
+
CREATE TABLE IF NOT EXISTS authz.reviews (
|
|
125
|
+
id bigserial PRIMARY KEY,
|
|
126
|
+
object_type text NOT NULL,
|
|
127
|
+
object_id text NOT NULL,
|
|
128
|
+
created_by text,
|
|
129
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
130
|
+
closes_at timestamptz,
|
|
131
|
+
closed_at timestamptz);
|
|
132
|
+
CREATE TABLE IF NOT EXISTS authz.review_items (
|
|
133
|
+
review_id bigint NOT NULL REFERENCES authz.reviews ON DELETE CASCADE,
|
|
134
|
+
item int NOT NULL,
|
|
135
|
+
relation text NOT NULL,
|
|
136
|
+
subject_type text NOT NULL,
|
|
137
|
+
subject_id text NOT NULL,
|
|
138
|
+
subject_relation text NOT NULL,
|
|
139
|
+
expires_at timestamptz,
|
|
140
|
+
keep boolean,
|
|
141
|
+
decided_by text,
|
|
142
|
+
decided_at timestamptz,
|
|
143
|
+
PRIMARY KEY (review_id, item));
|
|
144
|
+
-- Tables whose table-wide SELECT was replaced by column privileges because of masks
|
|
145
|
+
CREATE TABLE IF NOT EXISTS authz.masked_tables (tbl text, role text, PRIMARY KEY (tbl, role));
|
|
146
|
+
REVOKE ALL ON authz.changes, authz.requests, authz.reviews, authz.review_items, authz.masked_tables FROM PUBLIC;
|
|
147
|
+
|
|
148
|
+
-- Every policy the rowstile command applied or removed, with the rowstile version that did it
|
|
149
|
+
CREATE TABLE IF NOT EXISTS authz.policy_versions (
|
|
150
|
+
id bigserial PRIMARY KEY,
|
|
151
|
+
at timestamptz NOT NULL DEFAULT now(),
|
|
152
|
+
by_role text NOT NULL DEFAULT current_user,
|
|
153
|
+
action text NOT NULL CHECK (action IN ('apply', 'remove')),
|
|
154
|
+
policy text,
|
|
155
|
+
files jsonb NOT NULL DEFAULT '{}',
|
|
156
|
+
version text NOT NULL);
|
|
157
|
+
-- 0.1.0 kept it in the extension, as extension_version
|
|
158
|
+
DO $pv$
|
|
159
|
+
BEGIN
|
|
160
|
+
IF EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'authz.policy_versions'::regclass
|
|
161
|
+
AND attname = 'extension_version' AND NOT attisdropped) THEN
|
|
162
|
+
ALTER TABLE authz.policy_versions RENAME COLUMN extension_version TO version;
|
|
163
|
+
END IF;
|
|
164
|
+
END $pv$;
|
|
165
|
+
-- what the migrations' lock file said about this policy (its hash), for the next migration to check
|
|
166
|
+
ALTER TABLE authz.policy_versions ADD COLUMN IF NOT EXISTS lock text;
|
|
167
|
+
REVOKE ALL ON authz.policy_versions FROM PUBLIC;
|
|
168
|
+
|
|
169
|
+
-- Request context for conditions: SET LOCAL authz_ctx.mfa = 'yes', then {authz.ctx('mfa') = 'yes'}
|
|
170
|
+
CREATE OR REPLACE FUNCTION authz.ctx(key text) RETURNS text LANGUAGE sql STABLE AS
|
|
171
|
+
$$ SELECT current_setting('authz_ctx.' || key, true) $$;
|
|
172
|
+
|
|
173
|
+
-- Link tokens the request presents: SET LOCAL authz_ctx.links = '<token>,<token>'
|
|
174
|
+
CREATE OR REPLACE FUNCTION authz.link_hashes() RETURNS text[] LANGUAGE sql STABLE AS $$
|
|
175
|
+
SELECT coalesce(array_agg(encode(sha256(convert_to(btrim(x), 'UTF8')), 'hex')), '{}')
|
|
176
|
+
FROM unnest(string_to_array(nullif(current_setting('authz_ctx.links', true), ''), ',')) x
|
|
177
|
+
WHERE btrim(x) <> '' $$;"""
|
|
178
|
+
|
|
179
|
+
# Functions each apply recreates (dropped first, whatever their old signature).
|
|
180
|
+
GENERATED_FUNCTIONS = ("act_as", "can", "list", "perms", "perms_of", "share", "unshare", "verify", "create_link", "principal",
|
|
181
|
+
"create_role", "set_role_permissions", "delete_role", "roles_of", "who", "explain", "list_shares", "shares",
|
|
182
|
+
"create_api_key", "list_api_keys", "revoke_api_key", "login_key", "login_jwt", "view_as",
|
|
183
|
+
"sync_members", "changes_since", "trim_changes", "trim_audit", "request_access", "pending_requests", "decide_request",
|
|
184
|
+
"cancel_request", "break_glass", "start_review", "review_items", "review_decide",
|
|
185
|
+
"close_review", "check_invariants", "lint", "connection_check", "explain_rule", "who_among")
|