@everystack/cli 0.4.53 → 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.
@@ -106,19 +106,72 @@ export function ensureOrReplace(sql: string): string {
106
106
  const WITH_NO_DATA_RE = /WITH\s+NO\s+DATA\s*$/i;
107
107
 
108
108
  /**
109
- * An object's full creation SQL: the CREATE statement plus its attachments, in
110
- * source order. A matview whose source says `WITH NO DATA` gets a trailing
111
- * REFRESH: the reconciler dropped a populated matview to rebuild it, and
112
- * executing the source verbatim would leave it unpopulated matching the
113
- * text but regressing the database. (Found by a consumer whose extracted
114
- * sources carry pg_dump's WITH NO DATA ordering.)
109
+ * How the applier enacts a declared owner. Chosen from the BUILDER, not from taste.
110
+ *
111
+ * `set-role` — run the CREATE as the owner. Works for any builder that can assume the role, and
112
+ * it is the only option for a non-superuser builder: PostgreSQL will not let it hand an object
113
+ * to somebody else. The cost is real, and it is borne by the role the feature exists to protect:
114
+ * the CREATE happens AS the owner, so the owner needs `CREATE` on the schema — and a
115
+ * deliberately-powerless operator role commonly has CREATE nowhere.
116
+ *
117
+ * `alter-owner` — create as the builder, then `ALTER … OWNER TO`. Available only to a SUPERUSER
118
+ * (or rds_superuser member), which needs neither membership in the owner nor CREATE for it, and
119
+ * can replace a function it does not own on the re-run. Measured on PG16.
120
+ *
121
+ * Getting this backwards is not cosmetic: forcing `set-role` on a superuser builder would demand
122
+ * a privilege expansion onto the very role that is supposed to hold none.
123
+ */
124
+ export type OwnerApplyMode = 'set-role' | 'alter-owner';
125
+
126
+ /**
127
+ * An object's full creation SQL: the CREATE statement plus its attachments, in source order. A
128
+ * matview whose source says `WITH NO DATA` gets a trailing REFRESH: the reconciler dropped a
129
+ * populated matview to rebuild it, and executing the source verbatim would leave it unpopulated
130
+ * — matching the text but regressing the database. (Found by a consumer whose extracted sources
131
+ * carry pg_dump's WITH NO DATA ordering.)
132
+ *
133
+ * A declared owner is enacted one of two ways; see {@link OwnerApplyMode} for which and why.
115
134
  */
116
- function objectSql(obj: SourceObject): string[] {
135
+ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role'): string[] {
117
136
  const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
118
137
  if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
119
138
  statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
120
139
  }
121
- return statements;
140
+ if (!obj.owner) return statements;
141
+
142
+ if (ownerMode === 'alter-owner') {
143
+ // A superuser builder creates as itself and hands the object over. It needs no membership in
144
+ // the owner and no CREATE on the owner's behalf, so the owner role stays as powerless as it
145
+ // was designed to be — which is the whole point of naming it. The re-run is safe for the
146
+ // same reason: a superuser may CREATE OR REPLACE a function it does not own.
147
+ //
148
+ // The ALTER trails the attachments because a superuser can still make them either way, and
149
+ // "build it, then hand it over" is the order a reader expects.
150
+ return [...statements, `${ALTER_OWNER[obj.kind] ?? 'ALTER FUNCTION'} ${ownerTarget(obj)} OWNER TO ${obj.owner}`];
151
+ }
152
+
153
+ // Every other builder creates AS the owner. Measured on PG16: a non-superuser cannot give an
154
+ // object away, and once ownership has moved off the applying role the next `CREATE OR REPLACE`
155
+ // fails with "must be owner of function" — so the alter-after form would work exactly once and
156
+ // break every later reconcile. Creating under SET ROLE is correct by construction and stays
157
+ // correct on re-run.
158
+ //
159
+ // The attachments stay INSIDE the block: GRANT and COMMENT on a function require ownership, so
160
+ // once the function belongs to `owner` the applying role can no longer make them. RESET ROLE is
161
+ // last and unconditional — a leaked SET ROLE would silently re-own every object created after
162
+ // it. The role name is validated at declaration (defineFunction), never here.
163
+ return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
164
+ }
165
+
166
+ const ALTER_OWNER: Record<string, string> = {
167
+ function: 'ALTER FUNCTION',
168
+ view: 'ALTER VIEW',
169
+ 'materialized view': 'ALTER MATERIALIZED VIEW',
170
+ };
171
+
172
+ /** The `ALTER … OWNER TO` target. A function needs its argument types — the same rule as DROP. */
173
+ function ownerTarget(obj: SourceObject): string {
174
+ return obj.kind === 'function' ? dropTarget('function', obj.identity) : quoteQualified(obj.identity);
122
175
  }
