@everystack/cli 0.4.55 → 0.4.56

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.55",
3
+ "version": "0.4.56",
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.14"
112
+ "@everystack/model": "0.4.15"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -265,6 +265,27 @@ export interface FunctionContract {
265
265
  * operator. Pinned search_path is necessary but not sufficient.
266
266
  */
267
267
  ownerBypassesRls?: boolean;
268
+ /**
269
+ * SUPERUSER (or rds_superuser member) specifically — DIFFED.
270
+ *
271
+ * A deliberately-scoped operator role is commonly BYPASSRLS by design, so
272
+ * `ownerBypassesRls` reads `true` both for that correct design and for a function left
273
+ * owned by whoever ran the build. Identical value, wildly different blast radius: a
274
+ * consumer with 60 correctly-owned SECDEF functions saw the same `true` they would see if
275
+ * every one of them had silently moved to a superuser. This separates the two, and it is
276
+ * portable — no role name is compared.
277
+ */
278
+ ownerIsSuperuser?: boolean;
279
+ /**
280
+ * The owning role's NAME — RECORDED AND RENDERED, NEVER DIFFED.
281
+ *
282
+ * The name legitimately differs between a dev machine, a deployed master and a normalized
283
+ * operator, so comparing it would false-fire on every environment — that is why it was
284
+ * originally left out entirely. But omitting it also left a reader unable to tell
285
+ * "owned by our scoped operator" from "owned by a superuser" at a glance. It is carried
286
+ * for reading; `ownerIsSuperuser` is what the gate compares.
287
+ */
288
+ owner?: string;
268
289
  }
269
290
 
270
291
  /** The whole contract: every table and every function, as introspected. */
@@ -280,6 +301,50 @@ export interface AuthzContract {
280
301
  * that as "unknown", never as "no grants".
281
302
  */
282
303
  schemaAcls?: Record<string, Record<string, string[]>>;
304
+ /**
305
+ * The sequences behind serial/identity columns, with their live ACLs.
306
+ *
307
+ * These are INVISIBLE to every other read here: `schema-introspect`'s SEQUENCES_SQL
308
+ * deliberately excludes column-owned sequences (they are an implementation detail of the
309
+ * column, not a declarable object), and GRANTS_SQL covers relkind r/v/m only. So nothing
310
+ * carried their privileges, and a role that could INSERT into a serial-PK table could not
311
+ * draw its sequence value: every INSERT failed `permission denied for sequence`.
312
+ *
313
+ * Absent means NOT MEASURED (older recorder, hand-built fixture) and must never be read as
314
+ * "no sequences" — the emitter skips the whole pass rather than plan revokes against a set
315
+ * it did not read.
316
+ */
317
+ sequenceGrants?: SequenceGrantContract[];
318
+ }
319
+
320
+ /**
321
+ * One serial/identity sequence and the privileges it actually holds.
322
+ *
323
+ * The sequence is not declared anywhere — there is no `sequence:` key in the model and nobody
324
+ * wants one. Its privileges are DERIVED: they follow the owning table's declared grants. So
325
+ * this carries the live half plus the ownership edge that says which table to derive from.
326
+ */
327
+ export interface SequenceGrantContract {
328
+ /** `schema.sequence_name`. */
329
+ sequence: string;
330
+ /** `schema.table` of the column that owns it — whose grants this sequence inherits. */
331
+ table: string;
332
+ /** Grantee → sorted privileges (USAGE | SELECT | UPDATE). Owner excluded, as in GRANTS_SQL. */
333
+ grants: Record<string, string[]>;
334
+ /**
335
+ * The owning column is `GENERATED … AS IDENTITY`, not `serial` — and that changes who needs
336
+ * what. A serial column's value comes from a DEFAULT `nextval(...)` evaluated with the
337
+ * CALLER's privileges, so an INSERTing role must hold USAGE/SELECT/UPDATE on the sequence or
338
+ * every insert fails `permission denied for sequence`. An identity column's value is supplied
339
+ * by the system as part of the column definition; no caller privilege is consulted at all.
340
+ *
341
+ * Measured, not assumed: a role with INSERT on the table and NOTHING on the sequence inserts
342
+ * successfully into an identity table and fails on a serial one. So the A6 inheritance rule
343
+ * applies to serial sequences only — see `reconcileSequenceGrants`.
344
+ *
345
+ * Absent on a read that predates this field (treated as serial, the prior behaviour).
346
+ */
347
+ identity?: boolean;
283
348
  }
