@everystack/cli 0.4.105 → 0.4.106

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.106] - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - declare request roles with defineModule({ roles: { request } })
12
+
13
+ ### Fixed
14
+
15
+ - a granted definer role is not a request role in the lifecycle check
16
+ - lifecycle rule 2 judges schema owners and the deploy history tables
17
+ - lifecycle reach counts mixed-option membership chains
18
+ - move the database when an app role owns public through pg_database_owner
19
+ - refuse an empty request-role list on the reconcile wire
20
+
21
+ ### Documentation
22
+
23
+ - say why a granted owner counts, and document roles.request
24
+ - state what an older server skips under the role declaration
25
+
7
26
  ## [0.4.105] - 2026-10-31
8
27
 
9
28
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.105",
3
+ "version": "0.4.106",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.25"
148
+ "@everystack/model": "0.4.26"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -192,7 +192,7 @@
192
192
  "react": "19.2.0",
193
193
  "ts-jest": "29.4.9",
194
194
  "zod": "3.25.67",
195
- "@everystack/server": "0.4.41"
195
+ "@everystack/server": "0.4.42"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -77,7 +77,7 @@ import { readStageStateTwice } from '../stage-read-consistency';
77
77
  import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
78
78
  import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
79
79
  import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
80
- import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal, LifecycleFindingsError } from '../lifecycle-preflight';
80
+ import { lifecycleApplyRefusal, lifecycleRolesOf, lifecycleStateOnlyRefusal, LifecycleFindingsError, unclassifiedGranteesWarning } from '../lifecycle-preflight';
81
81
  import { buildReconcileReport } from './db-reconcile';
82
82
  import { step, success, fail, info, warn, line as printLine } from '../output';
83
83
 
