@everystack/cli 0.4.36 → 0.4.39

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,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `everystack` entry point.
4
+ *
5
+ * This package ships TypeScript source deliberately — every `exports` target is a
6
+ * `./src/*.ts` file — so the CLI needs a TypeScript loader at runtime. It used to get one
7
+ * from `#!/usr/bin/env tsx` on `src/cli/index.ts`, which cannot work once the package is
8
+ * installed: `env` searches the CONSUMER's PATH, and tsx is a dependency of
9
+ * @everystack/cli, so under pnpm's isolated layout it lives in this package's own
10
+ * node_modules and is never on that PATH. `everystack --help` died with
11
+ * "env: tsx: No such file or directory"; only `pnpm exec everystack` worked, because that
12
+ * puts the local .bin on PATH first.
13
+ *
14
+ * So: boot under plain `node` — always present, it is what runs npm — and find tsx by
15
+ * MODULE RESOLUTION from this file rather than by PATH lookup.
16
+ *
17
+ * The loader is installed by re-executing node with `--import`, not by calling tsx's
18
+ * register() in-process. register() followed by `import()` of the TypeScript entry makes
19
+ * the entry load through a require(esm) path and Node rejects it with
20
+ * ERR_REQUIRE_CYCLE_MODULE. `--import` installs the hooks before any module graph exists,
21
+ * which is the only ordering that works. The extra process is the price of shipping
22
+ * source, and it is paid once per invocation.
23
+ */
24
+ import { createRequire } from 'node:module';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { pathToFileURL, fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ let tsx;
31
+ try {
32
+ tsx = pathToFileURL(require.resolve('tsx')).href;
33
+ } catch {
34
+ console.error('everystack: could not resolve the "tsx" TypeScript loader from this package.');
35
+ console.error('This usually means a partial install — try reinstalling @everystack/cli.');
36
+ process.exit(1);
37
+ }
38
+
39
+ const entry = fileURLToPath(new URL('../src/cli/index.ts', import.meta.url));
40
+
41
+ const result = spawnSync(process.execPath, ['--import', tsx, entry, ...process.argv.slice(2)], {
42
+ stdio: 'inherit',
43
+ });
44
+
45
+ // Re-raise a signal death as a signal death so `everystack … &` + Ctrl-C behaves, and
46
+ // otherwise pass the child's exit code through unchanged — the CLI's non-zero exits are
47
+ // load-bearing (db:check, db:authz:diff and friends are CI gates).
48
+ if (result.signal) {
49
+ process.kill(process.pid, result.signal);
50
+ } else {
51
+ process.exit(result.status ?? 1);
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.36",
3
+ "version": "0.4.39",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -17,6 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "files": [
20
+ "bin",
20
21
  "src",
21
22
  "README.md"
22
23
  ],
@@ -91,7 +92,7 @@
91
92
  }
92
93
  },
93
94
  "bin": {
94
- "everystack": "./src/cli/index.ts"
95
+ "everystack": "./bin/everystack.mjs"
95
96
  },
96
97
  "dependencies": {
97
98
  "@aws-sdk/client-cloudfront": "3.1053.0",
@@ -108,10 +109,10 @@
108
109
  "structured-headers": "1.0.1",
109
110
  "tsx": "4.21.0",
110
111
  "typescript": "5.9.3",
111
- "@everystack/model": "0.4.5"
112
+ "@everystack/model": "0.4.6"
112
113
  },
113
114
  "peerDependencies": {
114
- "@everystack/server": ">=0.1.0",
115
+ "@everystack/server": ">=0.4.0",
115
116
  "@aws-sdk/client-cloudwatch": "3.1053.0",
116
117
  "@aws-sdk/client-rds": "3.1053.0",
117
118
  "@aws-sdk/s3-request-presigner": "3.1053.0",
@@ -210,6 +210,34 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
210
210
  return Object.keys(out).length ? out : undefined;
211
211
  }
212
212
 