284
349
 
285
350
  export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
@@ -430,6 +495,78 @@ export interface SchemaAclRow {
430
495
  acl: unknown;
431
496
  }
432
497
 
498
+ /**
499
+ * The sequences behind serial/identity columns, with their owning table and their ACL.
500
+ *
501
+ * `pg_depend` with `deptype IN ('a','i')` and `refobjsubid > 0` is the column-ownership edge —
502
+ * the same predicate `schema-introspect`'s SEQUENCES_SQL uses to EXCLUDE these, inverted. 'a'
503
+ * is a serial's auto dependency, 'i' an identity column's internal one; both are "this sequence
504
+ * belongs to that column", and missing either would silently drop half the sequences.
505
+ *
506
+ * LEFT JOIN LATERAL, not CROSS JOIN: a sequence with a NULL acl (owner-only, the common case
507
+ * before this fix) must still come back as a row. Otherwise "no grants" and "no such sequence"
508
+ * would be the same answer, and the emitter could not tell an ungranted sequence from an
509
+ * unmeasured one. `grantee IS NULL` marks the no-ACL row; a real PUBLIC grant has grantee 0,
510
+ * which `aclexplode` reports and the CASE turns into the literal 'PUBLIC'.
511
+ */
512
+ export const SEQUENCE_ACLS_SQL = `
513
+ SELECT
514
+ n.nspname || '.' || c.relname AS sequence,
515
+ tn.nspname || '.' || t.relname AS "table",
516
+ CASE WHEN a.grantee IS NULL THEN NULL ELSE COALESCE(r.rolname, 'PUBLIC') END AS grantee,
517
+ a.privilege_type AS privilege,
518
+ -- 'i' = an IDENTITY column's internal dependency, 'a' = a serial's auto one. Carried, not
519
+ -- just filtered on, because the two need OPPOSITE treatment from the inheritance rule.
520
+ (d.deptype = 'i') AS is_identity
521
+ FROM pg_class c
522
+ JOIN pg_namespace n ON n.oid = c.relnamespace
523
+ JOIN pg_depend d
524
+ ON d.classid = 'pg_class'::regclass AND d.objid = c.oid
525
+ AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
526
+ JOIN pg_class t ON t.oid = d.refobjid
527
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
528
+ LEFT JOIN LATERAL aclexplode(c.relacl) a ON a.grantee <> c.relowner
529
+ LEFT JOIN pg_roles r ON r.oid = a.grantee
530
+ WHERE c.relkind = 'S'
531
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
532
+ AND n.nspname NOT LIKE 'pg_%'
533
+ AND NOT EXISTS (
534
+ SELECT 1 FROM pg_depend e
535
+ WHERE e.objid = c.oid AND e.deptype = 'e'
536
+ )
537
+ ORDER BY 1, 3, 4;
538
+ `.trim();
539
+
540
+ export interface SequenceAclRow {
541
+ sequence: string;
542
+ table: string;
543
+ /** NULL when the sequence has no ACL at all — the row still exists. */
544
+ grantee: unknown;
545
+ privilege: unknown;
546
+ /** `d.deptype = 'i'` — the owning column is an identity one. Absent on an older read. */
547
+ is_identity?: unknown;
548
+ }
549
+
550
+ /** Fold the per-privilege rows into one entry per sequence, grants sorted for stability. */
551
+ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): SequenceGrantContract[] {
552
+ const bySequence = new Map<string, SequenceGrantContract>();
553
+ for (const row of rows) {
554
+ const entry = bySequence.get(row.sequence)
555
+ ?? {
556
+ sequence: row.sequence, table: row.table, grants: {} as Record<string, string[]>,
557
+ // Omitted rather than `false` on a serial sequence, so a contract read before this
558
+ // field existed and one read after compare structurally equal.
559
+ ...(truthy(row.is_identity) ? { identity: true } : {}),
560
+ };
561
+ if (row.grantee != null && row.privilege != null) {
562
+ const grantee = String(row.grantee);
563
+ entry.grants[grantee] = [...new Set([...(entry.grants[grantee] ?? []), String(row.privilege)])].sort();
564
+ }
565
+ bySequence.set(row.sequence, entry);
566
+ }
567
+ return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
568
+ }
569
+
433
570
  /** The two privileges a schema ACL can carry, in aclitem letter form. */
