@everystack/cli 0.4.45 → 0.4.47
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/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +82 -20
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-backfill.ts +1 -1
- package/src/cli/commands/db-exec.ts +20 -1
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +11 -17
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +16 -18
- package/src/cli/commands/db-reconcile.ts +19 -21
- package/src/cli/commands/db-refresh.ts +33 -5
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +112 -16
- package/src/cli/exec-digest.ts +55 -13
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +3 -3
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +145 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
|
@@ -18,14 +18,19 @@
|
|
|
18
18
|
* is exact even on brownfield targets (mint predicted it with the
|
|
19
19
|
* unmodeled tables merged in). A mismatch here is a real fault, loud.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* Two lanes. DIRECT (--database-url / --stage --direct) executes CLI-side over
|
|
22
|
+
* one session. STAGE (--stage) executes credential-free in the ops Lambda —
|
|
23
|
+
* and reads over `lambdaQueryRunner`, one Lambda invoke per catalog query, so a
|
|
24
|
+
* single introspection can be assembled from several containers holding
|
|
25
|
+
* connections to different databases. That lane therefore reads the stage TWICE
|
|
26
|
+
* and refuses a disagreement, reports agreement as a non-detection (it cannot
|
|
27
|
+
* verify read consistency), and refuses a DESTRUCTIVE plan outright — losing
|
|
28
|
+
* data on a read that cannot be verified is not a trade the ceremony can buy.
|
|
29
|
+
* See stage-read-consistency.ts.
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import fs from 'node:fs/promises';
|
|
25
33
|
import { type QueryRunner } from '../authz-contract.js';
|
|
26
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
27
|
-
import { introspectContract } from '../authz-contract.js';
|
|
28
|
-
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
29
34
|
import { currentGitRef } from '../state-apply.js';
|
|
30
35
|
import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
31
36
|
import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
|
|
@@ -37,12 +42,14 @@ import { compileTableContract } from '../authz-compile.js';
|
|
|
37
42
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
38
43
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
39
44
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
45
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
40
46
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
|
-
import { invokeAction, lambdaQueryRunner } from '../aws.js';
|
|
47
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
42
48
|
import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
|
|
43
49
|
import { estimateOpsRuntimeFit } from '../ops-fit.js';
|
|
44
50
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
45
51
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
52
|
+
import { readStageStateTwice, stageDestructiveRefusal } from '../stage-read-consistency.js';
|
|
46
53
|
import { step, success, fail, info, warn } from '../output.js';
|
|
47
54
|
|
|
48
55
|
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
@@ -128,20 +135,34 @@ async function applyPlanViaStage(
|
|
|
128
135
|
|
|
129
136
|
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
130
137
|
|
|
138
|
+
// THE STAGE LANE'S READ GUARD, before anything is dispatched. Each introspection is
|
|
139
|
+
// one session, but a full read is two of them (state, then authz) and they can be
|
|
140
|
+
// answered by two containers at two moments. Read the stage twice and refuse a
|
|
141
|
+
// disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
|
|
142
|
+
// refusal must exit as itself, not as "Apply failed".
|
|
143
|
+
step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
|
|
144
|
+
const readSession = lambdaSessionRunner(region, fn);
|
|
145
|
+
const readPair = await readStageStateTwice(readSession).catch((err: any) => {
|
|
146
|
+
fail(`Apply failed: could not read the stage — ${err.message}`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
149
|
+
if (!readPair.ok) {
|
|
150
|
+
fail(readPair.reason);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
warn(readPair.warning);
|
|
154
|
+
|
|
131
155
|
try {
|
|
132
156
|
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
133
|
-
// decides it (the Lambda has no checkout).
|
|
134
|
-
//
|
|
135
|
-
//
|
|
157
|
+
// decides it (the Lambda has no checkout). It runs against the state the
|
|
158
|
+
// double read already produced (no third trip), and the verdict goes to the
|
|
159
|
+
// write action, which enforces + records it in order.
|
|
136
160
|
let descentVerdict: { ok: true } | { ok: false; reason: string };
|
|
137
161
|
if (forceDescent !== undefined) {
|
|
138
162
|
warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
|
|
139
163
|
descentVerdict = { ok: true };
|
|
140
164
|
} else {
|
|
141
|
-
|
|
142
|
-
const readRunner = lambdaQueryRunner(region, fn);
|
|
143
|
-
const snapshot = await introspectSchema(readRunner);
|
|
144
|
-
const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
|
|
165
|
+
const { snapshot, contract } = readPair.state;
|
|
145
166
|
step("Descent: searching git for the commit that declares the stage's state...");
|
|
146
167
|
const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
|
|
147
168
|
switch (verdict.status) {
|
|
@@ -166,6 +187,11 @@ async function applyPlanViaStage(
|
|
|
166
187
|
// and checks it against the stage's declared approver set (SSM). The safety
|
|
167
188
|
// net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
|
|
168
189
|
// the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
|
|
190
|
+
//
|
|
191
|
+
// CURRENTLY UNREACHABLE: dbApplyCommand refuses every destructive plan on the
|
|
192
|
+
// stage lane before it gets here (read consistency cannot be guaranteed on this
|
|
193
|
+
// lane). Kept intact and second in line, so lifting that refusal restores the
|
|
194
|
+
// full ceremony rather than silently shipping without one.
|
|
169
195
|
let authorityVerdict: AuthorityVerdict | undefined;
|
|
170
196
|
if (plan.destructive > 0) {
|
|
171
197
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
@@ -272,6 +298,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
272
298
|
// Deployed stage, no direct URL: the write runs in the ops Lambda — the
|
|
273
299
|
// operator never holds a database URL. --database-url stays local-only.
|
|
274
300
|
if (dbSource.kind === 'stage') {
|
|
301
|
+
// A DESTRUCTIVE plan does not run on this lane at all — no ceremony lifts it.
|
|
302
|
+
// The stage lane's fingerprint (the concurrency lock) can be assembled from
|
|
303
|
+
// several ops-Lambda containers, and the double read below only DETECTS that;
|
|
304
|
+
// it cannot rule it out. Dropping data on a read that may be a collage is the
|
|
305
|
+
// one trade never worth making, so the destructive path is the direct lane's.
|
|
306
|
+
if (plan.destructive > 0) {
|
|
307
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
308
|
+
fail(stageDestructiveRefusal(shape, planPath, flags.stage));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
275
311
|
// Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
|
|
276
312
|
// name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
|
|
277
313
|
const fit = estimateOpsRuntimeFit(plan);
|
|
@@ -295,13 +331,29 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
295
331
|
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
296
332
|
process.exit(1);
|
|
297
333
|
}
|
|
298
|
-
// A DIRECT apply is operator-attested: the
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
334
|
+
// A DIRECT apply is operator-attested: the safety net is a backup the operator took and
|
|
335
|
+
// NAMES here, because this lane has no ops Lambda verifying one server-side.
|
|
336
|
+
//
|
|
337
|
+
// This used to end by offering "or drop --direct and let the credential-free --stage apply
|
|
338
|
+
// auto-verify your latest backup". THAT ADVICE CANNOT BE FOLLOWED. Auto-verification is
|
|
339
|
+
// real, but only for NON-destructive stage applies — and every plan reaching this block is
|
|
340
|
+
// destructive, so dropping --direct lands on the outright refusal above (the `--stage`
|
|
341
|
+
// destructive gate). It sent operators from a lane that works to one that refuses, and the
|
|
342
|
+
// refusal's own text then pointed them at --database-url. Two wrong signposts in a row are
|
|
343
|
+
// how a consumer ended up printing a privileged DSN to do something --direct already did.
|
|
344
|
+
//
|
|
345
|
+
// For a destructive plan there is exactly one remedy: take a safety point and name it.
|
|
302
346
|
if (!flags['snapshot-ref']) {
|
|
303
|
-
const
|
|
304
|
-
|
|
347
|
+
const stageArg = flags.stage ? ` --stage ${flags.stage}` : '';
|
|
348
|
+
const takeIt = `everystack db:backup${stageArg}`;
|
|
349
|
+
const lane = flags.stage
|
|
350
|
+
? `${stageArg.trim()} --direct`
|
|
351
|
+
: '--database-url <url>';
|
|
352
|
+
fail(
|
|
353
|
+
`This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. `
|
|
354
|
+
+ `Take a safety point (${takeIt}) and name it: everystack db:apply --plan ${planPath} ${lane} --confirm --snapshot-ref <id>. `
|
|
355
|
+
+ 'A destructive plan always requires an attested --snapshot-ref; there is no lane that takes one for you.',
|
|
356
|
+
);
|
|
305
357
|
process.exit(1);
|
|
306
358
|
}
|
|
307
359
|
if (flags.stage) {
|
|
@@ -328,7 +380,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
328
380
|
}
|
|
329
381
|
|
|
330
382
|
step(connectingVia(dbSource));
|
|
331
|
-
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
383
|
+
const { runner, session, end } = await createUrlRunner(dbSource.url);
|
|
332
384
|
|
|
333
385
|
try {
|
|
334
386
|
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
@@ -342,7 +394,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
342
394
|
const result = await withMutationLease(
|
|
343
395
|
runner,
|
|
344
396
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
345
|
-
() => executeApplyPlan(runner, plan, {
|
|
397
|
+
() => executeApplyPlan(runner, session, plan, {
|
|
346
398
|
actor: process.env.USER ?? null,
|
|
347
399
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
348
400
|
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
@@ -371,6 +423,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
371
423
|
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
372
424
|
return { ok: true };
|
|
373
425
|
case 'no-git':
|
|
426
|
+
// A DESTRUCTIVE plan may not ride an accidental waiver: the fast-forward
|
|
427
|
+
// rule's verdict must never depend on which directory the operator ran
|
|
428
|
+
// from. Unverifiable + destructive ⇒ the waiver must be the deliberate
|
|
429
|
+
// ceremony (--force-descent <snapshot-ref> --confirm), not a warning.
|
|
430
|
+
if (plan.destructive > 0) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: 'descent: not a git checkout, and this plan is DESTRUCTIVE — the fast-forward rule cannot be verified here, so waive it deliberately: re-run with --force-descent <snapshot-ref> --confirm.',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
374
436
|
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
375
437
|
return { ok: true };
|
|
376
438
|
case 'diverged':
|
|
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
|
57
57
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
58
58
|
import { resolveModelsPath } from '../models-path.js';
|
|
59
59
|
import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
|
|
60
|
-
import {
|
|
60
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
61
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
61
62
|
import { step, success, fail, info, warn } from '../output.js';
|
|
62
63
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
63
64
|
import { loadModels } from './db-generate.js';
|
|
64
65
|
|
|
65
66
|
const DEFAULT_DIR = 'authz';
|
|
66
67
|
|
|
67
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
68
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
69
|
-
return async (sql: string) => {
|
|
70
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
71
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
72
|
-
return result?.rows ?? [];
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
/**
|
|
77
69
|
* Where an authz command runs. Both venues expose the same two capabilities, so the
|
|
78
70
|
* commands never branch on venue after this point — that is what keeps the local
|
|
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
81
73
|
interface AuthzVenue {
|
|
82
74
|
/** Read-only introspection. */
|
|
83
75
|
runner: QueryRunner;
|
|
76
|
+
/** The consistent multi-statement read — what every introspection takes. */
|
|
77
|
+
session: SessionRunner;
|
|
84
78
|
/** Self-reverting red-team probe (writes, always rolled back). */
|
|
85
79
|
probe: (setup: string, read: string) => Promise<any[]>;
|
|
86
80
|
/** Human-readable venue, printed with every verdict. */
|
|
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
|
|
|
97
91
|
const source = resolveDbSource(flags);
|
|
98
92
|
if (source.kind === 'url') {
|
|
99
93
|
step(connectingVia(source));
|
|
100
|
-
const { runner, probe, end } = await createUrlProbeRunner(source.url);
|
|
101
|
-
return { runner, probe, end, label: 'direct connection' };
|
|
94
|
+
const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
|
|
95
|
+
return { runner, session, probe, end, label: 'direct connection' };
|
|
102
96
|
}
|
|
103
97
|
step('Resolving deployed config...');
|
|
104
98
|
const config = await resolveConfig(flags.stage);
|
|
105
99
|
const fn = opsFunction(config);
|
|
106
100
|
info(`Region: ${config.region}, Function: ${fn}`);
|
|
107
101
|
return {
|
|
108
|
-
runner:
|
|
102
|
+
runner: lambdaQueryRunner(config.region, fn),
|
|
103
|
+
session: lambdaSessionRunner(config.region, fn),
|
|
109
104
|
probe: async (setup: string, read: string) => {
|
|
110
105
|
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
|
|
111
106
|
if (result?.error) throw new Error(result.error);
|
|
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
|
|
|
120
115
|
const venue = await resolveVenue(flags);
|
|
121
116
|
try {
|
|
122
117
|
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
123
|
-
return { contract: await introspectContract(venue.
|
|
118
|
+
return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
|
|
124
119
|
} finally {
|
|
125
120
|
await venue.end();
|
|
126
121
|
}
|
|
@@ -87,7 +87,7 @@ async function runBackfillViaStage(
|
|
|
87
87
|
if (res?.error) {
|
|
88
88
|
fail(`db:backfill failed: ${res.error}`);
|
|
89
89
|
if (/Unknown action/i.test(String(res.error))) {
|
|
90
|
-
info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run direct: db:backfill --apply --database-url <url>
|
|
90
|
+
info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run credential-free over the direct lane: db:backfill --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
91
91
|
}
|
|
92
92
|
process.exit(1);
|
|
93
93
|
}
|
|
@@ -31,7 +31,18 @@ async function readStdin(): Promise<string> {
|
|
|
31
31
|
return Buffer.concat(chunks).toString('utf8');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The schemas argument the digest query still ACCEPTS but no longer scopes by.
|
|
36
|
+
*
|
|
37
|
+
* `--schemas` never reached the stage path at all — the ops invoke sent only `{ sql, actor,
|
|
38
|
+
* stage }`, so a consumer who passed `--schemas auth,public` had it silently dropped and the
|
|
39
|
+
* server fell back to its own `options.schemas ?? ['public']`. Their 21 `auth.*` functions were
|
|
40
|
+
* outside the projection and the guard stayed silent on 43 schema statements.
|
|
41
|
+
*
|
|
42
|
+
* Rather than plumb the flag through, the digest now covers EVERY non-system schema
|
|
43
|
+
* (exec-digest.ts): a guard that can be scoped can be scoped to blindness. This is kept so the
|
|
44
|
+
* call sites read unchanged, and `--schemas` is now a documented no-op rather than a silent one.
|
|
45
|
+
*/
|
|
35
46
|
function directSchemas(flags: Record<string, string>): string[] {
|
|
36
47
|
if (flags.schemas && flags.schemas !== 'true') {
|
|
37
48
|
return flags.schemas.split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -39,6 +50,13 @@ function directSchemas(flags: Record<string, string>): string[] {
|
|
|
39
50
|
return ['public'];
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/** Say it, rather than accept a flag that does nothing. */
|
|
54
|
+
function noteSchemasIsNoLongerNeeded(flags: Record<string, string>): void {
|
|
55
|
+
if (flags.schemas && flags.schemas !== 'true') {
|
|
56
|
+
info('--schemas is no longer needed and no longer narrows anything: the DML-only guard now covers every non-system schema. (It used to default to `public`, which is how a file of auth.* functions once passed it.)');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function reportOk(res: { rowsAffected?: number[] }): void {
|
|
43
61
|
const rows = res.rowsAffected ?? [];
|
|
44
62
|
const total = rows.reduce((a, b) => a + b, 0);
|
|
@@ -70,6 +88,7 @@ export async function dbExecCommand(flags: Record<string, string>, file?: string
|
|
|
70
88
|
|
|
71
89
|
const source = resolveDbSource(flags);
|
|
72
90
|
const actor = process.env.USER ?? null;
|
|
91
|
+
noteSchemasIsNoLongerNeeded(flags);
|
|
73
92
|
|
|
74
93
|
// --- Stage venue: ship the SQL to the ops Lambda's db:exec action (operator holds no URL). ---
|
|
75
94
|
if (source.kind === 'stage') {
|
|
@@ -21,46 +21,70 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
|
21
21
|
import {
|
|
22
22
|
fingerprintLive,
|
|
23
23
|
fingerprintModels,
|
|
24
|
+
governedRolesForModels,
|
|
24
25
|
mapUnfingerprintedRows,
|
|
25
26
|
UNFINGERPRINTED_SQL,
|
|
26
27
|
type UnfingerprintedObject,
|
|
27
28
|
} from '../schema-fingerprint.js';
|
|
29
|
+
import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan.js';
|
|
28
30
|
import { resolveModelsPath } from '../models-path.js';
|
|
29
31
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
32
|
+
import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session.js';
|
|
30
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
31
|
-
import { invokeAction } from '../aws.js';
|
|
34
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
32
35
|
import { step, success, fail, info, warn } from '../output.js';
|
|
33
36
|
import { loadModels } from './db-generate.js';
|
|
34
37
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
35
38
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
36
39
|
|
|
37
40
|
export interface FingerprintStatus {
|
|
41
|
+
/** The RAW live hash — everything introspected. The flavor plan.from and the backup chain use. */
|
|
38
42
|
live: string;
|
|
43
|
+
/**
|
|
44
|
+
* The GOVERNED live hash — the live state filtered through the models' governed-role set,
|
|
45
|
+
* i.e. "the state the models describe". Present only when models loaded; equals `live` when
|
|
46
|
+
* no foreign grantee holds anything. THIS is the half MATCH compares — a live grant nothing
|
|
47
|
+
* will ever reconcile must not keep MATCH unreachable (the v4 rule, wired to the verdict).
|
|
48
|
+
*/
|
|
49
|
+
governedLive?: string;
|
|
39
50
|
declared: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* The predicted endpoint: models + unmodeled live tables riding through, governed —
|
|
53
|
+
* `db:generate`'s no-op state. Equals `declared` when nothing is unmodeled. MATCH is
|
|
54
|
+
* `predicted === governedLive` (the declares-test): the identity is with what generate
|
|
55
|
+
* MANAGES, and generate manages only declared tables.
|
|
56
|
+
*/
|
|
57
|
+
predicted?: string;
|
|
40
58
|
match: boolean | null;
|
|
41
59
|
unfingerprinted: UnfingerprintedObject[];
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/** The testable core: live fingerprint, declared fingerprint, verdict, honesty report. */
|
|
45
63
|
export async function computeFingerprintStatus(
|
|
46
|
-
|
|
64
|
+
session: SessionRunner,
|
|
47
65
|
models: ModelDescriptor[] | null,
|
|
48
66
|
sequences?: SequenceDescriptor[],
|
|
67
|
+
governedExtras?: readonly string[],
|
|
49
68
|
): Promise<FingerprintStatus> {
|
|
50
|
-
const snapshot = await introspectSchema(
|
|
51
|
-
const contract = await introspectContract(
|
|
69
|
+
const snapshot = await introspectSchema(session);
|
|
70
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
71
|
+
// Rides the SAME session policy as the two introspections above. It used to be issued
|
|
72
|
+
// bare, outside any search_path pin — the one read in this command that was not
|
|
73
|
+
// canonicalized, and on the stage lane its own separate invoke.
|
|
74
|
+
const [unfingerprintedRows] = await session([UNFINGERPRINTED_SQL], INTROSPECTION_SESSION);
|
|
52
75
|
const live = fingerprintLive(snapshot, contract).hash;
|
|
53
76
|
const declared = models ? fingerprintModels(models, { sequences }).hash : null;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
78
|
+
const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
|
|
79
|
+
const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
|
|
80
|
+
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
|
|
81
|
+
return {
|
|
82
|
+
live,
|
|
83
|
+
...(governedLive !== undefined ? { governedLive } : {}),
|
|
84
|
+
declared,
|
|
85
|
+
...(predicted !== undefined ? { predicted } : {}),
|
|
86
|
+
match: declared === null ? null : predicted === governedLive,
|
|
87
|
+
unfingerprinted,
|
|
64
88
|
};
|
|
65
89
|
}
|
|
66
90
|
|
|
@@ -76,9 +100,12 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
76
100
|
const modelsPath = resolveModelsPath(flags.models);
|
|
77
101
|
let models: ModelDescriptor[] | null = null;
|
|
78
102
|
let sequences: SequenceDescriptor[] | undefined;
|
|
103
|
+
let governedExtras: string[] | undefined;
|
|
79
104
|
try {
|
|
80
105
|
models = await loadModels(modelsPath);
|
|
81
|
-
|
|
106
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
107
|
+
sequences = declared?.sequences;
|
|
108
|
+
governedExtras = declared?.governedRoles;
|
|
82
109
|
} catch (err: any) {
|
|
83
110
|
// Two very different situations used to land here identically.
|
|
84
111
|
//
|
|
@@ -100,18 +127,21 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
let runner: QueryRunner;
|
|
130
|
+
|
|
131
|
+
let session: SessionRunner;
|
|
103
132
|
let end: (() => Promise<void>) | undefined;
|
|
104
133
|
if (dbSource.kind === 'url') {
|
|
105
134
|
step(connectingVia(dbSource));
|
|
106
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
135
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
107
136
|
} else {
|
|
108
137
|
step('Resolving deployed config...');
|
|
109
138
|
const config = await resolveConfig(flags.stage);
|
|
110
|
-
runner =
|
|
139
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
140
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
try {
|
|
114
|
-
const status = await computeFingerprintStatus(
|
|
144
|
+
const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
|
|
115
145
|
|
|
116
146
|
if (flags.json === 'true') {
|
|
117
147
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -120,7 +150,13 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
120
150
|
if (status.declared === null) {
|
|
121
151
|
warn(`declared: (no models barrel at ${modelsPath} — live-only)`);
|
|
122
152
|
} else {
|
|
153
|
+
if (status.governedLive !== undefined && status.governedLive !== status.live) {
|
|
154
|
+
info(`governed: ${status.governedLive} (live state minus ungoverned grantees — the half MATCH compares)`);
|
|
155
|
+
}
|
|
123
156
|
info(`declared: ${status.declared}`);
|
|
157
|
+
if (status.predicted !== undefined && status.predicted !== status.declared) {
|
|
158
|
+
info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
|
|
159
|
+
}
|
|
124
160
|
if (status.match) success('MATCH — the database is the state the models declare.');
|
|
125
161
|
else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
|
|
126
162
|
}
|
|
@@ -34,26 +34,18 @@ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.
|
|
|
34
34
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
35
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
36
36
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
37
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
37
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
38
39
|
import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
|
|
39
40
|
import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
|
|
40
41
|
import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
41
42
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
42
|
-
import { invokeAction } from '../aws.js';
|
|
43
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
43
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
44
45
|
|
|
45
46
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
46
47
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
47
48
|
|
|
48
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
49
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
50
|
-
return async (sql: string) => {
|
|
51
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
52
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
53
|
-
return result?.rows ?? [];
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
|
|
58
50
|
* Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
|
|
59
51
|
export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
@@ -164,11 +156,11 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
|
|
|
164
156
|
function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
|
|
165
157
|
if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
|
|
166
158
|
const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
167
|
-
const { counts } = classifyAdoption(declared, liveAuthz);
|
|
159
|
+
const { counts, statements } = classifyAdoption(declared, liveAuthz);
|
|
168
160
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
169
161
|
if (total === 0) return;
|
|
170
162
|
info(`${total} authorization statement(s), by why they exist:`);
|
|
171
|
-
for (const line of renderAdoptionReport(counts)) info(line);
|
|
163
|
+
for (const line of renderAdoptionReport(counts, statements)) info(line);
|
|
172
164
|
}
|
|
173
165
|
|
|
174
166
|
/**
|
|
@@ -249,6 +241,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
249
241
|
let liveAuthz;
|
|
250
242
|
let liveOwners: TableOwner[] = [];
|
|
251
243
|
let runner!: QueryRunner;
|
|
244
|
+
let session!: SessionRunner;
|
|
252
245
|
let end: (() => Promise<void>) | undefined;
|
|
253
246
|
try {
|
|
254
247
|
step(`Loading models from ${modelsPath}...`);
|
|
@@ -270,17 +263,18 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
270
263
|
}
|
|
271
264
|
if (dbSource.kind === 'url') {
|
|
272
265
|
step(connectingVia(dbSource));
|
|
273
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
266
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
274
267
|
} else {
|
|
275
268
|
step('Resolving deployed config...');
|
|
276
269
|
const config = await resolveConfig(flags.stage);
|
|
277
270
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
278
|
-
runner =
|
|
271
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
272
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
279
273
|
}
|
|
280
274
|
step('Introspecting live database (columns + constraints)...');
|
|
281
|
-
current = await introspectSchema(
|
|
275
|
+
current = await introspectSchema(session);
|
|
282
276
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
283
|
-
liveAuthz = await introspectContract(
|
|
277
|
+
liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
284
278
|
// Ownership rides the same connection: it is not part of the contract (it never
|
|
285
279
|
// enters the fingerprint), but it decides whether the contract is actually in force.
|
|
286
280
|
liveOwners = await introspectTableOwners(runner);
|
|
@@ -352,7 +346,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
352
346
|
step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
|
|
353
347
|
let outcome: StateSyncOutcome;
|
|
354
348
|
try {
|
|
355
|
-
outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
349
|
+
outcome = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
356
350
|
allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
357
351
|
});
|
|
358
352
|
} catch (err: any) {
|