@everystack/cli 0.4.105 → 0.4.107

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.
@@ -6,6 +6,8 @@ import { compileTableContract } from './authz-compile';
6
6
  import { reconcileOwner, triggerFunctionOf } from './derived-apply';
7
7
  import { isSessionError, type SessionRunner } from './session';
8
8
  import { parseQualified, type SourceObject } from './derived-source';
9
+ import { quoteQualified } from './pg-ident';
10
+ import { ALWAYS_GOVERNED } from './authz-reconcile';
9
11
  import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
10
12
 
11
13
  export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
@@ -32,14 +34,127 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
32
34
  /**
33
35
  * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
34
36
  * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
37
+ *
38
+ * `planStatements`: the reviewed plan this check runs before. A finding the plan resolves is not
39
+ * a refusal (see planRevokedGrants). The plan's state transaction commits before the derived
40
+ * catch-up creates any lifecycle function, and the post-check reads the catalog again after it.
35
41
  */
36
- export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
37
- if (!models.some(model => model.lifecycle === true)) return null;
42
+ export async function lifecycleApplyRefusal(
43
+ runner: QueryRunner,
44
+ models: readonly ModelDescriptor[],
45
+ roles: LifecycleRoleDeclaration = {},
46
+ planStatements: readonly string[] = [],
47
+ ): Promise<string | null> {
48
+ return (await lifecycleApplyReport(runner, models, roles, planStatements)).refusal;
49
+ }
50
+
51
+ /** lifecycleApplyRefusal, plus the findings the plan resolves (one line, or null when none). */
52
+ export async function lifecycleApplyReport(
53
+ runner: QueryRunner,
54
+ models: readonly ModelDescriptor[],
55
+ roles: LifecycleRoleDeclaration = {},
56
+ planStatements: readonly string[] = [],
57
+ ): Promise<{ refusal: string | null; resolved: string | null }> {
58
+ if (!models.some(model => model.lifecycle === true)) return { refusal: null, resolved: null };
38
59
  const [log] = await runner(LIFECYCLE_LOG_SQL);
60
+ const shape = lifecycleLogRowRefusal(log);
61
+ if (shape) return { refusal: shape, resolved: null };
39
62
  // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
40
63
  // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
41
64
  const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
42
- return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
65
+ const report = await lifecycleCatalogFindings(runner, { ...catalogInputsFromModels(models, roles), modeledTables },
66
+ { planRevokes: planRevokedGrants(planStatements) });
67
+ return { refusal: catalogRefusalText(report), resolved: resolvedLine(report.resolved) };
68
+ }
69
+
70
+ /**
71
+ * What `db:provision` must do before a lifecycle plan can apply, as one line naming the command,
72
+ * or null. The same reads the apply refuses on: everystack.destroy_log missing or out of date
73
+ * (lifecycleLogRowRefusal), and the lifecycle role missing or holding LOGIN or BYPASSRLS (the
74
+ * attributes db:provision resets). `command` is the db:provision invocation for this target.
75
+ */
76
+ export async function lifecycleProvisionFirst(
77
+ runner: QueryRunner,
78
+ models: readonly ModelDescriptor[],
79
+ command: string,
80
+ options: { lifecycleRole?: string } = {},
81
+ ): Promise<string | null> {
82
+ if (!models.some(model => model.lifecycle === true)) return null;
83
+ const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
84
+ const reasons: string[] = [];
85
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
86
+ if (log?.present !== true) reasons.push('everystack.destroy_log is missing');
87
+ else if (log.current !== true) reasons.push('everystack.destroy_log is out of date');
88
+ const [role] = await runner(roleSql(lc));
89
+ if (!truthy(role?.present)) reasons.push(`role ${lc} is missing`);
90
+ else {
91
+ const wrong = (['login', 'bypassrls'] as const).filter(key => truthy(role[key])).map(key => key.toUpperCase());
92
+ if (wrong.length > 0) reasons.push(`role ${lc} has ${wrong.join(', ')}`);
93
+ }
94
+ if (reasons.length === 0) return null;
95
+ return `run db:provision first: ${reasons.join('; ')}. Run: ${command}`;
96
+ }
97
+
98
+ /**
99
+ * The grants a reviewed plan revokes, one key per (table, grantee, privilege, column) — the exact
100
+ * statements the reconciler emits (authz-reconcile.ts reconcileGrants, reconcileColumnGrants):
101
+ *
102
+ * REVOKE REFERENCES, TRIGGER, TRUNCATE ON public.t FROM admin;
103
+ * REVOKE REFERENCES (a, b) ON public.t FROM admin;
104
+ *
105
+ * Anything else (a sequence, a statement this does not recognise) resolves nothing, so an
106
+ * unrecognised form can only leave a finding standing, never hide one.
107
+ */
108
+ export function planRevokedGrants(statements: readonly string[]): ReadonlySet<string> {
109
+ const keys = new Set<string>();
110
+ for (const statement of statements) {
111
+ const column = /^REVOKE ([A-Z]+) \(([^()]+)\) ON (.+) FROM ([^;]+);$/.exec(statement);
112
+ if (column) {
113
+ for (const name of column[2].split(', ')) keys.add(revokeKey(column[3], column[4], column[1], name));
114
+ continue;
115
+ }
116
+ const table = /^REVOKE ([A-Z]+(?:, [A-Z]+)*) ON (.+) FROM ([^;]+);$/.exec(statement);
117
+ if (!table || table[2].startsWith('SEQUENCE ')) continue;
118
+ for (const privilege of table[1].split(', ')) keys.add(revokeKey(table[2], table[3], privilege));
119
+ }
120
+ return keys;
121
+ }
122
+
123
+ /** `table` as the reconciler emits it (quoteQualified of the contract's `schema.table`). */
124
+ const revokeKey = (table: string, grantee: string, privilege: string, column?: string): string =>
125
+ JSON.stringify([table, grantee, privilege, column ?? null]);
126
+
127
+ /**
128
+ * What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
129
+ * reads it to tell a definer role the models grant to apart from a role a request can become.
130
+ *
131
+ * requestRoles `roles: { request }`, the union across modules. Undefined = not declared, and
132
+ * the check runs exactly as before: every grantee counts as a request role.
133
+ * declaredOwners every `owner` a declared derived object names.
134
+ */
135
+ export interface LifecycleRoleDeclaration {
136
+ requestRoles?: string[];
137
+ declaredOwners?: string[];
138
+ }
139
+
140
+ /** The role declaration from a loaded declared layer; empty (not declared) when there is none. */
141
+ export function lifecycleRolesOf(declared: { requestRoles?: string[]; declaredOwners?: string[] } | null | undefined): LifecycleRoleDeclaration {
142
+ return declared?.requestRoles === undefined ? {} : { requestRoles: declared.requestRoles, declaredOwners: declared.declaredOwners ?? [] };
143
+ }
144
+
145
+ /**
146
+ * The grantees the declaration does not classify: each counts as a request role, so the lifecycle
147
+ * check treats it as application-side. A warning, never a refusal. Null when the request roles are
148
+ * not declared (nothing is classified), when no model is a lifecycle model, or when none is left.
149
+ */
150
+ export function unclassifiedGranteesWarning(models: readonly ModelDescriptor[], roles: LifecycleRoleDeclaration): string | null {
151
+ if (roles.requestRoles === undefined || !models.some(model => model.lifecycle === true)) return null;
152
+ const classified = new Set([...roles.requestRoles, ...(roles.declaredOwners ?? [])]);
153
+ const unclassified = catalogInputsFromModels(models).applicationRoles.filter(role => !classified.has(role));
154
+ if (unclassified.length === 0) return null;
155
+ return `the models grant privileges to ${unclassified.join(', ')}, which ${unclassified.length === 1 ? 'is' : 'are'} neither a declared request role `
156
+ + `(roles: { request }) nor a declared owner, so the lifecycle check counts ${unclassified.length === 1 ? 'it' : 'them'} as a role a request can become. `
157
+ + 'Add each to roles.request, or name it as the owner of the derived objects it owns.';
43
158
  }
