@everystack/cli 0.2.39 → 0.2.40

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,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
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The human-readable view of the Authorization Contract.
3
+ *
4
+ * The JSON contract is machine-grade — correct for diff/test/probe, but a CTO cannot
5
+ * scan `(author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->>
6
+ * 'sub'::text))::uuid)` and confirm it means "owns the row". This renders the same
7
+ * contract as a role x action matrix with the predicates translated to plain English,
8
+ * so a human can read it and confirm or deny the rules match intent.
9
+ *
10
+ * `humanizePredicate` recognises the handful of shapes that make up ~all real policies
11
+ * (owner check, soft-delete guard, boolean, simple comparisons) — the same
12
+ * classification the v3 reverse-compiler (db:pull -> can()) needs, so it is not
13
+ * throwaway. Anything it does not recognise is shown verbatim, never hidden.
14
+ */
15
+
16
+ import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
17
+ import { hasPrivilege } from './authz-redteam.js';
18
+
19
+ /** True when the expression resolves to the signed-in user's id (any of the forms). */
20
+ function isCurrentUserExpr(s: string): boolean {
21
+ return /current_setting\([^)]*jwt\.claims/i.test(s)
22
+ || /auth\.uid\(\)/i.test(s)
23
+ || /auth\.jwt\(\)\s*->>\s*'sub'/i.test(s);
24
+ }
25
+
26
+ /** Strip one layer of redundant outer parentheses, repeatedly. */
27
+ function unwrap(s: string): string {
28
+ let t = s.trim();
29
+ while (t.startsWith('(') && t.endsWith(')') && balanced(t.slice(1, -1))) {
30
+ t = t.slice(1, -1).trim();
31
+ }
32
+ return t;
33
+ }
34
+
35
+ /** True when every '(' in s closes before the end — i.e. the outer parens were redundant. */
36
+ function balanced(s: string): boolean {
37
+ let depth = 0;
38
+ for (const ch of s) {
39
+ if (ch === '(') depth++;
40
+ else if (ch === ')') { if (--depth < 0) return false; }
41
+ }
42
+ return depth === 0;
43
+ }
44
+
45
+ /** Split on a top-level keyword (OR / AND), respecting parentheses. */
46
+ function splitTop(s: string, keyword: 'OR' | 'AND'): string[] {
47
+ const parts: string[] = [];
48
+ let depth = 0;
49
+ let last = 0;
50
+ const re = new RegExp(`\\b${keyword}\\b`, 'gi');
51
+ for (let i = 0; i < s.length; i++) {
52
+ if (s[i] === '(') depth++;
53
+ else if (s[i] === ')') depth--;
54
+ else if (depth === 0) {
55
+ re.lastIndex = i;
56
+ const m = re.exec(s);
57
+ if (m && m.index === i) {
58
+ parts.push(s.slice(last, i));
59
+ i += m[0].length - 1;
60
+ last = i + 1;
61
+ }
62
+ }
63
+ }
64
+ parts.push(s.slice(last));
65
+ return parts.map((p) => p.trim()).filter(Boolean);
66
+ }
67
+
68
+ /** One comparison/atom -> a phrase. Falls back to the raw SQL in backticks. */
69
+ function ownerOf(col: string): string {
70
+ return `owner (\`${col.replace(/"/g, '')}\`)`;
71
+ }
72
+
73
+ function humanizeAtom(atom: string): string {
74
+ const a = unwrap(atom);
75
+ if (/^true$/i.test(a)) return 'any row';
76
+ if (/^false$/i.test(a)) return 'no row';
77
+
78
+ // <col> IN ( SELECT ... FROM <parent> WHERE ... <current user> ... ) — parent-visibility
79
+ // inheritance: you may touch this row when you own the parent it links to.
80
+ let m = a.match(/^([\w".]+)\s+IN\s*\(\s*SELECT[\s\S]*?\bFROM\s+([\w".]+)/i);
81
+ if (m && isCurrentUserExpr(a)) {
82
+ return `owner of linked \`${m[2].replace(/"/g, '')}\` (via \`${m[1].replace(/"/g, '')}\`)`;
83
+ }
84
+
85
+ // <col> = <current-user-id-expr> (in either order) -> ownership. Anchored on the
86
+ // column and the start of the identity expression, so the trailing cast/paren noise
87
+ // (`::uuid`, `))`) never matters.
88
+ if (isCurrentUserExpr(a)) {
89
+ m = a.match(/^\(*\s*([\w".]+)\s*=\s*\(*\s*(?:current_setting|auth\.)/i);
90
+ if (m) return ownerOf(m[1]);
91
+ m = a.match(/=\s*([\w".]+)\s*\)*$/);
92
+ if (m) return ownerOf(m[1]);
93
+ // A subquery / EXISTS keyed on the current user that we can't reduce cleanly — be
94
+ // honest that it depends on the user via related data, and point at the JSON.
95
+ if (/\bselect\b|\bexists\b/i.test(a)) return 'depends on the signed-in user (via a subquery — see JSON)';
96
+ return 'the current user';
97
+ }
98
+
99
+ // a bare boolean column used as a predicate (e.g. `active`) means "<col> is true"
100
+ if (/^[\w".]+$/.test(a)) return `\`${a.replace(/"/g, '')}\` is true`;
101
+
102
+ // soft-delete / null guards
103
+ m = a.match(/^([\w".]+)\s+IS\s+NULL$/i);
104
+ if (m) return /delet/i.test(m[1]) ? 'not deleted' : `\`${m[1].replace(/"/g, '')}\` is empty`;
105
+ m = a.match(/^([\w".]+)\s+IS\s+NOT\s+NULL$/i);
106
+ if (m) return `\`${m[1].replace(/"/g, '')}\` is set`;
107
+
108
+ // simple equality / membership against a literal
109
+ m = a.match(/^([\w".]+)\s*=\s*('[^']*'|\w+)$/i);
110
+ if (m) return `\`${m[1].replace(/"/g, '')}\` = ${m[2]}`;
111
+ m = a.match(/^([\w".]+)\s+IN\s+(\(.+\))$/i);
112
+ if (m) return `\`${m[1].replace(/"/g, '')}\` in ${m[2]}`;
113
+
114
+ return `\`${a}\``;
115
+ }
116
+
117
+ /**
118
+ * Translate an RLS predicate to a plain-English phrase. null/empty -> "any row".
119
+ * Handles OR / AND combinations of the recognised atoms; unknown atoms are shown raw.
120
+ */
121
+ export function humanizePredicate(sql: string | null): string {
122
+ if (!sql || !sql.trim()) return 'any row';
123
+ const expr = unwrap(sql);
124
+
125
+ const ors = splitTop(expr, 'OR');
126
+ if (ors.length > 1) return ors.map(renderConj).join(' OR ');
127
+ return renderConj(expr);
128
+ }
129
+
130
+ function renderConj(s: string): string {
131
+ const ands = splitTop(s, 'AND');
132
+ if (ands.length > 1) return ands.map(humanizeAtom).join(' AND ');
133
+ return humanizeAtom(s);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // The matrix renderer.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const ACTIONS: { label: string; command: string; clause: 'using' | 'check' }[] = [
141
+ { label: 'Read', command: 'SELECT', clause: 'using' },
142
+ { label: 'Create', command: 'INSERT', clause: 'check' },
143
+ { label: 'Update', command: 'UPDATE', clause: 'using' },
144
+ { label: 'Delete', command: 'DELETE', clause: 'using' },
145
+ ];
146
+ const COMMAND_PRIVILEGE: Record<string, string> = { SELECT: 'SELECT', INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE' };
147
+
148
+ /** Roles to show as rows: the table's grantees (and column-grantees), minus PUBLIC. */
149
+ function tableRoles(t: TableContract): string[] {
150
+ const roles = new Set<string>();
151
+ Object.keys(t.grants).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
152
+ Object.keys(t.columnGrants ?? {}).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
153
+ return [...roles].sort();
154
+ }
155
+
156
+ function policiesFor(t: TableContract, role: string, command: string): { permissive: PolicyContract[]; restrictive: PolicyContract[] } {
157
+ const applies = (p: PolicyContract) =>
158
+ (p.command === command || p.command === 'ALL') &&
159
+ (p.roles.includes(role) || p.roles.includes('public'));
160
+ return {
161
+ permissive: t.policies.filter((p) => applies(p) && p.permissive),
162
+ restrictive: t.policies.filter((p) => applies(p) && !p.permissive),
163
+ };
164
+ }
165
+
166
+ /** One matrix cell for (table, role, action). */
167
+ export function renderCell(t: TableContract, role: string, action: typeof ACTIONS[number]): string {
168
+ const priv = COMMAND_PRIVILEGE[action.command];
169
+ if (!hasPrivilege(t, role, priv)) return '—';
170
+ if (!t.rls.enabled) return '✓ all';
171
+
172
+ const { permissive, restrictive } = policiesFor(t, role, action.command);
173
+ if (permissive.length === 0) return '⚠ no policy';
174
+
175
+ const conds = permissive.map((p) => humanizePredicate(p[action.clause]));
176
+ let cell = conds.some((c) => c === 'any row') ? '✓ all' : `✓ ${[...new Set(conds)].join(' OR ')}`;
177
+ if (restrictive.length) {
178
+ cell += ` (only if ${restrictive.map((p) => humanizePredicate(p[action.clause])).join(' AND ')})`;
179
+ }
180
+ // Column-restricted read/write: note the columns when access is column-scoped only.
181
+ const cols = t.columnGrants?.[role]?.[priv];
182
+ if (cols?.length && !(t.grants[role] ?? []).includes(priv)) {
183
+ cell += ` — cols: ${cols.join(', ')}`;
184
+ }
185
+ return cell;
186
+ }
187
+
188
+ /** Render the whole contract as a human-readable Markdown review document. */
189
+ export function renderContractMarkdown(contract: AuthzContract): string {
190
+ const policyCount = contract.tables.reduce((n, t) => n + t.policies.length, 0);
191
+ const lines: string[] = [];
192
+
193
+ lines.push('# Authorization Review');
194
+ lines.push('');
195
+ lines.push('> The human-readable view of `authz/*.contract.json`. Read it to confirm the rules');
196
+ lines.push('> match intent — the JSON is the machine-checked source, this is generated from it.');
197
+ lines.push('> Regenerate with `everystack db:authz:report`; never hand-edit.');
198
+ lines.push('');
199
+ lines.push(`_${contract.tables.length} tables · ${policyCount} policies · ${contract.functions.length} SECURITY DEFINER functions._`);
200
+ lines.push('');
201
+ lines.push('**Legend:**');
202
+ lines.push('');
203
+ lines.push('- **✓** — allowed, followed by the condition a row must satisfy');
204
+ lines.push('- **—** — not granted');
205
+ lines.push('- **⚠ no policy** — the privilege is granted but no RLS policy matches, so no rows are returned (usually a mistake)');
206
+ lines.push('- **owner (`col`)** — the row\'s `col` equals the signed-in user');
207
+ lines.push('- **owner of linked `t` (via `col`)** — you may act when you own the `t` row that `col` points to');
208
+ lines.push('');
209
+
210
+ for (const t of contract.tables) {
211
+ const posture = t.rls.enabled ? (t.rls.forced ? 'enforced (forced)' : 'enabled (not forced)') : '**OFF — no row security**';
212
+ lines.push(`## ${t.table}`);
213
+ lines.push(`RLS: ${posture}`);
214
+ lines.push('');
215
+ const roles = tableRoles(t);
216
+ if (roles.length === 0) {
217
+ lines.push('_No roles granted any access._');
218
+ lines.push('');
219
+ continue;
220
+ }
221
+ lines.push(`| Role | ${ACTIONS.map((a) => a.label).join(' | ')} |`);
222
+ lines.push(`|---|${ACTIONS.map(() => '---').join('|')}|`);
223
+ for (const role of roles) {
224
+ lines.push(`| \`${role}\` | ${ACTIONS.map((a) => renderCell(t, role, a)).join(' | ')} |`);
225
+ }
226
+ lines.push('');
227
+ }
228
+
229
+ if (contract.functions.length) {
230
+ lines.push('## SECURITY DEFINER functions');
231
+ lines.push('');
232
+ lines.push('These run with their owner\'s privileges, **above row security** — their own bodies enforce access. Review each one deliberately. A missing `search_path` is a hijack vector; an owner that bypasses RLS (superuser / BYPASSRLS) makes the function run above RLS as that role — the maximum blast radius. `db:migrate` often leaves functions superuser-owned; normalize ownership to a non-superuser operator.');
233
+ lines.push('');
234
+ const elevated = contract.functions.filter((f) => f.ownerBypassesRls);
235
+ if (elevated.length) {
236
+ lines.push(`> **⚠ ${elevated.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Normalize their ownership.`);
237
+ lines.push('');
238
+ }
239
+ for (const f of contract.functions) {
240
+ const sp = f.hasSearchPath ? 'search_path pinned ✓' : '**search_path NOT pinned ⚠**';
241
+ const own = f.ownerBypassesRls ? ' · **owner bypasses RLS ⚠**' : '';
242
+ lines.push(`- \`${f.name}\` — ${sp}${own}`);
243
+ }
244
+ lines.push('');
245
+ }
246
+
247
+ return lines.join('\n');
248
+ }