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 +3 -2
- package/bin/rowstile.js +29 -1
- package/command/authzlib/__init__.py +1 -1
- package/command/authzlib/coverage.py +4 -3
- package/command/authzlib/database.py +2 -1
- package/command/authzlib/devtools.py +31 -4
- package/command/authzlib/draft.py +10 -6
- package/command/authzlib/errors.py +22 -5
- package/command/authzlib/fmt.py +5 -2
- package/command/authzlib/grant.py +69 -8
- package/command/authzlib/hardening.py +47 -12
- package/command/authzlib/migrate.py +3 -1
- package/command/authzlib/parse.py +39 -10
- package/command/authzlib/perf.py +2 -2
- package/command/authzlib/review.py +14 -8
- package/command/authzlib/testing.py +19 -5
- package/command/cli/init.py +65 -5
- package/command/cli/lsp.py +5 -2
- package/command/cli/migrations.py +9 -1
- package/command/cli/pgwire.py +6 -2
- package/command/cli/rowstile_cli.py +27 -6
- package/command/cli/stack.py +19 -5
- package/package.json +30 -9
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# rowstile
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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}`) +
|
|
@@ -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
|
-
+
|
|
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
|
|
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
|
|
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}
|
|
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,
|
|
311
|
-
|
|
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,
|
|
623
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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$'
|
|
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.
|
|
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
|
|
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:
|
package/command/authzlib/fmt.py
CHANGED
|
@@ -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
|
-
(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 :
|
|
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
|
-
--
|
|
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),
|
|
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(
|
|
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(
|
|
926
|
-
if not m.group(
|
|
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(
|
|
953
|
+
sql=m.group(7).strip(),
|
|
954
|
+
scopes=scopes_of(m.group(4)),
|
|
936
955
|
)
|
|
937
|
-
m = re.fullmatch(
|
|
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(
|
|
946
|
-
perm=m.group(
|
|
947
|
-
type=m.group(
|
|
948
|
-
obj=value(m.group(
|
|
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
|
|
package/command/authzlib/perf.py
CHANGED
|
@@ -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
|
-
|
|
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')} {
|
|
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')} {
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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]
|
package/command/cli/init.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""rowstile init: a first policy drafted from the database's catalog, a test file,
|
|
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
|
|
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:
|
package/command/cli/lsp.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
297
|
+
bind.exec_driver_sql(sql, execution_options={{"no_parameters": True}})
|
|
290
298
|
|
|
291
299
|
|
|
292
300
|
def downgrade() -> None:
|
package/command/cli/pgwire.py
CHANGED
|
@@ -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"
|
|
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,
|
|
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",
|
|
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 "") + "\
|
|
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 "") + "\
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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")
|
package/command/cli/stack.py
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
for m in re.finditer(r
|
|
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
|
-
|
|
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
|
-
"description": "
|
|
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.
|
|
24
|
-
"@rowstile/cli-linux-arm64": "0.1.0-alpha.
|
|
25
|
-
"@rowstile/cli-linux-x64-musl": "0.1.0-alpha.
|
|
26
|
-
"@rowstile/cli-linux-arm64-musl": "0.1.0-alpha.
|
|
27
|
-
"@rowstile/cli-darwin-x64": "0.1.0-alpha.
|
|
28
|
-
"@rowstile/cli-darwin-arm64": "0.1.0-alpha.
|
|
29
|
-
"@rowstile/cli-win32-x64": "0.1.0-alpha.
|
|
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
|
}
|