44
159
 
45
160
  const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
@@ -85,6 +200,19 @@ export interface LifecycleCatalogInputs {
85
200
  * rule 6 leaves to the plan. Absent after the state apply: every key is checked.
86
201
  */
87
202
  modeledTables?: readonly string[];
203
+ /**
204
+ * The declared request roles (LifecycleRoleDeclaration). Absent = not declared: the
205
+ * application roles are the grantees, exactly as before. Present: see applicationRoleSet.
206
+ */
207
+ requestRoles?: readonly string[];
208
+ /** Every declared derived-object owner. Read only when `requestRoles` is present. */
209
+ declaredOwners?: readonly string[];
210
+ /**
211
+ * The roles a fresh plan governs (ALWAYS_GOVERNED and every grantee of the models): the plan
212
+ * revokes any REFERENCES, TRIGGER or TRUNCATE such a role holds on a lifecycle table, since a
213
+ * lifecycle model cannot declare one. Absent (a db:reconcile payload): every rule-3 fix is SQL.
214
+ */
215
+ governedRoles?: readonly string[];
88
216
  }
89
217
 
90
218
  /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
@@ -127,19 +255,25 @@ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<type
127
255
 
128
256
  const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
129
257
 
130
- /** The catalog check's inputs, read from the models. */
131
- export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
258
+ /** The catalog check's inputs, read from the models (and the role declaration, when there is one). */
259
+ export function catalogInputsFromModels(
260
+ models: readonly ModelDescriptor[],
261
+ declaration: LifecycleRoleDeclaration = {},
262
+ ): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[]; governedRoles: string[] } {
132
263
  const roles = new Set<string>();
264
+ const governed = new Set<string>(ALWAYS_GOVERNED);
133
265
  for (const model of models) {
134
266
  const contract = compileTableContract(model);
135
- for (const role of Object.keys(contract.grants)) roles.add(role);
136
- for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
267
+ for (const role of Object.keys(contract.grants)) { roles.add(role); governed.add(role); }
268
+ for (const role of Object.keys(contract.columnGrants ?? {})) { roles.add(role); governed.add(role); }
137
269
  for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
138
270
  }
139
271
  return {
272
+ governedRoles: [...governed].sort(),
140
273
  lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
141
274
  applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
142
275
  dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
276
+ ...(declaration.requestRoles === undefined ? {} : { requestRoles: [...declaration.requestRoles], declaredOwners: [...(declaration.declaredOwners ?? [])] }),
143
277
  };
144
278
  }
