@everystack/cli 0.4.36 → 0.4.39
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/bin/everystack.mjs +52 -0
- package/package.json +5 -4
- package/src/cli/authz-compile.ts +71 -7
- package/src/cli/authz-derive.ts +210 -0
- package/src/cli/authz-redteam.ts +132 -9
- package/src/cli/commands/db-authz.ts +112 -40
- package/src/cli/commands/db-fingerprint.ts +19 -2
- package/src/cli/commands/db-pull.ts +30 -5
- package/src/cli/commands/db-swap.ts +122 -21
- package/src/cli/commands/db.ts +11 -0
- package/src/cli/db-source.ts +67 -16
- package/src/cli/derived-apply.ts +15 -1
- package/src/cli/derived-compile.ts +11 -1
- package/src/cli/derived-introspect.ts +36 -8
- package/src/cli/derived-plan.ts +53 -0
- package/src/cli/derived-render.ts +65 -5
- package/src/cli/index.ts +6 -5
- package/src/cli/model-render.ts +26 -5
- package/src/cli/pg-argtypes.ts +52 -0
- package/src/cli/swap-heartbeat.ts +106 -17
- package/src/cli/swap-snapshot.ts +260 -0
|
@@ -9,9 +9,20 @@
|
|
|
9
9
|
* can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
|
|
10
10
|
* audit and the CI gate (non-zero exit on any drift).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Two venues, one verdict. The core (introspect/assemble/diff/evaluate) is backend-agnostic
|
|
13
|
+
* and pure — only the VENUE differs, and both satisfy the same `AuthzVenue` contract, so
|
|
14
|
+
* every command below runs identical evaluation code either way:
|
|
15
|
+
*
|
|
16
|
+
* - **Deployed** (`--stage`, the default): the ops Lambda's `db:query` + `db:authz:probe`
|
|
17
|
+
* actions. Credentials never leave AWS.
|
|
18
|
+
* - **Direct** (`--database-url`, or an inherited ADMIN_DATABASE_URL / DATABASE_URL): a
|
|
19
|
+
* postgres.js connection from this process.
|
|
20
|
+
*
|
|
21
|
+
* The direct venue is what makes a brownfield authz migration rehearsable. Translating
|
|
22
|
+
* hand-written RLS into Model abilities is the hardest part of an adoption, and until the
|
|
23
|
+
* local venue existed it was the ONLY part that required a deployed stage to iterate
|
|
24
|
+
* against. Every run names its venue, because a security verdict against an unintended
|
|
25
|
+
* database is worse than no verdict.
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
28
|
import path from 'node:path';
|
|
@@ -29,6 +40,8 @@ import {
|
|
|
29
40
|
evaluateRedTeam,
|
|
30
41
|
GRANT_COMPLETENESS_SQL,
|
|
31
42
|
toGrantGap,
|
|
43
|
+
buildInheritedPrivilegeSql,
|
|
44
|
+
toInheritedGrant,
|
|
32
45
|
} from '../authz-redteam.js';
|
|
33
46
|
import {
|
|
34
47
|
buildOwnerProbeSql,
|
|
@@ -43,6 +56,7 @@ import { renderContractMarkdown } from '../authz-render.js';
|
|
|
43
56
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
44
57
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
45
58
|
import { resolveModelsPath } from '../models-path.js';
|
|
59
|
+
import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
|
|
46
60
|
import { invokeAction } from '../aws.js';
|
|
47
61
|
import { step, success, fail, info, warn } from '../output.js';
|
|
48
62
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
@@ -59,20 +73,65 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
59
73
|
};
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Where an authz command runs. Both venues expose the same two capabilities, so the
|
|
78
|
+
* commands never branch on venue after this point — that is what keeps the local
|
|
79
|
+
* rehearsal honest: same SQL, same evaluation, same verdict.
|
|
80
|
+
*/
|
|
81
|
+
interface AuthzVenue {
|
|
82
|
+
/** Read-only introspection. */
|
|
83
|
+
runner: QueryRunner;
|
|
84
|
+
/** Self-reverting red-team probe (writes, always rolled back). */
|
|
85
|
+
probe: (setup: string, read: string) => Promise<any[]>;
|
|
86
|
+
/** Human-readable venue, printed with every verdict. */
|
|
87
|
+
label: string;
|
|
88
|
+
/** Release any connection this venue holds. */
|
|
89
|
+
end: () => Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Precedence is `resolveDbSource`'s, shared with db:pull / db:generate / db:check:
|
|
94
|
+
* `--database-url` > explicit `--stage` > ADMIN_DATABASE_URL > DATABASE_URL > default stage.
|
|
95
|
+
*/
|
|
96
|
+
async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue> {
|
|
97
|
+
const source = resolveDbSource(flags);
|
|
98
|
+
if (source.kind === 'url') {
|
|
99
|
+
step(connectingVia(source));
|
|
100
|
+
const { runner, probe, end } = await createUrlProbeRunner(source.url);
|
|
101
|
+
return { runner, probe, end, label: 'direct connection' };
|
|
102
|
+
}
|
|
63
103
|
step('Resolving deployed config...');
|
|
64
104
|
const config = await resolveConfig(flags.stage);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
const fn = opsFunction(config);
|
|
106
|
+
info(`Region: ${config.region}, Function: ${fn}`);
|
|
107
|
+
return {
|
|
108
|
+
runner: lambdaRunner(config.region, fn),
|
|
109
|
+
probe: async (setup: string, read: string) => {
|
|
110
|
+
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
|
|
111
|
+
if (result?.error) throw new Error(result.error);
|
|
112
|
+
return result?.rows ?? [];
|
|
113
|
+
},
|
|
114
|
+
label: flags.stage ? `stage ${flags.stage}` : 'deployed stage (.sst/outputs.json)',
|
|
115
|
+
end: async () => {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function introspectVenue(flags: Record<string, string>): Promise<{ contract: AuthzContract; label: string }> {
|
|
120
|
+
const venue = await resolveVenue(flags);
|
|
121
|
+
try {
|
|
122
|
+
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
123
|
+
return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
|
|
124
|
+
} finally {
|
|
125
|
+
await venue.end();
|
|
126
|
+
}
|
|
69
127
|
}
|
|
70
128
|
|
|
71
129
|
export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
|
|
72
130
|
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
73
131
|
let contract: AuthzContract;
|
|
132
|
+
let venueLabel: string;
|
|
74
133
|
try {
|
|
75
|
-
contract = await
|
|
134
|
+
({ contract, label: venueLabel } = await introspectVenue(flags));
|
|
76
135
|
} catch (err: any) {
|
|
77
136
|
fail(err.message);
|
|
78
137
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
@@ -82,7 +141,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
|
|
|
82
141
|
const files = await writeContract(dir, contract);
|
|
83
142
|
const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
|
|
84
143
|
console.log('');
|
|
85
|
-
success(`Wrote ${files.length} file(s) to ${dir}`);
|
|
144
|
+
success(`Wrote ${files.length} file(s) to ${dir} (from ${venueLabel})`);
|
|
86
145
|
info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
|
|
87
146
|
console.log('');
|
|
88
147
|
warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
|
|
@@ -97,9 +156,10 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
|
|
|
97
156
|
|
|
98
157
|
let declared: AuthzContract;
|
|
99
158
|
let live: AuthzContract;
|
|
159
|
+
let venueLabel: string;
|
|
100
160
|
try {
|
|
101
161
|
declared = await loadContract(dir);
|
|
102
|
-
live = await
|
|
162
|
+
({ contract: live, label: venueLabel } = await introspectVenue(flags));
|
|
103
163
|
} catch (err: any) {
|
|
104
164
|
fail(err.message);
|
|
105
165
|
process.exit(1);
|
|
@@ -113,11 +173,11 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
|
|
|
113
173
|
const findings = diffContracts(declared, live);
|
|
114
174
|
console.log('');
|
|
115
175
|
if (findings.length === 0) {
|
|
116
|
-
success(`db:authz:diff —
|
|
176
|
+
success(`db:authz:diff — ${venueLabel} matches the declared contract (${declared.tables.length} tables)`);
|
|
117
177
|
process.exit(0);
|
|
118
178
|
}
|
|
119
179
|
|
|
120
|
-
fail(`db:authz:diff — ${findings.length} drift finding(s):
|
|
180
|
+
fail(`db:authz:diff — ${findings.length} drift finding(s): ${venueLabel} does NOT match the declared contract`);
|
|
121
181
|
console.log('');
|
|
122
182
|
for (const f of findings) {
|
|
123
183
|
warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
|
|
@@ -163,36 +223,49 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
163
223
|
|
|
164
224
|
let rows: any[];
|
|
165
225
|
let gapRows: any[];
|
|
226
|
+
let inheritedRows: any[];
|
|
227
|
+
let venueLabel: string;
|
|
228
|
+
let venue: AuthzVenue | undefined;
|
|
166
229
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
230
|
+
venue = await resolveVenue(flags);
|
|
231
|
+
venueLabel = venue.label;
|
|
232
|
+
const roles = probeRoles(contract);
|
|
233
|
+
const tables = contract.tables.map((t) => t.table);
|
|
171
234
|
step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
rows = result?.rows ?? [];
|
|
235
|
+
rows = await venue.probe(buildProbeSql(roles, tables), PROBE_SELECT_SQL);
|
|
236
|
+
step('Attributing undeclared privileges to role membership...');
|
|
237
|
+
inheritedRows = await venue.runner(buildInheritedPrivilegeSql(roles, tables));
|
|
176
238
|
step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
|
|
177
|
-
|
|
178
|
-
if (gc?.error) throw new Error(gc.error);
|
|
179
|
-
gapRows = gc?.rows ?? [];
|
|
239
|
+
gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
|
|
180
240
|
} catch (err: any) {
|
|
181
241
|
fail(`db:authz:test failed: ${err.message}`);
|
|
182
|
-
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe
|
|
242
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
|
|
243
|
+
info('or rehearse locally: db:authz:test --database-url <url>.');
|
|
183
244
|
process.exit(1);
|
|
245
|
+
} finally {
|
|
246
|
+
await venue?.end();
|
|
184
247
|
}
|
|
185
248
|
|
|
186
|
-
const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
|
|
249
|
+
const findings = evaluateRedTeam(contract, rows.map(toProbeResult), inheritedRows.map(toInheritedGrant));
|
|
187
250
|
const holes = findings.filter((f) => f.severity === 'hole');
|
|
188
251
|
const broken = findings.filter((f) => f.severity === 'broken');
|
|
189
252
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
253
|
+
const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
|
|
254
|
+
const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
|
|
190
255
|
const gaps = gapRows.map(toGrantGap);
|
|
191
256
|
|
|
192
257
|
console.log('');
|
|
193
258
|
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
194
259
|
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
195
260
|
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
261
|
+
for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
|
|
262
|
+
if (inconclusive.length) {
|
|
263
|
+
// Never a pass and never a failure — a probe that proved nothing, said out loud so it
|
|
264
|
+
// can be fixed rather than silently counted as clean.
|
|
265
|
+
info(`[inconclusive] ${inconclusive.length} probe(s) failed before the privilege check and prove nothing:`);
|
|
266
|
+
for (const f of inconclusive.slice(0, 5)) info(` ${f.command} ${f.table} (${f.role})`);
|
|
267
|
+
if (inconclusive.length > 5) info(` …and ${inconclusive.length - 5} more`);
|
|
268
|
+
}
|
|
196
269
|
if (unprobed.length) {
|
|
197
270
|
const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
|
|
198
271
|
info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
|
|
@@ -200,10 +273,10 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
200
273
|
console.log('');
|
|
201
274
|
|
|
202
275
|
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
203
|
-
success(`db:authz:test —
|
|
276
|
+
success(`db:authz:test — ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
|
|
204
277
|
process.exit(0);
|
|
205
278
|
}
|
|
206
|
-
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s).
|
|
279
|
+
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
|
|
207
280
|
process.exit(1);
|
|
208
281
|
}
|
|
209
282
|
|
|
@@ -223,6 +296,8 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
223
296
|
let probes: OwnerProbe[];
|
|
224
297
|
let publicReadTables = new Set<string>();
|
|
225
298
|
let rows: any[];
|
|
299
|
+
let venueLabel = '';
|
|
300
|
+
let venue: AuthzVenue | undefined;
|
|
226
301
|
try {
|
|
227
302
|
step(`Loading models from ${modelsPath}...`);
|
|
228
303
|
const models = await loadModels(modelsPath);
|
|
@@ -240,20 +315,17 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
240
315
|
success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
|
|
241
316
|
process.exit(0);
|
|
242
317
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const fn = opsFunction(config);
|
|
246
|
-
info(`Region: ${config.region}, Function: ${fn}`);
|
|
318
|
+
venue = await resolveVenue(flags);
|
|
319
|
+
venueLabel = venue.label;
|
|
247
320
|
step('Red-teaming owner isolation (two JWT identities per table, rolled back)...');
|
|
248
|
-
|
|
249
|
-
setup: buildOwnerProbeSql(probes), read: OWNER_PROBE_SELECT_SQL,
|
|
250
|
-
});
|
|
251
|
-
if (result?.error) throw new Error(result.error);
|
|
252
|
-
rows = result?.rows ?? [];
|
|
321
|
+
rows = await venue.probe(buildOwnerProbeSql(probes), OWNER_PROBE_SELECT_SQL);
|
|
253
322
|
} catch (err: any) {
|
|
254
323
|
fail(`db:authz:owner failed: ${err.message}`);
|
|
255
|
-
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe
|
|
324
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
|
|
325
|
+
info('or rehearse locally: db:authz:owner --database-url <url>.');
|
|
256
326
|
process.exit(1);
|
|
327
|
+
} finally {
|
|
328
|
+
await venue?.end();
|
|
257
329
|
}
|
|
258
330
|
|
|
259
331
|
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
|
|
@@ -270,9 +342,9 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
270
342
|
console.log('');
|
|
271
343
|
|
|
272
344
|
if (leaks.length === 0 && vacuous.length === 0) {
|
|
273
|
-
success(`db:authz:owner — owner isolation holds (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
|
|
345
|
+
success(`db:authz:owner — owner isolation holds on ${venueLabel} (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
|
|
274
346
|
process.exit(0);
|
|
275
347
|
}
|
|
276
|
-
fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies). One user can reach another's rows.`);
|
|
348
|
+
fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies) on ${venueLabel}. One user can reach another's rows.`);
|
|
277
349
|
process.exit(1);
|
|
278
350
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* is deliberately absent here — `db:reconcile --check` is its verifier.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import type { ModelDescriptor } from '@everystack/model';
|
|
16
17
|
import type { QueryRunner } from '../authz-contract.js';
|
|
17
18
|
import { introspectSchema } from '../schema-introspect.js';
|
|
@@ -78,8 +79,24 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
78
79
|
try {
|
|
79
80
|
models = await loadModels(modelsPath);
|
|
80
81
|
sequences = (await loadDeclaredDerived(flags.models))?.sequences;
|
|
81
|
-
} catch {
|
|
82
|
-
//
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
// Two very different situations used to land here identically.
|
|
84
|
+
//
|
|
85
|
+
// No barrel at all is legitimate live-only mode — "what fingerprint is this database
|
|
86
|
+
// at?" is a useful question on its own.
|
|
87
|
+
//
|
|
88
|
+
// A barrel that EXISTS but will not load is not. Reporting "(live-only)" for it turns
|
|
89
|
+
// a broken measurement into a passing one: the operator asked whether the database
|
|
90
|
+
// matches their models, and got an answer that never looked at the models. That is
|
|
91
|
+
// how someone concludes a round-trip closed when nothing was compared.
|
|
92
|
+
if (existsSync(modelsPath)) {
|
|
93
|
+
fail(`Could not load the models barrel at ${modelsPath}`);
|
|
94
|
+
info(String(err?.message ?? err).split('\n').slice(0, 6).join('\n'));
|
|
95
|
+
info('');
|
|
96
|
+
info('Fix the barrel and re-run. (Refusing rather than falling back to live-only —');
|
|
97
|
+
info('a comparison that silently compares nothing is worse than an error.)');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
let runner: QueryRunner;
|
|
@@ -31,6 +31,8 @@ import fs from 'node:fs/promises';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
33
33
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
34
|
+
import { introspectContract, type TableContract } from '../authz-contract.js';
|
|
35
|
+
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
34
36
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
35
37
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
36
38
|
import type { QueryRunner } from '../authz-contract.js';
|
|
@@ -102,8 +104,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
102
104
|
// The read-model scaffold: default 'commented' surfaces the authz decision in every
|
|
103
105
|
// model; a preset stamps it. Validated up front so a typo fails before introspection.
|
|
104
106
|
const abilities = flags.abilities || 'commented';
|
|
105
|
-
if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
|
|
106
|
-
fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
|
|
107
|
+
if (abilities !== 'commented' && abilities !== 'live' && !ABILITY_PRESETS[abilities]) {
|
|
108
|
+
fail(`Unknown --abilities preset '${abilities}'. Known: live, ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
|
|
107
109
|
process.exit(1);
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -123,6 +125,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
let current;
|
|
128
|
+
let liveAuthz: Map<string, TableContract> | undefined;
|
|
126
129
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
127
130
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
128
131
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
@@ -143,6 +146,28 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
143
146
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
144
147
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
145
148
|
derivedCatalog = await introspectDerived(runner);
|
|
149
|
+
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
150
|
+
// the grants and policies that already exist become the models' declared abilities,
|
|
151
|
+
// instead of a human transcribing them by hand.
|
|
152
|
+
if (abilities === 'live') {
|
|
153
|
+
note('Introspecting live authorization (grants + policies)...');
|
|
154
|
+
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
155
|
+
liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
156
|
+
detail(`${liveAuthz.size} table(s) carry authorization.`);
|
|
157
|
+
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
158
|
+
// operator account granted across the whole schema. Said once here rather than
|
|
159
|
+
// repeated in all N models, where it would bury the per-table findings.
|
|
160
|
+
const unmapped = new Set<string>();
|
|
161
|
+
for (const t of contract.tables) {
|
|
162
|
+
for (const r of Object.keys(t.grants)) {
|
|
163
|
+
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (unmapped.size) {
|
|
167
|
+
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
168
|
+
detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
146
171
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
147
172
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
148
173
|
if (matviewsAsTables) {
|
|
@@ -235,7 +260,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
260
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
236
261
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
237
262
|
const dir = path.resolve(flags.out);
|
|
238
|
-
const files = renderModelFiles(current, { schema, abilities, derived: embeddedDerived });
|
|
263
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
239
264
|
try {
|
|
240
265
|
await fs.mkdir(dir, { recursive: true });
|
|
241
266
|
const written = new Set(files.map((f) => f.file));
|
|
@@ -252,7 +277,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
252
277
|
source = files.map((f) => f.source).join('\n');
|
|
253
278
|
} else if (flags.out) {
|
|
254
279
|
const outPath = path.resolve(flags.out);
|
|
255
|
-
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
280
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
256
281
|
try {
|
|
257
282
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
258
283
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -262,7 +287,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
262
287
|
}
|
|
263
288
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
264
289
|
} else {
|
|
265
|
-
source = renderModelSource(current, { schema, abilities, derived: embeddedDerived });
|
|
290
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
266
291
|
process.stdout.write(source);
|
|
267
292
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
268
293
|
}
|
|
@@ -29,6 +29,7 @@ import { loadDeclaredDerived } from '../declared-derived.js';
|
|
|
29
29
|
import type { SourceObject } from '../derived-source.js';
|
|
30
30
|
import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage, swapSchemaRoles, expectedIncomingObjects, renderPairedProvenance } from '../swap-pair.js';
|
|
31
31
|
import { introspectDerived } from '../derived-introspect.js';
|
|
32
|
+
import { legacyFunctionIdentity } from '../pg-argtypes.js';
|
|
32
33
|
import { createUrlRunner } from '../db-source.js';
|
|
33
34
|
import type { QueryRunner } from '../authz-contract.js';
|
|
34
35
|
import { executeSwap, type SwapVerdict } from '../swap-execute.js';
|
|
@@ -37,9 +38,12 @@ import { formatBytes } from '../bundle-weight.js';
|
|
|
37
38
|
import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-rewrite.js';
|
|
38
39
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
39
40
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
40
|
-
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
|
-
import { invokeAction, presignGet } from '../aws.js';
|
|
42
|
-
import { keyForArtifactId, metaKey } from '../backup.js';
|
|
41
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
42
|
+
import { invokeAction, presignGet, createRdsSnapshot, describeRdsSnapshots } from '../aws.js';
|
|
43
|
+
import { keyForArtifactId, metaKey, utcStamp } from '../backup.js';
|
|
44
|
+
import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
|
|
45
|
+
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
46
|
+
import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot.js';
|
|
43
47
|
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
|
|
44
48
|
import { step, success, fail, warn, info } from '../output.js';
|
|
45
49
|
|
|
@@ -264,11 +268,29 @@ async function restoreIntoIncoming(
|
|
|
264
268
|
// never argv (libpq also REJECTS non-keyword URI params like the `search_path` the operator URL
|
|
265
269
|
// bakes in — fine for postgres.js, fatal for a libpq URI). `-d` carries ONLY keepalives, which
|
|
266
270
|
// have no PG* env equivalent and are what keep this connection from dying in the index phase.
|
|
271
|
+
//
|
|
272
|
+
// psql reads the file from STDIN (`-f -`) rather than opening it itself, purely so the restore
|
|
273
|
+
// knows its own write position. That position is the fact the heartbeat was missing: a server
|
|
274
|
+
// parked in `Client/ClientRead` is a STALL when bytes remain unsent and the successful TAIL when
|
|
275
|
+
// they do not, and those two used to print the same line. `-f -` keeps psql's `psql:<stdin>:N:`
|
|
276
|
+
// error prefixes, so the line number of a failing statement survives the change (verified
|
|
277
|
+
// against psql 16).
|
|
267
278
|
io.log(`restore phase B: psql streaming ${formatBytes(written)} to the target — heartbeat every 10s.`);
|
|
268
|
-
const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f',
|
|
269
|
-
stdio: ['
|
|
279
|
+
const psql = spawn('psql', ['-d', pgKeepaliveConninfo(), '-v', 'ON_ERROR_STOP=1', '-f', '-'], {
|
|
280
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
270
281
|
env: { ...process.env, ...pgEnvFromUrl(url) },
|
|
271
282
|
});
|
|
283
|
+
let fedBytes = 0;
|
|
284
|
+
let feedDone = false;
|
|
285
|
+
// A psql that exits early (ON_ERROR_STOP) makes this pipeline fail with EPIPE. That is a
|
|
286
|
+
// DOWNSTREAM symptom — psql's own exit code and stderr are the authority on what went wrong, and
|
|
287
|
+
// a previous version of this code mistook the EPIPE for the cause and chased the wrong bug for
|
|
288
|
+
// two sessions. So the feed's error is swallowed here and psql's exit decides.
|
|
289
|
+
const feeding = pipeline(
|
|
290
|
+
fs.createReadStream(sqlPath),
|
|
291
|
+
countingTap((n) => { fedBytes = n; }),
|
|
292
|
+
psql.stdin!,
|
|
293
|
+
).then(() => { feedDone = true; }).catch(() => { /* psql's exit is the authority */ });
|
|
272
294
|
let pErr = '';
|
|
273
295
|
psql.stderr.on('data', (d) => {
|
|
274
296
|
const s = d.toString();
|
|
@@ -285,6 +307,9 @@ async function restoreIntoIncoming(
|
|
|
285
307
|
incoming,
|
|
286
308
|
log: io.log,
|
|
287
309
|
warn: io.warn,
|
|
310
|
+
// The client half of the picture. Without it the heartbeat cried "deadlock signature" over the
|
|
311
|
+
// last poll of a run that had landed every row and was about to succeed.
|
|
312
|
+
clientFeed: () => ({ fedBytes, totalBytes: written, done: feedDone }),
|
|
288
313
|
onSample: (sample) => {
|
|
289
314
|
if (sample.state === null) deadBackendPolls += 1;
|
|
290
315
|
else deadBackendPolls = 0;
|
|
@@ -319,8 +344,11 @@ async function restoreIntoIncoming(
|
|
|
319
344
|
});
|
|
320
345
|
} finally {
|
|
321
346
|
await stopHeartbeat();
|
|
347
|
+
// The feed is already finished on the success path; on a failure path it is rejecting with
|
|
348
|
+
// EPIPE. Either way, await it so no stream work outlives the phase.
|
|
349
|
+
await feeding;
|
|
322
350
|
}
|
|
323
|
-
io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}).`);
|
|
351
|
+
io.log(`restore phase B done in ${humanElapsed(Date.now() - bStart)} (restore total ${humanElapsed(Date.now() - t0)}); fed ${formatBytes(fedBytes)} of ${formatBytes(written)}.`);
|
|
324
352
|
} finally {
|
|
325
353
|
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
326
354
|
}
|
|
@@ -407,6 +435,60 @@ async function resolveSwapArtifact(
|
|
|
407
435
|
return fetchArtifactFromS3(from, stage, fingerprintFlag);
|
|
408
436
|
}
|
|
409
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Take (or account for) the pre-swap rollback point, and do not return until it EXISTS.
|
|
440
|
+
*
|
|
441
|
+
* Every branch here either produces a confirmed rollback point or throws — and a throw at this point
|
|
442
|
+
* means executeSwap never reaches the restore, so live is untouched. That property is the entire
|
|
443
|
+
* reason this is not a fire-and-forget dispatch any more.
|
|
444
|
+
*/
|
|
445
|
+
async function takePreSwapSnapshot(
|
|
446
|
+
plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
|
|
447
|
+
ctx: { stage?: string; region?: string; opsFn?: string },
|
|
448
|
+
): Promise<void> {
|
|
449
|
+
if (plan.mode === 'attested') {
|
|
450
|
+
info(`pre-swap rollback point: ${plan.ref} (attested via --snapshot-ref — no new snapshot taken).`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (plan.mode === 'none') {
|
|
455
|
+
warn('NO pre-swap snapshot (--snapshot none). If this swap lands bad data there is no rollback point — the retiring schema is dropped once verify passes.');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (plan.mode === 'physical') {
|
|
460
|
+
step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
|
|
461
|
+
const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
|
|
462
|
+
const region = ctx.region!;
|
|
463
|
+
const { id } = await confirmPhysicalSnapshot({
|
|
464
|
+
create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
|
|
465
|
+
describe: () => describeRdsSnapshots(region, plan.instanceId),
|
|
466
|
+
log: (m) => info(m),
|
|
467
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
468
|
+
now: () => Date.now(),
|
|
469
|
+
}, { instanceId: plan.instanceId, snapshotId });
|
|
470
|
+
info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
|
|
475
|
+
// conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
|
|
476
|
+
step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
|
|
477
|
+
const dispatched: any = await invokeAction(ctx.region!, ctx.opsFn!, 'db:backup', {
|
|
478
|
+
stage: ctx.stage,
|
|
479
|
+
actor: process.env.USER ?? null,
|
|
480
|
+
});
|
|
481
|
+
if (dispatched?.error) throw new Error(`the pre-swap backup would not dispatch, so the swap was NOT applied: ${dispatched.error}`);
|
|
482
|
+
const { runId, taskArn, id } = dispatched as { runId: string; taskArn: string; id: string };
|
|
483
|
+
info(`backup ${id} dispatched (run ${runId}) — waiting for the task to stop before the restore starts.`);
|
|
484
|
+
const verdict = interpretBackupPoll(
|
|
485
|
+
await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId, taskArn }),
|
|
486
|
+
{ runId, id },
|
|
487
|
+
);
|
|
488
|
+
if (!verdict.ok) throw new Error(verdict.reason);
|
|
489
|
+
info(`rollback point CONFIRMED: backup ${id} complete — restore with db:restore --from ${id} --confirm.`);
|
|
490
|
+
}
|
|
491
|
+
|
|
410
492
|
export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
|
|
411
493
|
const schema = flags.schema;
|
|
412
494
|
const from = flags.from;
|
|
@@ -445,9 +527,9 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
445
527
|
process.exit(1);
|
|
446
528
|
}
|
|
447
529
|
let url = urlFlag;
|
|
448
|
-
let snapshotViaStage = false;
|
|
449
530
|
let region: string | undefined;
|
|
450
531
|
let opsFn: string | undefined;
|
|
532
|
+
let stageConfig: CliConfig | undefined;
|
|
451
533
|
|
|
452
534
|
if (stage) {
|
|
453
535
|
if (!direct) {
|
|
@@ -459,13 +541,12 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
459
541
|
process.exit(1);
|
|
460
542
|
}
|
|
461
543
|
try {
|
|
462
|
-
|
|
463
|
-
region =
|
|
464
|
-
opsFn = opsFunction(
|
|
544
|
+
stageConfig = await resolveConfig(stage);
|
|
545
|
+
region = stageConfig.region;
|
|
546
|
+
opsFn = opsFunction(stageConfig);
|
|
465
547
|
step('Resolving the operator connection from the stage (--direct)...');
|
|
466
548
|
const op = await resolveOperatorUrlViaStage(stage);
|
|
467
549
|
url = op.url;
|
|
468
|
-
snapshotViaStage = true;
|
|
469
550
|
info(`operator credential resolved (${op.source}) — swapping CLI-side, unbounded clock.`);
|
|
470
551
|
} catch (err: any) { fail(err.message); process.exit(1); }
|
|
471
552
|
}
|
|
@@ -475,6 +556,29 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
475
556
|
process.exit(1);
|
|
476
557
|
}
|
|
477
558
|
|
|
559
|
+
// THE ROLLBACK POINT. Decided here — before the models load, before the artifact is fetched, and
|
|
560
|
+
// long before anything is renamed — so a refusal costs nothing but a second.
|
|
561
|
+
//
|
|
562
|
+
// This step used to be a `db:backup` the swap did not wait for. The ops action dispatches a Task
|
|
563
|
+
// and returns, so the swap printed "snapshot on record" and began restoring while the pg_dump was
|
|
564
|
+
// still running: a consumer measured the restore blocked ~5 minutes on
|
|
565
|
+
// `Lock/relation HELD BY pid [pg_dump]`, the swap contending with its own backup. And a task that
|
|
566
|
+
// failed to start left a destructive swap running against a rollback point that did not exist.
|
|
567
|
+
//
|
|
568
|
+
// A physical RDS snapshot is now the default where the target is RDS (a control-plane call: no
|
|
569
|
+
// locks, no buffer-cache read, no client connection), the logical backup is the non-RDS fallback
|
|
570
|
+
// and is now WAITED ON, and the bare `--database-url` venue refuses rather than warning.
|
|
571
|
+
const snapshotPlan = decideSnapshotMode({
|
|
572
|
+
venue: stage ? 'stage' : 'url',
|
|
573
|
+
instanceId: flags.instance ?? stageConfig?.databaseInstanceId,
|
|
574
|
+
snapshotRef: flags['snapshot-ref'],
|
|
575
|
+
requested: flags.snapshot as SnapshotModeRequest | undefined,
|
|
576
|
+
});
|
|
577
|
+
if (snapshotPlan.mode === 'refuse') {
|
|
578
|
+
fail(snapshotPlan.reason);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
|
|
478
582
|
// --rebuild-derived carries a real outage window: the derived layer does not exist between the
|
|
479
583
|
// swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
|
|
480
584
|
// only afterward tells the operator about an outage they are already in. It is now the OPT-OUT:
|
|
@@ -534,7 +638,10 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
534
638
|
artifactFingerprint,
|
|
535
639
|
declaredFingerprint,
|
|
536
640
|
// What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
|
|
537
|
-
|
|
641
|
+
// NAME granularity: both consumers (the paired pre-flight's dependentIdentity, the TOC
|
|
642
|
+
// filter) read a function as `schema.name` with its argument list stripped, so the
|
|
643
|
+
// signature half of a derived identity is dropped here rather than never matching.
|
|
644
|
+
declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
|
|
538
645
|
rebuildDerived: flags['rebuild-derived'] === 'true',
|
|
539
646
|
paired,
|
|
540
647
|
// Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
|
|
@@ -606,17 +713,11 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
|
|
|
606
713
|
runner: r,
|
|
607
714
|
log: (m) => info(m),
|
|
608
715
|
warn: (m) => warn(m),
|
|
609
|
-
|
|
716
|
+
// Name granularity — the TOC entry's argument list is stripped before the lookup.
|
|
717
|
+
declaredIdentities: declaredDerivedObjects.map((o) => legacyFunctionIdentity(o.identity)),
|
|
610
718
|
});
|
|
611
719
|
},
|
|
612
|
-
snapshot:
|
|
613
|
-
? async () => {
|
|
614
|
-
step('Snapshotting the stage before the swap (db:backup)...');
|
|
615
|
-
const r: any = await invokeAction(region!, opsFn!, 'db:backup', { stage });
|
|
616
|
-
if (r?.error) throw new Error(`pre-swap snapshot failed, so the swap was NOT applied: ${r.error}`);
|
|
617
|
-
info(`snapshot on record: ${r?.id ?? 'backup complete'} — restore with db:restore --from ${r?.id ?? '<id>'} --confirm.`);
|
|
618
|
-
}
|
|
619
|
-
: async () => { warn('no snapshot taken (direct v1) — take one first: everystack db:backup --database-url … before a production swap.'); },
|
|
720
|
+
snapshot: () => takePreSwapSnapshot(snapshotPlan, { stage, region, opsFn }),
|
|
620
721
|
}),
|
|
621
722
|
);
|
|
622
723
|
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -721,6 +721,17 @@ function printDoctorReport(report: any): void {
|
|
|
721
721
|
success('db:doctor — database is least-privilege and RLS-subject');
|
|
722
722
|
} else {
|
|
723
723
|
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
724
|
+
// Probing a local database as your own superuser makes these fail by construction:
|
|
725
|
+
// the checks describe the credential the API serves with, not the schema. A developer
|
|
726
|
+
// reading them as defects in their app will go hunting for a bug that is not there.
|
|
727
|
+
if (report.api?.isSuperuser) {
|
|
728
|
+
console.log('');
|
|
729
|
+
info('Note: this probe connected as a SUPERUSER, so least-privilege / not-superuser / fails-closed');
|
|
730
|
+
info('cannot pass by construction. Those three describe the credential your API serves with —');
|
|
731
|
+
info('they are only meaningful against the app role (a deployed stage, or a local DATABASE_URL');
|
|
732
|
+
info('pointing at the least-privilege role from db:provision). The RLS and grant findings above');
|
|
733
|
+
info('are still real.');
|
|
734
|
+
}
|
|
724
735
|
}
|
|
725
736
|
}
|
|
726
737
|
|