123
176
 
124
177
  /**
@@ -166,10 +219,22 @@ export function renderSetSearchPath(schemas: string[], local: boolean): string |
166
219
  *
167
220
  * `GRANT USAGE` rides along per schema (0a-bis mirrored), derived from the objects'
168
221
  * own grant attachments: a role granted SELECT on a matview in a non-public schema
169
- * cannot reach it without USAGE, so the object grant is dead without this. A PUBLIC
170
- * that appears only as explicitly-revoked gets nothing. Empty for all-public apps
171
- * their apply stays byte-identical. Idempotent when the state layer already created
172
- * the schema (a model schema also carrying derived objects).
222
+ * cannot reach it without USAGE, so the object grant is dead without this. Empty for
223
+ * all-public apps their apply stays byte-identical. Idempotent when the state layer
224
+ * already created the schema (a model schema also carrying derived objects).
225
+ *
226
+ * A13 — PUBLIC IS NEVER GRANTED SCHEMA USAGE HERE, and the reason is the distinction this
227
+ * codebase draws everywhere else: an ability is an intended audience, a privilege is a recorded
228
+ * fact. PostgreSQL grants every new function EXECUTE to PUBLIC, `db:pull` faithfully records
229
+ * that as `privileges: { PUBLIC: ['EXECUTE'] }` — and this pass used to read it as an audience
230
+ * and widen the SCHEMA to match. Caught by the round-trip oracle (B7): a schema whose original
231
+ * ACL gave PUBLIC nothing came back from a rebuild with `GRANT USAGE ON SCHEMA … TO PUBLIC`,
232
+ * turning an unreachable default grant into a reachable one. Recording a fact about one object
233
+ * must never widen a DIFFERENT object.
234
+ *
235
+ * The consequence is deliberate: a genuinely PUBLIC-callable function in a non-public schema
236
+ * needs schema USAGE, and that is now a decision someone DECLARES on the schema, not one this
237
+ * pass infers from a default nobody chose.
173
238
  */
174
239
  export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
175
240
  const statements: string[] = [];
@@ -181,18 +246,184 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
181
246
  if (o.schema !== schema) continue;
182
247
  const { grants } = parseGrantAttachments(o.attachments);
183
248
  for (const [role, privileges] of Object.entries(grants)) {
184
- if (privileges.length > 0) roles.add(role);
249
+ // See A13 above: PUBLIC's presence here is a recorded default, never a declared
250
+ // audience, and widening the schema to match it is a real privilege escalation.
251
+ if (privileges.length > 0 && role.toUpperCase() !== 'PUBLIC') roles.add(role);
185
252
  }
186
253
  }
187
254
  if (roles.size > 0) {
188
- const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
189
- statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${targets}`);
255
+ statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${[...roles].sort().join(', ')}`);
190
256
  }
191
257
  }
192
258
  return statements;
193
259
  }
194
260
 
