@everystack/cli 0.4.38 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
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>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.5"
112
+ "@everystack/model": "0.4.6"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.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
  }
@@ -40,6 +40,8 @@ import {
40
40
  evaluateRedTeam,
41
41
  GRANT_COMPLETENESS_SQL,
42
42
  toGrantGap,
43
+ buildInheritedPrivilegeSql,
44
+ toInheritedGrant,
43
45
  } from '../authz-redteam.js';
44
46
  import {
45
47
  buildOwnerProbeSql,
@@ -221,14 +223,18 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
221
223
 
222
224
  let rows: any[];
223
225
  let gapRows: any[];
226
+ let inheritedRows: any[];
224
227
  let venueLabel: string;
225
228
  let venue: AuthzVenue | undefined;
226
229
  try {
227
230
  venue = await resolveVenue(flags);
228
231
  venueLabel = venue.label;
232
+ const roles = probeRoles(contract);
233
+ const tables = contract.tables.map((t) => t.table);
229
234
  step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
230
- const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
231
- rows = await venue.probe(setup, PROBE_SELECT_SQL);
235
+ rows = await venue.probe(buildProbeSql(roles, tables), PROBE_SELECT_SQL);
236
+ step('Attributing undeclared privileges to role membership...');
237
+ inheritedRows = await venue.runner(buildInheritedPrivilegeSql(roles, tables));
232
238
  step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
233
239
  gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
234
240
  } catch (err: any) {
@@ -240,16 +246,26 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
240
246
  await venue?.end();
241
247
  }
242
248
 
243
- const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
249
+ const findings = evaluateRedTeam(contract, rows.map(toProbeResult), inheritedRows.map(toInheritedGrant));
244
250
  const holes = findings.filter((f) => f.severity === 'hole');
245
251
  const broken = findings.filter((f) => f.severity === 'broken');
246
252
  const unprobed = findings.filter((f) => f.severity === 'unprobed');
253
+ const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
254
+ const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
247
255
  const gaps = gapRows.map(toGrantGap);
248
256
 
249
257
  console.log('');
250
258
  for (const f of holes) fail(`[HOLE] ${f.detail}`);
251
259
  for (const f of broken) warn(`[BROKEN] ${f.detail}`);
252
260
  for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
261
+ for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
262
+ if (inconclusive.length) {
263
+ // Never a pass and never a failure — a probe that proved nothing, said out loud so it
264
+ // can be fixed rather than silently counted as clean.
265
+ info(`[inconclusive] ${inconclusive.length} probe(s) failed before the privilege check and prove nothing:`);
266
+ for (const f of inconclusive.slice(0, 5)) info(` ${f.command} ${f.table} (${f.role})`);
267
+ if (inconclusive.length > 5) info(` …and ${inconclusive.length - 5} more`);
268
+ }
253
269
  if (unprobed.length) {
254
270
  const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
255
271
  info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
@@ -12,6 +12,7 @@
12
12
  * is deliberately absent here — `db:reconcile --check` is its verifier.
13
13
  */
14
14
 
15
+ import { existsSync } from 'node:fs';
15
16
  import type { ModelDescriptor } from '@everystack/model';
16
17
  import type { QueryRunner } from '../authz-contract.js';
17
18
  import { introspectSchema } from '../schema-introspect.js';
@@ -78,8 +79,24 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
78
79
  try {
79
80
  models = await loadModels(modelsPath);
80
81
  sequences = (await loadDeclaredDerived(flags.models))?.sequences;
81
- } catch {
82
- // Live-only mode: no models barrel still useful (what fingerprint is this DB at?).
82
+ } catch (err: any) {
83
+ // Two very different situations used to land here identically.
84
+ //
85
+ // No barrel at all is legitimate live-only mode — "what fingerprint is this database
86
+ // at?" is a useful question on its own.
87
+ //
88
+ // A barrel that EXISTS but will not load is not. Reporting "(live-only)" for it turns
89
+ // a broken measurement into a passing one: the operator asked whether the database
90
+ // matches their models, and got an answer that never looked at the models. That is
91
+ // how someone concludes a round-trip closed when nothing was compared.
92
+ if (existsSync(modelsPath)) {
93
+ fail(`Could not load the models barrel at ${modelsPath}`);
94
+ info(String(err?.message ?? err).split('\n').slice(0, 6).join('\n'));
95
+ info('');
96
+ info('Fix the barrel and re-run. (Refusing rather than falling back to live-only —');
97
+ info('a comparison that silently compares nothing is worse than an error.)');
98
+ process.exit(1);
99
+ }
83
100
  }
84
101
 
85
102
  let runner: QueryRunner;