145
279
 
@@ -153,9 +287,16 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
153
287
  const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
154
288
  WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
155
289
  ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
156
- /** `x` can act as `y`, or hold ADMIN on it: an ADMIN holder can grant `y` to itself WITH SET. */
290
+ /**
291
+ * `x` can act as `y`, or hold ADMIN on it (an ADMIN holder can grant `y` to itself WITH SET), or
292
+ * reach it through a chain of grants whose options differ link to link. MEMBER counts any chain:
293
+ * ADMIN-only on `i`, then `i` a member of `y` (grant `i` to itself WITH SET, SET ROLE `i`, then
294
+ * `y`); or SET-only on `i`, then `i` an INHERIT-only member of `y` (SET ROLE `i` holds `y`'s
295
+ * privileges). USAGE, SET and MEMBER WITH ADMIN OPTION are each false for both. Never in `assumes`:
296
+ * the side set must still leave out the RDS master's ADMIN-only membership.
297
+ */
157
298
  const reaches = (x: string, y: string): string =>
158
- `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
299
+ `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER'))`;
159
300
 
160
301
  /**
161
302
  * The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
@@ -166,6 +307,115 @@ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::t
166
307
  WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
167
308
  WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
168
309
 
310
+ /** How `x` reaches `y`, for a finding: INHERIT, SET, ADMIN or a chain of grants (the first that holds; see assumes and reaches). */
311
+ const reachHow = (x: string, y: string): string => `CASE WHEN pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') THEN 'INHERIT'
312
+ WHEN (CASE WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
313
+ ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END) THEN 'SET'
314
+ WHEN pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') THEN 'ADMIN' ELSE 'a chain of grants' END`;
315
+
316
+ /**
317
+ * The live facts the declaration rests on. Per declared request role: does it exist, is it a
318
+ * superuser, is it BYPASSRLS. Per declared owner that is a grantee or a request role: does it exist,
319
+ * can it log in, and which request roles reach it (reaches: INHERIT, SET or ADMIN, transitively).
320
+ */
321
+ const requestRoleSql = (request: string[], owners: string[]): string => `SELECT 'request' AS kind, d.name, o.oid IS NOT NULL AS present,
322
+ COALESCE(o.rolsuper, false) AS super, COALESCE(o.rolbypassrls, false) AS bypassrls, COALESCE(o.rolcanlogin, false) AS login,
323
+ ARRAY[]::text[] AS reached_by
324
+ FROM pg_catalog.unnest(${textArray(request)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
325
+ UNION ALL
326
+ SELECT 'owner', d.name, o.oid IS NOT NULL, COALESCE(o.rolsuper, false), COALESCE(o.rolbypassrls, false), COALESCE(o.rolcanlogin, false),
327
+ ARRAY(SELECT 'request role ' || r.rolname::text || ' can act as ' || d.name || ' (' || ${reachHow('r.oid', 'o.oid')} || ')'
328
+ FROM pg_catalog.pg_roles r WHERE r.rolname = ANY(${textArray(request)}) AND r.oid <> o.oid AND ${reaches('r.oid', 'o.oid')} ORDER BY 1)
329
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
330
+ ORDER BY 1, 2`;
331
+
332
+ /** The application roles every rule reads (sideCte's input), and why each counts where the declaration can say. */
333
+ interface ApplicationRoleSet {
334
+ apps: string[];
335
+ /** A role in `apps` → one sentence saying why it is there. Empty when nothing is declared. */
336
+ why: Map<string, string>;
337
+ /** Live findings on the declared request roles. */
338
+ findings: string[];
339
+ /** The models' grantees (applicationRoles), PUBLIC and the lifecycle role left out. */
340
+ grantees: ReadonlySet<string>;
341
+ /** The request roles are declared. */
342
+ declared: boolean;
343
+ }
344
+
345
+ /**
346
+ * THE one place the application-role set is computed. Rules 1, 2 and 5, the pre-exposure gate, the
347
+ * post-check and the reconcile path all read it.
348
+ *
349
+ * Not declared (`requestRoles` absent): the grantees, exactly as before, and no query runs.
350
+ *
351
+ * Declared: the request roles and the grantees, minus each declared owner D that holds every guard,
352
+ * read live: D is not a request role, D is not the lifecycle role (never in the set: PUBLIC and it are
353
+ * filtered out), D exists and cannot log in, and no request role reaches D. A declared owner that
354
+ * fails a guard stays, and `why` names the guard. A grantee in neither list stays as well. Each
355
+ * request role must exist, and be neither a superuser nor BYPASSRLS: either is a finding.
356
+ */
357
+ async function applicationRoleSet(runner: QueryRunner, inputs: LifecycleCatalogInputs, lc: string): Promise<ApplicationRoleSet> {
358
+ const grantees = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
359
+ if (inputs.requestRoles === undefined) return { apps: grantees, why: new Map(), findings: [], grantees: new Set(grantees), declared: false };
360
+ const request = new Set([...inputs.requestRoles].filter(notApplicationRole(lc)));
361
+ const owners = new Set(inputs.declaredOwners ?? []);
362
+ const set = new Set([...request, ...grantees]);
363
+ const candidates = [...set].filter(role => owners.has(role)).sort();
364
+ const findings: string[] = [];
365
+ const why = new Map<string, string>();
366
+ for (const row of await runner(requestRoleSql([...request].sort(), candidates))) {
367
+ const name = String(row.name);
368
+ if (row.kind === 'request') {
369
+ if (!truthy(row.present)) {
370
+ findings.push(`request role ${name} does not exist. Fix: create it, or remove it from roles.request.`);
371
+ continue;
372
+ }
373
+ if (truthy(row.super)) findings.push(`request role ${name} is a superuser: a request that becomes it passes every check. Fix: ALTER ROLE ${ident(name)} NOSUPERUSER;`);
374
+ if (truthy(row.bypassrls)) findings.push(`request role ${name} has BYPASSRLS: a request that becomes it skips every row-level policy. Fix: ALTER ROLE ${ident(name)} NOBYPASSRLS;`);
375
+ continue;
376
+ }
377
+ const failed: string[] = [];
378
+ if (request.has(name)) failed.push(`declared owner ${name} is a request role`);
379
+ if (!truthy(row.present)) failed.push(`declared owner ${name} does not exist`);
380
+ else if (truthy(row.login)) failed.push(`declared owner ${name} can log in`);
381
+ failed.push(...textList(row.reached_by));
382
+ if (failed.length === 0) set.delete(name);
383
+ else why.set(name, `${name} counts as an application role: ${failed.join('; ')}.`);
384
+ }
385
+ for (const role of grantees) {
386
+ if (!request.has(role) && !owners.has(role)) {
387
+ why.set(role, `${role} counts as an application role because the models grant it privileges, and it is neither a declared request role nor a declared owner.`);
388
+ }
389
+ }
390
+ return { apps: [...set].sort(), why, findings, grantees: new Set(grantees), declared: true };
391
+ }
392
+
393
+ /**
394
+ * Why `owner`, itself an application-side role, counts: for a rule-5 finding. The declaration's
395
+ * reason when it has one. Undeclared, and the owner counts only because the models grant to it (it
396
+ * acts as no other application role, and no other application role reaches it), the finding says
397
+ * so and names the declaration that tells a definer role apart. Reached, the REVOKE fix stands alone.
398
+ */
399
+ function ownerCause(set: ApplicationRoleSet, owner: string, actsAs: readonly string[], appReach: readonly string[]): string | undefined {
400
+ const known = set.why.get(owner);
401
+ if (known) return known;
402
+ if (set.declared) return actsAs.length > 0 ? `${owner} can act as the application role${actsAs.length === 1 ? '' : 's'} ${actsAs.join(', ')}.` : undefined;
403
+ const granted = set.grantees.has(owner) && actsAs.length === 0 && appReach.length === 0;
404
+ return granted ? grantedCause(owner) : undefined;
405
+ }
406
+
407
+ const grantedCause = (owner: string): string => `${owner} counts as an application role because the models grant it privileges. `
408
+ + 'If it is a definer role that no request role reaches, declare the request roles with roles: { request: [...] } in a module.';
409
+
410
+ /**
411
+ * Two columns for the rule-5 queries: the application roles `owner` can act as (acts_as), and the
412
+ * other application roles that reach `owner` (app_reach). Neither counts `owner` itself.
413
+ */
414
+ const ownerReachColumns = (apps: string[], owner: string): string => `ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
415
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${assumes(owner, 'a.oid')} ORDER BY 1) AS acts_as,
416
+ ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
417
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${reaches('a.oid', owner)} ORDER BY 1) AS app_reach`;
418
+
169
419
  /** Rule 1, attributes. */
