@everystack/cli 0.4.55 → 0.4.57

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.
@@ -194,9 +194,20 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
194
194
  .join(', ');
195
195
  const returns = typeof fn.returns === 'string' ? fn.returns : `SETOF ${refName(fn.returns.setof)}`;
196
196
  const volatility = fn.volatility === 'volatile' ? '' : ` ${fn.volatility.toUpperCase()}`;
197
- const security = fn.security === 'definer'
198
- ? ` SECURITY DEFINER SET search_path = ${(fn.searchPath ?? []).join(', ')}`
197
+ const definer = fn.security === 'definer' ? ' SECURITY DEFINER' : '';
198
+ // The pin is INDEPENDENT of the security mode, in both directions.
199
+ //
200
+ // `'unpinned'` emits no `SET search_path` at all — that is the whole point of the sentinel:
201
+ // a brownfield definer function that genuinely has none is now declarable, instead of the
202
+ // pull inventing `pg_catalog` and the build applying the invention for real.
203
+ //
204
+ // And a pin on an INVOKER function is emitted too. It used to be silently dropped, so a model
205
+ // could declare one, the build would not apply it, and the differ (which never read proconfig)
206
+ // would not notice — a declared property with no effect and no complaint.
207
+ const pin = Array.isArray(fn.searchPath) && fn.searchPath.length > 0
208
+ ? ` SET search_path = ${fn.searchPath.join(', ')}`
199
209
  : '';
210
+ const security = `${definer}${pin}`;
200
211
  const tag = dollarTag(fn.body);
201
212
  const signature = functionSignature(target, fn);
202
213
  return {
@@ -321,10 +332,12 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
321
332
  kind, schema, name,
322
333
  identity: extra.identity ?? `${schema}.${name}`,
323
334
  sql, attachments,
324
- hash: hashSourceContent(sql, attachments),
335
+ hash: hashSourceContent(sql, attachments, extra.owner),
325
336
  // bodyHash excludes PLAIN grants (column-scoped grants stay in — attacl isn't drift-checked, so
326
337
  // a column-grant change must still rebuild). Equal across an authz-only plain-grant change.
327
- bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a))),
338
+ // The OWNER stays in: changing who a SECURITY DEFINER function (or a non-invoker view)
339
+ // executes as is a rebuild, not an authz delta, so it must not be routed to a bare GRANT diff.
340
+ bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a)), extra.owner),
328
341
  file: DECLARED, seq,
329
342
  ...extra,
330
343
  });
@@ -369,18 +382,23 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
369
382
  switch (d.kind) {
370
383
  case 'view': {
371
384
  const { sql, attachments } = renderView(d);
372
- nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
385
+ // A15: the owner rides on the object, so it enters the content hash (a view that
386
+ // changes owner IS a different object) and the applier can create it under SET ROLE.
387
+ nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
373
388
  break;
374
389
  }
375
390
  case 'materialized view': {
376
391
  const { sql, attachments } = renderMaterializedView(d);
377
- nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
392
+ nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
378
393
  break;
379
394
  }
380
395
  case 'function': {
381
396
  const { sql, attachments } = renderFunction(d);
382
397
  const setofDep = typeof d.returns === 'string' ? [] : depIdentities([d.returns.setof]);
383
- nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments, { identity }) });
398
+ // The pin rides STRUCTURALLY as well as inside `sql`, so the differ can compare it
399
+ // against the live catalog directly instead of only against a recorded hash. It is not
400
+ // in the hash (it is already inside `sql`), so carrying it moves nobody's fingerprint.
401
+ nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments, { identity, ...(d.owner ? { owner: d.owner } : {}), ...(d.searchPath !== undefined ? { searchPath: d.searchPath } : {}) }) });
384
402
  break;
385
403
  }