@@ -519,7 +519,9 @@ async function applyPlanViaStage(
519
519
  const { region } = config;
520
520
  const fn = opsFunction(config);
521
521
  const declared = await loadDeclaredDerived(flags.models);
522
- const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
522
+ const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
523
+ if (unclassified) warn(unclassified);
524
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared))
523
525
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
524
526
  if (lifecycleRefusal) {
525
527
  fail(lifecycleRefusal);
@@ -956,7 +958,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
956
958
  const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
957
959
  declaredDbPromise ??= loadDeclaredDerived(flags.models);
958
960
  // Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
959
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [])
961
+ const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
962
+ if (unclassified) warn(unclassified);
963
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()))
960
964
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
961
965
  if (lifecycleRefusal) {
962
966
  fail(lifecycleRefusal);
@@ -1113,7 +1117,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1113
1117
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1114
1118
  async () => {
1115
1119
  // Re-checked under the lease, where the writes happen.
1116
- const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
1120
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
1117
1121
  if (leasedRefusal) {
1118
1122
  fail(leasedRefusal);
1119
1123
  process.exit(1);
@@ -41,6 +41,7 @@ import { resolveDbSource, type DbSource } from '../db-source';
41
41
  import { resolveModelsPath } from '../models-path';
42
42
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
43
43
  import { loadModels } from './db-generate';
44
+ import { lifecycleRolesOf } from '../lifecycle-preflight';
44
45
  import { step, success, fail, info, warn } from '../output';
45
46
 
46
47
  function requireUrl(flags: Record<string, string>, verb: string): string {
@@ -134,6 +135,7 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
134
135
  const result = await executeTemplateRefresh(url, models, {
135
136
  declared: declaredDb?.objects,
136
137
  sequences: declaredDb?.sequences,
138
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
137
139
  actor: process.env.USER ?? null,
138
140
  gitRef: currentGitRef(),
139
141
  seed: seedCommand === null ? null : (templateUrl) => {
@@ -26,7 +26,7 @@
26
26
 
27
27
  import type { ModelDescriptor } from '@everystack/model';
28
28
  import { buildIntoDatabase } from '../db-build';
29
- import { LifecycleFindingsError } from '../lifecycle-preflight';
29
+ import { LifecycleFindingsError, lifecycleRolesOf } from '../lifecycle-preflight';
30
30
  import { createUrlRunner } from '../db-source';
31
31
  import { resolveModelsPath } from '../models-path';
32
32
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
@@ -124,6 +124,7 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
124
124
  declared: declaredDb?.objects,
125
125
  sequences: declaredDb?.sequences,
126
126
  extensions: declaredDb?.extensions,
127
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
127
128
  actor: process.env.USER ?? null,
128
129
  gitRef: currentGitRef(),
129
130
  });
@@ -44,6 +44,7 @@ import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '..
44
44
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
45
45
  import type { SequenceDescriptor } from '@everystack/model';
46
46
  import type { SourceObject } from '../derived-source';
47
+ import { lifecycleRolesOf, unclassifiedGranteesWarning, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
47
48
  import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
48
49
  import { findSelectGrantGaps } from '../select-grant-advisory';
49
50
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
@@ -125,6 +126,8 @@ export interface StaticCheckInput {
125
126
  * Absent (undefined) = the caller did not read it (older callers): no map finding at all.
126
127
  */
127
128
  modelMapSource?: string | null;
129
+ /** The modules' role declaration (lifecycleRolesOf). Its unclassified grantees are a warning. */
130
+ lifecycleRoles?: LifecycleRoleDeclaration;
128
131
  }
129
132
 
130
133
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -285,6 +288,11 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
285
288
  }
286
289
  }
287
290
 
291
+ // A grantee the role declaration leaves unclassified counts as a request role in the lifecycle
292
+ // catalog check. Visible, never fatal: classifying it is the operator's call.
293
+ const unclassified = unclassifiedGranteesWarning(input.models, input.lifecycleRoles ?? {});
294
+ if (unclassified) findings.push({ level: 'warn', area: 'authz', message: unclassified });
295
+
288
296
  if (input.artifactSource === null && input.artifactOrigin === 'flag') {
289
297
  findings.push({
290
298
  level: 'fail',
@@ -369,7 +377,7 @@ export interface EphemeralComposeResult {
369
377
  export async function executeEphemeralCompose(
370
378
  adminUrl: string,
371
379
  models: ModelDescriptor[],
372
- opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
380
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[]; lifecycleRoles?: LifecycleRoleDeclaration } = {},
373
381
  ): Promise<EphemeralComposeResult> {
374
382
  const database = `escheck_${process.pid}_${Date.now()}`;
375
383
  await createDatabase(adminUrl, database);
@@ -378,6 +386,7 @@ export async function executeEphemeralCompose(
378
386
  declared: opts.declared,
379
387
  sequences: opts.sequences,
380
388
  extensions: opts.extensions,
389
+ lifecycleRoles: opts.lifecycleRoles,
381
390
  actor: opts.actor ?? 'db:check',
382
391
  gitRef: opts.gitRef ?? null,
383
392
  });
@@ -478,6 +487,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
478
487
  moduleModels: declaredDb?.models ?? null,
479
488
  baselineSource,
480
489
  modelMapPath: mapPath, modelMapSource,
490
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
481
491
  });
482
492
  // Per-finding detail is the --verbose report. The quiet default answers the one question a
483
493
  // human asked — is anything pending? — and names the verb that shows it.
@@ -504,6 +514,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
504
514
  declared: declaredDb?.objects,
505
515
  sequences: declaredDb?.sequences,
506
516
  extensions,
517
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
507
518
  });
508
519
  if (verbose) for (const line of ephemeral.report) info(` ${line}`);
509
520
  } catch (err: any) {
@@ -63,7 +63,7 @@ import { resolveModelsPath } from '../models-path';
63
63
  import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
- import { lifecycleApplyRefusal } from '../lifecycle-preflight';
66
+ import { lifecycleApplyRefusal, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
67
67
  import { loadModels } from './db-generate';
68
68
  import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
69
69
  import { introspectDerived } from '../derived-introspect';
@@ -238,7 +238,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
238
238
  }
239
239
 
240
240
  try {
241
- const lifecycleNote = await lifecycleApplyRefusal(runner, models);
241
+ // The modules' role declaration, as db:apply reads it. A barrel that does not compose leaves it
242
+ // undeclared here (the check as before); the paths that need the derived layer report the error.
243
+ const lifecycleRoles = lifecycleRolesOf(await loadDeclaredDerived(flags.models).catch(() => null));
244
+ const lifecycleNote = await lifecycleApplyRefusal(runner, models, lifecycleRoles);
245
+ const unclassified = unclassifiedGranteesWarning(models, lifecycleRoles);
246
+ if (unclassified) warn(unclassified);
242
247
  // db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
243
248
  if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
244
249
  let snapshot: SchemaSnapshot;
@@ -32,7 +32,7 @@
32
32
  import type { QueryRunner } from '../authz-contract';
33
33
  import type { SourceObject } from '../derived-source';
34
34
  import { introspectDerived } from '../derived-introspect';
35
- import { catalogInputsFromModels, lifecycleReconcileRefusal } from '../lifecycle-preflight';
35
+ import { catalogInputsFromModels, lifecycleReconcileRefusal, lifecycleRolesOf } from '../lifecycle-preflight';
36
36
  import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
37
37
  import {
38
38
  renderReconcileSql,
@@ -84,6 +84,12 @@ export interface ExecuteOptions extends ReconcileOptions {
84
84
  * the check. `unknown` because the ops Lambda passes it straight off the wire.
85
85
  */
86
86
  applicationRoles?: unknown;
87
+ /**
88
+ * The modules' declared request roles and declared owners (LifecycleRoleDeclaration), beside
89
+ * applicationRoles. Optional: absent, the catalog check runs as before. `unknown` for the wire.
90
+ */
91
+ requestRoles?: unknown;
92
+ declaredOwners?: unknown;
87
93
  /**
88
94
  * The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
89
95
  * process (executeSync does, first thing), so the reconcile does not run the catalog check again.
@@ -159,7 +165,9 @@ export async function executeReconcile(
159
165
  // --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
160
166
  // it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
161
167
  const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
162
- : lifecycleReconcileRefusal(runner, parsed.objects, { applicationRoles: options.applicationRoles }));
168
+ : lifecycleReconcileRefusal(runner, parsed.objects, {
169
+ applicationRoles: options.applicationRoles, requestRoles: options.requestRoles, declaredOwners: options.declaredOwners,
170
+ }));
163
171
 
164
172
  if (!options.apply) {
165
173
  const lifecycleFindings = await lifecycleGate();
@@ -768,6 +776,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
768
776
  // The lifecycle catalog check's roles, from the barrel's models. The tables come from the
769
777
  // declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
770
778
  applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
779
+ ...lifecycleRolesOf(declared),
771
780
  renamedTables: declared?.renamedTables,
772
781
  baseline: flags.baseline === 'true',
773
782
  rebaseline: flags.rebaseline === 'true',
@@ -31,7 +31,7 @@ import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import type { ModelDescriptor } from '@everystack/model';
33
33
  import { introspectContract, type QueryRunner } from '../authz-contract';
34
- import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError } from '../lifecycle-preflight';
34
+ import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
35
35
  import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
36
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
37
37
  import { generateMigrationSql, unmodeledTables } from '../migration-generate';
@@ -86,6 +86,8 @@ export interface SyncOptions {
86
86
  governedRoles?: string[];
87
87
  /** Pending table renames — trigger provenance migrates instead of drop+create. */
88
88
  renamedTables?: Record<string, string>;
89
+ /** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
90
+ lifecycleRoles?: LifecycleRoleDeclaration;
89
91
  /** Injectable clock for tests. */
90
92
  now?: () => number;
91
93
  }
@@ -183,6 +185,8 @@ async function syncOnce(
183
185
  // The catalog check runs again here, after the state apply: a table this sync just created can
184
186
  // carry a finding (a default privilege, its owner) the check before the state apply could not see.
185
187
  applicationRoles: catalogInputsFromModels(models).applicationRoles,
188
+ requestRoles: options.lifecycleRoles?.requestRoles,
189
+ declaredOwners: options.lifecycleRoles?.declaredOwners,
186
190
  });
187
191
 
188
192
  const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
@@ -240,11 +244,11 @@ export async function executeSync(
240
244
  hooks: SyncHooks = {},
241
245
  ): Promise<SyncRun> {
242
246
  // State runs before compute, so without this a lifecycle table would land with no transition trigger.
243
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
247
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
244
248
  if (lifecycleRefusal) throw new Error(lifecycleRefusal);
245
249
  // Before anything is written, so before both places a pass creates derived objects (the
246
250
  // functions the state batch calls, then the reconcile).
247
- const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
251
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
248
252
  if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
249
253
  const first = await syncOnce(runner, session, models, options, hooks, 1);
250
254
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
@@ -451,6 +455,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
451
455
  sequences: declaredDb?.sequences,
452
456
  extensions: declaredDb?.extensions,
453
457
  renamedTables: declaredDb?.renamedTables,
458
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
454
459
  allowDrops: flags['allow-drops'] === 'true',
455
460
  overwriteDrift: flags['overwrite-drift'] === 'true',
456
461
  baseline: flags.baseline === 'true',
@@ -18,7 +18,7 @@ import { compileDeclaredState } from './declared-diff';
18
18
  import { createUrlRunner } from './db-source';
19
19
  import { executeSync, buildSyncReport } from './commands/db-sync';
20
20
  import { executeReconcile } from './commands/db-reconcile';
21
- import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
21
+ import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, type LifecycleRoleDeclaration } from './lifecycle-preflight';
22
22
 
23
23
  const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
24
24
 
@@ -101,6 +101,8 @@ export interface BuildOptions {
101
101
  extensions?: string[];
102
102
  actor?: string | null;
103
103
  gitRef?: string | null;
104
+ /** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
105
+ lifecycleRoles?: LifecycleRoleDeclaration;
104
106
  }
105
107
 
106
108
  /**
@@ -184,7 +186,7 @@ export async function buildIntoDatabase(
184
186
  // Before the first derived write: the owners the reconcile will give the declared trigger
185
187
  // functions on lifecycle tables. This pass runs before executeSync, which judges them again
186
188
  // first thing, before its own state apply.
187
- const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false });
189
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
188
190
  if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
189
191
  const declaredFunctions = (options.declared ?? [])
190
192
  .filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
@@ -192,7 +194,7 @@ export async function buildIntoDatabase(
192
194
  // The lifecycle catalog check, with the full models, before any lifecycle-owned function
193
195
  // lands: this pass declares functions only, so it carries no transition trigger for the
194
196
  // reconcile to read the lifecycle tables from. executeSync runs the same check again below.
195
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
197
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
196
198
  if (lifecycleRefusal) throw new Error(lifecycleRefusal);
197
199
  await runner('SET check_function_bodies = off');
198
200
  try {
@@ -216,12 +218,13 @@ export async function buildIntoDatabase(
216
218
  const run = await executeSync(runner, session, models, {
217
219
  declared: options.declared,
218
220
  sequences: options.sequences,
221
+ lifecycleRoles: options.lifecycleRoles,
219
222
  actor: options.actor ?? 'db-build',
220
223
  gitRef: options.gitRef ?? null,
221
224
  });
222
225
  // Verification: the live catalog check again, over what the derived layer just made live. A
223
226
  // throw, so no caller (db:check's compose ring, the dev template, a test database) can miss it.
224
- const post = await lifecyclePostApplyRefusal(session, models);
227
+ const post = await lifecyclePostApplyRefusal(session, models, options.lifecycleRoles);
225
228
  if (post) throw new LifecycleFindingsError(post);
226
229
  return {
227
230
  converged: run.converged,
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import fs from 'node:fs/promises';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
- import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
15
+ import { linkModels, ModelLinkError, moduleGovernedRoles, moduleRequestRoles } from '@everystack/model';
16
16
  import { compileDerived } from './derived-compile';
17
17
  import { lifecycleDerived } from './lifecycle-derived';
18
18
  import { compileTableRenames, compileTableMoves } from './schema-compile';
@@ -35,6 +35,13 @@ export interface DeclaredDerived {
35
35
  /** Roles the modules declare as governed beyond the ones the models name. A live grantee
36
36
  * outside the governed set is exempted from reconciliation and ENUMERATED instead. */
37
37
  governedRoles: string[];
38
+ /**
39
+ * The roles a request can become (`roles: { request }`, the union across modules). Undefined
40
+ * when no module declares them: the lifecycle catalog check then runs exactly as before.
41
+ */
42
+ requestRoles?: string[];
43
+ /** Every `owner` a declared derived object names, across modules, sorted. */
44
+ declaredOwners: string[];
38
45
  /**
39
46
  * Postgres extensions the modules declare — the bootstrap the state layer needs before
40
47
  * anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
@@ -194,6 +201,9 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
194
201
  renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
195
202
  models,
196
203
  governedRoles: moduleGovernedRoles(modules),
204
+ requestRoles: moduleRequestRoles(modules),
205
+ declaredOwners: [...new Set(modules.flatMap((m) => m.derived)
206
+ .flatMap((d) => ('owner' in d && typeof d.owner === 'string' ? [d.owner] : [])))].sort(),
197
207
  extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
198
208
  };
199
209
  } catch (err) {
@@ -35,7 +35,7 @@ import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
35
35
  import { resolveConfig, opsFunction } from './config';
36
36
  import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
37
37
  import { currentGitRef } from './state-apply';
38
- import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal } from './lifecycle-preflight';
38
+ import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, lifecycleRolesOf } from './lifecycle-preflight';
39
39
 
40
40
  /** Where the catch-up runs. The two lanes db:apply itself already has. */
41
41
  export type CatchUpVenue =
@@ -118,6 +118,8 @@ export async function catchUpDerived(
118
118
  // The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
119
119
  // lifecycle-owned objects without them.
120
120
  applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
121
+ // What the modules declare about the roles; optional on the wire (absent = the check as before).
122
+ ...lifecycleRolesOf(declared),
121
123
  actor: process.env.USER ?? null,
122
124
  gitRef: currentGitRef(),
123
125
  };
@@ -130,7 +132,7 @@ export async function catchUpDerived(
130
132
  // operator connection, while db:query may run on a reader.
131
133
  const operator: SessionRunner = (statements, opts) =>
132
134
  (venue.kind === 'direct' ? venue.session : lambdaSessionRunner(config!.region, opsFunction(config!)))(statements, opts);
133
- const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied });
135
+ const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied, roles: lifecycleRolesOf(declared) });
134
136
  if (gate) throw new LifecycleFindingsError(gate);
135
137
 
136
138
  let run: ReconcileRun;
@@ -172,7 +174,7 @@ export async function catchUpDerived(
172
174
 
173
175
  // Verification: the live catalog check again, over what the reconcile just made live. On the
174
176
  // operator session, where the commit is: db:query may read a replica that has not caught up.
175
- const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? []);
177
+ const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
176
178
  if (post) throw new LifecycleFindingsError(post);
177
179
 
178
180
  return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
@@ -33,13 +33,50 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
33
33
  * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
34
34
  * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
35
35
  */
36
- export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
36
+ export async function lifecycleApplyRefusal(
37
+ runner: QueryRunner,
38
+ models: readonly ModelDescriptor[],
39
+ roles: LifecycleRoleDeclaration = {},
40
+ ): Promise<string | null> {
37
41
  if (!models.some(model => model.lifecycle === true)) return null;
38
42
  const [log] = await runner(LIFECYCLE_LOG_SQL);
39
43
  // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
40
44
  // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
41
45
  const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
42
- return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
46
+ return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models, roles), modeledTables });
47
+ }
48
+
49
+ /**
50
+ * What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
51
+ * reads it to tell a definer role the models grant to apart from a role a request can become.
52
+ *
53
+ * requestRoles `roles: { request }`, the union across modules. Undefined = not declared, and
54
+ * the check runs exactly as before: every grantee counts as a request role.
55
+ * declaredOwners every `owner` a declared derived object names.
56
+ */
57
+ export interface LifecycleRoleDeclaration {
58
+ requestRoles?: string[];
59
+ declaredOwners?: string[];
60
+ }
61
+
62
+ /** The role declaration from a loaded declared layer; empty (not declared) when there is none. */
63
+ export function lifecycleRolesOf(declared: { requestRoles?: string[]; declaredOwners?: string[] } | null | undefined): LifecycleRoleDeclaration {
64
+ return declared?.requestRoles === undefined ? {} : { requestRoles: declared.requestRoles, declaredOwners: declared.declaredOwners ?? [] };
65
+ }
66
+
67
+ /**
68
+ * The grantees the declaration does not classify: each counts as a request role, so the lifecycle
69
+ * check treats it as application-side. A warning, never a refusal. Null when the request roles are
70
+ * not declared (nothing is classified), when no model is a lifecycle model, or when none is left.
71
+ */
72
+ export function unclassifiedGranteesWarning(models: readonly ModelDescriptor[], roles: LifecycleRoleDeclaration): string | null {
73
+ if (roles.requestRoles === undefined || !models.some(model => model.lifecycle === true)) return null;
74
+ const classified = new Set([...roles.requestRoles, ...(roles.declaredOwners ?? [])]);
75
+ const unclassified = catalogInputsFromModels(models).applicationRoles.filter(role => !classified.has(role));
76
+ if (unclassified.length === 0) return null;
77
+ return `the models grant privileges to ${unclassified.join(', ')}, which ${unclassified.length === 1 ? 'is' : 'are'} neither a declared request role `
78
+ + `(roles: { request }) nor a declared owner, so the lifecycle check counts ${unclassified.length === 1 ? 'it' : 'them'} as a role a request can become. `
79
+ + 'Add each to roles.request, or name it as the owner of the derived objects it owns.';
43
80
  }