213
+ /**
214
+ * Unwrap a `sql` fragment into predicate text.
215
+ *
216
+ * `where` / `sql` / `check` are typed `unknown` because the model package brands its
217
+ * fragments (`RawSql`) rather than accepting bare strings — a predicate must be written
218
+ * deliberately, never assembled by accident. Anything else THROWS: these conditions were
219
+ * declared and documented long before they were compiled, so a model could carry a
220
+ * `sql:` predicate that silently did nothing. Failing loudly is the fix; a predicate that
221
+ * evaporates is an authorization hole wearing the costume of a working rule.
222
+ */
223
+ function rawPredicate(value: unknown, where: string): string | null {
224
+ if (value == null) return null;
225
+ const text = (value as { sql?: unknown }).sql;
226
+ if (typeof text !== 'string' || !text.trim()) {
227
+ throw new Error(
228
+ `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
229
+ );
230
+ }
231
+ return `(${text.trim()})`;
232
+ }
233
+
234
+ /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
235
+ function andPredicates(...parts: (string | null)[]): string | null {
236
+ const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
237
+ if (real.length === 0) return null;
238
+ return real.length === 1 ? real[0] : `(${real.join(' AND ')})`;
239
+ }
240
+
213
241
  // ---------------------------------------------------------------------------
214
242
  // Compile.
215
243
  // ---------------------------------------------------------------------------
@@ -238,6 +266,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
238
266
  // A "row-scoped" condition is either a direct owner or a transitive via.
239
267
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
240
268
 
269
+ // Author-supplied predicates, per action. `manage` carries no predicate of its own —
270
+ // it is the admin bypass — so only the specific verbs are consulted.
271
+ const predFor = (action: Ability['action']): string | null => {
272
+ for (const a of model.abilities) {
273
+ if (a.action !== action) continue;
274
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
275
+ if (p) return p;
276
+ }
277
+ return null;
278
+ };
279
+ /** The WRITE half, when the author declared one distinct from the read half. */
280
+ const checkFor = (action: Ability['action']): string | null => {
281
+ for (const a of model.abilities) {
282
+ if (a.action !== action) continue;
283
+ const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
284
+ if (p) return p;
285
+ }
286
+ return null;
287
+ };
288
+
241
289
  const abilities = model.abilities;
242
290
  const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
243
291
  const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
@@ -264,7 +312,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
264
312
  if (hasPublicRead) {
265
313
  // public read on a soft-delete table — split per role; authenticated owners
266
314
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
267
- const anonUsing = sdGuard ?? 'true';
315
+ const readPred = predFor('read');
316
+ const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
268
317
  policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
269
318
 
270
319
  // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
@@ -272,12 +321,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
272
321
  // condition on a WRITE ability used to narrow the authenticated SELECT to
273
322
  // own-rows-only, so anon saw the whole table and a signed-in user lost it.
274
323
  // Without a soft-delete guard, `true OR owner` is just `true`.
275
- const authedUsing = sdGuard && rowPred ? `(${sdGuard} OR ${rowPred})` : anonUsing;
324
+ const authedUsing = sdGuard && rowPred
325
+ ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
326
+ : anonUsing;
276
327
  policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
277
328
  } else if (hasOwnerRead && rowPred) {
278
329
  // owner-scoped read — no anon visibility; you see only the rows you own
279
330
  // (directly, or transitively through a `via:` parent).
280
- policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
331
+ policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
281
332
  } else if (hasColumnRead && rowPred) {
282
333
  // column-scoped self read — `authenticated` reads only its OWN row, and only the
283
334
  // granted columns (the GRANT scopes fields; this policy scopes rows). Named
@@ -287,10 +338,23 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
287
338
 
288
339
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
289
340
  // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
290
- if (rowPred) {
291
- if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, rowPred);
292
- if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
293
- if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], rowPred, null);
341
+ //
342
+ // A declared `check` is the WITH CHECK half; without one the read half governs both
343
+ // (Postgres' own default). The two differ when a row may be written into a shape its
344
+ // author could not have selected by — the direction that matters is a check WEAKER than
345
+ // the using, which lets an owner move their own row past whatever the using guarded.
346
+ const createPred = andPredicates(rowPred, predFor('create'));
347
+ const updatePred = andPredicates(rowPred, predFor('update'));
348
+ const deletePred = andPredicates(rowPred, predFor('delete'));
349
+
350
+ if (canCreate && createPred) {
351
+ policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, checkFor('create') ?? createPred);
352
+ }
353
+ if ((hasUpdateOwner || predFor('update')) && updatePred) {
354
+ policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
355
+ }
356
+ if ((hasDeleteOwner || predFor('delete')) && deletePred) {
357
+ policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
294
358
  }
295
359
 
296
360
  policies.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,210 @@
1
+ /**
2
+ * authz-derive — live authorization → declared `can()` abilities. The brownfield on-ramp's
3
+ * missing half.
4
+ *
5
+ * `db:pull` already renders fields, indexes and relations from a live database, but left
6
+ * authz as commented scaffolding: every adopter hand-transcribed rules that were sitting in
7
+ * the catalog, which is the single highest-risk manual step in a migration and the one
8
+ * place a human introduces drift.
9
+ *
10
+ * The rule that governs every decision here:
11
+ *
12
+ * **policy presence ≠ effective privilege.**
13
+ *
14
+ * Postgres checks the GRANT before it checks the policy. A table can carry a perfectly
15
+ * good UPDATE policy and still be un-updatable, because no role was ever granted UPDATE —
16
+ * the policy is dead code. Rendering that policy as `can('update', { owner })` would
17
+ * compile to a policy PLUS a table-wide `GRANT UPDATE`, manufacturing a privilege the
18
+ * database does not currently give. On a real schema that path was a moderation bypass:
19
+ * the live USING carried a state guard the WITH CHECK did not, so handing out the grant
20
+ * would have let owners publish their own drafts.
21
+ *
22
+ * So an ability is emitted only where the grant and the policy AGREE. Everything else
23
+ * becomes a comment that names what was found and why it was not rendered — the same
24
+ * honesty the view-skip message already practices. A false ability is worse than a
25
+ * comment: the comment costs a human five minutes, the ability costs them a privilege.
26
+ */
27
+
28
+ import type { TableContract, PolicyContract } from './authz-contract.js';
29
+
30
+ /** One rendered decision: either an ability line, or a comment explaining the omission. */
31
+ export interface DerivedAbilities {
32
+ /** `can(...)` lines, ready to sit inside `abilities: [ ... ]`. */
33
+ abilities: string[];
34
+ /** `//` comment lines naming what could not be rendered, and why. */
35
+ notes: string[];
36
+ /**
37
+ * Roles holding grants that the model vocabulary has no name for (anything outside
38
+ * anon/authenticated/admin) — an operator or reporting role, typically.
39
+ *
40
+ * Returned rather than written into `notes` because it is usually the SAME role on every
41
+ * table: a migrator or ops account granted across the schema. Repeating that fact once
42
+ * per model buries the per-table findings that are actually specific. The caller states
43
+ * it once, for the whole pull.
44
+ */
45
+ unmappedRoles: string[];
46
+ }
47
+
48
+ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
49
+ SELECT: 'read',
50
+ INSERT: 'create',
51
+ UPDATE: 'update',
52
+ DELETE: 'delete',
53
+ };
54
+
55
+ /** The roles the compiler itself emits policies for; anything else is app-specific. */
56
+ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
57
+
58
+ /**
59
+ * The deparsed owner predicate the compiler produces, in both its casts:
60
+ * (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
61
+ * Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
62
+ * instead of an opaque `sql:` fragment. Anything unrecognised stays verbatim rather than
63
+ * being guessed at.
64
+ */
65
+ const OWNER_RE =
66
+ /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
67
+
68
+ /** Does this predicate say "the row is mine"? Returns the column and the claim if so. */
69
+ export function parseOwnerPredicate(pred: string | null): { column: string; claim: string } | null {
70
+ if (!pred) return null;
71
+ const m = OWNER_RE.exec(pred.trim());
72
+ return m ? { column: m[1], claim: m[2] } : null;
73
+ }
74
+
75
+ /** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
76
+ function granted(contract: TableContract, role: string, privilege: string): boolean {
77
+ const direct = contract.grants[role] ?? [];
78
+ const publicGrant = contract.grants.PUBLIC ?? contract.grants.public ?? [];
79
+ return direct.includes(privilege) || publicGrant.includes(privilege);
80
+ }
81
+
82
+ /** The policies that apply to a role for a command (an `ALL` policy covers every command). */
83
+ function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
84
+ return contract.policies.filter(
85
+ (p) => (p.command === command || p.command === 'ALL') && (p.roles.includes(role) || p.roles.includes('public')),
86
+ );
87
+ }
88
+
89
+ /** A sql`…` fragment literal, or null when the predicate is vacuous. */
90
+ function sqlFragment(pred: string | null): string | null {
91
+ if (!pred) return null;
92
+ const t = pred.trim();
93
+ if (t === 'true' || t === '(true)') return null;
94
+ return `sql\`${t.replace(/`/g, '\\`')}\``;
95
+ }
96
+
97
+ /**
98
+ * Derive the ability stanza for ONE table from its live contract.
99
+ *
100
+ * Deliberately conservative. Every branch that cannot prove what it would emit falls
101
+ * through to `notes` rather than guessing — the caller renders those as comments beside
102
+ * the model, so the human sees the real rule and decides.
103
+ */
104
+ export function deriveAbilities(contract: TableContract): DerivedAbilities {
105
+ const abilities: string[] = [];
106
+ const notes: string[] = [];
107
+ const table = contract.table;
108
+
109
+ // --- the admin bypass: one role holding every privilege ------------------------------
110
+ const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
111
+ if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
112
+
113
+ // --- dead policies: policed but never granted ----------------------------------------
114
+ // The trap. Rendering one of these would ADD a privilege that does not exist today.
115
+ for (const p of contract.policies) {
116
+ const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
117
+ for (const cmd of commands) {
118
+ const priv = cmd;
119
+ const live = p.roles.filter((r) => r !== 'public' && r !== 'admin' && !granted(contract, r, priv));
120
+ if (live.length && p.roles.some((r) => r !== 'admin')) {
121
+ notes.push(
122
+ `policy "${p.name}" (${cmd}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
123
+ );
124
+ notes.push(
125
+ ` it is dead code today. Declaring it would ADD a privilege the database does not give.`,
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ // --- reads ---------------------------------------------------------------------------
132
+ const anonRead = granted(contract, 'anon', 'SELECT');
133
+ const authedRead = granted(contract, 'authenticated', 'SELECT');
134
+
135
+ if (anonRead) {
136
+ // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
137
+ // carry that predicate — a public read with a filter is still a public read.
138
+ const pol = policiesFor(contract, 'anon', 'SELECT')[0];
139
+ const frag = sqlFragment(pol?.using ?? null);
140
+ abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
141
+ } else if (authedRead) {
142
+ const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
143
+ const owner = parseOwnerPredicate(pol?.using ?? null);
144
+ if (owner) {
145
+ const claim = owner.claim === 'sub' ? '' : `, userField: '${owner.claim}'`;
146
+ abilities.push(`can('read', { owner: '${owner.column}'${claim} })`);
147
+ } else {
148
+ const frag = sqlFragment(pol?.using ?? null);
149
+ abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
150
+ }
151
+ }
152
+
153
+ // --- writes --------------------------------------------------------------------------
154
+ for (const [command, action] of Object.entries(DML)) {
155
+ if (action === 'read') continue;
156
+ if (!granted(contract, 'authenticated', command)) continue;
157
+
158
+ const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
159
+ ?? policiesFor(contract, 'authenticated', command)[0];
160
+ const using = pol?.using ?? null;
161
+ const check = pol?.check ?? null;
162
+ const owner = parseOwnerPredicate(using) ?? parseOwnerPredicate(check);
163
+
164
+ const parts: string[] = [];
165
+ if (owner) {
166
+ parts.push(`owner: '${owner.column}'`);
167
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
168
+ }
169
+ // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
170
+ const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
171
+ if (usingFrag) parts.push(`sql: ${usingFrag}`);
172
+ // The WRITE half, only when it genuinely differs. Postgres defaults it to USING, and
173
+ // emitting it unconditionally would be noise that also defeats the round-trip diff.
174
+ if (check && check !== using) {
175
+ const checkFrag = sqlFragment(check);
176
+ if (checkFrag) parts.push(`check: ${checkFrag}`);
177
+ }
178
+
179
+ if (!parts.length && !pol) {
180
+ // Granted with no policy at all. Under RLS that is a table nobody can actually
181
+ // reach; without RLS it is a wide-open privilege. Either way, say so.
182
+ notes.push(`${command} is granted to authenticated with no policy — ${contract.rls.enabled ? 'RLS is on, so no row qualifies' : 'RLS is OFF: this is unrestricted'}.`);
183
+ continue;
184
+ }
185
+ abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
186
+ }
187
+
188
+ // --- roles the compiler has no vocabulary for ----------------------------------------
189
+ const unmappedRoles = Object.keys(contract.grants).filter(
190
+ (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
191
+ );
192
+
193
+ if (!abilities.length && !notes.length && !unmappedRoles.length) {
194
+ notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
195
+ }
196
+
197
+ return { abilities, notes, unmappedRoles };
198
+ }
199
+
200
+ /** Render one table's derived stanza as the lines that sit inside a model literal. */
201
+ export function renderDerivedAbilities(d: DerivedAbilities): string {
202
+ const out: string[] = [];
203
+ for (const n of d.notes) out.push(` // ${n}`);
204
+ if (d.abilities.length) {
205
+ out.push(` abilities: [${d.abilities.join(', ')}],`);
206
+ } else {
207
+ out.push(` private: true, // no effective privilege found — not part of the data API`);
208
+ }
209
+ return out.join('\n');
210
+ }
@@ -29,7 +29,8 @@ export type ProbeOutcome =
29
29
  | 'allowed' // the privilege is present (RLS may still filter rows)
30
30
  | 'denied-privilege' // no grant: "permission denied for table"
31
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
32
+ | 'no-impersonate' // the probing role could not SET ROLE into this role
33
+ | 'inconclusive'; // the statement failed BEFORE the privilege check — proves nothing
33
34
 
34
35
  export interface ProbeResult {
35
36
  role: string;
@@ -39,7 +40,7 @@ export interface ProbeResult {
39
40
  }
40
41
 
41
42
  export interface RedTeamFinding {
42
- severity: 'hole' | 'broken' | 'unprobed';
43
+ severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
43
44
  role: string;
44
45
  table: string;
45
46
  command: ProbeCommand;
@@ -85,8 +86,15 @@ function pgArrayLiteral(values: string[]): string {
85
86
  * INSERT INTO t DEFAULT VALUES — INSERT privilege (rolled back; NOT NULL = allowed)
86
87
  * UPDATE t SET col = col WHERE false — UPDATE privilege, touches no rows
87
88
  * 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.
89
+ * "permission denied" -> denied-privilege; "row-level security" -> denied-rls.
90
+ *
91
+ * Any OTHER error needs care, and getting this wrong is how the tool cries wolf. A
92
+ * constraint or NOT NULL violation happens AFTER the permission check, so it proves the
93
+ * privilege was present -> allowed. But a statement rejected at parse/analysis time never
94
+ * reached the permission check at all and proves NOTHING -> inconclusive. Classifying the
95
+ * second kind as `allowed` is what reported `anon` as able to UPDATE a table it cannot
96
+ * touch: the probe's `SET pk = pk` hit 428C9 ("can only be updated to DEFAULT") on an
97
+ * identity PK, and a non-grant then read as "enforcement exceeds declaration".
90
98
  */
91
99
  export function buildProbeSql(roles: string[], tables: string[]): string {
92
100
  return `
@@ -111,9 +119,20 @@ BEGIN
111
119
  WHEN 'UPDATE' THEN NULL
112
120
  END;
113
121
  IF v_cmd = 'UPDATE' THEN
122
+ -- Prefer a column the statement can actually SET. An identity or generated
123
+ -- column rejects "SET col = col" at analysis time (428C9), before the permission
124
+ -- check — which makes the probe prove nothing. Fall back to the first column
125
+ -- only when every column is generated; that run reports inconclusive.
114
126
  SELECT quote_ident(attname) INTO v_col FROM pg_attribute
115
127
  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;
128
+ AND attnum > 0 AND NOT attisdropped
129
+ AND attidentity = '' AND attgenerated = ''
130
+ ORDER BY attnum LIMIT 1;
131
+ IF v_col IS NULL THEN
132
+ SELECT quote_ident(attname) INTO v_col FROM pg_attribute
133
+ WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
134
+ AND attnum > 0 AND NOT attisdropped ORDER BY attnum LIMIT 1;
135
+ END IF;
117
136
  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
137
  END IF;
119
138
 
@@ -126,7 +145,21 @@ BEGIN
126
145
  WHEN insufficient_privilege THEN
127
146
  v_outcome := CASE WHEN SQLERRM ILIKE '%row-level security%' THEN 'denied-rls' ELSE 'denied-privilege' END;
128
147
  WHEN OTHERS THEN
129
- v_outcome := 'allowed'; -- privilege passed; failed on data/constraint
148
+ -- Did this error happen BEFORE the permission check, or after it?
149
+ -- Before (parse/analysis): the probe proves nothing about the privilege.
150
+ -- After (data/constraint): the privilege was present.
151
+ v_outcome := CASE
152
+ WHEN SQLSTATE IN (
153
+ '428C9', -- generated_always: identity/generated column (the probe's own SET pk = pk)
154
+ '42703', -- undefined_column
155
+ '42P01', -- undefined_table
156
+ '42601', -- syntax_error
157
+ '42P10', -- invalid_column_reference
158
+ '42804', -- datatype_mismatch
159
+ '0A000' -- feature_not_supported (e.g. writable-view restrictions)
160
+ ) THEN 'inconclusive'
161
+ ELSE 'allowed'
162
+ END;
130
163
  END;
131
164
  RESET ROLE;
132
165
  EXCEPTION WHEN insufficient_privilege THEN
@@ -205,6 +238,67 @@ export function toGrantGap(row: any): GrantGap {
205
238
  }
206
239
 
207
240
  /** Map a probe result row (from the SELECT) into a typed ProbeResult. */
241
+ /** A privilege a role holds only by MEMBERSHIP in another role, not by a direct grant. */
242
+ export interface InheritedGrant {
243
+ role: string;
244
+ table: string; // schema-qualified
245
+ command: ProbeCommand;
246
+ via: string; // the ancestor role(s) supplying it
247
+ }
248
+
249
+ /**
250
+ * Why does a role hold a privilege the contract never granted it?
251
+ *
252
+ * The contract's grants come from the table ACL, which records DIRECT grants. Effective
253
+ * privilege also flows through role membership (`GRANT parent TO child` with INHERIT), and
254
+ * the probe measures the effective answer. The gap is real but reads as noise: on a real
255
+ * brownfield schema a migrator role that inherits `postgres` reported 20 undeclared
256
+ * privileges — 5 tables × 4 commands — when the whole story is one line about the role.
257
+ *
258
+ * This attributes each such privilege to the ancestor that supplies it, so the finding can
259
+ * be stated once per role instead of once per table × command.
260
+ */
261
+ export function buildInheritedPrivilegeSql(roles: string[], tables: string[]): string {
262
+ return `
263
+ WITH RECURSIVE anc AS (
264
+ SELECT am.member AS member, am.roleid AS ancestor
265
+ FROM pg_auth_members am
266
+ JOIN pg_roles child ON child.oid = am.member AND child.rolinherit
267
+ UNION
268
+ SELECT a.member, am2.roleid
269
+ FROM anc a
270
+ JOIN pg_auth_members am2 ON am2.member = a.ancestor
271
+ JOIN pg_roles mid ON mid.oid = a.ancestor AND mid.rolinherit
272
+ )
273
+ SELECT r.rolname AS role,
274
+ (c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
275
+ p.priv AS command,
276
+ string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
277
+ FROM pg_class c
278
+ CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
279
+ JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
280
+ JOIN anc ON anc.member = r.oid
281
+ JOIN pg_roles ar ON ar.oid = anc.ancestor
282
+ WHERE (c.relnamespace::regnamespace::text || '.' || c.relname) = ANY(${pgArrayLiteral(tables)})
283
+ AND has_table_privilege(r.oid, c.oid, p.priv)
284
+ AND has_table_privilege(ar.oid, c.oid, p.priv)
285
+ AND NOT EXISTS (
286
+ SELECT 1 FROM aclexplode(c.relacl) a
287
+ WHERE a.privilege_type = p.priv AND a.grantee IN (r.oid, 0)
288
+ )
289
+ GROUP BY 1, 2, 3
290
+ `;
291
+ }
292
+
293
+ export function toInheritedGrant(row: any): InheritedGrant {
294
+ return {
295
+ role: String(row.role),
296
+ table: String(row.table),
297
+ command: String(row.command).toUpperCase() as ProbeCommand,
298
+ via: String(row.via),
299
+ };
300
+ }
301
+
208
302
  export function toProbeResult(row: any): ProbeResult {
209
303
  return {
210
304
  role: String(row.role),
@@ -226,9 +320,17 @@ export function toProbeResult(row: any): ProbeResult {
226
320
  * denied-rls counts as the privilege being present (RLS filtering rows is the policy's
227
321
  * job, exercised behaviourally by the RPC layer, not a grant-level finding).
228
322
  */
229
- export function evaluateRedTeam(contract: AuthzContract, results: ProbeResult[]): RedTeamFinding[] {
323
+ export function evaluateRedTeam(
324
+ contract: AuthzContract,
325
+ results: ProbeResult[],
326
+ inherited: InheritedGrant[] = [],
327
+ ): RedTeamFinding[] {
230
328
  const tablesByName = new Map(contract.tables.map((t) => [t.table, t]));
231
329
  const findings: RedTeamFinding[] = [];
330
+ // Privileges explained by role membership rather than a direct grant. Keyed so the
331
+ // per-result lookup is exact; reported once per role, not once per table × command.
332
+ const inheritedBy = new Map(inherited.map((g) => [`${g.role}${g.table}${g.command}`, g]));
333
+ const inheritedRoles = new Map<string, { via: string; count: number }>();
232
334
 
233
335
  for (const r of results) {
234
336
  const table = tablesByName.get(r.table);
@@ -239,13 +341,34 @@ export function evaluateRedTeam(contract: AuthzContract, results: ProbeResult[])
239
341
  if (r.outcome === 'no-impersonate') {
240
342
  findings.push({ severity: 'unprobed', role: r.role, table: r.table, command: r.command,
241
343
  detail: `could not SET ROLE into ${r.role} — grant the probe connection membership to cover it` });
344
+ } else if (r.outcome === 'inconclusive') {
345
+ // The statement never reached the permission check, so this proves nothing either
346
+ // way. Reported so it is visible, never counted as a pass or a hole.
347
+ findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
348
+ detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
242
349
  } 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)` });
350
+ const via = inheritedBy.get(`${r.role}${r.table}${r.command}`);
351
+ if (via) {
352
+ // Real, but it is one fact about a role, not N facts about N tables.
353
+ const seen = inheritedRoles.get(r.role);
354
+ if (seen) seen.count += 1;
355
+ else inheritedRoles.set(r.role, { via: via.via, count: 1 });
356
+ } else {
357
+ findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
358
+ detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
359
+ }
245
360
  } else if (!allowed && granted) {
246
361
  findings.push({ severity: 'broken', role: r.role, table: r.table, command: r.command,
247
362
  detail: `contract grants ${r.role} ${r.command} on ${r.table} but the database denied it (declared but not enforced)` });
248
363
  }
249
364
  }
365
+
366
+ // One line per inheriting role. The membership IS the finding — a role that reaches
367
+ // tables through `GRANT parent TO child` is worth stating out loud once, and worth not
368
+ // stating N times.
369
+ for (const [role, { via, count }] of inheritedRoles) {
370
+ findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
371
+ detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
372
+ }
250
373
  return findings;
251
374
  }