434
571
  const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
435
572
 
@@ -615,8 +752,10 @@ export interface ContractRows {
615
752
  columnGrants?: ColumnGrantRow[];
616
753
  /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
617
754
  schemaAcls?: SchemaAclRow[];
755
+ /** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
756
+ sequenceAcls?: SequenceAclRow[];
618
757
  /** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
619
- functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
758
+ functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
620
759
  }
621
760
 
622
761
  /**
@@ -673,6 +812,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
673
812
  securityDefiner: true,
674
813
  hasSearchPath: f.hasSearchPath,
675
814
  ...(f.ownerBypassesRls !== undefined ? { ownerBypassesRls: f.ownerBypassesRls } : {}),
815
+ ...(f.ownerIsSuperuser !== undefined ? { ownerIsSuperuser: f.ownerIsSuperuser } : {}),
816
+ ...(f.owner !== undefined ? { owner: f.owner } : {}),
676
817
  }))
677
818
  .sort((a, b) => a.name.localeCompare(b.name));
678
819
 
@@ -692,6 +833,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
692
833
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
693
834
  functions,
694
835
  ...(schemaAcls ? { schemaAcls } : {}),
836
+ // Same absent-means-unmeasured contract as schemaAcls above.
837
+ ...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
695
838
  };
696
839
  }
697
840
 
@@ -704,8 +847,8 @@ export async function introspectContract(
704
847
  // ONE session: the six queries describe one moment under one pinned search_path.
705
848
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
706
849
  // across connections can report authz drift that does not exist.
707
- const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
708
- [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
850
+ const [rls, policies, grants, columnGrants, schemaAcls, sequenceAcls, fnRows] = await session(
851
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
709
852
  INTROSPECTION_SESSION,
710
853
  );
711
854
  return assembleContract({
@@ -714,6 +857,7 @@ export async function introspectContract(
714
857
  grants: grants as GrantRow[],
715
858
  columnGrants: columnGrants as ColumnGrantRow[],
716
859
  schemaAcls: schemaAcls as SchemaAclRow[],
860
+ sequenceAcls: sequenceAcls as SequenceAclRow[],
717
861
  functions: (fnRows as any[]).map(mapFunctionRow),
718
862
  });
719
863
  }
@@ -814,6 +958,14 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
814
958
  if (d.ownerBypassesRls !== undefined && l.ownerBypassesRls !== undefined && d.ownerBypassesRls !== l.ownerBypassesRls) {
815
959
  findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner bypasses RLS: declared ${d.ownerBypassesRls}, live ${l.ownerBypassesRls}` });
816
960
  }
961
+ // The name is recorded but NEVER compared — it legitimately differs between a dev
962
+ // machine, a deployed master and a normalized operator, and diffing it would false-fire
963
+ // on every environment. This boolean is the portable half, and it is the one that
964
+ // separates a deliberately-scoped BYPASSRLS operator from a superuser: both read `true`
965
+ // for ownerBypassesRls, and only one of them is a catastrophe.
966
+ if (d.ownerIsSuperuser !== undefined && l.ownerIsSuperuser !== undefined && d.ownerIsSuperuser !== l.ownerIsSuperuser) {
967
+ findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner is SUPERUSER: declared ${d.ownerIsSuperuser}, live ${l.ownerIsSuperuser}${l.ownerIsSuperuser ? ` (live owner '${l.owner ?? 'unknown'}') — a SECURITY DEFINER function owned by a superuser runs above RLS as that superuser on every call` : ''}` });
968
+ }
817
969
  }
818
970
  for (const name of lFns.keys()) {
819
971
  if (!dFns.has(name)) {
@@ -15,7 +15,7 @@
15
15
  * this layer reconciles without a migration file (unlike the data layer).
16
16
  */
17
17
 
18
- import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
18
+ import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract.js';
19
19
  import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
20
20
  import { matchPolicies } from './authz-identity.js';
21
21
  import { quoteQualified } from './pg-ident.js';
@@ -118,6 +118,58 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
118
118
  return lines;
119
119
  }