44
81
 
45
82
  const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
@@ -85,6 +122,13 @@ export interface LifecycleCatalogInputs {
85
122
  * rule 6 leaves to the plan. Absent after the state apply: every key is checked.
86
123
  */
87
124
  modeledTables?: readonly string[];
125
+ /**
126
+ * The declared request roles (LifecycleRoleDeclaration). Absent = not declared: the
127
+ * application roles are the grantees, exactly as before. Present: see applicationRoleSet.
128
+ */
129
+ requestRoles?: readonly string[];
130
+ /** Every declared derived-object owner. Read only when `requestRoles` is present. */
131
+ declaredOwners?: readonly string[];
88
132
  }
89
133
 
90
134
  /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
@@ -127,8 +171,11 @@ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<type
127
171
 
128
172
  const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
129
173
 
130
- /** The catalog check's inputs, read from the models. */
131
- export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
174
+ /** The catalog check's inputs, read from the models (and the role declaration, when there is one). */
175
+ export function catalogInputsFromModels(
176
+ models: readonly ModelDescriptor[],
177
+ declaration: LifecycleRoleDeclaration = {},
178
+ ): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[] } {
132
179
  const roles = new Set<string>();
133
180
  for (const model of models) {
134
181
  const contract = compileTableContract(model);
@@ -140,6 +187,7 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
140
187
  lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
141
188
  applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
142
189
  dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
190
+ ...(declaration.requestRoles === undefined ? {} : { requestRoles: [...declaration.requestRoles], declaredOwners: [...(declaration.declaredOwners ?? [])] }),
143
191
  };
144
192
  }