170
420
  const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
171
421
  COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
@@ -184,11 +434,17 @@ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
184
434
  WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
185
435
  ORDER BY 1`;
186
436
 
437
+ /** The deploy history: who owns one can rewrite what was applied, run and reconciled. */
438
+ const HISTORY_TABLES = ['everystack.schema_log', 'everystack.exec_log', 'everystack.derived_provenance'];
439
+
187
440
  /**
188
441
  * Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
189
- * write the marker; the owner of a marker function can replace it. Each such object is a finding
190
- * when its owner is the lifecycle role, or when an application-side role is that owner, can act
191
- * as it, or holds ADMIN on it. The operator owning all of them is the documented setup and passes.
442
+ * write the marker; the owner of a marker function can replace it; the owner of a schema holding
443
+ * any of them can drop what is in it (DROP ... CASCADE takes the delete-path trigger along); the
444
+ * owner of schema_log, exec_log or derived_provenance can forge the deploy history. Each such
445
+ * object is a finding when its owner is the lifecycle role, or when an application-side role is
446
+ * that owner, can act as it, or holds ADMIN on it. The operator owning all of them is the
447
+ * documented setup and passes. A table that does not exist is not judged.
192
448
  */
193
449
  const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
194
450
  owned AS (
@@ -200,8 +456,19 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
200
456
  WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
201
457
  UNION ALL
202
458
  SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
203
- JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig))
459
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)
460
+ UNION ALL
461
+ SELECT 'table', n.nspname::text || '.' || c.relname::text, c.relowner
462
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
463
+ WHERE c.oid IN (SELECT pg_catalog.to_regclass(t) FROM pg_catalog.unnest(${textArray(HISTORY_TABLES)}) AS t)
464
+ UNION ALL
465
+ SELECT 'schema', n.nspname::text, n.nspowner FROM pg_catalog.pg_namespace n
466
+ WHERE n.nspname = ANY(${textArray([...new Set(tables.map(table => table.slice(0, table.indexOf('.'))))])})
467
+ OR n.oid IN (SELECT c.relnamespace FROM pg_catalog.pg_class c WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log'))
468
+ OR n.oid IN (SELECT p.pronamespace FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
469
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)))
204
470
  SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
471
+ pg_catalog.current_database()::text AS database,
205
472
  ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
206
473
  FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
207
474
  WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
@@ -211,23 +478,27 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
211
478
  * Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
212
479
  * columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
213
480
  * (table or column) held by anyone but its owner: a writer could forge or remove a marker.
481
+ * One row per grantor: PostgreSQL revokes only the grants made by the role REVOKE runs as, and
482
+ * runs a member's (or a superuser's) REVOKE as the owner, so who granted it decides what removes it.
214
483
  */
215
484
  const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
216
- a.privilege_type AS privilege, NULL::text AS "column"
485
+ a.privilege_type AS privilege, NULL::text AS "column", a.grantor = c.relowner AS owner_granted,
486
+ pg_catalog.pg_get_userbyid(a.grantor)::text AS grantor
217
487
  FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
218
488
  CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
219
489
  WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
220
490
  ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
221
491
  OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
222
492
  UNION ALL
223
- SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
493
+ SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text,
494
+ a.grantor = c.relowner, pg_catalog.pg_get_userbyid(a.grantor)::text
224
495
  FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
225
496
  JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
226
497
  CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
227
498
  WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
228
499
  ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
229
500
  OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
230
- ORDER BY 1, 4 NULLS FIRST, 3, 2`;
501
+ ORDER BY 1, 4 NULLS FIRST, 3, 2, 6`;
231
502
 
232
503
  /**
233
504
  * Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
@@ -260,7 +531,8 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
260
531
  SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
261
532
  pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
262
533
  pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
263
- ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via
534
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via,
535
+ ${ownerReachColumns(apps, 'p.proowner')}
264
536
  FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
265
537
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
266
538
  JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
@@ -272,8 +544,12 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
272
544
  * One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
273
545
  * owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
274
546
  * `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