195
- export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
261
+ // ---------------------------------------------------------------------------
262
+ // Declared-owner preflight — the refusal that beats a raw Postgres error.
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /** One declared owner and every schema the batch will create an object of theirs in. */
266
+ export interface OwnerRequirement {
267
+ owner: string;
268
+ schemas: string[];
269
+ }
270
+
271
+ /**
272
+ * The declared owners this batch will actually enact, and where.
273
+ *
274
+ * Scoped to CREATE and REPLACE, because those are the actions that run under `SET ROLE`. A
275
+ * baseline, a prune or an unchanged object never assumes anyone's identity, so demanding its
276
+ * owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
277
+ * build every object is created, so this set is every declared owner anyway.
278
+ */
279
+ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]): OwnerRequirement[] {
280
+ const srcById = new Map(source.map((o) => [o.identity, o]));
281
+ const byOwner = new Map<string, Set<string>>();
282
+ for (const action of plan.actions) {
283
+ if (action.action !== 'create' && action.action !== 'replace') continue;
284
+ const obj = srcById.get(action.identity);
285
+ if (!obj?.owner) continue;
286
+ (byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
287
+ }
288
+ return [...byOwner.entries()]
289
+ .map(([owner, schemas]) => ({ owner, schemas: [...schemas].sort() }))
290
+ .sort((a, b) => a.owner.localeCompare(b.owner));
291
+ }
292
+
293
+ /**
294
+ * ONE catalog read answering every question the preflight has, for every declared owner at once
295
+ * — so the refusal can name the COMPLETE list. A first-failure refusal makes the operator
296
+ * provision one role, re-run, and discover the next; the whole point is that they see the work.
297
+ *
298
+ * `has_schema_privilege` throws on a schema that does not exist, so the schema is LEFT JOINed
299
+ * and the privilege only evaluated when it is really there.
300
+ */
301
+ export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
302
+ const pairs = requirements.flatMap((r) => r.schemas.map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)})`));
303
+ return `
304
+ WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
305
+ SELECT
306
+ CURRENT_USER AS builder,
307
+ -- Which mechanism the applier may use. A SUPERUSER (RDS: an rds_superuser member) can hand an
308
+ -- object to a role it is not a member of, so it creates as itself and ALTERs the owner —
309
+ -- demanding SET ROLE of it would force CREATE-on-schema onto the very role that is supposed to
310
+ -- hold none. Everyone else creates AS the owner. Same shape as security-catalog's rule.
311
+ (
312
+ SELECT b.rolsuper OR EXISTS (
313
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
314
+ WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
315
+ )
316
+ FROM pg_roles b WHERE b.rolname = CURRENT_USER
317
+ ) AS builder_is_superuser,
318
+ d.owner_name,
319
+ d.schema_name,
320
+ (r.oid IS NOT NULL) AS role_exists,
321
+ (n.oid IS NOT NULL) AS schema_exists,
322
+ r.rolsuper AS owner_is_superuser,
323
+ CASE WHEN r.oid IS NULL THEN NULL ELSE pg_has_role(CURRENT_USER, r.oid, 'SET') END AS can_set_role,
324
+ CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
325
+ ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
326
+ FROM declared d
327
+ LEFT JOIN pg_roles r ON r.rolname = d.owner_name
328
+ LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
329
+ ORDER BY d.owner_name, d.schema_name
330
+ `.trim();
331
+ }
332
+
333
+ export interface OwnerPreflightRow {
334
+ builder?: unknown;
335
+ builder_is_superuser?: unknown;
336
+ owner_name: string;
337
+ schema_name: string;
338
+ role_exists?: unknown;
339
+ schema_exists?: unknown;
340
+ owner_is_superuser?: unknown;
341
+ can_set_role?: unknown;
342
+ owner_can_create?: unknown;
343
+ }
344
+
345
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
346
+
347
+ /**
348
+ * The mechanism this builder may use, read off the same preflight rows.
349
+ *
350
+ * Defaults to `set-role` when the column is absent — an older caller or a fixture — because that
351
+ * is the mechanism that works for everyone. Guessing `alter-owner` for an unknown builder would
352
+ * emit an ALTER a non-superuser cannot run, and fail mid-batch.
353
+ */
354
+ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
355
+ return rows.some((r) => truthy(r.builder_is_superuser)) ? 'alter-owner' : 'set-role';
356
+ }
357
+
358
+ /**
359
+ * The refusal text, or null when every declared owner is usable.
360
+ *
361
+ * Three ways a declared owner fails, all named at once with the exact statement that fixes each:
362
+ *
363
+ * 1. THE ROLE DOES NOT EXIST. The build refuses and does NOT create it. A role is
364
+ * cluster-scoped and a security principal, and a principal invented by a tool cannot know
365
+ * the design it stands in for — it would arrive with no memberships, no grants and no
366
+ * review, wearing the name of something that was supposed to be deliberate.
367
+ * 2. THE BUILDER CANNOT ASSUME IT. The CREATE runs under `SET ROLE <owner>`, so membership
368
+ * WITH SET TRUE is required. `WITH INHERIT FALSE` is part of the fix on purpose: it lets
369
+ * the builder become the owner without silently inheriting the owner's privileges.
370
+ * 3. THE OWNER CANNOT CREATE IN THE SCHEMA. Same mechanism — the CREATE happens AS the owner,
371
+ * so the owner needs CREATE on the schema the object lives in. A schema that does not exist
372
+ * yet counts: this build creates it as the BUILDER, and the owner will not hold CREATE on it.
373
+ */
374
+ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null {
375
+ const builder = rows.find((r) => r.builder != null)?.builder;
376
+ const builderName = builder == null ? 'the connecting role' : String(builder);
377
+ const missing = new Set<string>();
378
+ const unassumable = new Set<string>();
379
+ const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
380
+
381
+ // A superuser builder creates as itself and ALTERs the owner, so it needs neither membership in
382
+ // the owner nor CREATE on the owner's behalf. Refusing on either would be a false refusal that
383
+ // demands a privilege expansion onto a role designed to hold none. Only "the role must exist"
384
+ // survives, because no mechanism can hand an object to a principal that is not there.
385
+ const alterOwner = builderOwnerMode(rows) === 'alter-owner';
386
+
387
+ for (const row of rows) {
388
+ const owner = String(row.owner_name);
389
+ if (!truthy(row.role_exists)) { missing.add(owner); continue; }
390
+ if (alterOwner) continue;
391
+ if (!truthy(row.can_set_role)) unassumable.add(owner);
392
+ // A superuser owner holds CREATE everywhere, including in a schema this build is about to
393
+ // make — there is nothing to refuse.
394
+ if (truthy(row.owner_is_superuser)) continue;
395
+ if (!truthy(row.schema_exists)) noCreate.push({ owner, schema: String(row.schema_name), exists: false });
396
+ else if (!truthy(row.owner_can_create)) noCreate.push({ owner, schema: String(row.schema_name), exists: true });
397
+ }
398
+
399
+ if (!missing.size && !unassumable.size && !noCreate.length) return null;
400
+
401
+ const lines: string[] = [
402
+ `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied:`,
403
+ ];
404
+ for (const owner of [...missing].sort()) {
405
+ lines.push(
406
+ ` NO SUCH ROLE '${owner}' does not exist in this cluster. The build will not create it —`,
407
+ ` a role is a security principal, and one invented by a tool arrives with no memberships,`,
408
+ ` no grants and no review. Provision it, then re-run: CREATE ROLE ${owner} NOLOGIN;`,
409
+ );
410
+ }
411
+ for (const owner of [...unassumable].sort()) {
412
+ lines.push(
413
+ ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE runs as the owner:`,
414
+ ` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
415
+ );
416
+ }
417
+ for (const { owner, schema, exists } of noCreate) {
418
+ lines.push(
419
+ ` NO CREATE ON SCHEMA '${owner}' cannot create in schema "${schema}"${exists ? '' : ' (which this build creates as the builder)'}:`,
420
+ ` GRANT CREATE ON SCHEMA ${schema} TO ${owner};`,
421
+ );
422
+ }
423
+ return lines.join('\n');
424
+ }
425
+
426
+ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[], ownerMode: OwnerApplyMode = 'set-role'): RenderedReconcile {
196
427
  const srcById = new Map(source.map((o) => [o.identity, o]));
197
428
  const statements: string[] = [];
198
429
  const record: string[] = [];
@@ -214,14 +445,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
214
445
  case 'replace': {
215
446
  const obj = srcById.get(action.identity);
216
447
  if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
217
- statements.push(...objectSql(obj).map(ensureOrReplace));
448
+ statements.push(...objectSql(obj, ownerMode).map(ensureOrReplace));
218
449
  record.push(action.identity);
219
450
  break;
220
451
  }
221
452
  case 'create': {
222
453
  const obj = srcById.get(action.identity);
223
454
  if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
224
- statements.push(...objectSql(obj));
455
+ statements.push(...objectSql(obj, ownerMode));
225
456
  record.push(action.identity);
226
457
  break;
227
458
  }
@@ -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: {