145
193
 
@@ -153,9 +201,16 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
153
201
  const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
154
202
  WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
155
203
  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. */
204
+ /**
205
+ * `x` can act as `y`, or hold ADMIN on it (an ADMIN holder can grant `y` to itself WITH SET), or
206
+ * reach it through a chain of grants whose options differ link to link. MEMBER counts any chain:
207
+ * ADMIN-only on `i`, then `i` a member of `y` (grant `i` to itself WITH SET, SET ROLE `i`, then
208
+ * `y`); or SET-only on `i`, then `i` an INHERIT-only member of `y` (SET ROLE `i` holds `y`'s
209
+ * privileges). USAGE, SET and MEMBER WITH ADMIN OPTION are each false for both. Never in `assumes`:
210
+ * the side set must still leave out the RDS master's ADMIN-only membership.
211
+ */
157
212
  const reaches = (x: string, y: string): string =>
158
- `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
213
+ `(${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
214
 
160
215
  /**
161
216
  * The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
@@ -166,6 +221,115 @@ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::t
166
221
  WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
167
222
  WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
168
223
 
224
+ /** How `x` reaches `y`, for a finding: INHERIT, SET, ADMIN or a chain of grants (the first that holds; see assumes and reaches). */
225
+ const reachHow = (x: string, y: string): string => `CASE WHEN pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') THEN 'INHERIT'
226
+ WHEN (CASE WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
227
+ ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END) THEN 'SET'
228
+ WHEN pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') THEN 'ADMIN' ELSE 'a chain of grants' END`;
229
+
230
+ /**
231
+ * The live facts the declaration rests on. Per declared request role: does it exist, is it a
232
+ * superuser, is it BYPASSRLS. Per declared owner that is a grantee or a request role: does it exist,
233
+ * can it log in, and which request roles reach it (reaches: INHERIT, SET or ADMIN, transitively).
234
+ */
235
+ const requestRoleSql = (request: string[], owners: string[]): string => `SELECT 'request' AS kind, d.name, o.oid IS NOT NULL AS present,
236
+ COALESCE(o.rolsuper, false) AS super, COALESCE(o.rolbypassrls, false) AS bypassrls, COALESCE(o.rolcanlogin, false) AS login,
237
+ ARRAY[]::text[] AS reached_by
238
+ FROM pg_catalog.unnest(${textArray(request)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
239
+ UNION ALL
240
+ SELECT 'owner', d.name, o.oid IS NOT NULL, COALESCE(o.rolsuper, false), COALESCE(o.rolbypassrls, false), COALESCE(o.rolcanlogin, false),
241
+ ARRAY(SELECT 'request role ' || r.rolname::text || ' can act as ' || d.name || ' (' || ${reachHow('r.oid', 'o.oid')} || ')'
242
+ 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)
243
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
244
+ ORDER BY 1, 2`;
245
+
246
+ /** The application roles every rule reads (sideCte's input), and why each counts where the declaration can say. */
247
+ interface ApplicationRoleSet {
248
+ apps: string[];
249
+ /** A role in `apps` → one sentence saying why it is there. Empty when nothing is declared. */
250
+ why: Map<string, string>;
251
+ /** Live findings on the declared request roles. */
252
+ findings: string[];
253
+ /** The models' grantees (applicationRoles), PUBLIC and the lifecycle role left out. */
254
+ grantees: ReadonlySet<string>;
255
+ /** The request roles are declared. */
256
+ declared: boolean;
257
+ }
258
+
259
+ /**
260
+ * THE one place the application-role set is computed. Rules 1, 2 and 5, the pre-exposure gate, the
261
+ * post-check and the reconcile path all read it.
262
+ *
263
+ * Not declared (`requestRoles` absent): the grantees, exactly as before, and no query runs.
264
+ *
265
+ * Declared: the request roles and the grantees, minus each declared owner D that holds every guard,
266
+ * read live: D is not a request role, D is not the lifecycle role (never in the set: PUBLIC and it are
267
+ * filtered out), D exists and cannot log in, and no request role reaches D. A declared owner that
268
+ * fails a guard stays, and `why` names the guard. A grantee in neither list stays as well. Each
269
+ * request role must exist, and be neither a superuser nor BYPASSRLS: either is a finding.
270
+ */
271
+ async function applicationRoleSet(runner: QueryRunner, inputs: LifecycleCatalogInputs, lc: string): Promise<ApplicationRoleSet> {
272
+ const grantees = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
273
+ if (inputs.requestRoles === undefined) return { apps: grantees, why: new Map(), findings: [], grantees: new Set(grantees), declared: false };
274
+ const request = new Set([...inputs.requestRoles].filter(notApplicationRole(lc)));
275
+ const owners = new Set(inputs.declaredOwners ?? []);
276
+ const set = new Set([...request, ...grantees]);
277
+ const candidates = [...set].filter(role => owners.has(role)).sort();
278
+ const findings: string[] = [];
279
+ const why = new Map<string, string>();
280
+ for (const row of await runner(requestRoleSql([...request].sort(), candidates))) {
281
+ const name = String(row.name);
282
+ if (row.kind === 'request') {
283
+ if (!truthy(row.present)) {
284
+ findings.push(`request role ${name} does not exist. Fix: create it, or remove it from roles.request.`);
285
+ continue;
286
+ }
287
+ 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;`);
288
+ 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;`);
289
+ continue;
290
+ }
291
+ const failed: string[] = [];
292
+ if (request.has(name)) failed.push(`declared owner ${name} is a request role`);
293
+ if (!truthy(row.present)) failed.push(`declared owner ${name} does not exist`);
294
+ else if (truthy(row.login)) failed.push(`declared owner ${name} can log in`);
295
+ failed.push(...textList(row.reached_by));
296
+ if (failed.length === 0) set.delete(name);
297
+ else why.set(name, `${name} counts as an application role: ${failed.join('; ')}.`);
298
+ }
299
+ for (const role of grantees) {
300
+ if (!request.has(role) && !owners.has(role)) {
301
+ 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.`);
302
+ }
303
+ }
304
+ return { apps: [...set].sort(), why, findings, grantees: new Set(grantees), declared: true };
305
+ }
306
+
307
+ /**
308
+ * Why `owner`, itself an application-side role, counts: for a rule-5 finding. The declaration's
309
+ * reason when it has one. Undeclared, and the owner counts only because the models grant to it (it
310
+ * acts as no other application role, and no other application role reaches it), the finding says
311
+ * so and names the declaration that tells a definer role apart. Reached, the REVOKE fix stands alone.
312
+ */
313
+ function ownerCause(set: ApplicationRoleSet, owner: string, actsAs: readonly string[], appReach: readonly string[]): string | undefined {
314
+ const known = set.why.get(owner);
315
+ if (known) return known;
316
+ if (set.declared) return actsAs.length > 0 ? `${owner} can act as the application role${actsAs.length === 1 ? '' : 's'} ${actsAs.join(', ')}.` : undefined;
317
+ const granted = set.grantees.has(owner) && actsAs.length === 0 && appReach.length === 0;
318
+ return granted ? grantedCause(owner) : undefined;
319
+ }
320
+
321
+ const grantedCause = (owner: string): string => `${owner} counts as an application role because the models grant it privileges. `
322
+ + 'If it is a definer role that no request role reaches, declare the request roles with roles: { request: [...] } in a module.';
323
+
324
+ /**
325
+ * Two columns for the rule-5 queries: the application roles `owner` can act as (acts_as), and the
326
+ * other application roles that reach `owner` (app_reach). Neither counts `owner` itself.
327
+ */
328
+ const ownerReachColumns = (apps: string[], owner: string): string => `ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
329
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${assumes(owner, 'a.oid')} ORDER BY 1) AS acts_as,
330
+ ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
331
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${reaches('a.oid', owner)} ORDER BY 1) AS app_reach`;
332
+
169
333
  /** Rule 1, attributes. */
170
334
  const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
171
335
  COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
@@ -184,11 +348,17 @@ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
184
348
  WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
185
349
  ORDER BY 1`;
186
350
 
351
+ /** The deploy history: who owns one can rewrite what was applied, run and reconciled. */
352
+ const HISTORY_TABLES = ['everystack.schema_log', 'everystack.exec_log', 'everystack.derived_provenance'];
353
+
187
354
  /**
188
355
  * 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.
356
+ * write the marker; the owner of a marker function can replace it; the owner of a schema holding
357
+ * any of them can drop what is in it (DROP ... CASCADE takes the delete-path trigger along); the
358
+ * owner of schema_log, exec_log or derived_provenance can forge the deploy history. Each such
359
+ * object is a finding when its owner is the lifecycle role, or when an application-side role is
360
+ * that owner, can act as it, or holds ADMIN on it. The operator owning all of them is the
361
+ * documented setup and passes. A table that does not exist is not judged.
192
362
  */
193
363
  const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
194
364
  owned AS (
@@ -200,8 +370,19 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
200
370
  WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
201
371
  UNION ALL
202
372
  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))
373
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)
374
+ UNION ALL
375
+ SELECT 'table', n.nspname::text || '.' || c.relname::text, c.relowner
376
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
377
+ WHERE c.oid IN (SELECT pg_catalog.to_regclass(t) FROM pg_catalog.unnest(${textArray(HISTORY_TABLES)}) AS t)
378
+ UNION ALL
379
+ SELECT 'schema', n.nspname::text, n.nspowner FROM pg_catalog.pg_namespace n
380
+ WHERE n.nspname = ANY(${textArray([...new Set(tables.map(table => table.slice(0, table.indexOf('.'))))])})
381
+ OR n.oid IN (SELECT c.relnamespace FROM pg_catalog.pg_class c WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log'))
382
+ OR n.oid IN (SELECT p.pronamespace FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
383
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)))
204
384
  SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
