@everystack/cli 0.2.37 → 0.3.0

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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The owner-aware red-team — proving RLS actually isolates one user from another.
3
+ *
4
+ * `authz-redteam.ts` probes (role × table × command): it `SET ROLE`s and asks "does the
5
+ * role hold the privilege?", treating an RLS-filtered row as "privilege present" — it is
6
+ * deliberately identity-blind. That leaves the highest-value class unverified: can user A
7
+ * read or mutate user B's row? RLS owner policies turn on the JWT identity
8
+ * (`request.jwt.claims ->> 'sub'`), not the role, so two `authenticated` users look
9
+ * identical to the grant probe. This module fills that gap.
10
+ *
11
+ * For each owner-scoped table it DISCOVERS two distinct owner values from the live data
12
+ * (as the privileged connection, before assuming a role), then assumes role `authenticated`
13
+ * and sets `request.jwt.claims` to the first identity and checks three things:
14
+ * - read-own — the owner's own rows ARE visible (the anti-vacuous twin: a deny that
15
+ * also hides the owner's rows is a policy bug, not a pass).
16
+ * - read-other — the other identity's rows are NOT visible (an IDOR read leak).
17
+ * - write-other— an UPDATE touching the other identity's rows affects ZERO rows (an IDOR
18
+ * write leak). Rolled back by the runner; nothing persists.
19
+ * A table with fewer than two distinct owners records `unprobed` (can't test isolation with
20
+ * one identity) — surfaced, never a false pass. Discovery casts the owner to text, so the
21
+ * same probe works for a uuid, bigint, or text owner column.
22
+ *
23
+ * The builder and evaluator are pure; the SQL runs through the `db:authz:probe` action, which
24
+ * executes it in a transaction it always rolls back.
25
+ */
26
+
27
+ /** One owner-scoped table to probe. Identity values are discovered from the data at run time. */
28
+ export interface OwnerProbe {
29
+ /** Schema-qualified table, e.g. `public.notes`. */
30
+ table: string;
31
+ /** The SQL column holding the owner id (e.g. `author_id`). */
32
+ ownerColumn: string;
33
+ /** JWT claim carrying the owner id. Default `sub`. */
34
+ claim?: string;
35
+ /** App role to assume. Default `authenticated`. */
36
+ role?: string;
37
+ }
38
+
39
+ export type OwnerCheck = 'discover' | 'read-own' | 'read-other' | 'write-other';
40
+ /** `visible`/`hidden` = rows seen-or-affected / none; `error` = the attempt raised; `unprobed` = <2 owners. */
41
+ export type OwnerOutcome = 'visible' | 'hidden' | 'error' | 'unprobed';
42
+
43
+ export interface OwnerProbeResult {
44
+ table: string;
45
+ check: OwnerCheck;
46
+ outcome: OwnerOutcome;
47
+ /** Rows seen (read) / affected (write); for `discover`, the number of distinct owners found. */
48
+ rows: number;
49
+ }
50
+
51
+ export interface OwnerFinding {
52
+ severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed';
53
+ table: string;
54
+ detail: string;
55
+ }
56
+
57
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+
59
+ /** A SQL single-quoted literal, doubling embedded quotes. */
60
+ function sqlLiteral(value: string): string {
61
+ return `'${value.replace(/'/g, "''")}'`;
62
+ }
63
+
64
+ /**
65
+ * Build the server-side owner probe. Returns SQL that populates
66
+ * `_authz_owner_probe(tbl, check, outcome, rows)` for every probe, then a trailing SELECT.
67
+ * The caller (the `db:authz:probe` action) runs both in one transaction it always rolls back.
68
+ * Schema/table/column identifiers are split and `%I`-quoted in PL/pgSQL; discovered identity
69
+ * values live in PL/pgSQL variables (never concatenated into SQL), so there is no injection
70
+ * surface from the data. Only the column/claim/role idents are inlined, and they are validated.
71
+ */
72
+ export function buildOwnerProbeSql(probes: OwnerProbe[]): string {
73
+ const blocks = probes.map((p) => {
74
+ const role = p.role ?? 'authenticated';
75
+ const claim = p.claim ?? 'sub';
76
+ if (!IDENT.test(role)) throw new Error(`Invalid role: ${JSON.stringify(role)}`);
77
+ if (!IDENT.test(p.ownerColumn)) throw new Error(`Invalid owner column: ${JSON.stringify(p.ownerColumn)}`);
78
+ if (!IDENT.test(claim)) throw new Error(`Invalid claim: ${JSON.stringify(claim)}`);
79
+
80
+ const tbl = sqlLiteral(p.table);
81
+ const col = sqlLiteral(p.ownerColumn);
82
+ const roleLit = sqlLiteral(role);
83
+ const claimLit = sqlLiteral(claim);
84
+
85
+ return `
86
+ -- ${p.table} (owner ${p.ownerColumn})
87
+ v_schema := split_part(${tbl}, '.', 1);
88
+ v_table := split_part(${tbl}, '.', 2);
89
+ v_write_err := false;
90
+ EXECUTE format(
91
+ 'SELECT array_agg(v) FROM (SELECT DISTINCT %I::text AS v FROM %I.%I WHERE %I IS NOT NULL ORDER BY v LIMIT 2) s',
92
+ ${col}, v_schema, v_table, ${col}
93
+ ) INTO v_owners;
94
+
95
+ IF v_owners IS NULL OR array_length(v_owners, 1) < 2 THEN
96
+ INSERT INTO _authz_owner_probe VALUES (${tbl}, 'discover', 'unprobed', COALESCE(array_length(v_owners, 1), 0));
97
+ ELSE
98
+ v_owner := v_owners[1];
99
+ v_intruder := v_owners[2];
100
+
101
+ EXECUTE format('SET LOCAL ROLE %I', ${roleLit});
102
+ PERFORM set_config('request.jwt.claims', jsonb_build_object(${claimLit}, v_owner, 'role', ${roleLit})::text, true);
103
+
104
+ EXECUTE format('SELECT count(*) FROM %I.%I WHERE %I = %L', v_schema, v_table, ${col}, v_owner) INTO v_own;
105
+ EXECUTE format('SELECT count(*) FROM %I.%I WHERE %I = %L', v_schema, v_table, ${col}, v_intruder) INTO v_other;
106
+ BEGIN
107
+ EXECUTE format('UPDATE %I.%I SET %I = %I WHERE %I = %L', v_schema, v_table, ${col}, ${col}, ${col}, v_intruder);
108
+ GET DIAGNOSTICS v_write = ROW_COUNT;
109
+ EXCEPTION WHEN OTHERS THEN
110
+ v_write := 0; v_write_err := true;
111
+ END;
112
+
113
+ RESET ROLE;
114
+ PERFORM set_config('request.jwt.claims', '', true);
115
+
116
+ INSERT INTO _authz_owner_probe VALUES
117
+ (${tbl}, 'read-own', CASE WHEN v_own > 0 THEN 'visible' ELSE 'hidden' END, v_own),
118
+ (${tbl}, 'read-other', CASE WHEN v_other > 0 THEN 'visible' ELSE 'hidden' END, v_other),
119
+ (${tbl}, 'write-other', CASE WHEN v_write_err THEN 'error' WHEN v_write > 0 THEN 'visible' ELSE 'hidden' END, v_write);
120
+ END IF;`;
121
+ });
122
+
123
+ return `
124
+ DROP TABLE IF EXISTS _authz_owner_probe;
125
+ CREATE TEMP TABLE _authz_owner_probe(tbl text, "check" text, outcome text, rows int) ON COMMIT DROP;
126
+ DO $owner$
127
+ DECLARE
128
+ v_schema text;
129
+ v_table text;
130
+ v_owners text[];
131
+ v_owner text;
132
+ v_intruder text;
133
+ v_own bigint;
134
+ v_other bigint;
135
+ v_write bigint;
136
+ v_write_err boolean;
137
+ BEGIN
138
+ ${blocks.join('\n')}
139
+ END
140
+ $owner$;
141
+ `.trim();
142
+ }
143
+
144
+ /** Read the probe outcomes (same transaction as buildOwnerProbeSql; the runner rolls back). */
145
+ export const OWNER_PROBE_SELECT_SQL =
146
+ 'SELECT tbl AS table, "check", outcome, rows FROM _authz_owner_probe ORDER BY tbl, "check"';
147
+
148
+ /** Map a probe-result row to a typed OwnerProbeResult. */
149
+ export function toOwnerProbeResult(row: any): OwnerProbeResult {
150
+ return {
151
+ table: String(row.table),
152
+ check: String(row.check) as OwnerCheck,
153
+ outcome: String(row.outcome) as OwnerOutcome,
154
+ rows: Number(row.rows ?? 0),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Turn probe outcomes into findings. Each is a behavioural authorization failure:
160
+ * - read-leak — as the owner, the other identity's rows were visible (read-other = visible).
161
+ * - write-leak — as the owner, an UPDATE touched the other identity's rows (write-other visible).
162
+ * - vacuous — the owner could NOT see their own rows (read-own = hidden): the policy denies
163
+ * the owner too, so the read-other deny proves nothing — the faithfulness guard.
164
+ * - unprobed — fewer than two distinct owners in the table; isolation can't be tested (a
165
+ * warning, not a verdict — seed a second owner's row, or accept the gap).
166
+ * A correctly-isolated table with two owners produces zero findings.
167
+ */
168
+ export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[] {
169
+ const findings: OwnerFinding[] = [];
170
+ const byTable = new Map<string, Map<OwnerCheck, OwnerProbeResult>>();
171
+ for (const r of results) {
172
+ (byTable.get(r.table) ?? byTable.set(r.table, new Map()).get(r.table)!).set(r.check, r);
173
+ }
174
+
175
+ for (const [table, checks] of byTable) {
176
+ if (checks.get('discover')?.outcome === 'unprobed') {
177
+ findings.push({ severity: 'unprobed', table,
178
+ detail: `fewer than two distinct owners in ${table} — cannot test reader/writer isolation; seed a second owner's row to cover it` });
179
+ continue;
180
+ }
181
+ const own = checks.get('read-own');
182
+ const other = checks.get('read-other');
183
+ const write = checks.get('write-other');
184
+
185
+ if (own && own.outcome !== 'visible') {
186
+ findings.push({ severity: 'vacuous', table,
187
+ detail: `owner could not see their own rows in ${table} — the policy denies the owner too, or the probe identity is wrong; the isolation checks prove nothing until this is fixed` });
188
+ continue; // the other checks are meaningless while the owner can't see own rows
189
+ }
190
+ if (other && other.outcome === 'visible') {
191
+ findings.push({ severity: 'read-leak', table,
192
+ detail: `as the owner, ${other.rows} of another user's row(s) in ${table} were visible — RLS does not isolate readers (IDOR read)` });
193
+ }
194
+ if (write && write.outcome === 'visible') {
195
+ findings.push({ severity: 'write-leak', table,
196
+ detail: `as the owner, an UPDATE affected ${write.rows} of another user's row(s) in ${table} — RLS does not isolate writers (IDOR write)` });
197
+ }
198
+ }
199
+ return findings;
200
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Reconcile — the authz diff turned back into SQL (the "correct" to the contract
3
+ * suite's "validate"). `diffContracts` detects drift as prose findings;
4
+ * `emitReconcileSql` emits the idempotent DDL that makes the live database match
5
+ * the declared contract.
6
+ *
7
+ * It works off the structured contracts, not DriftFinding (whose `detail` is
8
+ * prose — "USING changed" can't be turned into SQL). Declared is the target
9
+ * (compiled from the Model); live is the current database; the output transforms
10
+ * live -> declared.
11
+ *
12
+ * Authz-only by construction: it emits CREATE/DROP POLICY, GRANT/REVOKE, and
13
+ * ALTER TABLE ... ROW LEVEL SECURITY — never anything that touches table data. A
14
+ * policy/grant change loses no rows and is idempotently replaceable, which is why
15
+ * this layer reconciles without a migration file (unlike the data layer).
16
+ */
17
+
18
+ import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
+ import { quoteQualified } from './pg-ident.js';
20
+
21
+ /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
22
+ function isWrapped(s: string): boolean {
23
+ if (!s.startsWith('(') || !s.endsWith(')')) return false;
24
+ let depth = 0;
25
+ for (let i = 0; i < s.length; i++) {
26
+ if (s[i] === '(') depth++;
27
+ else if (s[i] === ')') {
28
+ depth--;
29
+ if (depth === 0 && i < s.length - 1) return false;
30
+ }
31
+ }
32
+ return depth === 0;
33
+ }
34
+
35
+ /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
36
+ function clause(expr: string): string {
37
+ return isWrapped(expr) ? expr : `(${expr})`;
38
+ }
39
+
40
+ /** A CREATE POLICY statement from a policy descriptor. */
41
+ function policyCreateSql(table: string, p: PolicyContract): string {
42
+ const parts = [`CREATE POLICY ${p.name} ON ${table}`];
43
+ if (!p.permissive) parts.push('AS RESTRICTIVE');
44
+ parts.push(`FOR ${p.command}`);
45
+ parts.push(`TO ${p.roles.join(', ')}`);
46
+ if (p.using != null) parts.push(`USING ${clause(p.using)}`);
47
+ if (p.check != null) parts.push(`WITH CHECK ${clause(p.check)}`);
48
+ return `${parts.join(' ')};`;
49
+ }
50
+
51
+ function policyDropSql(table: string, name: string): string {
52
+ return `DROP POLICY IF EXISTS ${name} ON ${table};`;
53
+ }
54
+
55
+ /** Two policies are the same authorization when every diffed field matches. */
56
+ function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
57
+ return a.command === b.command
58
+ && a.permissive === b.permissive
59
+ && a.roles.join(',') === b.roles.join(',')
60
+ && (a.using ?? '') === (b.using ?? '')
61
+ && (a.check ?? '') === (b.check ?? '');
62
+ }
63
+
64
+ function tableMap(c: AuthzContract): Map<string, TableContract> {
65
+ return new Map(c.tables.map((t) => [t.table, t]));
66
+ }
67
+
68
+ /**
69
+ * Emit the ordered SQL that transforms the live database into the declared
70
+ * contract. Empty when they already match (the reconcile no-op). Order per table:
71
+ * enable RLS, drop removed/changed policies, create added/changed policies, then
72
+ * revoke/grant — so a replaced policy never briefly co-exists with its old form
73
+ * and policies exist before grants reference the table.
74
+ */
75
+ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract): string[] {
76
+ const sql: string[] = [];
77
+ const lTables = tableMap(live);
78
+
79
+ for (const d of declared.tables) {
80
+ const l = lTables.get(d.table);
81
+ // The contract keys on the bare `<schema>.<table>` (so the diff matches both producers);
82
+ // the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
83
+ const table = quoteQualified(d.table);
84
+ reconcileRls(table, d, l, sql);
85
+ reconcilePolicies(table, d, l, sql);
86
+ reconcileGrants(table, d, l, sql);
87
+ }
88
+
89
+ return sql;
90
+ }
91
+
92
+ function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
93
+ if (d.rls.enabled && !l?.rls.enabled) out.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
94
+ if (d.rls.forced && !l?.rls.forced) out.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
95
+ if (!d.rls.enabled && l?.rls.enabled) out.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
96
+ if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
97
+ }
98
+
99
+ function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
100
+ const dMap = new Map(d.policies.map((p) => [p.name, p]));
101
+ const lMap = new Map((l?.policies ?? []).map((p) => [p.name, p]));
102
+
103
+ // Drops first: policies removed, or changed (dropped then recreated below).
104
+ for (const [name, lp] of lMap) {
105
+ const dp = dMap.get(name);
106
+ if (!dp || !policiesEqual(dp, lp)) out.push(policyDropSql(table, name));
107
+ }
108
+ // Creates: policies added, or changed (recreated to the declared form).
109
+ for (const [name, dp] of dMap) {
110
+ const lp = lMap.get(name);
111
+ if (!lp || !policiesEqual(dp, lp)) out.push(policyCreateSql(table, dp));
112
+ }
113
+ }
114
+
115
+ function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
116
+ const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
117
+ for (const grantee of [...grantees].sort()) {
118
+ const declared = new Set(d.grants[grantee] ?? []);
119
+ const live = new Set(l?.grants?.[grantee] ?? []);
120
+ const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
121
+
122
+ const toRevoke = [...live].filter((p) => !declared.has(p)).sort();
123
+ const toGrant = [...declared].filter((p) => !live.has(p)).sort();
124
+ if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
125
+ if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
126
+ }
127
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The Authorization Contract red-team — proving *enforcement*, not just declaration.
3
+ *
4
+ * db:authz:diff proves the live ACL matches the committed contract (static text). This
5
+ * proves the database actually *behaves* that way: for each (role, table, command) it
6
+ * SET ROLEs into the app role and attempts the operation, then asks "was it allowed?".
7
+ * That catches enforcement holes static inspection cannot — privilege escalation via
8
+ * role inheritance, a BYPASSRLS attribute, or a `USING(true)` policy quietly defeating
9
+ * intent. The worst finding is a role that can do *more* than the contract declares.
10
+ *
11
+ * Execution model (built into the `db:authz:probe` ops action): one server-side DO
12
+ * block loops every (role, table, command); each probe runs inside its own savepoint
13
+ * (SET LOCAL ROLE -> attempt -> classify SQLSTATE -> rollback to savepoint -> RESET
14
+ * ROLE) and writes its outcome to a temp table the surrounding transaction then
15
+ * discards. Nothing persists; one round trip. The builder and evaluator here are pure.
16
+ */
17
+
18
+ import type { AuthzContract, TableContract } from './authz-contract.js';
19
+
20
+ export type ProbeCommand = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
21
+ export const PROBE_COMMANDS: ProbeCommand[] = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
22
+
23
+ /** SELECT/INSERT/UPDATE/DELETE map to the privilege a grant must carry to allow them. */
24
+ const COMMAND_PRIVILEGE: Record<ProbeCommand, string> = {
25
+ SELECT: 'SELECT', INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE',
26
+ };
27
+
28
+ export type ProbeOutcome =
29
+ | 'allowed' // the privilege is present (RLS may still filter rows)
30
+ | 'denied-privilege' // no grant: "permission denied for table"
31
+ | 'denied-rls' // grant present, RLS blocked the row: privilege IS present
32
+ | 'no-impersonate'; // the probing role could not SET ROLE into this role
33
+
34
+ export interface ProbeResult {
35
+ role: string;
36
+ table: string; // schema-qualified
37
+ command: ProbeCommand;
38
+ outcome: ProbeOutcome;
39
+ }
40
+
41
+ export interface RedTeamFinding {
42
+ severity: 'hole' | 'broken' | 'unprobed';
43
+ role: string;
44
+ table: string;
45
+ command: ProbeCommand;
46
+ detail: string;
47
+ }
48
+
49
+ /**
50
+ * Does a role hold a privilege on a table — by table grant, by a column-scoped grant
51
+ * (for SELECT/INSERT/UPDATE), or via a PUBLIC grant (which applies to every role)? The
52
+ * privilege-isolated probe (`SELECT 1`, `UPDATE ... WHERE false`) needs the privilege on
53
+ * *any* column, so a column grant confers it — matching what the database enforces, and
54
+ * the reason a table-grant-only check produced false "holes" on `auth.users`.
55
+ */
56
+ export function hasPrivilege(table: TableContract, role: string, privilege: string): boolean {
57
+ const tableGrant = (g: string) => (table.grants[g] ?? []).includes(privilege);
58
+ const columnGrant = (g: string) => (table.columnGrants?.[g]?.[privilege]?.length ?? 0) > 0;
59
+ return tableGrant(role) || columnGrant(role) || tableGrant('PUBLIC') || columnGrant('PUBLIC');
60
+ }
61
+
62
+ /** Roles to probe: every grantee named in the contract (minus PUBLIC) plus anon — so a
63
+ * role that should have NO access is still actively tested for default-deny. */
64
+ export function probeRoles(contract: AuthzContract): string[] {
65
+ const roles = new Set<string>(['anon']);
66
+ const add = (g: string) => { if (g.toUpperCase() !== 'PUBLIC') roles.add(g); };
67
+ for (const t of contract.tables) {
68
+ Object.keys(t.grants).forEach(add);
69
+ Object.keys(t.columnGrants ?? {}).forEach(add);
70
+ }
71
+ return [...roles].sort();
72
+ }
73
+
74
+ /** A Postgres string-array literal: `ARRAY['a','b']` with each element escaped. */
75
+ function pgArrayLiteral(values: string[]): string {
76
+ const escaped = values.map((v) => `'${v.replace(/'/g, "''")}'`);
77
+ return `ARRAY[${escaped.join(', ')}]::text[]`;
78
+ }
79
+
80
+ /**
81
+ * Build the server-side probe. Returns SQL that populates `_authz_probe(role, tbl,
82
+ * command, outcome)` and a trailing SELECT of it. The caller runs both inside one
83
+ * transaction it always rolls back. Probe statements are privilege-isolated:
84
+ * SELECT 1 FROM t WHERE false — SELECT privilege, reads no rows
85
+ * INSERT INTO t DEFAULT VALUES — INSERT privilege (rolled back; NOT NULL = allowed)
86
+ * UPDATE t SET col = col WHERE false — UPDATE privilege, touches no rows
87
+ * DELETE FROM t WHERE false — DELETE privilege, touches no rows
88
+ * "permission denied" -> denied-privilege; "row-level security" -> denied-rls; any
89
+ * other error (constraint, etc.) means the privilege passed -> allowed.
90
+ */
91
+ export function buildProbeSql(roles: string[], tables: string[]): string {
92
+ return `
93
+ DROP TABLE IF EXISTS _authz_probe;
94
+ CREATE TEMP TABLE _authz_probe(role text, tbl text, command text, outcome text) ON COMMIT DROP;
95
+ DO $probe$
96
+ DECLARE
97
+ v_role text;
98
+ v_tbl text;
99
+ v_cmd text;
100
+ v_col text;
101
+ v_stmt text;
102
+ v_outcome text;
103
+ BEGIN
104
+ FOREACH v_role IN ARRAY ${pgArrayLiteral(roles)} LOOP
105
+ FOREACH v_tbl IN ARRAY ${pgArrayLiteral(tables)} LOOP
106
+ FOREACH v_cmd IN ARRAY ${pgArrayLiteral(PROBE_COMMANDS)} LOOP
107
+ v_stmt := CASE v_cmd
108
+ WHEN 'SELECT' THEN format('SELECT 1 FROM %I.%I WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
109
+ WHEN 'INSERT' THEN format('INSERT INTO %I.%I DEFAULT VALUES', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
110
+ WHEN 'DELETE' THEN format('DELETE FROM %I.%I WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
111
+ WHEN 'UPDATE' THEN NULL
112
+ END;
113
+ IF v_cmd = 'UPDATE' THEN
114
+ SELECT quote_ident(attname) INTO v_col FROM pg_attribute
115
+ WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
116
+ AND attnum > 0 AND NOT attisdropped ORDER BY attnum LIMIT 1;
117
+ v_stmt := format('UPDATE %I.%I SET %s = %s WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2), v_col, v_col);
118
+ END IF;
119
+
120
+ BEGIN
121
+ EXECUTE format('SET LOCAL ROLE %I', v_role);
122
+ BEGIN
123
+ EXECUTE v_stmt;
124
+ v_outcome := 'allowed';
125
+ EXCEPTION
126
+ WHEN insufficient_privilege THEN
127
+ v_outcome := CASE WHEN SQLERRM ILIKE '%row-level security%' THEN 'denied-rls' ELSE 'denied-privilege' END;
128
+ WHEN OTHERS THEN
129
+ v_outcome := 'allowed'; -- privilege passed; failed on data/constraint
130
+ END;
131
+ RESET ROLE;
132
+ EXCEPTION WHEN insufficient_privilege THEN
133
+ v_outcome := 'no-impersonate'; -- the SET ROLE itself was refused
134
+ RESET ROLE;
135
+ END;
136
+
137
+ INSERT INTO _authz_probe VALUES (v_role, v_tbl, v_cmd, v_outcome);
138
+ END LOOP;
139
+ END LOOP;
140
+ END LOOP;
141
+ END
142
+ $probe$;
143
+ `.trim();
144
+ }
145
+
146
+ /**
147
+ * Read the probe outcomes. Runs in the same transaction as `buildProbeSql` (the temp
148
+ * table is `ON COMMIT DROP`), which the caller always rolls back so nothing persists.
149
+ */
150
+ export const PROBE_SELECT_SQL =
151
+ 'SELECT role, tbl AS table, command, outcome FROM _authz_probe ORDER BY tbl, role, command';
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Grant-completeness — the SECDEF-helper EXECUTE gap.
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Find SECURITY DEFINER functions whose owner cannot EXECUTE a helper their body calls.
159
+ *
160
+ * A SECDEF function runs as its owner; calling a helper needs the owner to hold EXECUTE
161
+ * on it. While every function is superuser-owned (db:migrate's default) the owner can
162
+ * execute everything, so the gaps are invisible — local tests pass. Normalize ownership
163
+ * to a non-superuser operator and the same call fails in production with 42501. This
164
+ * query surfaces those gaps from the catalog so they never reach prod unnoticed.
165
+ *
166
+ * Helper references are extracted from the function body by regex (schema-qualified
167
+ * `schema.fn(` calls); the join to pg_proc keeps only real functions, and
168
+ * has_function_privilege resolves ownership/PUBLIC/inheritance. Read-only — runs through
169
+ * the db:query action. A normalized, least-privilege database returns zero rows.
170
+ */
171
+ export const GRANT_COMPLETENESS_SQL = `
172
+ WITH secdef AS (
173
+ SELECT p.oid, p.proowner, n.nspname || '.' || p.proname AS name, p.prosrc
174
+ FROM pg_proc p
175
+ JOIN pg_namespace n ON n.oid = p.pronamespace
176
+ WHERE p.prosecdef
177
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
178
+ AND n.nspname NOT LIKE 'pg_%'
179
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
180
+ )
181
+ SELECT DISTINCT s.name AS secdef, ref.helper AS helper
182
+ FROM secdef s
183
+ CROSS JOIN LATERAL (
184
+ SELECT DISTINCT lower(m[1]) AS helper
185
+ FROM regexp_matches(s.prosrc, '([a-zA-Z_][a-zA-Z0-9_]*\\.[a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(', 'g') AS m
186
+ ) ref
187
+ JOIN pg_proc hp ON true
188
+ JOIN pg_namespace hn ON hn.oid = hp.pronamespace
189
+ WHERE lower(hn.nspname || '.' || hp.proname) = ref.helper
190
+ AND hp.oid <> s.oid
191
+ AND hn.nspname NOT IN ('pg_catalog', 'information_schema')
192
+ AND NOT has_function_privilege(s.proowner, hp.oid, 'EXECUTE')
193
+ ORDER BY secdef, helper;
194
+ `.trim();
195
+
196
+ export interface GrantGap {
197
+ /** The SECDEF function whose owner is missing a grant. */
198
+ secdef: string;
199
+ /** The helper it calls but its owner cannot EXECUTE. */
200
+ helper: string;
201
+ }
202
+
203
+ export function toGrantGap(row: any): GrantGap {
204
+ return { secdef: String(row.secdef), helper: String(row.helper) };
205
+ }
206
+
207
+ /** Map a probe result row (from the SELECT) into a typed ProbeResult. */
208
+ export function toProbeResult(row: any): ProbeResult {
209
+ return {
210
+ role: String(row.role),
211
+ table: String(row.table),
212
+ command: String(row.command).toUpperCase() as ProbeCommand,
213
+ outcome: String(row.outcome) as ProbeOutcome,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Compare the live probe matrix against the contract's grants. A finding is a
219
+ * *disagreement* between declared authority and enforced behaviour:
220
+ * - `hole` — the role was ALLOWED something the contract does not grant. The
221
+ * dangerous one: enforced access exceeds the declaration.
222
+ * - `broken` — the contract grants it but the role was DENIED by privilege. The
223
+ * declaration is a dead letter; the feature is silently off.
224
+ * - `unprobed` — the probing connection could not SET ROLE into the role (a warning,
225
+ * not a verdict — grant the probe role membership to cover it).
226
+ * denied-rls counts as the privilege being present (RLS filtering rows is the policy's
227
+ * job, exercised behaviourally by the RPC layer, not a grant-level finding).
228
+ */
229
+ export function evaluateRedTeam(contract: AuthzContract, results: ProbeResult[]): RedTeamFinding[] {
230
+ const tablesByName = new Map(contract.tables.map((t) => [t.table, t]));
231
+ const findings: RedTeamFinding[] = [];
232
+
233
+ for (const r of results) {
234
+ const table = tablesByName.get(r.table);
235
+ if (!table) continue; // a table not in the contract (e.g. ignored schema)
236
+ const granted = hasPrivilege(table, r.role, COMMAND_PRIVILEGE[r.command]);
237
+ const allowed = r.outcome === 'allowed' || r.outcome === 'denied-rls';
238
+
239
+ if (r.outcome === 'no-impersonate') {
240
+ findings.push({ severity: 'unprobed', role: r.role, table: r.table, command: r.command,
241
+ detail: `could not SET ROLE into ${r.role} — grant the probe connection membership to cover it` });
242
+ } else if (allowed && !granted) {
243
+ findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
244
+ detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
245
+ } else if (!allowed && granted) {
246
+ findings.push({ severity: 'broken', role: r.role, table: r.table, command: r.command,
247
+ detail: `contract grants ${r.role} ${r.command} on ${r.table} but the database denied it (declared but not enforced)` });
248
+ }
249
+ }
250
+ return findings;
251
+ }