120
120
 
121
+ /** A grantee holding sequence privileges its table grants do not justify. */
122
+ export interface VestigialSequenceGrant {
123
+ grantee: string;
124
+ /** The sequences it holds privileges on while holding no INSERT on their tables. */
125
+ sequences: string[];
126
+ }
127
+
128
+ /**
129
+ * Sequence grants with no corresponding INSERT — an ADVISORY, never a revoke.
130
+ *
131
+ * The shape a brownfield database arrives in: a blanket `GRANT USAGE ON ALL SEQUENCES IN
132
+ * SCHEMA public` sitting beside a blanket `GRANT SELECT ON ALL TABLES`, years old, granted
133
+ * to a role that can INSERT into nothing. One consumer had exactly this — `authenticated`
134
+ * holding USAGE on all 27 sequences with INSERT on zero tables — and it was the single
135
+ * divergence between their database and the inheritance rule.
136
+ *
137
+ * Reported rather than revoked, and the distinction is the whole point: the inheritance rule
138
+ * only ever ADDS what an INSERT needs, so a vestigial grant is untouched by reconciliation and
139
+ * would otherwise sit there unnoticed forever. Naming it lets the owner decide. Revoking it
140
+ * would be the tool making a security decision from an inference — the same move we refused for
141
+ * the invented search_path.
142
+ */
143
+ export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceGrant[] {
144
+ if (!live.sequenceGrants) return []; // not measured
145
+ const insertsFor = new Map<string, Set<string>>();
146
+ for (const t of live.tables) {
147
+ for (const [grantee, privileges] of Object.entries(t.grants)) {
148
+ if (privileges.includes('INSERT')) {
149
+ (insertsFor.get(grantee) ?? insertsFor.set(grantee, new Set()).get(grantee)!).add(t.table);
150
+ }
151
+ }
152
+ }
153
+ const byGrantee = new Map<string, string[]>();
154
+ for (const seq of live.sequenceGrants) {
155
+ for (const grantee of Object.keys(seq.grants)) {
156
+ if (insertsFor.get(grantee)?.has(seq.table)) continue;
157
+ byGrantee.set(grantee, [...(byGrantee.get(grantee) ?? []), seq.sequence]);
158
+ }
159
+ }
160
+ return [...byGrantee.entries()]
161
+ .map(([grantee, sequences]) => ({ grantee, sequences: sequences.sort() }))
162
+ .sort((a, b) => a.grantee.localeCompare(b.grantee));
163
+ }
164
+
165
+ /** One line per grantee — the shared rendering, same idiom as renderGrantExemptions. */
166
+ export function renderVestigialSequenceGrants(findings: readonly VestigialSequenceGrant[]): string[] {
167
+ return findings.map(({ grantee, sequences }) =>
168
+ `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — `
169
+ + `probably a vestigial blanket GRANT ... ON ALL SEQUENCES. Nothing revokes it (the inheritance rule only adds); `
170
+ + `review and revoke by hand if it is dead: ${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}.`);
171
+ }
172
+
121
173
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
122
174
  const clause = parenthesizeOnce;
