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.
Files changed (55) hide show
  1. rowstile/__init__.py +335 -0
  2. rowstile/__main__.py +3 -0
  3. rowstile/_command/authzlib/__init__.py +33 -0
  4. rowstile/_command/authzlib/base.py +185 -0
  5. rowstile/_command/authzlib/compiler.py +1682 -0
  6. rowstile/_command/authzlib/conditions.py +297 -0
  7. rowstile/_command/authzlib/connection.py +84 -0
  8. rowstile/_command/authzlib/coverage.py +103 -0
  9. rowstile/_command/authzlib/database.py +802 -0
  10. rowstile/_command/authzlib/devtools.py +613 -0
  11. rowstile/_command/authzlib/draft.py +378 -0
  12. rowstile/_command/authzlib/errors.py +672 -0
  13. rowstile/_command/authzlib/evaluate.py +735 -0
  14. rowstile/_command/authzlib/fmt.py +315 -0
  15. rowstile/_command/authzlib/governance.py +655 -0
  16. rowstile/_command/authzlib/grant.py +316 -0
  17. rowstile/_command/authzlib/hardening.py +489 -0
  18. rowstile/_command/authzlib/identity.py +460 -0
  19. rowstile/_command/authzlib/insight.py +537 -0
  20. rowstile/_command/authzlib/migrate.py +672 -0
  21. rowstile/_command/authzlib/output.py +1230 -0
  22. rowstile/_command/authzlib/parse.py +1000 -0
  23. rowstile/_command/authzlib/perf.py +325 -0
  24. rowstile/_command/authzlib/prove.py +97 -0
  25. rowstile/_command/authzlib/refusals.py +259 -0
  26. rowstile/_command/authzlib/review.py +1032 -0
  27. rowstile/_command/authzlib/sqlutil.py +240 -0
  28. rowstile/_command/authzlib/statements.py +249 -0
  29. rowstile/_command/authzlib/testing.py +273 -0
  30. rowstile/_command/authzlib/trees.py +938 -0
  31. rowstile/_command/cli/init.py +96 -0
  32. rowstile/_command/cli/lsp.py +645 -0
  33. rowstile/_command/cli/mcp.py +245 -0
  34. rowstile/_command/cli/migrations.py +242 -0
  35. rowstile/_command/cli/pgwire.py +627 -0
  36. rowstile/_command/cli/rowstile_cli.py +1320 -0
  37. rowstile/_command/cli/stack.py +230 -0
  38. rowstile/_command/cli/studio/app.js +406 -0
  39. rowstile/_command/cli/studio/index.html +58 -0
  40. rowstile/_command/cli/studio/style.css +62 -0
  41. rowstile/_command/cli/studio.py +507 -0
  42. rowstile/_version.py +15 -0
  43. rowstile/alembic.py +37 -0
  44. rowstile/asyncpg.py +79 -0
  45. rowstile/command.py +30 -0
  46. rowstile/fastapi.py +142 -0
  47. rowstile/psycopg.py +121 -0
  48. rowstile/py.typed +0 -0
  49. rowstile/sqlalchemy.py +236 -0
  50. rowstile/testing.py +124 -0
  51. rowstile-0.1.0a2.dist-info/METADATA +70 -0
  52. rowstile-0.1.0a2.dist-info/RECORD +55 -0
  53. rowstile-0.1.0a2.dist-info/WHEEL +4 -0
  54. rowstile-0.1.0a2.dist-info/entry_points.txt +5 -0
  55. 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,3 @@
1
+ from .command import main
2
+
3
+ main()
@@ -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")