385
+ pg_catalog.current_database()::text AS database,
205
386
  ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
206
387
  FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
207
388
  WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
@@ -260,7 +441,8 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
260
441
  SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
261
442
  pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
262
443
  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
444
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via,
445
+ ${ownerReachColumns(apps, 'p.proowner')}
264
446
  FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
265
447
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
266
448
  JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
@@ -272,8 +454,12 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
272
454
  * One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
273
455
  * owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
274
456
  * `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
457
+ * `cause`: why the owner counts as an application role (ownerCause), when there is more to say.
275
458
  */
276
- export function triggerOwnerFinding(row: { trigger: string; table: string; function: string; owner: string; via: readonly string[] }, would = false): string {
459
+ export function triggerOwnerFinding(
460
+ row: { trigger: string; table: string; function: string; owner: string; via: readonly string[]; cause?: string },
461
+ would = false,
462
+ ): string {
277
463
  const { owner, via } = row;
278
464
  const sideOwned = via.includes(owner);
279
465
  const who = sideOwned ? `${owner}, an application-side role`
@@ -284,7 +470,8 @@ export function triggerOwnerFinding(row: { trigger: string; table: string; funct
284
470
  + `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
285
471
  : `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
286
472
  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}`;
473
+ + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`
474
+ + (sideOwned && row.cause ? ` ${row.cause}` : '');
288
475
  }
289
476
 
290
477
  /**
@@ -307,6 +494,8 @@ const OWNER_HAZARD = {
307
494
  table: 'an owner can disable its triggers',
308
495
  log: 'an owner can write the marker',
309
496
  function: 'an owner can replace the function',
497
+ schema: 'an owner can DROP ... CASCADE the delete-path trigger',
498
+ history: 'an owner can forge the deploy history',
310
499
  };
311
500
 
312
501
  /**
@@ -349,8 +538,9 @@ async function lifecycleCatalogFindings(
349
538
  const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
350
539
  const tables = [...new Set(inputs.lifecycleTables)].sort();
351
540
  if (tables.length === 0) return [];
352
- const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
353
- const findings: string[] = [];
541
+ const set = await applicationRoleSet(runner, inputs, lc);
542
+ const apps = set.apps;
543
+ const findings: string[] = [...set.findings];
354
544
 
355
545
  const [role] = await runner(roleSql(lc));
356
546
  if (!truthy(role?.present)) {
@@ -369,17 +559,23 @@ async function lifecycleCatalogFindings(
369
559
  }
370
560
 
371
561
  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];
562
+ const kind: 'function' | 'schema' | 'table' = row.kind === 'function' || row.kind === 'schema' ? row.kind : 'table';
563
+ const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
564
+ : HISTORY_TABLES.includes(String(row.name)) ? OWNER_HAZARD.history : OWNER_HAZARD[kind];
374
565
  const via = textList(row.via);
375
566
  const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
376
567
  : via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
377
568
  : `${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));