123
175
 
@@ -144,6 +196,91 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
144
196
  return new Map(c.tables.map((t) => [t.table, t]));
145
197
  }
146
198
 
199
+ /**
200
+ * A6 — the privileges a SERIAL sequence INHERITS from its table's declared grants.
201
+ *
202
+ * SERIAL only. An identity column's sequence is exempt and is skipped before this is
203
+ * consulted — see `reconcileSequenceGrants` for the measurement and why it matters.
204
+ *
205
+ * A role that may INSERT into a table but cannot draw its sequence value is not a state anyone
206
+ * declares deliberately; it is a state that happens when nobody carries the privilege across.
207
+ * Measured on a consumer's database: every INSERT into a serial-PK table failed
208
+ * `permission denied for sequence`, and two of their 563 tests were exactly that.
209
+ *
210
+ * The rule is INSERT on the table → USAGE + SELECT + UPDATE on its sequence, and all three
211
+ * matter. USAGE alone permits `nextval`, but `setval` needs UPDATE and reading `last_value`
212
+ * needs SELECT, so the common `rU` shape breaks the moment anything reseeds the counter.
213
+ *
214
+ * Signed off after measurement, not assumed: the consumer checked every role against every
215
+ * serial/identity sequence and found zero cases of INSERT-without-sequence-USAGE and zero of
216
+ * USAGE-without-UPDATE, so this under-grants nobody. Their one divergence — `authenticated`
217
+ * holding USAGE on all 27 sequences while able to INSERT into none — was a vestigial 2023
218
+ * blanket grant they are revoking, so their database converges to this rule rather than the
219
+ * reverse. The pull ADVISORY names that shape wherever it appears.
220
+ *
221
+ * Derived, never declared: there is no `sequence:` key in the model and nobody wants one.
222
+ */
223
+ export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['SELECT', 'UPDATE', 'USAGE'] as const;
224
+
225
+ export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
226
+ const out: Record<string, string[]> = {};
227
+ for (const [grantee, privileges] of Object.entries(table.grants)) {
228
+ if (privileges.includes('INSERT')) out[grantee] = [...SEQUENCE_PRIVILEGES_FOR_INSERT];
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Reconcile one table's SERIAL sequences against the grants they inherit. Identity sequences
235
+ * are read the same way but exempt from the rule — the skip below says why.
236
+ *
237
+ * Same per-grantee shape as `reconcileGrants`, and the same ungoverned-role exemption: a live
238
+ * grantee the declared authz does not govern is LEFT ALONE, because "no declaration" and "no
239
+ * privileges" are identical to a set difference, and treating them alike is what produced 32
240
+ * revokes against a brownfield adopter's migration role.
241
+ *
242
+ * The live side must have been READ for this to run at all. `sequenceGrants` absent means the
243
+ * recorder never looked, and planning revokes against a set nobody read is how a phantom
244
+ * statement gets minted on every run forever — a bug class that has already bitten this
245
+ * codebase twice.
246
+ */
247
+ function reconcileSequenceGrants(
248
+ d: TableContract,
249
+ liveSequences: readonly SequenceGrantContract[],
250
+ out: string[],
251
+ governed: ReadonlySet<string>,
252
+ ): void {
253
+ const declared = inheritedSequenceGrants(d);
254
+ for (const seq of liveSequences) {
255
+ // An IDENTITY sequence inherits NOTHING, and is neither granted nor revoked here.
256
+ //
257
+ // The A6 rule exists because a serial column's value comes from a DEFAULT `nextval(...)`
258
+ // that Postgres evaluates with the CALLER's privileges — no sequence grant, no INSERT. An
259
+ // identity column's value is supplied by the system as part of the column definition and
260
+ // consults no caller privilege at all. Measured: a role holding INSERT on the table and
261
+ // NOTHING on the sequence inserts fine into an identity table and fails on a serial one.
262
+ //
263
+ // Applying the rule here was a real WIDENING, caught by the round-trip oracle the day
264
+ // identity columns became declarable: the rebuild handed an INSERTing role SELECT + UPDATE
265
+ // + USAGE — direct `setval` control of a surrogate key — that the original database never
266
+ // granted and never needed. Nothing is revoked either: a grant somebody made deliberately
267
+ // is not ours to remove, and no declaration says it should not be there.
268
+ if (seq.identity) continue;
269
+ const target = quoteQualified(seq.sequence);
270
+ const grantees = new Set([...Object.keys(declared), ...Object.keys(seq.grants)]);
271
+ for (const grantee of [...grantees].sort()) {
272
+ if (!isGoverned(governed, grantee)) continue;
273
+ const want = new Set(declared[grantee] ?? []);
274
+ const have = new Set(seq.grants[grantee] ?? []);
275
+ const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
276
+ const toRevoke = [...have].filter((p) => !want.has(p)).sort();
277
+ const toGrant = [...want].filter((p) => !have.has(p)).sort();
278
+ if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
279
+ if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
+ }
281
+ }
282
+ }
283
+
147
284
  /**
148
285
  * Emit the ordered SQL that transforms the live database into the declared
149
286
  * contract. Empty when they already match (the reconcile no-op). Order per table:
@@ -162,6 +299,12 @@ export function emitReconcileSql(
162
299
  // that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
163
300
  // it with the modules' declared governedRoles.
164
301
  const governed = opts.governedRoles ?? governedRoleSet(declared);
302
+ // Absent = the recorder never read sequences; skip the pass entirely rather than diff
303
+ // against a set nobody measured. Grouped once, not re-scanned per table.
304
+ const sequencesByTable = new Map<string, SequenceGrantContract[]>();
305
+ for (const seq of live.sequenceGrants ?? []) {
306
+ (sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
307
+ }
165
308
 
166
309
  for (const d of declared.tables) {
167
310
  const l = lTables.get(d.table);
@@ -172,6 +315,8 @@ export function emitReconcileSql(
172
315
  reconcilePolicies(table, d, l, sql, governed);
173
316
  reconcileGrants(table, d, l, sql, governed);
174
317
  reconcileColumnGrants(table, d, l, sql, governed);
318
+ // A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
319
+ reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], sql, governed);
175
320
  }
176
321
 
177
322
  return sql;
@@ -231,15 +231,37 @@ export function renderContractMarkdown(contract: AuthzContract): string {
231
231
  lines.push('');
232
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
233
  lines.push('');
234
- const elevated = contract.functions.filter((f) => f.ownerBypassesRls);
234
+ // A correct design lights this whole report up: a deliberately-scoped operator is
235
+ // usually BYPASSRLS, so every SECDEF function it owns is "elevated". Splitting the
236
+ // superuser case out is what lets a reader tell correct from catastrophic at a glance —
237
+ // a consumer with 60 correctly-owned functions was reading the same warning they would
238
+ // have seen if all 60 had silently moved to a superuser.
239
+ // Three states, not two. `ownerIsSuperuser === undefined` means the contract predates
240
+ // the field — we do NOT know, and must not claim "non-superuser" for it.
241
+ const superuserOwned = contract.functions.filter((f) => f.ownerIsSuperuser === true);
242
+ const elevated = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === false);
243
+ const unknownOwner = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === undefined);
244
+ if (unknownOwner.length) {
245
+ lines.push(`> **⚠ ${unknownOwner.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Re-run \`db:authz:pull\` to record whether the owner is a SUPERUSER, which is the difference between a scoped operator and the maximum blast radius.`);
246
+ lines.push('');
247
+ }
248
+ if (superuserOwned.length) {
249
+ lines.push(`> **🛑 ${superuserOwned.length} function(s) are owned by a SUPERUSER** — each runs above row security as that superuser on every call. This is the maximum blast radius and is almost never intended; it is what \`db:migrate\` and a models-only build both leave behind by default. Normalize ownership to a scoped operator.`);
250
+ lines.push('');
251
+ }
235
252
  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.`);
253
+ lines.push(`> **⚠ ${elevated.length} function(s) are owned by a non-superuser role that bypasses RLS.** If that role is your intended scoped operator, this is expected and the bodies are what enforce access. Confirm the owner below is the role you meant.`);
237
254
  lines.push('');
238
255
  }
239
256
  for (const f of contract.functions) {
240
257
  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}`);
258
+ const owner = f.owner ? ` · owner \`${f.owner}\`` : '';
259
+ const own = f.ownerIsSuperuser === true
260
+ ? ' · **owned by SUPERUSER 🛑**'
261
+ : f.ownerBypassesRls
262
+ ? (f.ownerIsSuperuser === false ? ' · owner bypasses RLS' : ' · **owner bypasses RLS ⚠**')
263
+ : '';
264
+ lines.push(`- \`${f.name}\` — ${sp}${owner}${own}`);
243
265
  }
244
266
  lines.push('');
245
267
  }
@@ -47,6 +47,7 @@ import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
47
47
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
48
48
  import {
49
49
  findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
50
+ findUnpinnedDefiners,
50
51
  } from '../derived-lint.js';
51
52
  import { step, success, fail, info, warn } from '../output.js';
52
53
 
@@ -235,7 +236,9 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
235
236
  for (const gap of derivedGaps) {
236
237
  findings.push({ level: 'fail', area: 'authz', message: gap.message });
237
238
  }
238
- for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived)]) {
239
+ // findUnpinnedDefiners is the advisory half of A3: db:pull stopped inventing a pin, so the
240
+ // opinion moved here. It SUGGESTS one and never applies it.
241
+ for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived), ...findUnpinnedDefiners(input.derived)]) {
239
242
  findings.push({ level: 'warn', area: 'authz', message: warning.message });
240
243
  }
241
244
  if (derivedGaps.length === 0) {
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
65
65
  models: ModelDescriptor[] | null,
66
66
  sequences?: SequenceDescriptor[],
67
67
  governedExtras?: readonly string[],
68
+ /** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
69
+ declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
68
70
  ): Promise<FingerprintStatus> {
69
71
  const snapshot = await introspectSchema(session);
70
72
  const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
77
79
  const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
78
80
  const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
79
81
  const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
80
- const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
82
+ // B5 a declared owner IS hashed (derived-source prefixes it), so reporting it as
83
+ // unfingerprinted would be a false claim in the one report whose whole job is honesty about
84
+ // coverage. SQL cannot know what the models declare; the filter belongs here.
85
+ const ownerDeclared = new Set(
86
+ (declaredDerived ?? [])
87
+ .filter((o) => o.kind === 'function' && o.owner !== undefined)
88
+ .map((o) => o.identity.replace(/\(.*\)$/, '')),
89
+ );
90
+ const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
91
+ .filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
81
92
  return {
82
93
  live,
83
94
  ...(governedLive !== undefined ? { governedLive } : {}),
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
101
112
  let models: ModelDescriptor[] | null = null;
102
113
  let sequences: SequenceDescriptor[] | undefined;
103
114
  let governedExtras: string[] | undefined;
115
+ let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
104
116
  try {
105
117
  models = await loadModels(modelsPath);
106
118
  const declared = await loadDeclaredDerived(flags.models);
107
119
  sequences = declared?.sequences;
108
120
  governedExtras = declared?.governedRoles;
121
+ // B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
122
+ declaredDerived = declared?.objects;
109
123
  } catch (err: any) {
110
124
  // Two very different situations used to land here identically.
111
125
  //
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
141
155
  }
142
156
 
143
157
  try {
144
- const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
158
+ const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
145
159
 
146
160
  if (flags.json === 'true') {
147
161
  console.log(JSON.stringify(status, null, 2));
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
35
35
  import path from 'node:path';
36
36
  import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
37
37
  import { fingerprintLive } from '../schema-fingerprint.js';
38
- import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
38
+ import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
39
39
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
40
40
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
41
41
  import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
235
235
  // governed role's grants are DECLARED (transcribed as privileges), so recording them
236
236
  // as exemptions too would double-book them — declared and exempted at once.
237
237
  pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
238
+ // A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
239
+ // common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
240
+ // what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
241
+ // it would be the tool deciding a security property from an inference.
242
+ for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
238
243
  pulledFingerprint = fingerprintLive(current, contract).hash;
239
244
  }
240
245
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
@@ -43,6 +43,11 @@ import {
43
43
  derivedSearchPath,
44
44
  renderSetSearchPath,
45
45
  renderEnsureObjectSchemas,
46
+ ownerRequirements,
47
+ ownerPreflightSql,
48
+ ownerPreflightRefusal,
49
+ builderOwnerMode,
50
+ type OwnerApplyMode,
46
51
  ENSURE_RECONCILER_SQL,
47
52
  } from '../derived-apply.js';
48
53
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
@@ -78,6 +83,13 @@ export interface ReconcileRun {
78
83
  statements: string[];
79
84
  /** Why apply was refused, when it was. */
80
85
  refusal?: string;
86
+ /**
87
+ * Which mechanism enacted the declared owners, when any were enacted. Reported because the two
88
+ * make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
89
+ * the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
90
+ * operator debugging a permission error should not have to guess which one ran.
91
+ */
92
+ ownerMode?: OwnerApplyMode;
81
93
  }