547
+ * `cause`: why the owner counts as an application role (ownerCause), when there is more to say.
275
548
  */
276
- export function triggerOwnerFinding(row: { trigger: string; table: string; function: string; owner: string; via: readonly string[] }, would = false): string {
549
+ export function triggerOwnerFinding(
550
+ row: { trigger: string; table: string; function: string; owner: string; via: readonly string[]; cause?: string },
551
+ would = false,
552
+ ): string {
277
553
  const { owner, via } = row;
278
554
  const sideOwned = via.includes(owner);
279
555
  const who = sideOwned ? `${owner}, an application-side role`
@@ -284,7 +560,8 @@ export function triggerOwnerFinding(row: { trigger: string; table: string; funct
284
560
  + `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
285
561
  : `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
286
562
  return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
287
- + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`;
563
+ + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`
564
+ + (sideOwned && row.cause ? ` ${row.cause}` : '');
288
565
  }
289
566
 
290
567
  /**
@@ -307,6 +584,8 @@ const OWNER_HAZARD = {
307
584
  table: 'an owner can disable its triggers',
308
585
  log: 'an owner can write the marker',
309
586
  function: 'an owner can replace the function',
587
+ schema: 'an owner can DROP ... CASCADE the delete-path trigger',
588
+ history: 'an owner can forge the deploy history',
310
589
  };
311
590
 
312
591
  /**
@@ -331,26 +610,55 @@ export async function lifecycleCatalogRefusal(
331
610
  inputs: LifecycleCatalogInputs,
332
611
  options: { lifecycleRole?: string } = {},
333
612
  ): Promise<string | null> {
334
- const findings = await lifecycleCatalogFindings(runner, inputs, options);
613
+ return catalogRefusalText(await lifecycleCatalogFindings(runner, inputs, options));
614
+ }
615
+
616
+ const catalogRefusalText = ({ findings, resolved }: CatalogFindings): string | null => {
335
617
  if (findings.length === 0) return null;
336
- return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
618
+ const also = resolved.length === 0 ? '' : ` (the plan resolves ${resolved.length} more)`;
619
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'})${also}; `
337
620
  + `fix each, then re-run:\n${findingLines(findings)}`;
338
- }
621
+ };
622
+
623
+ /**
624
+ * The resolved rule-3 findings as one line: per grantee and privilege, the tables the plan
625
+ * revokes it on. Null when the plan resolves none.
626
+ */
627
+ const resolvedLine = (resolved: readonly ResolvedGrant[]): string | null => {
628
+ if (resolved.length === 0) return null;
629
+ const byGrant = new Map<string, string[]>();
630
+ for (const r of resolved) {
631
+ const grant = `${r.privilege}${r.column ? ` (${r.column})` : ''} from ${r.grantee}`;
632
+ byGrant.set(grant, [...(byGrant.get(grant) ?? []), r.table]);
633
+ }
634
+ const parts = [...byGrant].map(([grant, tables]) => `${grant} on ${[...new Set(tables)].sort().join(', ')}`);
635
+ return `the plan resolves ${countFindings(resolved.length)}: it revokes ${parts.join('; ')}, before any lifecycle function is created.`;
636
+ };
339
637
 
340
638
  const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
341
639
  const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
342
640
 
343
- /** lifecycleCatalogRefusal's findings, one string each, without the heading. */
641
+ /** A rule-3 finding the reviewed plan's own REVOKE removes. */
642
+ interface ResolvedGrant { table: string; grantee: string; privilege: string; column: string | null }
643
+ interface CatalogFindings { findings: string[]; resolved: ResolvedGrant[] }
644
+
645
+ /**
646
+ * lifecycleCatalogRefusal's findings, one string each, without the heading. `planRevokes`
647
+ * (planRevokedGrants): a rule-3 grant on a lifecycle table that the plan revokes, exactly that
648
+ * privilege from that grantee on that table (and column), moves to `resolved`. Nothing else does.
649
+ */
344
650
  async function lifecycleCatalogFindings(
345
651
  runner: QueryRunner,
346
652
  inputs: LifecycleCatalogInputs,
347
- options: { lifecycleRole?: string } = {},
348
- ): Promise<string[]> {
653
+ options: { lifecycleRole?: string; planRevokes?: ReadonlySet<string> } = {},
654
+ ): Promise<CatalogFindings> {
349
655
  const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
350
656
  const tables = [...new Set(inputs.lifecycleTables)].sort();
351
- if (tables.length === 0) return [];
352
- const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
353
- const findings: string[] = [];
657
+ const resolved: ResolvedGrant[] = [];
658
+ if (tables.length === 0) return { findings: [], resolved };
659
+ const set = await applicationRoleSet(runner, inputs, lc);
660
+ const apps = set.apps;
661
+ const findings: string[] = [...set.findings];
354
662
 
355
663
  const [role] = await runner(roleSql(lc));
356
664
  if (!truthy(role?.present)) {
@@ -369,24 +677,55 @@ async function lifecycleCatalogFindings(
369
677
  }
370
678
 
371
679
  for (const row of await runner(ownerSql(tables, apps, lc))) {
372
- const kind = row.kind === 'function' ? 'function' : 'table';
373
- const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log : OWNER_HAZARD[kind];
680
+ const kind: 'function' | 'schema' | 'table' = row.kind === 'function' || row.kind === 'schema' ? row.kind : 'table';
681
+ const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
682
+ : HISTORY_TABLES.includes(String(row.name)) ? OWNER_HAZARD.history : OWNER_HAZARD[kind];
374
683
  const via = textList(row.via);
375
684
  const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
376
685
  : via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
377
686
  : `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
378
- const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
687
+ const name = kind === 'function' ? String(row.name) : kind === 'schema' ? ident(String(row.name)) : quoteLabel(String(row.name));
688
+ // pg_database_owner (public's owner on PostgreSQL 15 and later) has one implicit member, the owner
689
+ // of the database, and REVOKE cannot remove it: the database itself has to move.
379
690
  const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
380
691
  ? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
381
- : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
382
- findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}`);
692
+ : row.owner === 'pg_database_owner'
693
+ ? `ALTER DATABASE ${ident(String(row.database))} OWNER TO <your operator role>; (the owner of the database is pg_database_owner's only member)`
694
+ : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
695
+ const cause = via.includes(String(row.owner)) ? set.why.get(String(row.owner)) : undefined;
696
+ findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
383
697
  }
384
698
 
699
+ const governed = new Set(inputs.governedRoles ?? []);
385
700
  for (const row of await runner(aclSql(tables))) {
701
+ const column = row.column == null ? null : String(row.column);
386
702
  const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
703
+ // Granted by another role WITH GRANT OPTION: the plan's REVOKE (run as the owner) and a fresh
704
+ // plan both leave it, so it is never resolved. No verb removes it today (item U6).
705
+ if (!truthy(row.owner_granted)) {
706
+ const table = quoteLabel(String(row.table));
707
+ const grantor = ident(String(row.grantor));
708
+ const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
709
+ // On destroy_log the revoke comes from db:provision, not from a plan.
710
+ const revoker = row.table === 'everystack.destroy_log' ? "db:provision's REVOKE" : "the plan's REVOKE";
711
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}, granted by ${row.grantor} with grant option; `
712
+ + `${revoker} runs as the owner and cannot remove it. Fix: REVOKE ${row.privilege}${on} ON TABLE ${table} FROM ${who} GRANTED BY ${grantor}; `
713
+ + `(as ${row.grantor}, a member of it, or a superuser), or REVOKE GRANT OPTION FOR ${row.privilege}${on} ON TABLE ${table} FROM ${grantor} CASCADE;`);
714
+ continue;
715
+ }
716
+ if (options.planRevokes && tables.includes(String(row.table))
717
+ && options.planRevokes.has(revokeKey(quoteQualified(String(row.table)), String(row.grantee), String(row.privilege), column ?? undefined))) {
718
+ resolved.push({ table: String(row.table), grantee: String(row.grantee), privilege: String(row.privilege), column });
719
+ continue;
720
+ }
387
721
  const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
388
- findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. `
389
- + `Fix: REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`);
722
+ // A verb where one exists: db:provision revokes every grant on destroy_log but its owner's
723
+ // (ENSURE_DESTROY_LOG_SQL); a fresh plan revokes a governed role's grant on a lifecycle table.
724
+ // SQL only for a role no plan governs, which the plan leaves alone by design.
725
+ const fix = row.table === 'everystack.destroy_log' ? 'run db:provision.'
726
+ : governed.has(String(row.grantee)) ? 'mint a plan (db:plan) and db:apply it; the plan revokes this grant.'
727
+ : `REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`;
728
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. Fix: ${fix}`);
390
729
  }
391
730
 
392
731
  for (const row of await runner(markerSql(lc))) {
@@ -413,8 +752,10 @@ async function lifecycleCatalogFindings(
413
752
  }
414
753
 
415
754
  for (const row of await runner(triggerOwnerSql(tables, apps))) {
755
+ const owner = String(row.owner);
756
+ const via = textList(row.via);
416
757
  findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
417
- owner: String(row.owner), via: textList(row.via) }));
758
+ owner, via, cause: ownerCause(set, owner, textList(row.acts_as), textList(row.app_reach)) }));
418
759
  }
419
760
 
420
761
  const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
@@ -443,7 +784,7 @@ async function lifecycleCatalogFindings(
443
784
  + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
444
785
  }
445
786
 
446
- return findings;
787
+ return { findings, resolved };
447
788
  }
448
789
 
449
790
  /**
@@ -472,10 +813,10 @@ export async function lifecycleDeclaredOwnerRefusal(
472
813
  session: SessionRunner,
473
814
  declared: readonly SourceObject[],
474
815
  models: readonly ModelDescriptor[],
475
- options: { stateApplied: boolean },
816
+ options: { stateApplied: boolean; roles?: LifecycleRoleDeclaration },
476
817
  ): Promise<string | null> {
477
- const { lifecycleTables, applicationRoles } = catalogInputsFromModels(models);
478
- const tables = new Set(lifecycleTables);
818
+ const inputs = catalogInputsFromModels(models, options.roles);
819
+ const tables = new Set(inputs.lifecycleTables);
479
820
  const triggers = declared.flatMap((object) => {
480
821
  if (object.kind !== 'trigger' || !object.table) return [];
481
822
  const { schema, name } = parseQualified(object.table);
@@ -488,20 +829,24 @@ export async function lifecycleDeclaredOwnerRefusal(
488
829
  const builder = String(slot[0].builder);
489
830
  const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
490
831
  const owners = [...new Set(judged.map(t => t.owner))].sort();
491
- const rows = await runner(`WITH ${sideCte(applicationRoles)}
832
+ const set = await applicationRoleSet(runner, inputs, LIFECYCLE_OWNER_ROLE);
833
+ const rows = await runner(`WITH ${sideCte(set.apps)}
492
834
  SELECT d.name AS owner, o.oid IS NOT NULL AS present,
493
- ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via
835
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via,
836
+ ${ownerReachColumns(set.apps, 'o.oid')}
494
837
  FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
495
- const byOwner = new Map(rows.map(row => [String(row.owner), { present: truthy(row.present), via: textList(row.via) }]));
496
- const findings = judged.flatMap((t) => {
838
+ const byOwner = new Map(rows.map(row => [String(row.owner),
839
+ { present: truthy(row.present), via: textList(row.via), actsAs: textList(row.acts_as), appReach: textList(row.app_reach) }]));
840
+ const findings = [...set.findings, ...judged.flatMap((t) => {
497
841
  const owner = byOwner.get(t.owner);
498
842
  if (!owner?.present) {
499
843
  return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
500
844
  + `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
501
845
  }
502
846
  if (owner.via.length === 0) return [];
503
- return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via }, true)];
504
- });
847
+ return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via,
848
+ cause: ownerCause(set, t.owner, owner.actsAs, owner.appReach) }, true)];
849
+ })];
505
850
  if (findings.length === 0) return null;