386
404
  case 'sql': {
@@ -99,20 +99,113 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
99
99
  * The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
100
100
  * the table path's reconcileGrants, per derived object. Empty when they match.
101
101
  */
102
+ export interface ObjectGrantDiff {
103
+ statements: string[];
104
+ /** REVOKEs withheld because ownership made the two sides incomparable, with the reason. */
105
+ suppressed: string[];
106
+ }
107
+
108
+ /**
109
+ * Ownership context for a FUNCTION's grant diff. Absent for relations.
110
+ *
111
+ * Owner-implicit EXECUTE is excluded from both grant reads (`grantee <> proowner`), which is
112
+ * correct in isolation — an owner's privileges are not grants. But it means the SET of
113
+ * non-owner grants is computed RELATIVE TO THE OWNER, so two sides with different owners
114
+ * produce two different sets for the same function, and the differ reconciles a difference
115
+ * that is purely ownership.
116
+ *
117
+ * Measured by a consumer, 2026-08-10: their local `resolve_user_slug` is owned by
118
+ * `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
119
+ * same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
120
+ * The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
121
+ * access removal minted from an ownership difference. They did not apply it.
122
+ */
123
+ export interface GrantOwnerContext {
124
+ /** The owner the DECLARED grant set was computed against, when the models declare one. */
125
+ declared?: string;
126
+ /** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
127
+ live?: string;
128
+ }
129
+
130
+ /**
131
+ * The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
132
+ * the table path's reconcileGrants, per derived object. Empty when they match.
133
+ *
134
+ * When `owners` is supplied (functions) and the two sides cannot be shown to have been
135
+ * computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs still flow: a
136
+ * redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
137
+ * revoke is the recoverable error and it is never silent.
138
+ *
139
+ * This withholds some legitimate revokes on a project that has not declared `owner` yet —
140
+ * deliberately. Declaring the owner restores full capability, and until then the tooling says
141
+ * plainly what it is not doing rather than guessing.
142
+ */
102
143
  export function diffObjectGrants(
103
144
  target: string,
104
145
  declared: Record<string, string[]>,
105
146
  live: Record<string, string[]>,
106
- ): string[] {
147
+ owners?: GrantOwnerContext,
148
+ ): ObjectGrantDiff {
107
149
  const out: string[] = [];
150
+ const suppressed: string[] = [];
151
+ // WHICH GRANTEE, not which object. An owner-implicit privilege can only make a grantee
152
+ // appear or disappear if THAT GRANTEE is an owner on one of the two sides — the owner's own
153
+ // rows are what each read excludes. So suppression is per-grantee, and every other revoke
154
+ // still flows.
155
+ //
156
+ // This matters concretely: PUBLIC is a pseudo-role that can never own an object, so a
157
+ // `REVOKE ... FROM PUBLIC` is NEVER explained by ownership. An earlier, object-scoped
158
+ // version of this guard swallowed exactly that revoke — a hand-run `GRANT EXECUTE TO PUBLIC`
159
+ // on a managed function stopped being cleaned up, turning a privilege-escalation cleanup
160
+ // into a silent no-op. Caught by __tests__/integration/derived-drift.test.ts against a real
161
+ // database.
162
+ //
163
+ // The declared owner is only known once the models declare one (`defineFunction owner`).
164
+ // Until then the pull-time owner is unrecorded, so a grantee that WAS the pull-time owner
165
+ // cannot be identified — that case is closed by db:pull emitting `owner`, not here. What we
166
+ // can always identify is the LIVE owner, and that is the half that produces the asymmetry
167
+ // in the direction that removes access.
168
+ const ownerish = new Set([owners?.declared, owners?.live].filter((o): o is string => !!o));
169
+ const suppressibleGrantee = (grantee: string): boolean =>
170
+ owners?.live !== undefined && grantee !== 'PUBLIC' && ownerish.has(grantee);
171
+ // NORMALIZATION, and it is a different move from the suppression above.
172
+ //
173
+ // The LIVE owner holds every privilege implicitly, and the live read excludes exactly those
174
+ // rows. So when the two sides share a basis — the models declare the live owner, or declare no
175
+ // owner at all — a declared grant TO that owner is already true and unreadable. Minting it
176
+ // produces a GRANT the next read still cannot see: a statement that runs on every reconcile,
177
+ // forever, and never converges. Measured as 31 of them on one consumer's function set.
178
+ //
179
+ // Both directions drop, because both are vacuous: an owner's implicit privileges cannot be
180
+ // granted (they are already held) and cannot be revoked (they follow ownership, not the ACL).
181
+ // This is not a withheld statement, so it is not reported as one.
182
+ //
183
+ // It applies ONLY when the bases agree. A declared owner that differs from the live one means
184
+ // ownership itself is converging, the two grant sets were computed against different owners,
185
+ // and that case stays with the suppression rule above.
186
+ const sameBasis = owners?.declared === undefined || owners.declared === owners.live;
187
+ const ownerImplicit = (grantee: string): boolean =>
188
+ owners?.live !== undefined && grantee !== 'PUBLIC' && grantee === owners.live && sameBasis;
108
189
  const grantees = new Set([...Object.keys(declared), ...Object.keys(live)]);
109
190
  for (const grantee of [...grantees].sort()) {
191
+ if (ownerImplicit(grantee)) continue;
110
192
  const want = new Set(declared[grantee] ?? []);
111
193
  const have = new Set(live[grantee] ?? []);
112
194
  const toRevoke = [...have].filter((p) => !want.has(p)).sort();
113
195
  const toGrant = [...want].filter((p) => !have.has(p)).sort();
114
- if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
196
+ if (toRevoke.length) {
197
+ if (!suppressibleGrantee(grantee)) {
198
+ out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
199
+ } else {
200
+ const why = grantee === owners!.live
201
+ ? `'${grantee}' is the function's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
202
+ : `'${grantee}' is the function's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
203
+ suppressed.push(
204
+ `${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the function's owner to restore revokes.`,
205
+ );
206
+ }
207
+ }
115
208
  if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${target} TO ${grantee}`);
116
209
  }
117
- return out;
210
+ return { statements: out, suppressed };
118
211
  }
@@ -37,6 +37,33 @@ export interface LiveObject {
37
37
  comment?: string;
38
38
  /** sha256 over normalized definition + indexes + comment. */
39
39
  defHash: string;
40
+ /**
41
+ * Functions, views and matviews: the owning role.
42
+ *
43
+ * Read because the grant differ CANNOT be correct without it. Owner-implicit privileges are
44
+ * excluded from the ACL read (`a.grantee <> p.proowner` / `<> c.relowner`), which is right —
45
+ * but it means the SET of non-owner grants shifts when the owner changes, so the same object
46
+ * under two owners yields two different privilege sets and the differ mints statements to
47
+ * reconcile a difference that is purely ownership.
48
+ *
49
+ * A15 — and for views it is a privilege boundary in its own right. A `security_invoker =
50
+ * false` view executes with its OWNER's rights, exactly as a SECURITY DEFINER function does.
51
+ * Undeclared, the owner is whoever ran the build: an `admin`-owned sealed-slice view rebuilds
52
+ * as builder-owned, `admin` can no longer read through it, and every gate stays green.
53
+ */
54
+ owner?: string;
55
+ /**
56
+ * Is `owner` a SUPERUSER (or an rds_superuser member — RDS has no true superuser)? Half of
57
+ * the discriminator db:pull uses to decide whether the observed owner is a CHOSEN principal
58
+ * or the ambient build credential. Absent means not measured.
59
+ */
60
+ ownerIsSuperuser?: boolean;
61
+ /**
62
+ * Is `owner` the role this introspection is CONNECTED AS? The other half of the
63
+ * discriminator. A normalized non-superuser migrator owns everything it creates by default,
64
+ * so "superuser" alone would miss it. Absent means not measured.
65
+ */
66
+ ownerIsConnectedRole?: boolean;
40
67
  /** Matview only: whether it holds data. */
41
68
  populated?: boolean;
42
69
  /** Matview only: pg_total_relation_size — the rebuild-cost signal. */
@@ -128,7 +155,22 @@ SELECT
128
155
  EXISTS (
129
156
  SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
130
157
  WHERE o IN ('security_invoker=true', 'security_invoker=on')
131
- ) AS security_invoker
158
+ ) AS security_invoker,
159
+ -- A15. The same three fields the function read carries, for the same reason: a
160
+ -- security_invoker = false view executes with its OWNER's rights, so its owner is a
161
+ -- privilege boundary exactly as a SECURITY DEFINER function's is. The two discriminators
162
+ -- let db:pull tell a CHOSEN owner from the ambient build credential.
163
+ pg_get_userbyid(c.relowner) AS owner,
164
+ COALESCE(
165
+ (SELECT r.rolsuper
166
+ OR EXISTS (
167
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
168
+ WHERE m.member = c.relowner AND g.rolname = 'rds_superuser'
169
+ )
170
+ FROM pg_roles r WHERE r.oid = c.relowner),
171
+ false
172
+ ) AS owner_is_superuser,
173
+ (pg_get_userbyid(c.relowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role
132
174
  FROM pg_class c
133
175
  JOIN pg_namespace n ON n.oid = c.relnamespace
134
176
  WHERE c.relkind IN ('v', 'm')
@@ -171,6 +213,25 @@ SELECT
171
213
  l.lanname AS language,
172
214
  p.prosecdef AS secdef,
173
215
  p.provolatile AS volatility,
216
+ pg_get_userbyid(p.proowner) AS owner,
217
+ -- The AMBIENT-BUILD-CREDENTIAL discriminator, two clauses and no more. db:pull renders
218
+ -- the owner only when the observed owner is neither of these — rendering the ambient
219
+ -- credential would BLESS an accident, turning "whoever happened to run the migration" into
220
+ -- fingerprinted, reproducible design. When it IS ambient the renderer emits a FIXME instead:
221
+ -- the defect stays visible without being ratified.
222
+ --
223
+ -- Superuser, matching security-catalog's rule (RDS has no true superuser, so rds_superuser
224
+ -- membership counts).
225
+ (
226
+ SELECT r.rolsuper OR EXISTS (
227
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
228
+ WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
229
+ )
230
+ FROM pg_roles r WHERE r.oid = p.proowner
231
+ ) AS owner_is_superuser,
232
+ -- ...or the role we are connected as. A normalized, deliberately non-superuser migrator owns
233
+ -- every object it creates, so the superuser clause alone would miss the commonest case.
234
+ (pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
174
235
  (SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
175
236
  WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
176
237
  p.prosrc AS src
@@ -458,6 +519,12 @@ export interface RelationRow {
458
519
  rows: unknown;
459
520
  /** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
460
521
  security_invoker?: unknown;
522
+ /** `pg_get_userbyid(relowner)` — see LiveObject.owner. Optional: pre-A15 fixtures omit it. */
523
+ owner?: unknown;
524
+ /** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
525
+ owner_is_superuser?: unknown;
526
+ /** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
527
+ owner_is_connected_role?: unknown;
461
528
  }
462
529
 
463
530
  export interface FunctionRow {
@@ -475,6 +542,12 @@ export interface FunctionRow {
475
542
  secdef?: unknown;
476
543
  volatility?: unknown;
477
544
  search_path?: unknown;
545
+ /** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
546
+ owner?: unknown;
547
+ /** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
548
+ owner_is_superuser?: unknown;
549
+ /** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
550
+ owner_is_connected_role?: unknown;
478
551
  src?: unknown;
479
552
  }
480
553
 
@@ -544,6 +617,19 @@ export interface DerivedRows {
544
617
  grants?: GrantAclRow[];
545
618
  }
546
619
 
620
+ /** Coerce a Postgres boolean as any driver spells it. `undefined` means NOT MEASURED —
621
+ * never conflated with a measured `false`. NULL is not-measured too: the only way these
622
+ * sub-selects go NULL is the role vanishing between reads, and guessing there would be
623
+ * exactly the fabrication this feature exists to stop. */
624
+ function pgBool(v: unknown): boolean | undefined {
625
+ if (v === undefined || v === null) return undefined;
626
+ if (typeof v === 'boolean') return v;
627
+ const s = String(v).toLowerCase();
628
+ if (s === 't' || s === 'true') return true;
629
+ if (s === 'f' || s === 'false') return false;
630
+ return undefined;
631
+ }
632
+
547
633
  /** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
548
634
  function pgTextArray(v: unknown): string[] {
549
635
  if (Array.isArray(v)) return v.map(String);
@@ -597,6 +683,12 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
597
683
  definition, indexes,
598
684
  ...(comment !== undefined ? { comment } : {}),
599
685
  defHash: computeDefHash(definition, indexes, comment),
686
+ // A15 — same three fields, same meaning, as the function branch below. Absent (not
687
+ // measured) and false (measured, not ambient) stay DIFFERENT, so a pre-A15 fixture
688
+ // keeps behaving exactly as it did.
689
+ ...(row.owner != null ? { owner: String(row.owner) } : {}),
690
+ ...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
691
+ ...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
600
692
  ...grantsFor('r', identity),
601
693
  };
602
694
  if (kind === 'materialized view') {
@@ -625,6 +717,11 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
625
717
  definition, indexes: [],
626
718
  ...(comment !== undefined ? { comment } : {}),
627
719
  defHash: computeDefHash(definition, [], comment),
720
+ ...(row.owner != null ? { owner: String(row.owner) } : {}),
721
+ // Absent (not measured) and false (measured, not ambient) are DIFFERENT — a pre-owner
722
+ // fixture must keep behaving exactly as it did, so undefined never becomes false here.
723
+ ...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
724
+ ...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
628
725
  ...grantsFor('f', identity),
629
726
  ...(row.src !== undefined ? {
630
727
  fn: {
@@ -233,6 +233,62 @@ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]
233
233
  return warnings;
234
234
  }
235
235
 
236
+ // ---------------------------------------------------------------------------
237
+ // Advisory: SECURITY DEFINER with no pin (db:check warn) — A3's linter half
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export interface UnpinnedDefinerFinding extends DerivedAuthzGap {
241
+ /** The schemas the DECLARATION can justify, `pg_catalog` first. A floor to review — never applied. */
242
+ suggested: string[];
243
+ }
244
+
245
+ /**
246
+ * Every SECURITY DEFINER function that declares `searchPath: 'unpinned'`, with a SUGGESTED pin.
247
+ *
248
+ * This is the half of A3 that keeps the honesty from becoming permission. `db:pull` no longer
249
+ * invents `['pg_catalog']` for a function whose live `proconfig` is empty — inventing it was an
250
+ * outage that passed every gate, and fabrication corrodes fingerprint, plan and apply, all of
251
+ * which rest on the model telling the truth. So the parser records what is there and the
252
+ * OPINION lives here: an unpinned definer function is a real latent vulnerability, and it gets
253
+ * named on every run until someone decides about it.
254
+ *
255
+ * SUGGEST, never apply. The suggestion is derived from the DECLARED structure — the function's
256
+ * own schema plus the schemas of its `dependsOn` — and not from reading the body. Body-derived
257
+ * pins are inventing with extra steps: dynamic SQL, operators and casts defeat the parse, and a
258
+ * pin that is wrong in the tool's favour is the same failure mode with better intentions. The
259
+ * suggestion is a floor. The author reviews it and lands it themselves.
260
+ *
261
+ * WARN, not fail. On an adopted database this is a faithful report of what is already true, and
262
+ * failing CI on a faithful pull would make `db:pull` unusable on exactly the schemas it exists
263
+ * for. Same call, and the same reasoning, as findPublicExecutableSecdef above.
264
+ */
265
+ export function findUnpinnedDefiners(derived: readonly DerivedDescriptor[]): UnpinnedDefinerFinding[] {
266
+ const findings: UnpinnedDefinerFinding[] = [];
267
+ for (const d of derived) {
268
+ if (d.kind !== 'function' || d.security !== 'definer' || d.searchPath !== 'unpinned') continue;
269
+ const identity = identityOf(d);
270
+ const schemas = new Set<string>([parseQualified(d.name).schema]);
271
+ for (const ref of d.dependsOn ?? []) {
272
+ schemas.add(isModelRef(ref) ? (ref.schema ?? 'public') : parseQualified(ref.name).schema);
273
+ }
274
+ // pg_catalog FIRST and always: it is what stops a caller shadowing a built-in the body
275
+ // calls unqualified, which is the whole attack this pin defends against.
276
+ const suggested = ['pg_catalog', ...[...schemas].filter((s) => s !== 'pg_catalog').sort()];
277
+ findings.push({
278
+ identity,
279
+ suggested,
280
+ message:
281
+ `${identity} is SECURITY DEFINER with NO search_path — declared 'unpinned', which is the truth about the `
282
+ + `database, not an approval. It runs with its owner's rights, and a caller controls the search_path, so an `
283
+ + `unqualified name in the body can be resolved to an object the caller planted. SUGGESTED pin from what the `
284
+ + `declaration references: searchPath: [${suggested.map((s) => `'${s}'`).join(', ')}]. That is a FLOOR derived `
285
+ + `from dependsOn, not from reading the body — dynamic SQL, operators and casts are invisible to it, so review `
286
+ + `it, extend it, and land it yourself. Nothing here is ever applied for you.`,
287
+ });
288
+ }
289
+ return findings;
290
+ }
291
+
236
292
  // ---------------------------------------------------------------------------
