@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 +19 -0
- package/package.json +3 -3
- package/src/cli/commands/db-apply.ts +8 -4
- package/src/cli/commands/db-branch.ts +2 -0
- package/src/cli/commands/db-build.ts +2 -1
- package/src/cli/commands/db-check.ts +12 -1
- package/src/cli/commands/db-plan.ts +7 -2
- package/src/cli/commands/db-reconcile.ts +11 -2
- package/src/cli/commands/db-sync.ts +8 -3
- package/src/cli/db-build.ts +7 -4
- package/src/cli/declared-derived.ts +11 -1
- package/src/cli/derived-catchup.ts +5 -3
- package/src/cli/lifecycle-preflight.ts +263 -36
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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',
|
package/src/cli/db-build.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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(
|
|
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
|
|
353
|
-
const
|
|
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'
|
|
373
|
-
const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
|
|
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
|
-
:
|
|
382
|
-
|
|
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:
|
|
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
|
|
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
|
|
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),
|
|
496
|
-
|
|
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
|
|
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(
|
|
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
|
|
574
|
-
* because the ops Lambda reads
|
|
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
|
|