569
+ const name = kind === 'function' ? String(row.name) : kind === 'schema' ? ident(String(row.name)) : quoteLabel(String(row.name));
570
+ // pg_database_owner (public's owner on PostgreSQL 15 and later) has one implicit member, the owner
571
+ // of the database, and REVOKE cannot remove it: the database itself has to move.
379
572
  const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
380
573
  ? `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}`);
574
+ : row.owner === 'pg_database_owner'
575
+ ? `ALTER DATABASE ${ident(String(row.database))} OWNER TO <your operator role>; (the owner of the database is pg_database_owner's only member)`
576
+ : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
577
+ const cause = via.includes(String(row.owner)) ? set.why.get(String(row.owner)) : undefined;
578
+ findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
383
579
  }
384
580
 
385
581
  for (const row of await runner(aclSql(tables))) {
@@ -413,8 +609,10 @@ async function lifecycleCatalogFindings(
413
609
  }
414
610
 
415
611
  for (const row of await runner(triggerOwnerSql(tables, apps))) {
612
+ const owner = String(row.owner);
613
+ const via = textList(row.via);
416
614
  findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
417
- owner: String(row.owner), via: textList(row.via) }));
615
+ owner, via, cause: ownerCause(set, owner, textList(row.acts_as), textList(row.app_reach)) }));
418
616
  }
