@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.
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
  import {
17
17
  audit,
18
+ schemaDescriptorsFromContract,
18
19
  parseFunctionsFromSql,
19
20
  parseViewsAndGrantsFromSql,
20
21
  type Waivers,
@@ -28,6 +29,7 @@ import {
28
29
  catalogFunctionToDescriptor,
29
30
  catalogRelationToDescriptor,
30
31
  } from '../security-catalog.js';
32
+ import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract.js';
31
33
  import { resolveConfig, opsFunction } from '../config.js';
32
34
  import { invokeAction } from '../aws.js';
33
35
  import { step, success, fail, info, warn } from '../output.js';
@@ -116,11 +118,28 @@ export async function auditDeployedSql(
116
118
  step('Introspecting database catalog (functions + relations)...');
117
119
  const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
118
120
  const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
121
+ // A10(ii) — the PRECONDITION leg. Unpinned SECDEF and owner-bypasses-RLS both describe the
122
+ // privileged CODE; neither says whether an attacker can plant something for it to resolve to.
123
+ // Only the catalog path can answer that, so the static path leaves `schemas` empty rather
124
+ // than report every schema as clean.
125
+ const aclRows = await catalogQuery(region, opsFn, SCHEMA_ACL_SQL);
119
126
 
120
127
  const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
121
128
  const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
122
- info(`Catalog: ${functions.length} function(s), ${views.length} relation(s).`);
123
- return audit(functions, views, waivers);
129
+ const schemaAcls: Record<string, Record<string, string[]>> = {};
130
+ for (const row of aclRows) {
131
+ schemaAcls[String(row.schema)] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
132
+ }
133
+ const schemas = schemaDescriptorsFromContract(
134
+ schemaAcls,
135
+ fnRows.map((r: any) => ({
136
+ name: `${r.schema}.${r.name}`,
137
+ securityDefiner: r.security_definer === true || r.security_definer === 't',
138
+ hasSearchPath: r.has_search_path === true || r.has_search_path === 't',
139
+ })),
140
+ );
141
+ info(`Catalog: ${functions.length} function(s), ${views.length} relation(s), ${schemas.length} schema(s).`);
142
+ return audit(functions, views, waivers, schemas);
124
143
  }
125
144
 
126
145
  async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
@@ -153,12 +172,14 @@ export function printReport(report: AuditReport, instrument: 'static' | 'catalog
153
172
  const reds = [
154
173
  ...report.functions.filter((f) => f.severity === 'red'),
155
174
  ...report.views.filter((v) => v.severity === 'red'),
175
+ ...report.schemas.filter((s) => s.severity === 'red'),
156
176
  ];
157
177
  const warns = [
158
178
  ...report.functions.filter((f) => f.severity === 'warn'),
159
179
  ...report.views.filter((v) => v.severity === 'warn'),
180
+ ...report.schemas.filter((s) => s.severity === 'warn'),
160
181
  ];
161
- const waived = report.functions.filter((f) => f.waived);
182
+ const waived = [...report.functions.filter((f) => f.waived), ...report.schemas.filter((s) => s.waived)];
162
183
 
163
184
  if (reds.length > 0) {
164
185
  console.log('');
@@ -129,6 +129,19 @@ export async function buildIntoDatabase(
129
129
  for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
130
130
  await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
131
131
  }
132
+ // A10(i) — ASSERT the schema posture, do not inherit it.
133
+ //
134
+ // PG15 removed PUBLIC's CREATE on schema `public`; before that it was the default, and it
135
+ // still rides in with any pre-PG15 dump. So a database everystack BUILDS was hardened only
136
+ // by accident of the server version, while one it ADOPTS was not — and db:fingerprint
137
+ // reported MATCH across both. A security posture that depends on which major created the
138
+ // database is exactly the environmental dependence this class of defect is about.
139
+ //
140
+ // Safe here and ONLY here: db:build refuses a database that already holds objects, so there
141
+ // is nothing in `public` to strand. Bringing an ADOPTED database to this posture is a
142
+ // reviewable REVOKE the operator sees before it runs, never a silent side effect — some
143
+ // legacy apps do create objects in `public` at runtime.
144
+ await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
132
145
  const createdRoles = await ensureContractRoles(runner, models);
133
146
 
134
147
  // FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
@@ -170,12 +183,34 @@ export async function buildIntoDatabase(
170
183
  await runner('RESET check_function_bodies');
171
184
  }
172
185
  }
173
- const run = await executeSync(runner, session, models, {
186
+ let run = await executeSync(runner, session, models, {
174
187
  declared: options.declared,
175
188
  sequences: options.sequences,
176
189
  actor: options.actor ?? 'db-build',
177
190
  gitRef: options.gitRef ?? null,
178
191
  });
192
+ // A12 — THE SECOND PASS, and it is inherent to building from nothing rather than a patch
193
+ // over one bug. `executeSync` is a single diff-and-verify: it reads the live authz contract,
194
+ // computes the delta, applies it. On a FROM-SCRATCH build some objects the authz layer must
195
+ // grant on do not exist at read time — most sharply the sequence behind a serial column,
196
+ // which our own CREATE TABLE makes moments later. So the first pass cannot see it, the
197
+ // sequence inheritance rule (A6) has nothing to grant on, and the build lands with a role
198
+ // that may INSERT into a table but cannot draw its sequence value.
199
+ //
200
+ // Caught by the round-trip oracle (B7), not by A6's own tests: the rule converges perfectly
201
+ // against an EXISTING database, which is what those tests exercise.
202
+ //
203
+ // Bounded at exactly one extra pass. The second read sees everything the first one created,
204
+ // so a third could only differ if the apply were non-convergent — and that is a real failure
205
+ // the `converged` bar must report, never something to loop away.
206
+ if (!run.converged) {
207
+ run = await executeSync(runner, session, models, {
208
+ declared: options.declared,
209
+ sequences: options.sequences,
210
+ actor: options.actor ?? 'db-build',
211
+ gitRef: options.gitRef ?? null,
212
+ });
213
+ }
179
214
  return {
180
215
  converged: run.converged,
181
216
  fingerprintMatch: run.fingerprintMatch,
@@ -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
  }