@everystack/cli 0.4.104 → 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 +31 -0
- package/package.json +3 -3
- package/src/cli/commands/db-apply.ts +12 -4
- package/src/cli/commands/db-branch.ts +2 -0
- package/src/cli/commands/db-build.ts +4 -1
- package/src/cli/commands/db-check.ts +12 -1
- package/src/cli/commands/db-plan.ts +9 -3
- package/src/cli/commands/db-reconcile.ts +11 -2
- package/src/cli/commands/db-sync.ts +11 -2
- package/src/cli/db-build.ts +14 -2
- package/src/cli/declared-derived.ts +11 -1
- package/src/cli/derived-apply.ts +12 -1
- package/src/cli/derived-catchup.ts +30 -7
- package/src/cli/index.ts +10 -0
- package/src/cli/lifecycle-preflight.ts +381 -35
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ 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
|
+
|
|
26
|
+
## [0.4.105] - 2026-10-31
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- rule 5 judges who can replace a trigger function, not its security mode
|
|
31
|
+
- judge declared trigger-function owners before the derived layer lands
|
|
32
|
+
- re-run the catalog check after the derived layer lands
|
|
33
|
+
- read the reconcile's builder on its own connection
|
|
34
|
+
- everystack --version prints the version
|
|
35
|
+
- run the post-check on the reconcile's own connection
|
|
36
|
+
- say the tables were applied only when this run applied them
|
|
37
|
+
|
|
7
38
|
## [0.4.104] - 2026-10-30
|
|
8
39
|
|
|
9
40
|
### BREAKING CHANGES
|
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 } 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
|
|
|
@@ -269,6 +269,8 @@ async function runCatchUp(
|
|
|
269
269
|
...(flags.models ? { models: flags.models } : {}),
|
|
270
270
|
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
271
271
|
onRefusal,
|
|
272
|
+
// A lifecycle refusal says the tables were applied only when this run applied some.
|
|
273
|
+
stateApplied: applied > 0,
|
|
272
274
|
});
|
|
273
275
|
if (outcome.run) {
|
|
274
276
|
if (verbose) {
|
|
@@ -289,6 +291,8 @@ async function runCatchUp(
|
|
|
289
291
|
return { ok: true, statements: outcome.statements };
|
|
290
292
|
} catch (err: any) {
|
|
291
293
|
const cause = String(err?.message ?? err);
|
|
294
|
+
// A lifecycle finding's report already says what committed and what did not, one line each.
|
|
295
|
+
if (err instanceof LifecycleFindingsError) return { ok: false, lines: cause.split('\n') };
|
|
292
296
|
return {
|
|
293
297
|
ok: false,
|
|
294
298
|
// The cause is printed ONCE, at every verbosity: its headline on the report line, and
|
|
@@ -515,7 +519,9 @@ async function applyPlanViaStage(
|
|
|
515
519
|
const { region } = config;
|
|
516
520
|
const fn = opsFunction(config);
|
|
517
521
|
const declared = await loadDeclaredDerived(flags.models);
|
|
518
|
-
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))
|
|
519
525
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
|
|
520
526
|
if (lifecycleRefusal) {
|
|
521
527
|
fail(lifecycleRefusal);
|
|
@@ -952,7 +958,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
952
958
|
const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
|
|
953
959
|
declaredDbPromise ??= loadDeclaredDerived(flags.models);
|
|
954
960
|
// Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
|
|
955
|
-
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()))
|
|
956
964
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
|
|
957
965
|
if (lifecycleRefusal) {
|
|
958
966
|
fail(lifecycleRefusal);
|
|
@@ -1109,7 +1117,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1109
1117
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
1110
1118
|
async () => {
|
|
1111
1119
|
// Re-checked under the lease, where the writes happen.
|
|
1112
|
-
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
|
|
1120
|
+
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
|
|
1113
1121
|
if (leasedRefusal) {
|
|
1114
1122
|
fail(leasedRefusal);
|
|
1115
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,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { ModelDescriptor } from '@everystack/model';
|
|
28
28
|
import { buildIntoDatabase } from '../db-build';
|
|
29
|
+
import { LifecycleFindingsError, lifecycleRolesOf } from '../lifecycle-preflight';
|
|
29
30
|
import { createUrlRunner } from '../db-source';
|
|
30
31
|
import { resolveModelsPath } from '../models-path';
|
|
31
32
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
|
|
@@ -123,11 +124,13 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
|
|
|
123
124
|
declared: declaredDb?.objects,
|
|
124
125
|
sequences: declaredDb?.sequences,
|
|
125
126
|
extensions: declaredDb?.extensions,
|
|
127
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
126
128
|
actor: process.env.USER ?? null,
|
|
127
129
|
gitRef: currentGitRef(),
|
|
128
130
|
});
|
|
129
131
|
} catch (err: any) {
|
|
130
|
-
|
|
132
|
+
// A lifecycle finding says itself what was and was not written.
|
|
133
|
+
fail(err instanceof LifecycleFindingsError ? err.message : `Build failed: ${err.message}`);
|
|
131
134
|
process.exit(1);
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -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,8 +238,14 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
|
247
|
+
// db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
|
|
248
|
+
if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
|
|
243
249
|
let snapshot: SchemaSnapshot;
|
|
244
250
|
let contract: AuthzContract;
|
|
245
251
|
if (dbSource.kind === 'url') {
|
|
@@ -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 } 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,8 +244,12 @@ 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);
|
|
249
|
+
// Before anything is written, so before both places a pass creates derived objects (the
|
|
250
|
+
// functions the state batch calls, then the reconcile).
|
|
251
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
|
|
252
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
245
253
|
const first = await syncOnce(runner, session, models, options, hooks, 1);
|
|
246
254
|
// The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
|
|
247
255
|
// only, so a compute refusal or an unadopted object cannot be settled by looking again —
|
|
@@ -447,6 +455,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
447
455
|
sequences: declaredDb?.sequences,
|
|
448
456
|
extensions: declaredDb?.extensions,
|
|
449
457
|
renamedTables: declaredDb?.renamedTables,
|
|
458
|
+
lifecycleRoles: lifecycleRolesOf(declaredDb),
|
|
450
459
|
allowDrops: flags['allow-drops'] === 'true',
|
|
451
460
|
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
452
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 } 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
|
/**
|
|
@@ -181,13 +183,18 @@ export async function buildIntoDatabase(
|
|
|
181
183
|
// declared state: 20 such pairs, `type "…_row" does not exist`, from-scratch only. Their
|
|
182
184
|
// explicit `dependsOn` could not have helped — this pass dropped the view from the wave, so
|
|
183
185
|
// no edge could order it back in.
|
|
186
|
+
// Before the first derived write: the owners the reconcile will give the declared trigger
|
|
187
|
+
// functions on lifecycle tables. This pass runs before executeSync, which judges them again
|
|
188
|
+
// first thing, before its own state apply.
|
|
189
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
|
|
190
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
184
191
|
const declaredFunctions = (options.declared ?? [])
|
|
185
192
|
.filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
|
|
186
193
|
if (declaredFunctions.length > 0) {
|
|
187
194
|
// The lifecycle catalog check, with the full models, before any lifecycle-owned function
|
|
188
195
|
// lands: this pass declares functions only, so it carries no transition trigger for the
|
|
189
196
|
// reconcile to read the lifecycle tables from. executeSync runs the same check again below.
|
|
190
|
-
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
|
|
197
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
|
|
191
198
|
if (lifecycleRefusal) throw new Error(lifecycleRefusal);
|
|
192
199
|
await runner('SET check_function_bodies = off');
|
|
193
200
|
try {
|
|
@@ -211,9 +218,14 @@ export async function buildIntoDatabase(
|
|
|
211
218
|
const run = await executeSync(runner, session, models, {
|
|
212
219
|
declared: options.declared,
|
|
213
220
|
sequences: options.sequences,
|
|
221
|
+
lifecycleRoles: options.lifecycleRoles,
|
|
214
222
|
actor: options.actor ?? 'db-build',
|
|
215
223
|
gitRef: options.gitRef ?? null,
|
|
216
224
|
});
|
|
225
|
+
// Verification: the live catalog check again, over what the derived layer just made live. A
|
|
226
|
+
// throw, so no caller (db:check's compose ring, the dev template, a test database) can miss it.
|
|
227
|
+
const post = await lifecyclePostApplyRefusal(session, models, options.lifecycleRoles);
|
|
228
|
+
if (post) throw new LifecycleFindingsError(post);
|
|
217
229
|
return {
|
|
218
230
|
converged: run.converged,
|
|
219
231
|
fingerprintMatch: run.fingerprintMatch,
|
|
@@ -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) {
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -181,6 +181,16 @@ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', re
|
|
|
181
181
|
return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The role that owns an object once the reconcile has created it: its declared owner (objectSql
|
|
186
|
+
* enacts it), else `builder`, the role the reconcile runs as, which creates it as itself. The
|
|
187
|
+
* lifecycle pre-exposure gate reads owners through this too, so the role it judges is the role the
|
|
188
|
+
* reconcile makes.
|
|
189
|
+
*/
|
|
190
|
+
export function reconcileOwner(obj: Pick<SourceObject, 'owner'>, builder: string): string {
|
|
191
|
+
return obj.owner || builder;
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
const ALTER_OWNER: Record<string, string> = {
|
|
185
195
|
function: 'ALTER FUNCTION',
|
|
186
196
|
view: 'ALTER VIEW',
|
|
@@ -228,7 +238,8 @@ export function triggerFunctionOf(trigger: SourceObject, source: readonly Source
|
|
|
228
238
|
function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
|
|
229
239
|
if (!grantee || obj.kind !== 'trigger') return statements;
|
|
230
240
|
const fn = triggerFunctionOf(obj, source);
|
|
231
|
-
|
|
241
|
+
// The builder owns a function it creates for itself, and an owner holds EXECUTE.
|
|
242
|
+
if (!fn || reconcileOwner(fn, grantee) === grantee) return statements;
|
|
232
243
|
const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
|
|
233
244
|
if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
|
|
234
245
|
const target = dropTarget('function', fn.identity);
|
|
@@ -33,9 +33,9 @@ import { loadDeclaredDerived, type DeclaredDerived } from './declared-derived';
|
|
|
33
33
|
import { modelContractPreflight } from './model-wire-lane';
|
|
34
34
|
import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
|
|
35
35
|
import { resolveConfig, opsFunction } from './config';
|
|
36
|
-
import { invokeAction } from './aws';
|
|
36
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
|
|
37
37
|
import { currentGitRef } from './state-apply';
|
|
38
|
-
import { catalogInputsFromModels } 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 =
|
|
@@ -77,6 +77,12 @@ export interface CatchUpOptions {
|
|
|
77
77
|
* wrongly handed an exit loses the sentence its operator was owed.
|
|
78
78
|
*/
|
|
79
79
|
onRefusal: 'exit' | 'throw';
|
|
80
|
+
/**
|
|
81
|
+
* Whether THIS run committed table or column changes before the catch-up: a lifecycle refusal
|
|
82
|
+
* then says so. False for db:apply with no --plan, and for a plan that was already applied.
|
|
83
|
+
* Required for the same reason as `onRefusal`: only the caller knows what it wrote.
|
|
84
|
+
*/
|
|
85
|
+
stateApplied: boolean;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
export interface CatchUpOutcome {
|
|
@@ -112,15 +118,27 @@ export async function catchUpDerived(
|
|
|
112
118
|
// The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
|
|
113
119
|
// lifecycle-owned objects without them.
|
|
114
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),
|
|
115
123
|
actor: process.env.USER ?? null,
|
|
116
124
|
gitRef: currentGitRef(),
|
|
117
125
|
};
|
|
118
126
|
|
|
127
|
+
// Before the first derived write, judge the owners the reconcile will give the declared trigger
|
|
128
|
+
// functions on lifecycle tables.
|
|
129
|
+
const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
|
|
130
|
+
const reader: QueryRunner = (q) => (venue.kind === 'direct' ? venue.runner : lambdaQueryRunner(config!.region, opsFunction(config!)))(q);
|
|
131
|
+
// The builder is read on the reconcile's own connection: on a stage, db:session runs on the
|
|
132
|
+
// operator connection, while db:query may run on a reader.
|
|
133
|
+
const operator: SessionRunner = (statements, opts) =>
|
|
134
|
+
(venue.kind === 'direct' ? venue.session : lambdaSessionRunner(config!.region, opsFunction(config!)))(statements, opts);
|
|
135
|
+
const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied, roles: lifecycleRolesOf(declared) });
|
|
136
|
+
if (gate) throw new LifecycleFindingsError(gate);
|
|
137
|
+
|
|
119
138
|
let run: ReconcileRun;
|
|
120
139
|
if (venue.kind === 'direct') {
|
|
121
140
|
run = await executeReconcile(venue.runner, venue.session, options);
|
|
122
141
|
} else {
|
|
123
|
-
const config = await resolveConfig(venue.stage);
|
|
124
142
|
// The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
|
|
125
143
|
// deployed stage understands their shape before sending them — the same handshake
|
|
126
144
|
// db:reconcile --apply --stage runs, for the same payload.
|
|
@@ -130,14 +148,14 @@ export async function catchUpDerived(
|
|
|
130
148
|
// before the process ends. A catch-up that is the whole command has written nothing, and
|
|
131
149
|
// asks to exit: print the skew, say nothing was sent, exit.
|
|
132
150
|
await modelContractPreflight({
|
|
133
|
-
region: config
|
|
134
|
-
fn: opsFunction(config),
|
|
151
|
+
region: config!.region,
|
|
152
|
+
fn: opsFunction(config!),
|
|
135
153
|
directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
|
|
136
154
|
onRefusal: opts.onRefusal,
|
|
137
155
|
})('db:apply');
|
|
138
156
|
const result: any = await invokeAction(
|
|
139
|
-
config
|
|
140
|
-
opsFunction(config),
|
|
157
|
+
config!.region,
|
|
158
|
+
opsFunction(config!),
|
|
141
159
|
'db:reconcile',
|
|
142
160
|
{ ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
|
|
143
161
|
);
|
|
@@ -154,5 +172,10 @@ export async function catchUpDerived(
|
|
|
154
172
|
// not land, so db:apply must say so and exit the same way.
|
|
155
173
|
if (run.refusal) throw new CatchUpError(run.refusal);
|
|
156
174
|
|
|
175
|
+
// Verification: the live catalog check again, over what the reconcile just made live. On the
|
|
176
|
+
// operator session, where the commit is: db:query may read a replica that has not caught up.
|
|
177
|
+
const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
|
|
178
|
+
if (post) throw new LifecycleFindingsError(post);
|
|
179
|
+
|
|
157
180
|
return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
|
|
158
181
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -44,6 +44,8 @@ import { auditLighthouseCommand } from './commands/lighthouse';
|
|
|
44
44
|
import { auditCommand } from './commands/audit';
|
|
45
45
|
import { uiAuditCommand } from './commands/ui-audit';
|
|
46
46
|
import { runbookCommand } from './commands/runbook';
|
|
47
|
+
import { readFileSync } from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
47
49
|
import { fail } from './output';
|
|
48
50
|
import { parseFlags } from './parse-flags';
|
|
49
51
|
|
|
@@ -56,6 +58,13 @@ async function main() {
|
|
|
56
58
|
process.exit(0);
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
if (command === '--version' || command === '-v') {
|
|
62
|
+
// tsx polyfills __dirname in ESM (see runbook.ts cliVersion).
|
|
63
|
+
const pkg = JSON.parse(readFileSync(path.join(eval('__dirname') as string, '..', '..', 'package.json'), 'utf8'));
|
|
64
|
+
console.log(pkg.version);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
const flags = parseFlags(args.slice(1));
|
|
60
69
|
|
|
61
70
|
// `everystack <command> --help` shows help instead of running the command
|
|
@@ -405,6 +414,7 @@ Auth:
|
|
|
405
414
|
|
|
406
415
|
Options:
|
|
407
416
|
--help, -h Show this help message
|
|
417
|
+
--version, -v Print the @everystack/cli version
|
|
408
418
|
`);
|
|
409
419
|
}
|
|
410
420
|
|
|
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
|
|
4
4
|
import type { QueryRunner } from './authz-contract';
|
|
5
5
|
import { compileTableContract } from './authz-compile';
|
|
6
|
+
import { reconcileOwner, triggerFunctionOf } from './derived-apply';
|
|
7
|
+
import { isSessionError, type SessionRunner } from './session';
|
|
6
8
|
import { parseQualified, type SourceObject } from './derived-source';
|
|
7
9
|
import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
|
|
8
10
|
|
|
@@ -31,13 +33,50 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
|
|
|
31
33
|
* The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
|
|
32
34
|
* rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
|
|
33
35
|
*/
|
|
34
|
-
export async function lifecycleApplyRefusal(
|
|
36
|
+
export async function lifecycleApplyRefusal(
|
|
37
|
+
runner: QueryRunner,
|
|
38
|
+
models: readonly ModelDescriptor[],
|
|
39
|
+
roles: LifecycleRoleDeclaration = {},
|
|
40
|
+
): Promise<string | null> {
|
|
35
41
|
if (!models.some(model => model.lifecycle === true)) return null;
|
|
36
42
|
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
37
43
|
// Before the plan: a modeled table's keys are the plan's to converge (the models declare each
|
|
38
44
|
// one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
|
|
39
45
|
const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
|
|
40
|
-
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.';
|
|
41
80
|
}
|
|
42
81
|
|
|
43
82
|
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
@@ -83,6 +122,13 @@ export interface LifecycleCatalogInputs {
|
|
|
83
122
|
* rule 6 leaves to the plan. Absent after the state apply: every key is checked.
|
|
84
123
|
*/
|
|
85
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[];
|
|
86
132
|
}
|
|
87
133
|
|
|
88
134
|
/** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
|
|
@@ -125,8 +171,11 @@ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<type
|
|
|
125
171
|
|
|
126
172
|
const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
|
|
127
173
|
|
|
128
|
-
/** The catalog check's inputs, read from the models. */
|
|
129
|
-
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[] } {
|
|
130
179
|
const roles = new Set<string>();
|
|
131
180
|
for (const model of models) {
|
|
132
181
|
const contract = compileTableContract(model);
|
|
@@ -138,6 +187,7 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
|
|
|
138
187
|
lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
|
|
139
188
|
applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
|
|
140
189
|
dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
|
|
190
|
+
...(declaration.requestRoles === undefined ? {} : { requestRoles: [...declaration.requestRoles], declaredOwners: [...(declaration.declaredOwners ?? [])] }),
|
|
141
191
|
};
|
|
142
192
|
}
|
|
143
193
|
|
|
@@ -151,9 +201,16 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
|
|
|
151
201
|
const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
|
|
152
202
|
WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
|
|
153
203
|
ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
|
|
154
|
-
/**
|
|
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
|
+
*/
|
|
155
212
|
const reaches = (x: string, y: string): string =>
|
|
156
|
-
`(${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'))`;
|
|
157
214
|
|
|
158
215
|
/**
|
|
159
216
|
* The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
|
|
@@ -164,6 +221,115 @@ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::t
|
|
|
164
221
|
WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
|
|
165
222
|
WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
|
|
166
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
|
+
|
|
167
333
|
/** Rule 1, attributes. */
|
|
168
334
|
const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
|
|
169
335
|
COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
|
|
@@ -182,11 +348,17 @@ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
|
|
|
182
348
|
WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
|
|
183
349
|
ORDER BY 1`;
|
|
184
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
|
+
|
|
185
354
|
/**
|
|
186
355
|
* Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
|
|
187
|
-
* write the marker; the owner of a marker function can replace it
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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.
|
|
190
362
|
*/
|
|
191
363
|
const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
|
|
192
364
|
owned AS (
|
|
@@ -198,8 +370,19 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
|
|
|
198
370
|
WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
|
|
199
371
|
UNION ALL
|
|
200
372
|
SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
|
|
201
|
-
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)))
|
|
202
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,
|
|
203
386
|
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
|
|
204
387
|
FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
|
|
205
388
|
WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
|
|
@@ -248,20 +431,49 @@ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS pr
|
|
|
248
431
|
ORDER BY s.i`;
|
|
249
432
|
|
|
250
433
|
/**
|
|
251
|
-
* Rule 5. A trigger on a lifecycle table whose function is
|
|
252
|
-
*
|
|
434
|
+
* Rule 5. A trigger on a lifecycle table whose function's owner is an application-side role, or
|
|
435
|
+
* one an application-side role reaches. The soft delete's stamp and destroy's DELETE run as the
|
|
436
|
+
* lifecycle role, so an INVOKER trigger function runs as it and a DEFINER one as its owner; either
|
|
437
|
+
* way whoever can replace the body acts inside every soft delete and destroy. So the security
|
|
438
|
+
* mode does not matter, and neither the table's owner nor the lifecycle role is exempt.
|
|
253
439
|
*/
|
|
254
|
-
const
|
|
440
|
+
const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${sideCte(apps)}
|
|
441
|
+
SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
|
|
255
442
|
pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
|
|
256
|
-
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
|
|
443
|
+
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
|
|
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')}
|
|
257
446
|
FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
|
|
258
447
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
|
|
259
448
|
JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
|
|
260
449
|
WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
|
|
261
|
-
AND
|
|
262
|
-
AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
|
|
450
|
+
AND EXISTS (SELECT 1 FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')})
|
|
263
451
|
ORDER BY 1, 2`;
|
|
264
452
|
|
|
453
|
+
/**
|
|
454
|
+
* One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
|
|
455
|
+
* owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
|
|
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.
|
|
458
|
+
*/
|
|
459
|
+
export function triggerOwnerFinding(
|
|
460
|
+
row: { trigger: string; table: string; function: string; owner: string; via: readonly string[]; cause?: string },
|
|
461
|
+
would = false,
|
|
462
|
+
): string {
|
|
463
|
+
const { owner, via } = row;
|
|
464
|
+
const sideOwned = via.includes(owner);
|
|
465
|
+
const who = sideOwned ? `${owner}, an application-side role`
|
|
466
|
+
: `${owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
|
|
467
|
+
const fix = sideOwned
|
|
468
|
+
? `declare ${row.function}'s owner in db/models as a role no application role can act as, and apply `
|
|
469
|
+
+ `(the apply re-creates a declared function with its declared owner, so a live ALTER alone is undone); `
|
|
470
|
+
+ `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
|
|
471
|
+
: `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
|
|
472
|
+
return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
|
|
473
|
+
+ `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`
|
|
474
|
+
+ (sideOwned && row.cause ? ` ${row.cause}` : '');
|
|
475
|
+
}
|
|
476
|
+
|
|
265
477
|
/**
|
|
266
478
|
* Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
|
|
267
479
|
* in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
|
|
@@ -282,6 +494,8 @@ const OWNER_HAZARD = {
|
|
|
282
494
|
table: 'an owner can disable its triggers',
|
|
283
495
|
log: 'an owner can write the marker',
|
|
284
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',
|
|
285
499
|
};
|
|
286
500
|
|
|
287
501
|
/**
|
|
@@ -298,18 +512,35 @@ const OWNER_HAZARD = {
|
|
|
298
512
|
*
|
|
299
513
|
* The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
|
|
300
514
|
* (inputs from the models); db:reconcile, local and in the ops Lambda, through
|
|
301
|
-
* lifecycleReconcileRefusal (tables from the declared transition triggers)
|
|
515
|
+
* lifecycleReconcileRefusal (tables from the declared transition triggers); db:apply and db:build
|
|
516
|
+
* again after the derived layer lands, through lifecyclePostApplyRefusal.
|
|
302
517
|
*/
|
|
303
518
|
export async function lifecycleCatalogRefusal(
|
|
304
519
|
runner: QueryRunner,
|
|
305
520
|
inputs: LifecycleCatalogInputs,
|
|
306
521
|
options: { lifecycleRole?: string } = {},
|
|
307
522
|
): Promise<string | null> {
|
|
523
|
+
const findings = await lifecycleCatalogFindings(runner, inputs, options);
|
|
524
|
+
if (findings.length === 0) return null;
|
|
525
|
+
return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
|
|
526
|
+
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
|
|
530
|
+
const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
|
|
531
|
+
|
|
532
|
+
/** lifecycleCatalogRefusal's findings, one string each, without the heading. */
|
|
533
|
+
async function lifecycleCatalogFindings(
|
|
534
|
+
runner: QueryRunner,
|
|
535
|
+
inputs: LifecycleCatalogInputs,
|
|
536
|
+
options: { lifecycleRole?: string } = {},
|
|
537
|
+
): Promise<string[]> {
|
|
308
538
|
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
309
539
|
const tables = [...new Set(inputs.lifecycleTables)].sort();
|
|
310
|
-
if (tables.length === 0) return
|
|
311
|
-
const
|
|
312
|
-
const
|
|
540
|
+
if (tables.length === 0) return [];
|
|
541
|
+
const set = await applicationRoleSet(runner, inputs, lc);
|
|
542
|
+
const apps = set.apps;
|
|
543
|
+
const findings: string[] = [...set.findings];
|
|
313
544
|
|
|
314
545
|
const [role] = await runner(roleSql(lc));
|
|
315
546
|
if (!truthy(role?.present)) {
|
|
@@ -328,17 +559,23 @@ export async function lifecycleCatalogRefusal(
|
|
|
328
559
|
}
|
|
329
560
|
|
|
330
561
|
for (const row of await runner(ownerSql(tables, apps, lc))) {
|
|
331
|
-
const kind = row.kind === 'function'
|
|
332
|
-
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];
|
|
333
565
|
const via = textList(row.via);
|
|
334
566
|
const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
|
|
335
567
|
: via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
|
|
336
568
|
: `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
|
|
337
|
-
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.
|
|
338
572
|
const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
|
|
339
573
|
? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
|
|
340
|
-
:
|
|
341
|
-
|
|
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}` : ''}`);
|
|
342
579
|
}
|
|
343
580
|
|
|
344
581
|
for (const row of await runner(aclSql(tables))) {
|
|
@@ -371,10 +608,11 @@ export async function lifecycleCatalogRefusal(
|
|
|
371
608
|
}
|
|
372
609
|
}
|
|
373
610
|
|
|
374
|
-
for (const row of await runner(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
611
|
+
for (const row of await runner(triggerOwnerSql(tables, apps))) {
|
|
612
|
+
const owner = String(row.owner);
|
|
613
|
+
const via = textList(row.via);
|
|
614
|
+
findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
|
|
615
|
+
owner, via, cause: ownerCause(set, owner, textList(row.acts_as), textList(row.app_reach)) }));
|
|
378
616
|
}
|
|
379
617
|
|
|
380
618
|
const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
|
|
@@ -403,9 +641,96 @@ export async function lifecycleCatalogRefusal(
|
|
|
403
641
|
+ 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
|
|
404
642
|
}
|
|
405
643
|
|
|
644
|
+
return findings;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* A lifecycle finding raised around the derived layer: by the pre-exposure gate (nothing derived
|
|
649
|
+
* was written) or by the post-check (the objects are live). Its message is the whole report, one
|
|
650
|
+
* line of head and one line per finding, so a caller prints it as is.
|
|
651
|
+
*/
|
|
652
|
+
export class LifecycleFindingsError extends Error {}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The pre-exposure gate: rule 5 over the DECLARED owners, before the reconcile creates anything.
|
|
656
|
+
* Each declared trigger on a lifecycle table whose function is declared is judged by the owner the
|
|
657
|
+
* reconcile will give that function (reconcileOwner: the declared owner, else the role the
|
|
658
|
+
* reconcile runs as). A declared owner that does not exist live is a finding. A trigger whose
|
|
659
|
+
* function is not declared is left to the post-check, which reads the live owner.
|
|
660
|
+
*
|
|
661
|
+
* `session` reads the builder: it runs on the connection the reconcile writes on. On a stage that
|
|
662
|
+
* is db:session, the operator connection; db:query (`runner`) may run on a reader
|
|
663
|
+
* (QUERY_DATABASE_URL), whose CURRENT_USER is not the role that will own the function. `runner`
|
|
664
|
+
* reads only pg_roles, which is the same on any connection.
|
|
665
|
+
*
|
|
666
|
+
* `stateApplied`: the table and column changes have committed, so the refusal says so.
|
|
667
|
+
*/
|
|
668
|
+
export async function lifecycleDeclaredOwnerRefusal(
|
|
669
|
+
runner: QueryRunner,
|
|
670
|
+
session: SessionRunner,
|
|
671
|
+
declared: readonly SourceObject[],
|
|
672
|
+
models: readonly ModelDescriptor[],
|
|
673
|
+
options: { stateApplied: boolean; roles?: LifecycleRoleDeclaration },
|
|
674
|
+
): Promise<string | null> {
|
|
675
|
+
const inputs = catalogInputsFromModels(models, options.roles);
|
|
676
|
+
const tables = new Set(inputs.lifecycleTables);
|
|
677
|
+
const triggers = declared.flatMap((object) => {
|
|
678
|
+
if (object.kind !== 'trigger' || !object.table) return [];
|
|
679
|
+
const { schema, name } = parseQualified(object.table);
|
|
680
|
+
const fn = tables.has(`${schema}.${name}`) ? triggerFunctionOf(object, declared) : undefined;
|
|
681
|
+
return fn ? [{ trigger: object.name, table: `${schema}.${name}`, fn }] : [];
|
|
682
|
+
});
|
|
683
|
+
if (triggers.length === 0) return null;
|
|
684
|
+
const [slot] = await session(['SELECT CURRENT_USER::text AS builder']);
|
|
685
|
+
if (isSessionError(slot) || slot?.[0]?.builder == null) throw new Error('could not read the role the reconcile runs as (CURRENT_USER)');
|
|
686
|
+
const builder = String(slot[0].builder);
|
|
687
|
+
const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
|
|
688
|
+
const owners = [...new Set(judged.map(t => t.owner))].sort();
|
|
689
|
+
const set = await applicationRoleSet(runner, inputs, LIFECYCLE_OWNER_ROLE);
|
|
690
|
+
const rows = await runner(`WITH ${sideCte(set.apps)}
|
|
691
|
+
SELECT d.name AS owner, o.oid IS NOT NULL AS present,
|
|
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')}
|
|
694
|
+
FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
|
|
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) => {
|
|
698
|
+
const owner = byOwner.get(t.owner);
|
|
699
|
+
if (!owner?.present) {
|
|
700
|
+
return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
|
|
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.`];
|
|
702
|
+
}
|
|
703
|
+
if (owner.via.length === 0) return [];
|
|
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
|
+
})];
|
|
707
|
+
if (findings.length === 0) return null;
|
|
708
|
+
return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
|
|
709
|
+
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The post-check: the live catalog check again, after the derived layer landed. The reconcile
|
|
714
|
+
* commits what it created, so the report never says refused or rolled back: the objects are live,
|
|
715
|
+
* and the next apply's preflight refuses until each finding is fixed.
|
|
716
|
+
*
|
|
717
|
+
* It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
|
|
718
|
+
* connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
|
|
719
|
+
*/
|
|
720
|
+
export async function lifecyclePostApplyRefusal(
|
|
721
|
+
session: SessionRunner,
|
|
722
|
+
models: readonly ModelDescriptor[],
|
|
723
|
+
roles: LifecycleRoleDeclaration = {},
|
|
724
|
+
): Promise<string | null> {
|
|
725
|
+
if (!models.some(model => model.lifecycle === true)) return null;
|
|
726
|
+
const read: QueryRunner = async (q) => {
|
|
727
|
+
const [slot] = await session([q]);
|
|
728
|
+
if (isSessionError(slot)) throw new Error(slot.message);
|
|
729
|
+
return slot ?? [];
|
|
730
|
+
};
|
|
731
|
+
const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
|
|
406
732
|
if (findings.length === 0) return null;
|
|
407
|
-
return
|
|
408
|
-
+ `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
|
|
733
|
+
return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
|
|
409
734
|
}
|
|
410
735
|
|
|
411
736
|
/** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
|
|
@@ -417,6 +742,20 @@ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]):
|
|
|
417
742
|
+ `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
|
|
418
743
|
+ 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
|
|
419
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
|
+
|
|
420
759
|
/** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
|
|
421
760
|
const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
|
|
422
761
|
|
|
@@ -451,13 +790,15 @@ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]):
|
|
|
451
790
|
/**
|
|
452
791
|
* db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
|
|
453
792
|
* declared layer's own consistency, the application roles' presence (fail closed), then the
|
|
454
|
-
* catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles
|
|
455
|
-
* 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).
|
|
456
797
|
*/
|
|
457
798
|
export async function lifecycleReconcileRefusal(
|
|
458
799
|
runner: QueryRunner,
|
|
459
800
|
declared: readonly SourceObject[],
|
|
460
|
-
options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
|
|
801
|
+
options: { applicationRoles?: unknown; requestRoles?: unknown; declaredOwners?: unknown; lifecycleRole?: string } = {},
|
|
461
802
|
): Promise<string | null> {
|
|
462
803
|
if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
|
|
463
804
|
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
@@ -467,11 +808,16 @@ export async function lifecycleReconcileRefusal(
|
|
|
467
808
|
if ('refusal' in derived) return derived.refusal;
|
|
468
809
|
const roles = options.applicationRoles;
|
|
469
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;
|
|
470
815
|
const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
|
|
471
816
|
&& !lifecycleManifestOf(object)).map(object => object.identity).sort();
|
|
472
817
|
if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
|
|
473
818
|
return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
|
|
474
|
-
nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared)
|
|
819
|
+
nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared),
|
|
820
|
+
...(requestRoles == null ? {} : { requestRoles, declaredOwners: isRoleList(declaredOwners) ? declaredOwners : [] }) },
|
|
475
821
|
{ lifecycleRole: options.lifecycleRole });
|
|
476
822
|
}
|
|
477
823
|
|