82
94
 
83
95
  /**
@@ -118,7 +130,10 @@ export async function executeReconcile(
118
130
  const live = await introspectDerived(session);
119
131
  const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
120
132
  const plan = planReconcile(parsed, live, options);
121
- const rendered = renderReconcileSql(plan, parsed.objects);
133
+ // Rendered with the mechanism that works for EVERY builder. The preflight below reads the
134
+ // catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
135
+ // (or is refused) shows the conservative form, which is the honest default.
136
+ let rendered = renderReconcileSql(plan, parsed.objects);
122
137
 
123
138
  if (!options.apply) return { plan, applied: false, statements: rendered.statements };
124
139
 
@@ -138,6 +153,23 @@ export async function executeReconcile(
138
153
  return { plan, applied: false, statements: [] };
139
154
  }
140
155
 
156
+ // DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
157
+ //
158
+ // Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
159
+ // rather than one per re-run, so the operator's first news is not a raw Postgres error from the
160
+ // middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
161
+ // with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
162
+ // owner, which needs membership and CREATE on the schema.
163
+ const requirements = ownerRequirements(plan, parsed.objects);
164
+ let ownerMode: OwnerApplyMode | undefined;
165
+ if (requirements.length > 0) {
166
+ const rows = (await runner(ownerPreflightSql(requirements))) as any[];
167
+ const refusal = ownerPreflightRefusal(rows);
168
+ if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
169
+ ownerMode = builderOwnerMode(rows);
170
+ if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
171
+ }
172
+
141
173
  const now = options.now ?? Date.now;
142
174
  await runner(ENSURE_RECONCILER_SQL.join(';\n'));
143
175
 
@@ -263,7 +295,7 @@ export async function executeReconcile(
263
295
  throw explainReconcileError(err);
264
296
  }
265
297
 
266
- return { plan, applied: true, statements: rendered.statements };
298
+ return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
267
299
  }
268
300
 
269
301
  /**
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
520
552
  fail(`not applied: ${run.refusal}`);
521
553
  } else if (run.applied) {
522
554
  success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
555
+ // Which ownership mechanism ran. The two make different demands, so an operator
556
+ // debugging a permission error should not have to guess which one they hit.
557
+ if (run.ownerMode === 'alter-owner') {
558
+ info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
559
+ } else if (run.ownerMode === 'set-role') {
560
+ info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
561
+ }
523
562
  if (run.plan.actions.some((a) => a.action === 'baseline')) {
524
563
  warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
525
564
  }