506
851
  return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
507
852
  + `fix each, then re-run:\n${findingLines(findings)}`;
@@ -515,14 +860,18 @@ export async function lifecycleDeclaredOwnerRefusal(
515
860
  * It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
516
861
  * connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
517
862
  */
518
- export async function lifecyclePostApplyRefusal(session: SessionRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
863
+ export async function lifecyclePostApplyRefusal(
864
+ session: SessionRunner,
865
+ models: readonly ModelDescriptor[],
866
+ roles: LifecycleRoleDeclaration = {},
867
+ ): Promise<string | null> {
519
868
  if (!models.some(model => model.lifecycle === true)) return null;
520
869
  const read: QueryRunner = async (q) => {
521
870
  const [slot] = await session([q]);
522
871
  if (isSessionError(slot)) throw new Error(slot.message);
523
872
  return slot ?? [];
524
873
  };
525
- const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
874
+ const { findings } = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
526
875
  if (findings.length === 0) return null;
527
876
  return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
528
877
  }
@@ -536,6 +885,20 @@ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]):
536
885
  + `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
537
886
  + 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
538
887
 
888
+ /**
889
+ * The role declaration is optional on the wire: absent, the check runs as before (every grantee
890
+ * counts). Present but not a list of role names, the check cannot tell what was declared: fail closed.
891
+ */
892
+ export const LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL = (field: 'request roles' | 'declared owners'): string => `this db:reconcile payload `
893
+ + `carries ${field} that are not a list of role names, so the lifecycle catalog check cannot run. `
894
+ + 'Compile the declared layer from the models with this CLI and re-run.';
895
+
896
+ /** An empty list treats no role as a request role, so no request role could reach a declared owner. */
897
+ export const LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL = 'this db:reconcile payload declares an empty list of request roles, so every declared owner '
898
+ + 'would pass the request-role guard. defineModule refuses that list; compile the declared layer from the models with this CLI and re-run.';
899
+
900
+ const isRoleList = (value: unknown): value is string[] => Array.isArray(value) && value.every(role => typeof role === 'string');
901
+
539
902
  /** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
540
903
  const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
541
904
 
@@ -570,13 +933,15 @@ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]):
570
933
  /**
571
934
  * db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
572
935
  * declared layer's own consistency, the application roles' presence (fail closed), then the
573
- * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles` is `unknown`
574
- * because the ops Lambda reads it off the wire.
936
+ * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles`, `requestRoles`
937
+ * and `declaredOwners` are `unknown` because the ops Lambda reads them off the wire. The last two are
938
+ * optional: an older CLI sends neither, and the check runs as before. An older server ignores them:
939
+ * it refuses more, and it skips the live checks on the request roles (see ReconcileWirePayload).
575
940
  */
576
941
  export async function lifecycleReconcileRefusal(
577
942
  runner: QueryRunner,
578
943
  declared: readonly SourceObject[],
579
- options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
944
+ options: { applicationRoles?: unknown; requestRoles?: unknown; declaredOwners?: unknown; lifecycleRole?: string } = {},
580
945
  ): Promise<string | null> {
581
946
  if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
582
947
  const [log] = await runner(LIFECYCLE_LOG_SQL);
@@ -586,11 +951,16 @@ export async function lifecycleReconcileRefusal(
586
951
  if ('refusal' in derived) return derived.refusal;
587
952
  const roles = options.applicationRoles;
588
953
  if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
954
+ const { requestRoles, declaredOwners } = options;
955
+ if (requestRoles != null && !isRoleList(requestRoles)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('request roles');
956
+ if (declaredOwners != null && !isRoleList(declaredOwners)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('declared owners');
957
+ if (Array.isArray(requestRoles) && requestRoles.length === 0) return LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL;
589
958
  const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
590
959
  && !lifecycleManifestOf(object)).map(object => object.identity).sort();
591
960
  if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
592
961
  return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
593
- nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
962
+ nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared),
963
+ ...(requestRoles == null ? {} : { requestRoles, declaredOwners: isRoleList(declaredOwners) ? declaredOwners : [] }) },
594
964
  { lifecycleRole: options.lifecycleRole });
595
965
  }
596
966