rowstile 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # rowstile
2
2
 
3
- Access rules for Postgres: a policy language that the `rowstile` command compiles into row-level security, and
4
- ships as migrations for your app's tool (Prisma, Drizzle Kit, Alembic, SQL files).
3
+ Authorization for Postgres apps: access rules in a policy language that the `rowstile` command compiles into
4
+ row-level security, and ships as migrations for your app's tool (Prisma, Drizzle Kit, Alembic, SQL files).
5
+ Sharing, groups, nested folders and tenants, with no service to run beside the database.
5
6
 
6
7
  npx rowstile init # a first policy from your tables, rowstile.toml, the SDK for your stack
7
8
  npx rowstile dev # on each save: check, apply to the development database, test, write the types
package/bin/rowstile.js CHANGED
@@ -64,7 +64,35 @@ function onPath() {
64
64
  return null;
65
65
  }
66
66
 
67
- const python = process.env.ROWSTILE_PYTHON || process.env.ROWFENCE_PYTHON || bundled() || onPath(); // ROWFENCE_PYTHON: the name before the rename
67
+ /** On Windows, a file of the bundled Python whose path is longer than Windows loads (259 characters, unless long
68
+ * paths are turned on): Python then stops with "DLL load failed ... The filename or extension is too long",
69
+ * from inside node_modules. Null when every file is within the limit.
70
+ * @param {string} exe @returns {string | null} */
71
+ function tooLong(exe) {
72
+ if (process.platform !== "win32") return null;
73
+ try {
74
+ const dlls = path.join(path.dirname(fs.realpathSync.native(exe)), "DLLs");
75
+ const longest = fs.readdirSync(dlls).map((f) => path.join(dlls, f)).sort((a, b) => b.length - a.length)[0];
76
+ if (!longest || longest.length <= 259) return null;
77
+ // with long paths turned on, or when only files the command never loads are past the limit, it runs all
78
+ // the same: ask it for the compiled modules the command uses
79
+ const probe = "import unicodedata, ssl, hashlib, socket, select, zlib, decimal";
80
+ return spawnSync(exe, ["-E", "-s", "-c", probe]).status === 0 ? null : longest;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ const own = process.env.ROWSTILE_PYTHON || process.env.ROWFENCE_PYTHON ? null : bundled(); // ROWFENCE_PYTHON: the name before the rename
87
+ const long = own && tooLong(own);
88
+ if (long && !onPath()) {
89
+ console.error(`rowstile: this project's path is too long for Windows to load the Python that came with the package ` +
90
+ `(${long.length} characters to ${path.basename(long)}; Windows loads up to 259). Move the project to a shorter path, ` +
91
+ "or turn long paths on in Windows (LongPathsEnabled), or install Python 3.11 or later: rowstile then runs on that one");
92
+ process.exit(1);
93
+ }
94
+ // a Python on PATH, when the bundled one can't be loaded from here
95
+ const python = process.env.ROWSTILE_PYTHON || process.env.ROWFENCE_PYTHON || (long ? null : own) || onPath();
68
96
  if (!python) {
69
97
  console.error(`rowstile: no Python to run on. The package for this platform (@rowstile/cli-${key}) isn't installed` +
70
98
  (PLATFORMS.includes(key) ? " (optional dependencies turned off?)" : `, and there is none for ${key}`) +
@@ -12,7 +12,7 @@ class Compiler(TestMixin, DevMixin, OutputMixin):
12
12
 
13
13
 
14
14
  # set with packaging/version.py (the packages copy it); authz.policy_versions records it on each apply
15
- __version__ = "0.1.0-alpha.4"
15
+ __version__ = "0.1.0-alpha.6"
16
16
 
17
17
 
18
18
  def _build() -> str:
@@ -92,14 +92,15 @@ def describe(r: Report, limit: int | None = None) -> str:
92
92
  more = len(r["missing"]) - len(shown)
93
93
  return (
94
94
  head
95
- + "; no test reaches:\n"
95
+ + '; no "can" check reaches:\n'
96
96
  + "\n".join(f" {line}: {name}: {item}" for line, name, item in shown)
97
97
  + (f"\n ... and {more} more" if more > 0 else "")
98
98
  )
99
99
 
100
100
 
101
101
  def summary(r: Report, limit: int = 2) -> str:
102
- """For rowstile dev's line: '2 branches no test reaches (line 18: org.admin, line 40: {not locked})'."""
102
+ """For rowstile dev's line: '2 branches no "can" check reaches (line 18: org.admin, line 40: {not locked})'.
103
+ It names the checks that count: a statement run `as` someone passes and covers nothing."""
103
104
  if not r["missing"]:
104
105
  return ""
105
106
  short: Callable[[str], str] = lambda item: (
@@ -107,4 +108,4 @@ def summary(r: Report, limit: int = 2) -> str:
107
108
  )
108
109
  shown = ", ".join(f"{line}: {short(item)}" for line, _, item in r["missing"][:limit])
109
110
  n = len(r["missing"])
110
- return f"{n} branch{'es' if n != 1 else ''} no test reaches ({shown}{', ...' if n > limit else ''})"
111
+ return f'{n} branch{"es" if n != 1 else ""} no "can" check reaches ({shown}{", ..." if n > limit else ""})'
@@ -171,7 +171,8 @@ def may_take(db: Db, role: str | None) -> None:
171
171
  f"{me} may not switch to the app role {role} (SET ROLE), and this looks at the data as the app "
172
172
  f"does [AZ618]",
173
173
  "42501",
174
- hint=f"once, as {me}: GRANT {q(role)} TO {q(me)}",
174
+ hint=f"once, as {me} if it made {role}, else as the role that did or a superuser: "
175
+ f"GRANT {q(role)} TO {q(me)}",
175
176
  )
176
177
 
177
178
 
@@ -181,6 +181,13 @@ const s = (id: Id): string => Array.isArray(id)
181
181
  ? "(" + id.map((v) => '"' + String(v).replace(/["\\\\]/g, (c) => "\\\\" + c) + '"').join(",") + ")"
182
182
  : String(id);
183
183
 
184
+ /** A key as the database writes it, for a message: 7, or (1,2) for a key of several columns. */
185
+ const shown = (id: Id): string => Array.isArray(id)
186
+ ? "(" + id.map((v) => /^$|[",\\\\()\\s]/.test(String(v)) ? '"' + String(v).replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '""') + '"' : String(v)).join(",") + ")"
187
+ : String(id);
188
+ // who is signed in, in the words the database's own refusals use
189
+ const WHO = "SELECT coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in') AS who";
190
+
184
191
  /** The database refused a write (SQLSTATE 42501): its message, the table and command, and why (lines). */
185
192
  export class Refused extends Error {{
186
193
  constructor(message: string, public table?: string, public command?: string, public why: string[] = []) {{ super(message); }}
@@ -307,8 +314,9 @@ export function authz(db: Queryable) {{
307
314
  [table, command, s(id), row === undefined ? null : JSON.stringify(row)])).rows[0].e;
308
315
  // nothing there for this user, or the rule allows it (its first line says yes) and the statement matched
309
316
  // nothing for another reason, such as a WHERE with more than the key
310
- if (why === null || /^\\s*yes\\b/.test(why[0] ?? "")) throw new NotFound(table, s(id));
311
- throw new Refused(`permission denied: may not ${{command}} ${{table}} ${{s(id)}}`, table, command, why);
317
+ if (why === null || /^\\s*yes\\b/.test(why[0] ?? "")) throw new NotFound(table, shown(id));
318
+ const who: string = (await db.query(WHO)).rows[0].who;
319
+ throw new Refused(`permission denied: ${{who}} may not ${{command}} row ${{shown(id)}} of ${{table}}`, table, command, why);
312
320
  }},
313
321
  /** Which of these people (or principals of another type) hold perm: one call, for live updates. Signs each one
314
322
  * in, so it needs what signing in needs (SET on authz.user_id). */
@@ -419,6 +427,24 @@ def _id(x: Id) -> str:
419
427
  return str(x)
420
428
 
421
429
 
430
+ def _shown(x: Id) -> str:
431
+ """A key as the database writes it, for a message: 7, or (1,2) for a key of several columns."""
432
+ if not isinstance(x, (tuple, list)):
433
+ return str(x)
434
+
435
+ def field(v: object) -> str:
436
+ s = str(v)
437
+ if s == "" or any(ch in s for ch in '",\\\\()') or any(ch.isspace() for ch in s):
438
+ return '"' + s.replace("\\\\", "\\\\\\\\").replace('"', '""') + '"'
439
+ return s
440
+
441
+ return "(" + ",".join(field(v) for v in x) + ")"
442
+
443
+
444
+ # who is signed in, in the words the database's own refusals use
445
+ _WHO = "SELECT coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in')"
446
+
447
+
422
448
  class Refused(Exception):
423
449
  """The database refused a write (SQLSTATE 42501): its message, the table and command, and why (lines)."""
424
450
 
@@ -619,8 +645,9 @@ class Authz:
619
645
  return result
620
646
  why = self.explain_rule(table, command, id_, row)
621
647
  if why is None or (why and why[0].lstrip().startswith("yes")):
622
- raise NotFound(table, _id(id_))
623
- raise Refused(f"permission denied: may not {{command}} {{table}} {{_id(id_)}}", table, command, why)
648
+ raise NotFound(table, _shown(id_))
649
+ who = self._one(_WHO, ())
650
+ raise Refused(f"permission denied: {{who}} may not {{command}} row {{_shown(id_)}} of {{table}}", table, command, why)
624
651
 
625
652
  @staticmethod
626
653
  def refusal(error: BaseException) -> Refused | None:
@@ -493,13 +493,17 @@ def draft(
493
493
  " -- no insert or delete rule: accounts are made and removed outside the app role",
494
494
  ]
495
495
  else:
496
- mine = [
497
- f"{{{in_sql(src)} = authz.uid()}}"
498
- for rel, target, src, k in rels[n]
499
- if k == "user" and not src.startswith("[")
500
- ][:1]
496
+ # in the maker's own name: the relation, where one says it (the language page: write the relation
497
+ # rather than its column again)
498
+ mine = [rel for rel, _, src, k in rels[n] if k == "user" and not src.startswith("[")][:1]
501
499
  where = [f"{parents[0]}.edit"] if parents else []
502
- insert = " and ".join(where + mine) or "signed_in"
500
+ # a tree of the type itself: "inside one you edit" alone lets nobody make the first row, so a row
501
+ # with nothing above it may be made too, in the maker's name
502
+ above = next((src for rel, _, src, _ in rels[n] if parents and rel == parents[0]), "")
503
+ if mine and parents and parent_of[n][parents[0]] == n and not above.startswith("["):
504
+ insert = f"{mine[0]} and ({parents[0]}.edit or {{{in_sql(above)} is null}})"
505
+ else:
506
+ insert = " and ".join(where + mine) or "signed_in"
503
507
  r += [
504
508
  f" insert : {insert}" + (" -- decide: who may add one" if insert == "signed_in" or parents else ""),
505
509
  " update : edit",
@@ -141,8 +141,9 @@ CODES = {
141
141
  "AZ110": Code(
142
142
  "A dollar-quote tag in a condition",
143
143
  "The SQL in `{...}` goes into generated functions, whose bodies are quoted with tags such as `$f$`. The "
144
- "same tag in a condition would end the body there. Write the text another way: `'$' || 'f$'` (`$$` is "
145
- "fine).",
144
+ "same tag in a condition would end the body there. Write the text another way: `'$' || 'f$'`. A bare "
145
+ "`$$` is fine; a tag with a name between the dollars (`$changed$`, as in `$$changed$$`) is not: quote "
146
+ "such text with apostrophes.",
146
147
  "type folder = app.folders\n owner : user = owner_id\n can view = owner and {name <> '$f$'}\n",
147
148
  "type folder = app.folders\n owner : user = owner_id\n can view = owner and {name <> '$' || 'f$'}\n",
148
149
  ),
@@ -415,6 +416,18 @@ CODES = {
415
416
  "test \"teams\"\n given t = {INSERT INTO app.teams (name) VALUES ('a') RETURNING id}\n anyone cannot see team $t\n",
416
417
  when="tests",
417
418
  ),
419
+ "AZ503": Code(
420
+ "A test names a scope the policy doesn't have",
421
+ "`with scope` checks a line as a key or a token limited to those scopes would be. The scopes are the "
422
+ "policy's `scope` lines, and `read`, which is built in.",
423
+ "type folder = app.folders\n owner : user = owner_id\n can view = owner\nscope files = folder.view\n"
424
+ "test \"keys\"\n given f = {INSERT INTO app.folders (owner_id, name) VALUES (1, 'a') RETURNING id}\n"
425
+ " user 1 with scope file can view folder $f\n",
426
+ "type folder = app.folders\n owner : user = owner_id\n can view = owner\nscope files = folder.view\n"
427
+ "test \"keys\"\n given f = {INSERT INTO app.folders (owner_id, name) VALUES (1, 'a') RETURNING id}\n"
428
+ " user 1 with scope files can view folder $f\n",
429
+ when="tests",
430
+ ),
418
431
  "AZ601": Code(
419
432
  "A table or column that isn't there",
420
433
  "Applying checks the policy against the database first: each table a type or relation names, and each "
@@ -476,7 +489,9 @@ CODES = {
476
489
  "`authz.policy_versions`. This database is somewhere else: a migration was skipped, or the database was "
477
490
  "changed with `rowstile push` or `apply` since. Apply the migrations in order; a database changed by "
478
491
  "`push` takes the migrations from the start, or `rowstile apply` of the policy the previous migration "
479
- "left.",
492
+ "left. A development database that `rowstile dev` or `push` brought to the newest policy already holds "
493
+ "what this migration brings: tell the migration tool it is applied (Prisma: `prisma migrate resolve "
494
+ "--applied <name>`; Alembic: `alembic stamp head`), or make the database again from the migrations.",
480
495
  "",
481
496
  "",
482
497
  when="deploy",
@@ -598,7 +613,8 @@ CODES = {
598
613
  "back. The role the command connects as has to hold the app role for that. A superuser always does. Since "
599
614
  "PostgreSQL 16 a role that makes another (`CREATE ROLE app_user`) only administers it, so an owner that "
600
615
  "isn't a superuser, as on managed Postgres, gives itself the role once: `GRANT app_user TO app_owner`. It "
601
- "may, because it made the role. Nothing changes for the app, which connects as the app role itself.",
616
+ "may, because it made the role. Where another role made both (a superuser setting up by hand), that "
617
+ "role runs the grant. Nothing changes for the app, which connects as the app role itself.",
602
618
  "",
603
619
  "",
604
620
  when="command",
@@ -739,7 +755,8 @@ def page(code: str) -> str:
739
755
  "apply": "when the policy is applied (`rowstile push`, `apply`, a migration), against the database",
740
756
  "deploy": "when a policy migration runs",
741
757
  "command": "by the command, against a database",
742
- "runtime": "to the app, by the `authz.*` functions and the policy's rules: the code is in the error's HINT (`rowstile help AZ709`), and the SDKs' errors carry it as `code`",
758
+ "runtime": "to the app, by the `authz.*` functions and the policy's rules: the code is in the error's HINT "
759
+ f"(`rowstile help {code}`), and the SDKs' errors carry it as `code`",
743
760
  }[c.when]
744
761
  out += [f"Reported {found}.", ""]
745
762
  if c.wrong:
@@ -308,9 +308,12 @@ def tests_of(text: str, files: dict[str, str]) -> list[object]:
308
308
 
309
309
  pol = parse_policy(text, None, files=files)
310
310
  return [
311
- *[(t.ptype, t.who, t.expect, t.perm, t.type, t.obj) for t in pol.tests],
311
+ *[(t.ptype, t.who, t.expect, t.perm, t.type, t.obj, t.scopes) for t in pol.tests],
312
312
  *[
313
- (sc.name, [(st.kind, st.var, st.sql, st.who, st.expect, st.perm, st.type, st.obj) for st in sc.steps])
313
+ (
314
+ sc.name,
315
+ [(st.kind, st.var, st.sql, st.who, st.expect, st.perm, st.type, st.obj, st.scopes) for st in sc.steps],
316
+ )
314
317
  for sc in pol.scenarios
315
318
  ],
316
319
  ]
@@ -13,6 +13,7 @@ from collections.abc import Callable, Sequence
13
13
  from dataclasses import dataclass, field
14
14
  from typing import TYPE_CHECKING
15
15
 
16
+ from .conditions import Bool, Cmp, Col, Const, In, IsNull, Node, Scalar, simple
16
17
  from .connection import Db, Value, flag, number, text
17
18
  from .parse import Expr, Loc, Ref, Relation, Source, Type
18
19
  from .sqlutil import lit, q, qt
@@ -26,6 +27,49 @@ TRIES = 40 # how many candidate changes (or combinations) it tries
26
27
  SHOWN = 5
27
28
 
28
29
 
30
+ def settles(where: str) -> dict[str, Scalar] | None:
31
+ """The columns' values that make a relation's `where` true, when it says no more than that
32
+ ({role = 'admin'}, {active and kind in ('a', 'b')}); None for any other condition."""
33
+ node = simple(where)
34
+ out: dict[str, Scalar] = {}
35
+
36
+ def walk(n: Node) -> bool:
37
+ match n:
38
+ case Col(name=c):
39
+ out[c] = True
40
+ case Bool(op="not", items=(Col(name=c),)):
41
+ out[c] = False
42
+ case (
43
+ Cmp(op="=", left=Col(name=c), right=Const(value=v))
44
+ | Cmp(op="=", left=Const(value=v), right=Col(name=c))
45
+ ):
46
+ if v is None:
47
+ return False
48
+ out[c] = v
49
+ case IsNull(item=Col(name=c), negated=False):
50
+ out[c] = None
51
+ case In(item=Col(name=c), values=values, negated=False):
52
+ if values[0] is None:
53
+ return False
54
+ out[c] = values[0]
55
+ case Bool(op="and", items=items):
56
+ return all(walk(x) for x in items)
57
+ case _:
58
+ return False
59
+ return True
60
+
61
+ return out if node is not None and walk(node) else None
62
+
63
+
64
+ def constant(v: Scalar) -> str:
65
+ """A condition's constant as SQL."""
66
+ if v is None:
67
+ return "NULL"
68
+ if isinstance(v, bool):
69
+ return "true" if v else "false"
70
+ return lit(v) if isinstance(v, str) else str(v)
71
+
72
+
29
73
  @dataclass
30
74
  class Held:
31
75
  objects: int # how many objects of the type the person holds the permission on
@@ -130,20 +174,37 @@ class Grants:
130
174
  src.loc,
131
175
  1,
132
176
  )
133
- if (
134
- src.kind == "table"
135
- and src.table
136
- and isinstance(src.obj_col, str)
137
- and isinstance(src.subj_col, str)
138
- and not src.where
139
- ):
177
+ if src.kind == "table" and src.table and isinstance(src.obj_col, str) and isinstance(src.subj_col, str):
140
178
  cols, vals = [src.obj_col, src.subj_col], [lit(oid), lit(sid)]
141
179
  if src.type_col:
142
180
  cols.append(src.type_col)
143
181
  vals.append(lit(st))
182
+ # a source with a `where`: the row must also hold what the condition asks, if it says which values
183
+ held = settles(src.where) if src.where else {}
184
+ if held is None or set(held) & set(cols):
185
+ self.notes.append(
186
+ f"{t.name}.{r.name} wasn't tried: it reads {src.table} where {{{src.where}}}, and it isn't "
187
+ "known which values make that true"
188
+ )
189
+ return None
190
+ more = ", ".join(f"{c} = {constant(v)}" for c, v in held.items())
191
+ if held:
192
+ # the link's row may be there already, left out by the condition: then its columns change
193
+ same = " AND ".join(f"{q(c)} = {v}" for c, v in zip(cols, vals, strict=True))
194
+ if self.probe(f"SELECT 1 AS x FROM {qt(src.table)} WHERE {same} LIMIT 1"):
195
+ sets = ", ".join(f"{q(c)} = {constant(v)}" for c, v in held.items())
196
+ return Change(
197
+ "link",
198
+ f"set {more} on {st} {sid}'s row of {src.table} for {t.name} {oid}",
199
+ f"UPDATE {qt(src.table)} SET {sets} WHERE {same}",
200
+ src.loc,
201
+ 2,
202
+ )
203
+ cols += list(held)
204
+ vals += [constant(v) for v in held.values()]
144
205
  return Change(
145
206
  "link",
146
- f"add {st} {sid} to {src.table} for {t.name} {oid}",
207
+ f"add {st} {sid} to {src.table} for {t.name} {oid}" + (f", with {more}" if held else ""),
147
208
  f"INSERT INTO {qt(src.table)} ({', '.join(q(x) for x in cols)}) VALUES ({', '.join(vals)})",
148
209
  src.loc,
149
210
  2,
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .compiler import MANY_EXPANSIONS, Core
6
- from .parse import Loc, cols
6
+ from .parse import KEYWORDS, Cond, Loc, cols
7
7
  from .sqlutil import POLICY_MARKS, VIEW_MARKS, lit, q, qt
8
8
 
9
9
  DEF = "SECURITY DEFINER SET search_path = pg_catalog, pg_temp"
@@ -147,7 +147,8 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
147
147
  governed = sorted({r.table for r in self.rules})
148
148
  typed_tables = sorted({t.table for t in self.types.values()})
149
149
  link_tables: set[str] = set() # tables that hold relationships (who is in what)
150
- link_cols: list[tuple[str, str, str, Loc]] = [] # (table, column, what it grants, loc) for column sources
150
+ # (table, column, what it grants, loc, the permission lint suggests asking for) for column sources
151
+ link_cols: list[tuple[str, str, str, Loc, str]] = []
151
152
  # (table, columns, relation, loc): a column placing a row under another of its type
152
153
  move_cols: list[tuple[str, tuple[str, ...], str, Loc]] = []
153
154
  index_cols: list[tuple[str, str, str]] = [] # (table, column, why) that lookups go through
@@ -173,8 +174,10 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
173
174
  )
174
175
  elif src.kind == "column":
175
176
  column = cols(self.source_columns(src))
177
+ # the type's own `share` where it has one: a suggestion that doesn't compile helps no one
178
+ ask = "share" if "share" in self.public_perms(t) or "share" in t.relations else "nobody"
176
179
  for c in column + ((src.type_col,) if src.type_col else ()):
177
- link_cols.append((t.table, c, f"{t.name}.{r.name}", src.loc))
180
+ link_cols.append((t.table, c, f"{t.name}.{r.name}", src.loc, ask))
178
181
  # (one that reads the row's own key is "this row", `self : user = id`: not a move)
179
182
  if any(st == t.name for st, _ in src.subjects) and column != tuple(c for c, _ in t.key):
180
183
  move_cols.append((t.table, column, r.name, src.loc))
@@ -183,8 +186,13 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
183
186
  # columns that grant something, on tables whose updates only need the plain 'update' rule
184
187
  guarded = {(r.table, c) for r in self.rules if r.columns and r.command != "mask" for c in r.columns}
185
188
  has_update = {r.table for r in self.rules if r.command == "update" and not r.columns}
189
+ # (`update : nobody` lets no one change any column: there is nothing to guard)
190
+ nobody = Cond("cond", KEYWORDS["nobody"])
191
+ open_update = {r.table for r in self.rules if r.command == "update" and not r.columns and r.expr != nobody}
186
192
  unguarded = [
187
- (tb, c, what, loc) for tb, c, what, loc in link_cols if tb in has_update and (tb, c) not in guarded
193
+ (tb, c, what, loc, ask)
194
+ for tb, c, what, loc, ask in link_cols
195
+ if tb in open_update and (tb, c) not in guarded
188
196
  ]
189
197
  # ...and those that move a row within its tree with nothing checking where it goes (an 'after' rule)
190
198
  after = {(r.table, c) for r in self.rules if r.command == "update check" for c in r.columns}
@@ -204,16 +212,29 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
204
212
  f"({lit(qt(tb))}, {lit(c)}, {lit('; '.join(dict.fromkeys(w)))})" for (tb, c), w in whys.items()
205
213
  )
206
214
  unguarded_rows = ", ".join(
207
- f"({lit(qt(tb))}, {lit(c)}, {lit(what)}, {lit(self.line_key(f'relation {what} {c}', loc))})"
208
- for tb, c, what, loc in unguarded
215
+ f"({lit(qt(tb))}, {lit(c)}, {lit(what)}, {lit(self.line_key(f'relation {what} {c}', loc))}, {lit(ask)})"
216
+ for tb, c, what, loc, ask in unguarded
209
217
  )
210
- # relations named like a type read, in a rule, like the type ("delete : user")
218
+ # relations named like a type that signs in read, alone in a rule, like the type ("delete : user" is
219
+ # the row's own user, not any user). One named like another type is only ever followed
220
+ # ("project.edit"), which reads as what it means: `project : project = project_id` is no one's mistake
211
221
  shadows = [
212
222
  (t.name, r.name, str(r.loc))
213
223
  for t in self.types.values()
214
224
  for r in t.relations.values()
215
- if r.name in self.types and not r.synthetic
225
+ if self.is_principal(r.name) and not r.synthetic
216
226
  ]
227
+ # a rule for a command the app role may not run on the table at all: it never applies, and the
228
+ # statement fails with Postgres's own "permission denied for table", which reads like the policy's
229
+ # refusal. (`nobody` asks for no privilege; a rule on a column, or `after`, is an update's.)
230
+ asked = sorted(
231
+ {
232
+ (r.table, "update" if r.command == "update check" else r.command)
233
+ for r in self.rules
234
+ if r.command != "mask" and r.expr != nobody
235
+ }
236
+ )
237
+ rule_rows = ", ".join(f"({lit(qt(tb))}, {lit(cmd)})" for tb, cmd in asked)
217
238
  # principal types nobody can make keys for (they sign in only through a backend or JWTs)
218
239
  keyless = "".join(
219
240
  f" severity := 'info'; object := {lit(t.name)};\n"
@@ -415,13 +436,13 @@ BEGIN
415
436
  END IF;
416
437
  END LOOP;
417
438
  -- columns that grant a relation, changed by anyone who may update the row
418
- FOR r IN SELECT * FROM (VALUES {unguarded_rows or "(NULL::text, NULL::text, NULL::text, NULL::text)"}) v(tbl, col, what, loc)
439
+ FOR r IN SELECT * FROM (VALUES {unguarded_rows or "(NULL::text, NULL::text, NULL::text, NULL::text, NULL::text)"}) v(tbl, col, what, loc, ask)
419
440
  WHERE tbl IS NOT NULL LOOP
420
441
  IF EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = to_regclass(r.tbl) AND a.attname = r.col
421
442
  AND a.attnum > 0 AND NOT a.attisdropped)
422
443
  AND has_column_privilege(v_role, r.tbl, r.col, 'UPDATE') THEN
423
444
  severity := 'warning'; object := to_regclass(r.tbl)::text || '.' || r.col;
424
- problem := format('grants %s (%s), and anyone who may update the row may change it: add a rule such as "update %s : share"', r.what, {self.line_sql(None, None, "r.loc")}, r.col);
445
+ problem := format('grants %s (%s), and anyone who may update the row may change it: add a rule such as "update %s : %s"', r.what, {self.line_sql(None, None, "r.loc")}, r.col, r.ask);
425
446
  RETURN NEXT;
426
447
  END IF;
427
448
  END LOOP;
@@ -434,11 +455,25 @@ BEGIN
434
455
  problem := format('runs with JIT on: permission checks are many small subplans, and JIT can take longer to compile a read than to run it (a third of a second for a large one): ALTER ROLE %s SET jit = off', {lit(role)});
435
456
  RETURN NEXT;
436
457
  END IF;
437
- -- relations named like a type
458
+ -- rules for a command the app role has no privilege for (one column's is enough for a read, an insert or
459
+ -- an update: the app may write some columns only)
460
+ FOR r IN SELECT to_regclass(v.tbl) AS tbl, v.cmd FROM (VALUES {rule_rows or "(NULL::text, NULL::text)"}) v(tbl, cmd)
461
+ WHERE v.tbl IS NOT NULL LOOP
462
+ CONTINUE WHEN r.tbl IS NULL;
463
+ IF r.cmd = 'delete' THEN
464
+ CONTINUE WHEN has_table_privilege(v_role, r.tbl, 'DELETE');
465
+ ELSE
466
+ CONTINUE WHEN has_any_column_privilege(v_role, r.tbl, r.cmd);
467
+ END IF;
468
+ severity := 'warning'; object := r.tbl::text;
469
+ problem := format('the policy has a rule for %s, but %s has no %s privilege on the table: the rule never applies, and the statement fails with Postgres''s own "permission denied for table". GRANT %s ON %s TO %s, or make the rule "%s : nobody"', r.cmd, {lit(role)}, upper(r.cmd), upper(r.cmd), r.tbl, {lit(role)}, r.cmd);
470
+ RETURN NEXT;
471
+ END LOOP;
472
+ -- relations named like a type that signs in
438
473
  FOR r IN SELECT * FROM (VALUES {shadow_rows or "(NULL::text, NULL::text, NULL::text)"}) v(type, rel, loc)
439
474
  WHERE type IS NOT NULL LOOP
440
475
  severity := 'info'; object := r.type || '.' || r.rel;
441
- problem := format('is named like the type %s (%s), so in rules it reads like the type: a name that says what the relation is (author, member) reads better', r.rel, {self.line_sql(None, None, "r.loc")});
476
+ problem := format('is named like the type %s (%s), which signs in: alone in a rule it reads like any %s, and it is this row''s. A name that says what the relation is (author, owner) reads better', r.rel, {self.line_sql(None, None, "r.loc")}, r.rel);
442
477
  RETURN NEXT;
443
478
  END LOOP;
444
479
  {keyless}{heavy} -- columns that move a row under another, with nothing checking where it moves to
@@ -731,7 +731,9 @@ BEGIN
731
731
  RAISE EXCEPTION 'rowstile: this migration changes the policy the migration before it left (%), but the database has % [AZ607]',
732
732
  {sql_lit(old_hash)}, coalesce(v_lock, 'none')
733
733
  USING HINT = 'apply the migrations in order; a database changed with rowstile push or apply since needs the '
734
- 'migrations from the start (or rowstile apply of the policy the previous migration left)';
734
+ 'migrations from the start (or rowstile apply of the policy the previous migration left). '
735
+ 'A development database that rowstile dev pushed the newest policy to already holds what '
736
+ 'this migration brings: mark it as applied in your migration tool (rowstile help AZ607)';
735
737
  END IF;
736
738
  END $authz_guard$;""")
737
739
  out.append(body)
@@ -211,6 +211,7 @@ class Step:
211
211
  perm: str | None = None
212
212
  type: str | None = None
213
213
  obj: str | None = None # check: an id or a $name
214
+ scopes: tuple[str, ...] = () # check, as: `with scope a, b`: signed in limited to them, as a key with them is
214
215
 
215
216
 
216
217
  @dataclass
@@ -907,6 +908,8 @@ def parse_policy(
907
908
 
908
909
 
909
910
  VALUE = r"(\$[A-Za-z_][A-Za-z0-9_]*|'(?:[^']|'')*'|[^\s{}']+)"
911
+ # after who a line is about: `with scope read` or `with scope read, files`
912
+ WITH_SCOPE = rf"(?:\s+with\s+scope\s+({IDENT}(?:\s*,\s*{IDENT})*))?"
910
913
 
911
914
 
912
915
  def value(v: str) -> str:
@@ -914,17 +917,32 @@ def value(v: str) -> str:
914
917
  return v[1:-1].replace("''", "'") if v.startswith("'") else v
915
918
 
916
919
 
920
+ def scopes_of(text: str | None) -> tuple[str, ...]:
921
+ return tuple(x.strip() for x in text.split(",")) if text else ()
922
+
923
+
917
924
  def parse_step(s: str, loc: Loc) -> Step:
918
925
  m = re.fullmatch(rf"given\s+(?:({IDENT})\s*=\s*)?\{{(.*)\}}", s, re.S)
919
926
  if m:
920
927
  if not m.group(2).strip():
921
928
  fail(loc, "given {...} needs a statement", "AZ106")
922
929
  return Step("given", s, loc, var=m.group(1), sql=m.group(2).strip())
923
- m = re.fullmatch(rf"as\s+(?:(anyone)|({IDENT})\s+{VALUE})\s+(allowed|refused|sees\s+(\d+))\s*\{{(.*)\}}", s, re.S)
930
+ m = re.fullmatch(
931
+ rf"as\s+(?:(anyone)|({IDENT})\s+{VALUE}{WITH_SCOPE})\s+(allowed|refused|sees\s+(\d+))\s*\{{(.*)\}}", s, re.S
932
+ )
924
933
  if m:
925
- expect: str | int = int(m.group(5)) if m.group(5) else m.group(4)
926
- if not m.group(6).strip():
934
+ expect: str | int = int(m.group(6)) if m.group(6) else m.group(5)
935
+ if not m.group(7).strip():
927
936
  fail(loc, "as ... {...} needs a statement", "AZ106")
937
+ # `sees N` counts the rows of a query, which a write can't be put inside: at run time that was a bare
938
+ # "syntax error" from Postgres
939
+ if isinstance(expect, int) and re.match(r"\s*(insert|update|delete|merge)\b", m.group(7), re.I):
940
+ fail(
941
+ loc,
942
+ "`sees N` counts the rows of a SELECT, and this statement writes: for a write that must change "
943
+ "no row, write `as ... refused {...}` (a write that changes no row is refused)",
944
+ "AZ106",
945
+ )
928
946
  return Step(
929
947
  "as",
930
948
  s,
@@ -932,9 +950,12 @@ def parse_step(s: str, loc: Loc) -> Step:
932
950
  ptype=m.group(1) or m.group(2),
933
951
  who=None if m.group(1) else value(m.group(3)),
934
952
  expect=expect,
935
- sql=m.group(6).strip(),
953
+ sql=m.group(7).strip(),
954
+ scopes=scopes_of(m.group(4)),
936
955
  )
937
- m = re.fullmatch(rf"(?:(anyone)|({IDENT})\s+{VALUE})\s+(can|cannot)\s+({IDENT})\s+({IDENT})\s+{VALUE}", s)
956
+ m = re.fullmatch(
957
+ rf"(?:(anyone)|({IDENT})\s+{VALUE}{WITH_SCOPE})\s+(can|cannot)\s+({IDENT})\s+({IDENT})\s+{VALUE}", s
958
+ )
938
959
  if m:
939
960
  return Step(
940
961
  "check",
@@ -942,15 +963,23 @@ def parse_step(s: str, loc: Loc) -> Step:
942
963
  loc,
943
964
  ptype=m.group(1) or m.group(2),
944
965
  who=None if m.group(1) else value(m.group(3)),
945
- expect=m.group(4) == "can",
946
- perm=m.group(5),
947
- type=m.group(6),
948
- obj=value(m.group(7)),
966
+ expect=m.group(5) == "can",
967
+ perm=m.group(6),
968
+ type=m.group(7),
969
+ obj=value(m.group(8)),
970
+ scopes=scopes_of(m.group(4)),
971
+ )
972
+ if re.match(r"(?:as\s+)?anyone\s+with\b", s):
973
+ fail(
974
+ loc,
975
+ "`anyone` is nobody signed in, and a scope limits someone who is: write `user 3 with scope read ...`",
976
+ "AZ106",
949
977
  )
950
978
  fail(
951
979
  loc,
952
980
  "a test's lines are: given name = {INSERT ... RETURNING id}, user $name can view file $f, "
953
- "or as user $name allowed|refused|sees N {SQL}",
981
+ "or as user $name allowed|refused|sees N {SQL} (after who: `with scope read`, to check as a key limited "
982
+ "to a scope)",
954
983
  "AZ106",
955
984
  )
956
985
 
@@ -115,7 +115,7 @@ def table_indexes(db: Db, table: str) -> list[tuple[str, ...]]:
115
115
  "SELECT array(SELECT a.attname::text FROM unnest(i.indkey::int2[]) WITH ORDINALITY k(n, o) "
116
116
  "LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.n ORDER BY k.o) AS cols "
117
117
  "FROM pg_catalog.pg_index i WHERE i.indrelid = to_regclass($1) AND i.indpred IS NULL AND i.indisvalid",
118
- [table],
118
+ [qt(table)], # quoted: to_regclass folds a bare name to lower case, and "TeamMember" is then not found
119
119
  )
120
120
  out: list[tuple[str, ...]] = []
121
121
  for r in rows:
@@ -354,7 +354,7 @@ def plain_column(db: Db, c: Compiler, t: Type) -> str | None:
354
354
  rows = db.rows(
355
355
  "SELECT attname::text AS a FROM pg_catalog.pg_attribute WHERE attrelid = to_regclass($1) AND attnum > 0 "
356
356
  "AND NOT attisdropped AND attgenerated = '' ORDER BY attnum",
357
- [t.table],
357
+ [qt(t.table)],
358
358
  )
359
359
  return next((text(r, "a") for r in rows if r["a"] not in used), None)
360
360
 
@@ -621,15 +621,17 @@ def grants_more(b: Side, h: Side, whats: list[str], worlds: int) -> dict[str, tu
621
621
  tname, p = what.split(".")
622
622
  more = set(sh.get((tname, p), ())) - set(sb.get((tname, p), ()))
623
623
  if more:
624
- who = user or "nobody signed in"
625
- found[what] = (
626
- (who if ":" in who or who.startswith("nobody") else f"user {who}").replace(":", " "),
627
- f"{tname} {sorted(more)[0]}",
628
- )
624
+ found[what] = (named(user), f"{tname} {sorted(more)[0]}")
629
625
  left.remove(what)
630
626
  return found
631
627
 
632
628
 
629
+ def named(user: str) -> str:
630
+ """Who an example is about, in words: user 1, service 3, nobody signed in."""
631
+ who = user or "nobody signed in"
632
+ return (who if ":" in who or who.startswith("nobody") else f"user {who}").replace(":", " ")
633
+
634
+
633
635
  def risk(b: Side, h: Side, worlds: int) -> list[Flag]:
634
636
  flags: list[Flag] = []
635
637
 
@@ -1009,11 +1011,13 @@ def summary(r: Review) -> dict[str, str]:
1009
1011
  loses = [g for g in loses if g["what"].startswith("permission ")] or loses
1010
1012
 
1011
1013
  def say(gs: list[Group], verb: str) -> str:
1014
+ # "1 user gains", "2 users gain"
1015
+ does = lambda g: verb + ("s" if g["users"] == 1 else "")
1012
1016
  return "; ".join(
1013
- f"{plural(g['users'], 'user')} {verb} `{g['what'].replace('permission ', '')}` "
1017
+ f"{plural(g['users'], 'user')} {does(g)} `{g['what'].replace('permission ', '')}` "
1014
1018
  f"on {plural(g['objects'], g['type'])}"
1015
1019
  if g["what"].startswith("permission ")
1016
- else f"{plural(g['users'], 'user')} {verb} {g['what']} in `{g['type']}` ({plural(g['objects'], 'row')})"
1020
+ else f"{plural(g['users'], 'user')} {does(g)} {g['what']} in `{g['type']}` ({plural(g['objects'], 'row')})"
1017
1021
  for g in gs[:3]
1018
1022
  ) + (f"; and {len(gs) - 3} more" if len(gs) > 3 else "")
1019
1023
 
@@ -1214,7 +1218,9 @@ def text(r: Review) -> str:
1214
1218
  out.append(f" through {t['what']}: uses {', '.join(t['via'])}")
1215
1219
  ce = m.get("counterexample")
1216
1220
  if ce:
1217
- out.append(f" not a refactor: {ce['what']} differs for {ce['user']} on {ce['object']}")
1221
+ # as the risks name their examples: user 1 on note 1 (a rule's object is a row of its table)
1222
+ kind = ce["what"].split(".")[0] if re.fullmatch(r"\w+\.\w+", ce["what"]) else "row"
1223
+ out.append(f" not a refactor: {ce['what']} differs for {named(ce['user'])} on {kind} {ce['object']}")
1218
1224
  if m.get("unreadable"):
1219
1225
  out.append(
1220
1226
  f" can't tell: {', '.join(m.get('unreadable', []))} changed only in conditions the review "
@@ -83,6 +83,11 @@ class TestMixin(Core):
83
83
  f"'{st.ptype}' doesn't sign in: a test acts as anyone or as one of {', '.join(signs_in)}",
84
84
  "AZ502",
85
85
  )
86
+ for scope in st.scopes:
87
+ # 'read' is built in unless the policy says its own
88
+ if scope not in self.pol.scopes and scope != "read":
89
+ have = sorted({*self.pol.scopes, "read"})
90
+ fail(st.loc, f"no scope '{scope}' in the policy (it has: {', '.join(have)})", "AZ503")
86
91
  if st.kind == "check":
87
92
  known(st.obj, st.loc, bound)
88
93
  self.check_test_perm(st.type or "", st.perm or "", st.loc)
@@ -115,7 +120,8 @@ class TestMixin(Core):
115
120
  blocks.append(f""" -- {sc.name}{f" ({sc.loc})" if sc.loc else ""}
116
121
  v_test := {lit(sc.name)};
117
122
  BEGIN
118
- PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true);
123
+ PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true),
124
+ set_config('authz.scopes', '', true);
119
125
  {steps} RAISE EXCEPTION USING ERRCODE = 'AZT00'; -- a test's data never stays
120
126
  EXCEPTION
121
127
  WHEN SQLSTATE 'AZT00' THEN NULL;
@@ -137,7 +143,8 @@ class TestMixin(Core):
137
143
  if others:
138
144
  who = f"CASE {others}ELSE {who} END"
139
145
  invariants = f""" v_test := 'invariants'; v_line := ''; v_n := 0;
140
- PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true);
146
+ PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true),
147
+ set_config('authz.scopes', '', true);
141
148
  FOR v_inv IN SELECT * FROM authz.check_invariants() LOOP
142
149
  v_n := v_n + 1;
143
150
  {self.add_result("''", "false", "'invariant ' || v_inv.invariant || ': ' || " + who + " || ' can reach ' || v_inv.object_ids::text", "'invariant ' || v_inv.invariant")}
@@ -157,9 +164,11 @@ DECLARE
157
164
  v_role text := current_setting('role');
158
165
  v_me text := coalesce(current_setting('authz.user_id', true), '');
159
166
  v_pt text := coalesce(current_setting('authz.principal_type', true), '');
167
+ v_scopes text := coalesce(current_setting('authz.scopes', true), '');
160
168
  {" ".join(variables)}
161
169
  BEGIN
162
- {"".join(blocks)}{invariants} PERFORM set_config('authz.user_id', v_me, true), set_config('authz.principal_type', v_pt, true);
170
+ {"".join(blocks)}{invariants} PERFORM set_config('authz.user_id', v_me, true), set_config('authz.principal_type', v_pt, true),
171
+ set_config('authz.scopes', v_scopes, true);
163
172
  RETURN QUERY SELECT * FROM unnest(r_test, r_line, r_ok, r_detail);
164
173
  END $authz_tests$;
165
174
  """
@@ -204,16 +213,21 @@ END $authz_tests$;
204
213
  def sign_in_sql(self, st: Step, names: dict[str, str]) -> str:
205
214
  uid = "''" if st.ptype == "anyone" else self.value_sql(st.who, names)
206
215
  pt = "''" if st.ptype in ("anyone", "user") else lit(st.ptype)
216
+ # the scopes a key or a token would carry (`with scope read`); none: not limited
207
217
  return (
208
218
  f" PERFORM set_config('authz.user_id', coalesce({uid}, ''), true), "
209
- f"set_config('authz.principal_type', {pt}, true);\n"
219
+ f"set_config('authz.principal_type', {pt}, true), "
220
+ f"set_config('authz.scopes', {lit(','.join(st.scopes))}, true);\n"
210
221
  )
211
222
 
212
223
  def step_sql(self, st: Step, names: dict[str, str]) -> str:
213
224
  head = f" v_line := {lit(str(st.loc))}; v_text := {lit(st.text)};\n"
214
225
  if st.kind == "given":
215
226
  stmt = self.statement_sql(self.one_statement(st.sql) if st.var else st.sql, names)
216
- reset = " PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true);\n"
227
+ reset = (
228
+ " PERFORM set_config('authz.user_id', '', true), set_config('authz.principal_type', '', true), "
229
+ "set_config('authz.scopes', '', true);\n"
230
+ )
217
231
  if not st.var:
218
232
  return head + reset + f" EXECUTE {stmt};\n"
219
233
  var = names[st.var]
@@ -1,4 +1,5 @@
1
- """rowstile init: a first policy drafted from the database's catalog, a test file, and rowstile.toml."""
1
+ """rowstile init: a first policy drafted from the database's catalog, a test file, rowstile.toml, and a note for
2
+ the coding agents that work in the app (AGENTS.md)."""
2
3
 
3
4
  from __future__ import annotations
4
5
 
@@ -10,6 +11,8 @@ if TYPE_CHECKING:
10
11
  from rowstile_cli import Config
11
12
 
12
13
  CONFIG = "rowstile.toml"
14
+ AGENTS = "AGENTS.md" # the file coding agents read in a repository
15
+ BEGIN, END = "<!-- rowstile:begin -->", "<!-- rowstile:end -->"
13
16
 
14
17
 
15
18
  def write_new(path: str, text: str, written: list[str]) -> None:
@@ -22,13 +25,60 @@ def write_new(path: str, text: str, written: list[str]) -> None:
22
25
  written.append(path)
23
26
 
24
27
 
28
+ def agents_note(policy_path: str, tests: str, role: str) -> str:
29
+ """What a coding agent working in the app needs to know of rowstile (llms.txt's list, with this app's paths),
30
+ between two markers."""
31
+ return f"""{BEGIN}
32
+ ## Access rules (rowstile)
33
+
34
+ Who may see or change a row is decided in Postgres, by row-level security that the `rowstile` command makes
35
+ from a policy file. For coding agents working in this repository:
36
+
37
+ - The policy is `{policy_path}`, its tests `{tests}`; `{CONFIG}` names both.
38
+ Everything that grants access is written in the policy: no permission checks in app code, no
39
+ `CREATE POLICY` by hand.
40
+ - After editing it: `rowstile check` (the first mistake, with its line and a code such as `[AZ201]`), then
41
+ `rowstile push` (the development database only) and `rowstile test`. `rowstile dev` does all three on each
42
+ save. `rowstile help AZ201` explains a code and shows the mistake fixed.
43
+ - Production takes migrations: `rowstile migrate` writes the next one. Never `rowstile push` there.
44
+ - The app connects as `{role}`, never as the tables' owner, and each transaction signs in first:
45
+ `SELECT authz.act_as('user', '42')` (the SDKs do it). Then plain queries are filtered, a refused insert
46
+ raises SQLSTATE 42501 with the reason, and an UPDATE or DELETE the rules don't allow changes no row.
47
+ - Why someone holds a permission or not: `rowstile why --as user:42 TYPE ID PERMISSION`.
48
+ - `rowstile mcp` is an MCP server with the command's tools. The language and the rest:
49
+ https://rowstile.dev/llms.txt
50
+ {END}
51
+ """
52
+
53
+
54
+ def write_agents_note(note: str, folder: str = ".") -> str | None:
55
+ """Puts the note in the app's AGENTS.md: the file is written if there is none, the note is added at the end
56
+ of one that doesn't have it, and one that has it (the markers) is left as it is. Returns what was done
57
+ ("wrote", "added"), or None."""
58
+ path = os.path.join(folder, AGENTS)
59
+ if not os.path.exists(path):
60
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
61
+ fh.write(note)
62
+ return "wrote"
63
+ with open(path, encoding="utf-8", newline="") as fh:
64
+ text = fh.read()
65
+ if BEGIN in text:
66
+ return None
67
+ eol = "\r\n" if "\r\n" in text else "\n"
68
+ gap = "" if not text.strip() else eol if text.endswith(("\n", "\r")) else eol * 2
69
+ with open(path, "w", encoding="utf-8", newline="") as fh:
70
+ fh.write(text + gap + note.replace("\n", eol))
71
+ return "added"
72
+
73
+
25
74
  def init(policy: str, opts: dict[str, str], cfg: Config) -> None:
26
- """Writes the drafted policy, a first test file and rowstile.toml, keeping any that exist."""
75
+ """Writes the drafted policy, a first test file, rowstile.toml and the note for coding agents, keeping any
76
+ that exist."""
27
77
  out = opts.get("--out", "db")
28
78
  written: list[str] = []
29
79
  policy_path = os.path.join(out, "policy.authz")
30
80
  write_new(policy_path, policy, written)
31
- m = re.search(r"^role (\S+)", policy, re.M)
81
+ m = re.search(r"^app role (\S+)", policy, re.M)
32
82
  role = m.group(1) if m else "app_user" # a draft always has one
33
83
  first = re.search(r"^type (?!user\b)(\w+) = (\S+)", policy, re.M)
34
84
  example = first.group(1) if first else "user"
@@ -73,6 +123,14 @@ database = "{database}" # a DSN or URL, or env:NAME for an environment varia
73
123
  print(f"using {os.path.relpath(cfg.path)} (it is there already)")
74
124
  for path in written:
75
125
  print(f"wrote {path}")
126
+ tests = os.path.join(out, "tests").replace(os.sep, "/") + "/*.authz"
127
+ noted = write_agents_note(agents_note(policy_path.replace(os.sep, "/"), tests, role))
128
+ if noted == "wrote":
129
+ print(f"wrote {AGENTS} (for coding agents: where the policy is, and the loop)")
130
+ elif noted == "added":
131
+ print(f"added rowstile's section to {AGENTS} (for coding agents: where the policy is, and the loop)")
132
+ else:
133
+ print(f"kept {AGENTS} (it has rowstile's section already)")
76
134
  if found.tool and CONFIG in written:
77
135
  import migrations
78
136
 
@@ -88,7 +146,7 @@ database = "{database}" # a DSN or URL, or env:NAME for an environment varia
88
146
  added = stack.add_npm(os.getcwd(), found.npm, __version__)
89
147
  if added:
90
148
  print(f"added {', '.join(added)} to package.json (then: npm install)")
91
- if found.pip:
149
+ if found.pip and not found.pip_there:
92
150
  req = stack.pip_requirement(found.pip, __version__)
93
151
  print(f"add {req} to your Python dependencies (pip install '{req}', or uv add '{req}')")
94
152
  setup = ""
@@ -100,7 +158,9 @@ database = "{database}" # a DSN or URL, or env:NAME for an environment varia
100
158
  {lines}
101
159
  The role your app connects as ({role}) must not bypass row-level security:
102
160
  CREATE ROLE {role} LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
103
- ALTER ROLE {role} SET jit = off;"""
161
+ ALTER ROLE {role} SET jit = off;
162
+ The app connects as {role}, with a URL of its own: never the one this command uses, the
163
+ tables' owner's, which row-level security doesn't apply to."""
104
164
  else:
105
165
  setup = f"""
106
166
  2. The role your app connects as ({role}) must not bypass row-level security:
@@ -332,7 +332,10 @@ class Server:
332
332
  name = parts[i]
333
333
  stripped = s.strip()
334
334
  # a test line: user $x can PERM TYPE ID
335
- m = re.match(r"(?:anyone|\S+\s+\S+)\s+(?:can|cannot)\s+(\w+)\s+(\w+)", stripped)
335
+ m = re.match(
336
+ r"(?:anyone|\S+\s+\S+(?:\s+with\s+scope\s+\w+(?:\s*,\s*\w+)*)?)\s+(?:can|cannot)\s+(\w+)\s+(\w+)",
337
+ stripped,
338
+ )
336
339
  if m and self.block(uri, line)[0] == "test" and name in (m.group(1), m.group(2)):
337
340
  t = pol.types.get(m.group(2))
338
341
  if name == m.group(2) and t:
@@ -556,7 +559,7 @@ class Server:
556
559
  if kind == "rules"
557
560
  else TYPE_WORDS
558
561
  if kind == "type"
559
- else ["given", "as", "can", "cannot", "allowed", "refused", "sees"],
562
+ else ["given", "as", "can", "cannot", "allowed", "refused", "sees", "with", "scope"],
560
563
  C_KEYWORD,
561
564
  )
562
565
  return dedupe(items)
@@ -284,9 +284,17 @@ depends_on = None
284
284
 
285
285
 
286
286
  def upgrade() -> None:
287
+ bind = op.get_bind()
288
+ if bind.dialect.driver == "asyncpg":
289
+ # asyncpg prepares each statement, and a prepared statement is one command: said here, in a line, rather
290
+ # than by the driver with the whole script in its message
291
+ raise RuntimeError(
292
+ "rowstile: Alembic is connected with asyncpg, which takes one statement at a time, and this revision "
293
+ "is one script. Give Alembic a sync driver (postgresql+psycopg://); the app keeps asyncpg"
294
+ )
287
295
  sql = Path(__file__).with_suffix(".sql").read_text(encoding="utf-8")
288
296
  # as one script, straight to the driver: the SQL has % signs and colons, which are not parameters
289
- op.get_bind().exec_driver_sql(sql, execution_options={{"no_parameters": True}})
297
+ bind.exec_driver_sql(sql, execution_options={{"no_parameters": True}})
290
298
 
291
299
 
292
300
  def downgrade() -> None:
@@ -131,6 +131,7 @@ class Connection:
131
131
  self.params: dict[str, str] = {}
132
132
  self.in_error = False
133
133
  self.busy = False # a query was sent and its answer not fully read (the connection is unusable)
134
+ self.tag = "" # what the last statement of query_described did, in the server's words ("DELETE 0")
134
135
  self.on_notice: Callable[[Fields], None] | None = None # called with each NOTICE / WARNING the server sends
135
136
  self.address: tuple[str, int] | None = None # where connect() went, for cancel()
136
137
  self.backend: tuple[int, int] | None = None # this session's process id and key, for cancel()
@@ -318,9 +319,12 @@ class Connection:
318
319
  rows: list[tuple[Value, ...]] = []
319
320
  cols: list[tuple[str, int]] = []
320
321
  error: PgError | None = None
322
+ self.tag = ""
321
323
  while True:
322
324
  kind, payload = self._read_or(error)
323
- if kind == b"T":
325
+ if kind == b"C":
326
+ self.tag = payload.rstrip(b"\0").decode()
327
+ elif kind == b"T":
324
328
  n = struct.unpack("!h", payload[:2])[0]
325
329
  pos, cols = 2, []
326
330
  for _ in range(n):
@@ -351,7 +355,7 @@ class Connection:
351
355
  if error:
352
356
  raise error
353
357
  return rows, [c[0] for c in cols]
354
- # 1, 2, n, C, A, S: nothing to do
358
+ # 1, 2, n, A, S: nothing to do
355
359
 
356
360
  def execute(self, sql: str) -> list[tuple[Value, ...]]:
357
361
  """Run a statement without parameters (BEGIN, COMMIT, ...)."""
@@ -154,6 +154,18 @@ def read_policy(path: str) -> tuple[str, dict[str, str]]:
154
154
  return text, collect_includes(text, read)
155
155
 
156
156
 
157
+ def cant_connect(e: BaseException, dsn: str | None) -> str:
158
+ """What to say when the connection failed. When nothing named a database, the defaults were tried (a local
159
+ socket, the system's user): the message says that, and how to name one, rather than quote a host nobody
160
+ gave."""
161
+ if dsn or any(os.environ.get(v) for v in ("PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGSERVICE")):
162
+ return f"can't connect: {e}"
163
+ return (
164
+ f"can't connect: no database was named, and the default failed ({e})\n"
165
+ f"name it with --db DSN, `database` in {CONFIG}, or DATABASE_URL"
166
+ )
167
+
168
+
157
169
  def fail(msg: str | None, code: int = 1) -> NoReturn:
158
170
  print(msg, file=sys.stderr)
159
171
  sys.exit(code)
@@ -552,6 +564,11 @@ class Dev:
552
564
  for line in lines[1:]:
553
565
  print(f" {line}")
554
566
 
567
+ @staticmethod
568
+ def left(pushed: bool) -> str:
569
+ """What an error in a pass leaves in the database."""
570
+ return "the policy is applied; its tests didn't run" if pushed else "nothing applied"
571
+
555
572
  def cycle(self, why: str) -> bool:
556
573
  """One pass; True if everything passed."""
557
574
  print(f"{time.strftime('%H:%M:%S')} {why}")
@@ -581,14 +598,16 @@ class Dev:
581
598
  try:
582
599
  conn = self.connect()
583
600
  except (ValueError, OSError, pgwire.PgError, pgwire.ProtocolError) as e:
584
- self.say("x", f"can't connect: {e}")
601
+ self.say("x", cant_connect(e, self.dsn))
585
602
  self.conn = None
586
603
  return False
604
+ pushed = False # an error after the push leaves the policy in force: only the tests didn't run
587
605
  try:
588
606
  self.diff(conn, text, files)
589
607
  self.notices.clear()
590
608
  started = time.monotonic()
591
609
  state = transaction(conn, lambda db: database.push(db, text, files))
610
+ pushed = True
592
611
  took = time.monotonic() - started
593
612
  self.say(
594
613
  "ok",
@@ -616,11 +635,11 @@ class Dev:
616
635
  self.say("x", str(e))
617
636
  return False
618
637
  except database.Error as e:
619
- self.say("x", str(e) + (f"\n{e.hint}" if e.hint else "") + "\nnothing applied")
638
+ self.say("x", str(e) + (f"\n{e.hint}" if e.hint else "") + "\n" + self.left(pushed))
620
639
  return False
621
640
  except pgwire.PgError as e:
622
641
  detail = e.fields.get("D")
623
- self.say("x", e.message + (f"\n{detail}" if detail else "") + "\nnothing applied")
642
+ self.say("x", e.message + (f"\n{detail}" if detail else "") + "\n" + self.left(pushed))
624
643
  return False
625
644
  except (OSError, pgwire.ProtocolError) as e:
626
645
  self.say("x", f"lost the database: {e}")
@@ -644,7 +663,7 @@ class Dev:
644
663
  self.say("~", "access: nobody gains or loses anything")
645
664
  return
646
665
  parts = [
647
- f"{users} user(s) {change.rstrip('s')} {what} on {objs} of {type_}"
666
+ f"{users} user(s) {change.rstrip('s')} {what} on {objs} {type_}{'' if objs == 1 else 's'}"
648
667
  for change, type_, what, users, objs in rows[:8]
649
668
  ]
650
669
  more = f"\n... and {len(rows) - 8} more (rowstile diff)" if len(rows) > 8 else ""
@@ -990,7 +1009,7 @@ def main(argv: list[str]) -> None:
990
1009
  try:
991
1010
  conn = pgwire.connect(**pgwire.parse_dsn(dsn))
992
1011
  except (ValueError, OSError, pgwire.PgError, pgwire.ProtocolError) as e:
993
- fail(f"can't connect: {e}", 2)
1012
+ fail(cant_connect(e, dsn), 2)
994
1013
  conn.on_notice = lambda f: print(
995
1014
  f"{f.get('V', f.get('S', 'NOTICE'))}: {f.get('M', '')}"
996
1015
  + (f"\nDETAIL: {f['D']}" if f.get("D") else "")
@@ -1509,7 +1528,9 @@ def ask(conn: pgwire.Connection, cmd: str, args: list[str], opts: dict[str, str]
1509
1528
  print(table(rows, cols))
1510
1529
  print(f"({len(rows)} row{'s' if len(rows) != 1 else ''}, as {who}; rolled back)")
1511
1530
  else:
1512
- print(f"done, as {who}; rolled back")
1531
+ # the server's own words for what the statement did: "DELETE 0" is a delete the rules let
1532
+ # through for no row, which "done" would hide
1533
+ print(f"{conn.tag or 'done'}, as {who}; rolled back")
1513
1534
  finally:
1514
1535
  try:
1515
1536
  q("ROLLBACK")
@@ -23,6 +23,7 @@ class Stack:
23
23
  clients: dict[str, str] = field(default_factory=dict) # {"ts": "src/authz.gen.ts"} or {"py": "app/authz_types.py"}
24
24
  npm: list[str] = field(default_factory=list) # @rowstile/* packages to add
25
25
  pip: str = "" # the pip requirement to add: rowstile[fastapi]
26
+ pip_there: bool = False # the project depends on rowstile already: nothing to add
26
27
  setup: list[str] = field(default_factory=list) # the change to make, in lines to print
27
28
  setup_file: str = "" # where to make it, if found
28
29
 
@@ -59,7 +60,8 @@ def _find(root: str, exts: tuple[str, ...], pattern: str) -> str:
59
60
 
60
61
 
61
62
  def _python_names(root: str) -> set[str]:
62
- """The Python packages the project depends on: names from pyproject.toml and requirements*.txt."""
63
+ """The Python packages the project depends on: names from pyproject.toml and requirements*.txt, and what
64
+ rowstile's own extras bring (`rowstile[fastapi,asyncpg]` is how the stack pages install FastAPI's part)."""
63
65
  text = (_read(root, "pyproject.toml") or "") + "\n"
64
66
  try:
65
67
  for n in sorted(os.listdir(root)):
@@ -67,10 +69,20 @@ def _python_names(root: str) -> set[str]:
67
69
  text += (_read(root, n) or "") + "\n"
68
70
  except OSError:
69
71
  pass
70
- return {m.group(1).lower().replace("_", "-") for m in re.finditer(r'(?m)^\s*"?([A-Za-z][A-Za-z0-9_.-]*)', text)} | {
71
- m.group(1).lower().replace("_", "-")
72
- for m in re.finditer(r'"([A-Za-z][A-Za-z0-9_.-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;][^"]*)?"', text)
72
+ extras = {
73
+ x.strip().lower()
74
+ for m in re.finditer(r"(?i)\browstile\s*\[([^\]]*)\]", text)
75
+ for x in m.group(1).split(",")
76
+ if x.strip()
73
77
  }
78
+ return (
79
+ {m.group(1).lower().replace("_", "-") for m in re.finditer(r'(?m)^\s*"?([A-Za-z][A-Za-z0-9_.-]*)', text)}
80
+ | {
81
+ m.group(1).lower().replace("_", "-")
82
+ for m in re.finditer(r'"([A-Za-z][A-Za-z0-9_.-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;][^"]*)?"', text)
83
+ }
84
+ | extras
85
+ )
74
86
 
75
87
 
76
88
  def _object(value: object) -> dict[str, object]:
@@ -152,7 +164,8 @@ def detect(root: str = ".") -> Stack:
152
164
  s.setup = [
153
165
  'import { PrismaPg } from "@prisma/adapter-pg";',
154
166
  'import { authz, signedIn } from "@rowstile/prisma";',
155
- "const adapter = signedIn(new PrismaPg({ connectionString: process.env.DATABASE_URL }),",
167
+ # the app's own connection, as the app role: DATABASE_URL is Prisma's, the owner's, for migrations
168
+ "const adapter = signedIn(new PrismaPg({ connectionString: process.env.ROWSTILE_APP_URL }),",
156
169
  f" {{ user: {user_ts} }});",
157
170
  "export const db = new PrismaClient({ adapter }).$extends(authz());",
158
171
  ]
@@ -194,6 +207,7 @@ def detect(root: str = ".") -> Stack:
194
207
  if "sqlalchemy" in py or "sqlmodel" in py:
195
208
  s.found.insert(1 if "fastapi" in py else 0, "SQLModel" if "sqlmodel" in py else "SQLAlchemy")
196
209
  s.pip = "rowstile" + (f"[{','.join(extras)}]" if extras else "")
210
+ s.pip_there = "rowstile" in py
197
211
  pkg_dir = next((d for d in ("app", "src", "backend/app") if os.path.isdir(os.path.join(root, d))), ".")
198
212
  s.clients["py"] = f"{pkg_dir}/authz_types.py".lstrip("./")
199
213
  if "fastapi" in py:
package/package.json CHANGED
@@ -1,8 +1,29 @@
1
1
  {
2
2
  "name": "rowstile",
3
- "version": "0.1.0-alpha.4",
4
- "description": "Access rules for Postgres: the rowstile command (policies compiled to row-level security, shipped as migrations), with its own Python",
3
+ "version": "0.1.0-alpha.6",
4
+ "description": "Authorization for Postgres apps: the rowstile command (access rules compiled to row-level security, shipped as migrations), with its own Python",
5
5
  "license": "Apache-2.0",
6
+ "mcpName": "io.github.rowstile/rowstile",
7
+ "homepage": "https://rowstile.dev",
8
+ "keywords": [
9
+ "rowstile",
10
+ "postgres",
11
+ "postgresql",
12
+ "authorization",
13
+ "row-level-security",
14
+ "rls",
15
+ "permissions",
16
+ "access-control",
17
+ "rbac",
18
+ "rebac",
19
+ "multi-tenant",
20
+ "policy",
21
+ "migrations",
22
+ "cli",
23
+ "prisma",
24
+ "drizzle",
25
+ "alembic"
26
+ ],
6
27
  "repository": {
7
28
  "type": "git",
8
29
  "url": "git+https://github.com/rowstile/rowstile.git",
@@ -20,12 +41,12 @@
20
41
  "node": ">=18"
21
42
  },
22
43
  "optionalDependencies": {
23
- "@rowstile/cli-linux-x64": "0.1.0-alpha.4",
24
- "@rowstile/cli-linux-arm64": "0.1.0-alpha.4",
25
- "@rowstile/cli-linux-x64-musl": "0.1.0-alpha.4",
26
- "@rowstile/cli-linux-arm64-musl": "0.1.0-alpha.4",
27
- "@rowstile/cli-darwin-x64": "0.1.0-alpha.4",
28
- "@rowstile/cli-darwin-arm64": "0.1.0-alpha.4",
29
- "@rowstile/cli-win32-x64": "0.1.0-alpha.4"
44
+ "@rowstile/cli-linux-x64": "0.1.0-alpha.6",
45
+ "@rowstile/cli-linux-arm64": "0.1.0-alpha.6",
46
+ "@rowstile/cli-linux-x64-musl": "0.1.0-alpha.6",
47
+ "@rowstile/cli-linux-arm64-musl": "0.1.0-alpha.6",
48
+ "@rowstile/cli-darwin-x64": "0.1.0-alpha.6",
49
+ "@rowstile/cli-darwin-arm64": "0.1.0-alpha.6",
50
+ "@rowstile/cli-win32-x64": "0.1.0-alpha.6"
30
51
  }
31
52
  }