237
293
  // Gate: matview snapshot over row-scoped sources (db:check warn)
238
294
  // ---------------------------------------------------------------------------
@@ -233,6 +233,30 @@ export function planReconcile(
233
233
  const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
234
234
  const extraWarnings: string[] = [];
235
235
 
236
+ /**
237
+ * B2 — the declared `SET search_path` compared against the LIVE one, structurally.
238
+ *
239
+ * Every other comparison in this table is a hash against a RECORDED hash, which is why the
240
+ * pin could differ in silence: `--baseline` records the declared source hash beside the live
241
+ * def hash without ever comparing the two, so a model claiming `search_path=pg_catalog` about
242
+ * a database with no pin reads as up to date forever. Measured — `db:reconcile --check`
243
+ * returned zero actions and zero drift on exactly that state.
244
+ *
245
+ * This is the one comparison that reads both sides at once. Null when they agree, or when
246
+ * either side was not measured (a parser-era object with no declared pin, a pre-B5 catalog
247
+ * read with no structured fields) — absent evidence is not evidence.
248
+ */
249
+ const pinMismatch = (src: SourceObject, liveObj: LiveObject): string | null => {
250
+ if (src.kind !== 'function' || src.searchPath === undefined || !liveObj.fn) return null;
251
+ const tokens = (v: string | undefined): string[] =>
252
+ (v ?? '').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
253
+ const declared = src.searchPath === 'unpinned' ? [] : src.searchPath.map((s) => s.trim());
254
+ const livePin = tokens(liveObj.fn.searchPath);
255
+ if (declared.length === livePin.length && declared.every((s, i) => s === livePin[i])) return null;
256
+ const say = (p: string[]): string => (p.length ? `search_path = ${p.join(', ')}` : 'no search_path');
257
+ return `declared ${say(declared)}, live has ${say(livePin)} — replaced from source`;
258
+ };
259
+
236
260
  for (const src of source.objects) {
237
261
  // --only restricts the primary decisions to the named identities; the rest are left
238
262
  // untouched (they are not skips — they were never in scope, so they get no plan entry).
@@ -271,9 +295,16 @@ export function planReconcile(
271
295
  // (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
272
296
  // --rebuild guarantees it by rebuilding from source (relations through the dependency graph
273
297
  // below; functions replace in place), and the default flags it for a decision.
298
+ const pin = pinMismatch(src, liveObj);
274
299
  if (options.rebuild) {
275
300
  if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
276
301
  else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
302
+ } else if (pin) {
303
+ // --baseline asserts "live IS the source, trust me". Here we can PROVE it is not, so
304
+ // recording the assertion would bless the difference permanently — nothing downstream
305
+ // ever compares these two again. Converge instead, on every path: an object whose pin
306
+ // is measurably wrong is not unverifiable, it is verifiably different.
307
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
277
308
  } else if (options.baseline) {
278
309
  baseline.push(src.identity);
279
310
  } else {
@@ -341,6 +372,15 @@ export function planReconcile(
341
372
  else fnReplace.set(src.identity, 'source changed');
342
373
  continue;
343
374
  }
375
+ // Self-consistent provenance is not proof. A --baseline taken before B2, or a mistaken
376
+ // --rebaseline, records the declared source hash beside the live def hash without ever
377
+ // comparing them — so a declared pin the database does not have reads as up to date on
378
+ // every run. The structural read is what breaks that loop; it self-heals in one apply.
379
+ const pin = pinMismatch(src, liveObj);
380
+ if (pin) {
381
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
382
+ continue;
383
+ }
344
384
  // Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
345
385
  // once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
346
386
  // Converges: after this run the row has body_hash, so it skips cleanly next time.
@@ -430,8 +470,15 @@ export function planReconcile(
430
470
  );
431
471
  } else {
432
472
  const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
433
- const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
473
+ // Functions carry ownership context; relations do not (relacl's NULL means owner-only
474
+ // and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
475
+ const owners = src.kind === 'function'
476
+ ? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
477
+ : undefined;
478
+ const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
434
479
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
480
+ // Never silent: a withheld revoke is reported every run until the owner is declared.
481
+ for (const note of suppressed) extraWarnings.push(note);
435
482
  }
436
483
  }
437
484