@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.
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
65
65
  models: ModelDescriptor[] | null,
66
66
  sequences?: SequenceDescriptor[],
67
67
  governedExtras?: readonly string[],
68
+ /** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
69
+ declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
68
70
  ): Promise<FingerprintStatus> {
69
71
  const snapshot = await introspectSchema(session);
70
72
  const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
77
79
  const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
78
80
  const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
79
81
  const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
80
- const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
82
+ // B5 a declared owner IS hashed (derived-source prefixes it), so reporting it as
83
+ // unfingerprinted would be a false claim in the one report whose whole job is honesty about
84
+ // coverage. SQL cannot know what the models declare; the filter belongs here.
85
+ const ownerDeclared = new Set(
86
+ (declaredDerived ?? [])
87
+ .filter((o) => o.kind === 'function' && o.owner !== undefined)
88
+ .map((o) => o.identity.replace(/\(.*\)$/, '')),
89
+ );
90
+ const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
91
+ .filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
81
92
  return {
82
93
  live,
83
94
  ...(governedLive !== undefined ? { governedLive } : {}),
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
101
112
  let models: ModelDescriptor[] | null = null;
102
113
  let sequences: SequenceDescriptor[] | undefined;
103
114
  let governedExtras: string[] | undefined;
115
+ let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
104
116
  try {
105
117
  models = await loadModels(modelsPath);
106
118
  const declared = await loadDeclaredDerived(flags.models);
107
119
  sequences = declared?.sequences;
108
120
  governedExtras = declared?.governedRoles;
121
+ // B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
122
+ declaredDerived = declared?.objects;
109
123
  } catch (err: any) {
110
124
  // Two very different situations used to land here identically.
111
125
  //
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
141
155
  }
142
156
 
143
157
  try {
144
- const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
158
+ const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
145
159
 
146
160
  if (flags.json === 'true') {
147
161
  console.log(JSON.stringify(status, null, 2));
@@ -279,9 +279,26 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
279
279
  // work — say so on the review surface, where a human still reads it.
280
280
  if (plan.statements.length > 0) {
281
281
  const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
282
- if (verdict.status === 'diverged' || verdict.status === 'drift') {
283
- warn(`descent: ${verdict.reason}`);
284
- warn('db:apply from this checkout will refuse this plan rebase first, then re-mint.');
282
+ // A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
283
+ // it did not name, so `indeterminate` would have minted a plan with no warning at all
284
+ // and then been refused at apply time with no hint of why. Exhaustive here means a new
285
+ // verdict cannot be forgotten.
286
+ switch (verdict.status) {
287
+ case 'ok':
288
+ case 'fresh-target':
289
+ case 'no-git':
290
+ break;
291
+ case 'diverged':
292
+ case 'drift':
293
+ warn(`descent: ${verdict.reason}`);
294
+ warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
295
+ break;
296
+ case 'indeterminate':
297
+ // NOT "rebase first" — nothing is wrong with the checkout or the database. The
298
+ // search failed, so the rule is unproven either way.
299
+ warn(`descent: ${verdict.reason}`);
300
+ warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
301
+ break;
285
302
  }
286
303
  }
287
304
 
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
35
35
  import path from 'node:path';
36
36
  import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
37
37
  import { fingerprintLive } from '../schema-fingerprint.js';
38
- import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
38
+ import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
39
39
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
40
40
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
41
41
  import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
235
235
  // governed role's grants are DECLARED (transcribed as privileges), so recording them
236
236
  // as exemptions too would double-book them — declared and exempted at once.
237
237
  pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
238
+ // A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
239
+ // common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
240
+ // what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
241
+ // it would be the tool deciding a security property from an inference.
242
+ for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
238
243
  pulledFingerprint = fingerprintLive(current, contract).hash;
239
244
  }
240
245
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
@@ -43,6 +43,11 @@ import {
43
43
  derivedSearchPath,
44
44
  renderSetSearchPath,
45
45
  renderEnsureObjectSchemas,
46
+ ownerRequirements,
47
+ ownerPreflightSql,
48
+ ownerPreflightRefusal,
49
+ builderOwnerMode,
50
+ type OwnerApplyMode,
46
51
  ENSURE_RECONCILER_SQL,
47
52
  } from '../derived-apply.js';
48
53
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
@@ -78,6 +83,13 @@ export interface ReconcileRun {
78
83
  statements: string[];
79
84
  /** Why apply was refused, when it was. */
80
85
  refusal?: string;
86
+ /**
87
+ * Which mechanism enacted the declared owners, when any were enacted. Reported because the two
88
+ * make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
89
+ * the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
90
+ * operator debugging a permission error should not have to guess which one ran.
91
+ */
92
+ ownerMode?: OwnerApplyMode;
81
93
  }
82
94
 
83
95
  /**
@@ -118,7 +130,10 @@ export async function executeReconcile(
118
130
  const live = await introspectDerived(session);
119
131
  const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
120
132
  const plan = planReconcile(parsed, live, options);
121
- const rendered = renderReconcileSql(plan, parsed.objects);
133
+ // Rendered with the mechanism that works for EVERY builder. The preflight below reads the
134
+ // catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
135
+ // (or is refused) shows the conservative form, which is the honest default.
136
+ let rendered = renderReconcileSql(plan, parsed.objects);
122
137
 
123
138
  if (!options.apply) return { plan, applied: false, statements: rendered.statements };
124
139
 
@@ -138,6 +153,23 @@ export async function executeReconcile(
138
153
  return { plan, applied: false, statements: [] };
139
154
  }
140
155
 
156
+ // DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
157
+ //
158
+ // Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
159
+ // rather than one per re-run, so the operator's first news is not a raw Postgres error from the
160
+ // middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
161
+ // with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
162
+ // owner, which needs membership and CREATE on the schema.
163
+ const requirements = ownerRequirements(plan, parsed.objects);
164
+ let ownerMode: OwnerApplyMode | undefined;
165
+ if (requirements.length > 0) {
166
+ const rows = (await runner(ownerPreflightSql(requirements))) as any[];
167
+ const refusal = ownerPreflightRefusal(rows);
168
+ if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
169
+ ownerMode = builderOwnerMode(rows);
170
+ if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
171
+ }
172
+
141
173
  const now = options.now ?? Date.now;
142
174
  await runner(ENSURE_RECONCILER_SQL.join(';\n'));
143
175
 
@@ -263,7 +295,7 @@ export async function executeReconcile(
263
295
  throw explainReconcileError(err);
264
296
  }
265
297
 
266
- return { plan, applied: true, statements: rendered.statements };
298
+ return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
267
299
  }
268
300
 
269
301
  /**
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
520
552
  fail(`not applied: ${run.refusal}`);
521
553
  } else if (run.applied) {
522
554
  success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
555
+ // Which ownership mechanism ran. The two make different demands, so an operator
556
+ // debugging a permission error should not have to guess which one they hit.
557
+ if (run.ownerMode === 'alter-owner') {
558
+ info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
559
+ } else if (run.ownerMode === 'set-role') {
560
+ info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
561
+ }
523
562
  if (run.plan.actions.some((a) => a.action === 'baseline')) {
524
563
  warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
525
564
  }
@@ -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
  }