419
617
 
420
618
  const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
@@ -472,10 +670,10 @@ export async function lifecycleDeclaredOwnerRefusal(
472
670
  session: SessionRunner,
473
671
  declared: readonly SourceObject[],
474
672
  models: readonly ModelDescriptor[],
475
- options: { stateApplied: boolean },
673
+ options: { stateApplied: boolean; roles?: LifecycleRoleDeclaration },
476
674
  ): Promise<string | null> {
477
- const { lifecycleTables, applicationRoles } = catalogInputsFromModels(models);
478
- const tables = new Set(lifecycleTables);
675
+ const inputs = catalogInputsFromModels(models, options.roles);
676
+ const tables = new Set(inputs.lifecycleTables);
479
677
  const triggers = declared.flatMap((object) => {
480
678
  if (object.kind !== 'trigger' || !object.table) return [];
481
679
  const { schema, name } = parseQualified(object.table);
@@ -488,20 +686,24 @@ export async function lifecycleDeclaredOwnerRefusal(
488
686
  const builder = String(slot[0].builder);
489
687
  const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
490
688
  const owners = [...new Set(judged.map(t => t.owner))].sort();
491
- const rows = await runner(`WITH ${sideCte(applicationRoles)}
689
+ const set = await applicationRoleSet(runner, inputs, LIFECYCLE_OWNER_ROLE);
690
+ const rows = await runner(`WITH ${sideCte(set.apps)}
492
691
  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
692
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via,
693
+ ${ownerReachColumns(set.apps, 'o.oid')}
494
694
  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) => {
695
+ const byOwner = new Map(rows.map(row => [String(row.owner),
696
+ { present: truthy(row.present), via: textList(row.via), actsAs: textList(row.acts_as), appReach: textList(row.app_reach) }]));
697
+ const findings = [...set.findings, ...judged.flatMap((t) => {
497
698
  const owner = byOwner.get(t.owner);
498
699
  if (!owner?.present) {
499
700
  return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
500
701
  + `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
702
  }
502
703
  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
- });
704
+ return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via,
705
+ cause: ownerCause(set, t.owner, owner.actsAs, owner.appReach) }, true)];
706
+ })];
505
707
  if (findings.length === 0) return null;
506
708
  return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
507
709
  + `fix each, then re-run:\n${findingLines(findings)}`;
@@ -515,14 +717,18 @@ export async function lifecycleDeclaredOwnerRefusal(
515
717
  * It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
516
718
  * connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
517
719
  */
518
- export async function lifecyclePostApplyRefusal(session: SessionRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
720
+ export async function lifecyclePostApplyRefusal(
721
+ session: SessionRunner,
722
+ models: readonly ModelDescriptor[],
723
+ roles: LifecycleRoleDeclaration = {},
724
+ ): Promise<string | null> {
519
725
  if (!models.some(model => model.lifecycle === true)) return null;
520
726
  const read: QueryRunner = async (q) => {
521
727
  const [slot] = await session([q]);
522
728
  if (isSessionError(slot)) throw new Error(slot.message);
523
729
  return slot ?? [];
524
730
  };
525
- const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
731
+ const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
526
732
  if (findings.length === 0) return null;
527
733
  return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
528
734
  }
@@ -536,6 +742,20 @@ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]):
536
742
  + `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
537
743
  + 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
538
744
 
745
+ /**
746
+ * The role declaration is optional on the wire: absent, the check runs as before (every grantee
747
+ * counts). Present but not a list of role names, the check cannot tell what was declared: fail closed.
748
+ */
749
+ export const LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL = (field: 'request roles' | 'declared owners'): string => `this db:reconcile payload `
750
+ + `carries ${field} that are not a list of role names, so the lifecycle catalog check cannot run. `
751
+ + 'Compile the declared layer from the models with this CLI and re-run.';
752
+
753
+ /** An empty list treats no role as a request role, so no request role could reach a declared owner. */
754
+ export const LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL = 'this db:reconcile payload declares an empty list of request roles, so every declared owner '
755
+ + 'would pass the request-role guard. defineModule refuses that list; compile the declared layer from the models with this CLI and re-run.';
756
+
757
+ const isRoleList = (value: unknown): value is string[] => Array.isArray(value) && value.every(role => typeof role === 'string');
758
+
539
759
  /** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
540
760
  const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
541
761
 
@@ -570,13 +790,15 @@ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]):
570
790
  /**
571
791
  * db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
572
792
  * 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.
793
+ * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles`, `requestRoles`
794
+ * and `declaredOwners` are `unknown` because the ops Lambda reads them off the wire. The last two are
795
+ * optional: an older CLI sends neither, and the check runs as before. An older server ignores them:
796
+ * it refuses more, and it skips the live checks on the request roles (see ReconcileWirePayload).
575
797
  */
576
798
  export async function lifecycleReconcileRefusal(
577
799
  runner: QueryRunner,
578
800
  declared: readonly SourceObject[],
579
- options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
801
+ options: { applicationRoles?: unknown; requestRoles?: unknown; declaredOwners?: unknown; lifecycleRole?: string } = {},
580
802
  ): Promise<string | null> {
581
803
  if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
582
804
  const [log] = await runner(LIFECYCLE_LOG_SQL);
@@ -586,11 +808,16 @@ export async function lifecycleReconcileRefusal(
586
808
  if ('refusal' in derived) return derived.refusal;
587
809
  const roles = options.applicationRoles;
588
810
  if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
811
+ const { requestRoles, declaredOwners } = options;
812
+ if (requestRoles != null && !isRoleList(requestRoles)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('request roles');
813
+ if (declaredOwners != null && !isRoleList(declaredOwners)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('declared owners');
814
+ if (Array.isArray(requestRoles) && requestRoles.length === 0) return LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL;
589
815
  const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
590
816
  && !lifecycleManifestOf(object)).map(object => object.identity).sort();
591
817
  if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
592
818
  return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
593
- nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
819
+ nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared),
820
+ ...(requestRoles == null ? {} : { requestRoles, declaredOwners: isRoleList(declaredOwners) ? declaredOwners : [] }) },
594
821
  { lifecycleRole: options.lifecycleRole });
595
822
  }
596
823