@everystack/cli 0.4.44 → 0.4.46
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 +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- 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 +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- 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 +128 -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
|
@@ -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
|
}
|
|
@@ -30,28 +30,22 @@ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrati
|
|
|
30
30
|
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
31
31
|
import { compileTableContract } from '../authz-compile.js';
|
|
32
32
|
import { governedRoleSet, ungovernedGrants, renderGrantExemptions } from '../authz-reconcile.js';
|
|
33
|
+
import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
|
|
34
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
33
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
34
36
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
37
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
35
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
36
39
|
import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
|
|
37
40
|
import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
|
|
38
41
|
import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
39
42
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
40
|
-
import { invokeAction } from '../aws.js';
|
|
43
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
41
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
42
45
|
|
|
43
46
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
44
47
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
45
48
|
|
|
46
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
47
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
48
|
-
return async (sql: string) => {
|
|
49
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
50
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
51
|
-
return result?.rows ?? [];
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
|
|
56
50
|
* Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
|
|
57
51
|
export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
@@ -151,6 +145,48 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
|
|
|
151
145
|
info(`Bring them under the reconciler with defineModule({ governedRoles: [...] }), or leave them exempt — either way they are listed here every run.`);
|
|
152
146
|
}
|
|
153
147
|
|
|
148
|
+
/**
|
|
149
|
+
* WHY does each authorization statement exist? — printed on every brownfield generate.
|
|
150
|
+
*
|
|
151
|
+
* A first plan against an existing database is long, and the length is not the problem: the
|
|
152
|
+
* problem is that nobody can tell which statements are everystack imposing its own spelling
|
|
153
|
+
* and which are real differences. `convention` is the count that indicts US, and driving it to
|
|
154
|
+
* zero is the whole point — so it is named first and never hidden behind a flag.
|
|
155
|
+
*/
|
|
156
|
+
function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
|
|
157
|
+
if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
|
|
158
|
+
const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
159
|
+
const { counts, statements } = classifyAdoption(declared, liveAuthz);
|
|
160
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
161
|
+
if (total === 0) return;
|
|
162
|
+
info(`${total} authorization statement(s), by why they exist:`);
|
|
163
|
+
for (const line of renderAdoptionReport(counts, statements)) info(line);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* WHO owns each governed table, and is that owner subject to its own RLS? — printed
|
|
168
|
+
* on every generate that has a live database.
|
|
169
|
+
*
|
|
170
|
+
* The owner never appears in the contract or the fingerprint: it is the dev user locally
|
|
171
|
+
* and the operator role on a stage, so committing it would drift on every environment.
|
|
172
|
+
* That is precisely why it must be SAID. A deployed owner that differs from the one the
|
|
173
|
+
* author has in mind is invisible in every artifact, and an unforced table lets that owner
|
|
174
|
+
* write past every policy in this repo. The report names the owner even when nothing is
|
|
175
|
+
* wrong, because silence here is what makes the mismatch impossible to notice.
|
|
176
|
+
*/
|
|
177
|
+
function reportOwnership(
|
|
178
|
+
liveAuthz: AuthzContract | undefined,
|
|
179
|
+
owners: readonly TableOwner[],
|
|
180
|
+
models: ModelDescriptor[],
|
|
181
|
+
): void {
|
|
182
|
+
if (!liveAuthz) return; // greenfield — nothing owns anything yet
|
|
183
|
+
const rows = buildOwnershipReport(owners, liveAuthz, { models });
|
|
184
|
+
for (const line of renderOwnershipReport(rows)) {
|
|
185
|
+
if (line.trimStart().startsWith('!')) warn(line.trim());
|
|
186
|
+
else info(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
154
190
|
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
155
191
|
try {
|
|
156
192
|
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
@@ -203,7 +239,9 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
203
239
|
let declaredDb: DeclaredDerived | null = null;
|
|
204
240
|
let current;
|
|
205
241
|
let liveAuthz;
|
|
242
|
+
let liveOwners: TableOwner[] = [];
|
|
206
243
|
let runner!: QueryRunner;
|
|
244
|
+
let session!: SessionRunner;
|
|
207
245
|
let end: (() => Promise<void>) | undefined;
|
|
208
246
|
try {
|
|
209
247
|
step(`Loading models from ${modelsPath}...`);
|
|
@@ -225,17 +263,21 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
225
263
|
}
|
|
226
264
|
if (dbSource.kind === 'url') {
|
|
227
265
|
step(connectingVia(dbSource));
|
|
228
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
266
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
229
267
|
} else {
|
|
230
268
|
step('Resolving deployed config...');
|
|
231
269
|
const config = await resolveConfig(flags.stage);
|
|
232
270
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
233
|
-
runner =
|
|
271
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
272
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
234
273
|
}
|
|
235
274
|
step('Introspecting live database (columns + constraints)...');
|
|
236
|
-
current = await introspectSchema(
|
|
275
|
+
current = await introspectSchema(session);
|
|
237
276
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
238
|
-
liveAuthz = await introspectContract(
|
|
277
|
+
liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
278
|
+
// Ownership rides the same connection: it is not part of the contract (it never
|
|
279
|
+
// enters the fingerprint), but it decides whether the contract is actually in force.
|
|
280
|
+
liveOwners = await introspectTableOwners(runner);
|
|
239
281
|
if (!apply) await end?.();
|
|
240
282
|
} catch (err: any) {
|
|
241
283
|
fail(err.message);
|
|
@@ -257,6 +299,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
257
299
|
// ungoverned grantee's privileges alone rather than revoking them; that is only
|
|
258
300
|
// defensible because the artifact says exactly whose access it chose not to govern.
|
|
259
301
|
reportGrantExemptions(liveAuthz, models, declaredDb?.governedRoles ?? []);
|
|
302
|
+
reportAdoptionClasses(liveAuthz, models);
|
|
303
|
+
reportOwnership(liveAuthz, liveOwners, models);
|
|
260
304
|
console.log('');
|
|
261
305
|
if (statements.length === 0) {
|
|
262
306
|
success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
|
|
@@ -302,7 +346,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
302
346
|
step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
|
|
303
347
|
let outcome: StateSyncOutcome;
|
|
304
348
|
try {
|
|
305
|
-
outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
349
|
+
outcome = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
306
350
|
allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
307
351
|
});
|
|
308
352
|
} catch (err: any) {
|
|
@@ -16,13 +16,23 @@
|
|
|
16
16
|
* Plans are EPHEMERAL — attach them to the release/PR run, never commit
|
|
17
17
|
* them (committing plans would rebuild the tape). Minting is read-only, so
|
|
18
18
|
* it works over the ops Lambda (--stage) as well as a direct connection.
|
|
19
|
+
*
|
|
20
|
+
* On the STAGE lane the read is not atomic: every catalog query is its own
|
|
21
|
+
* Lambda invoke, so one introspection can be assembled from several containers
|
|
22
|
+
* holding connections to different databases, and the minted `from` would then
|
|
23
|
+
* describe no real state. That lane reads TWICE and refuses a disagreement.
|
|
24
|
+
* Agreement is a non-detection, not a verification — see
|
|
25
|
+
* stage-read-consistency.ts. The direct lane reads once, over one session.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import fs from 'node:fs/promises';
|
|
22
29
|
import { spawnSync } from 'node:child_process';
|
|
23
30
|
import type { ModelDescriptor } from '@everystack/model';
|
|
24
|
-
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
25
|
-
import {
|
|
31
|
+
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
32
|
+
import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
|
|
33
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
|
|
34
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
|
|
35
|
+
import { readStageStateTwice } from '../stage-read-consistency.js';
|
|
26
36
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
27
37
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
|
|
28
38
|
import { verifyDescent } from '../git-descent.js';
|
|
@@ -30,16 +40,18 @@ import { planBackfills, readBackfillLog } from '../backfill.js';
|
|
|
30
40
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
31
41
|
import { currentGitRef } from '../state-apply.js';
|
|
32
42
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
43
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
33
44
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
45
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
35
|
-
import { lambdaQueryRunner } from '../aws.js';
|
|
46
|
+
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
36
47
|
import { loadModels } from './db-generate.js';
|
|
37
48
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
38
49
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
39
50
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
51
|
+
import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents.js';
|
|
40
52
|
import { compileTableContract } from '../authz-compile.js';
|
|
41
53
|
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
42
|
-
import { step, success, fail, info, warn } from '../output.js';
|
|
54
|
+
import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
|
|
43
55
|
|
|
44
56
|
const DEFAULT_OUT = 'db.plan.json';
|
|
45
57
|
|
|
@@ -61,6 +73,12 @@ function isGitIgnored(file: string): boolean {
|
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
|
|
76
|
+
const out = flags.out || DEFAULT_OUT;
|
|
77
|
+
// `--out -` makes stdout the artifact. Declared before anything prints, so the summary,
|
|
78
|
+
// the adoption report and the ownership report land on stderr and the plan JSON is the
|
|
79
|
+
// only thing on stdout — otherwise `db:plan --out - | jq` reads a report as its input.
|
|
80
|
+
if (out === '-') reserveStdoutForData();
|
|
81
|
+
|
|
64
82
|
let dbSource: DbSource;
|
|
65
83
|
try {
|
|
66
84
|
dbSource = resolveDbSource(flags);
|
|
@@ -81,20 +99,44 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
let runner: QueryRunner;
|
|
102
|
+
|
|
103
|
+
let session: SessionRunner;
|
|
84
104
|
let end: (() => Promise<void>) | undefined;
|
|
85
105
|
if (dbSource.kind === 'url') {
|
|
86
106
|
step(connectingVia(dbSource));
|
|
87
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
107
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
88
108
|
} else {
|
|
89
109
|
step('Resolving deployed config...');
|
|
90
110
|
const config = await resolveConfig(flags.stage);
|
|
91
111
|
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
112
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
let snapshot: SchemaSnapshot;
|
|
117
|
+
let contract: AuthzContract;
|
|
118
|
+
if (dbSource.kind === 'url') {
|
|
119
|
+
// One session, one database — the read is atomic enough to trust.
|
|
120
|
+
step('Asking the target its fingerprint (introspecting state + authz)...');
|
|
121
|
+
snapshot = await introspectSchema(session);
|
|
122
|
+
contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
123
|
+
} else {
|
|
124
|
+
// Each introspection is one session now, but a full read is TWO of them (state,
|
|
125
|
+
// then authz) and they can land on two containers at two moments. Read twice and
|
|
126
|
+
// refuse a disagreement — see stage-read-consistency.ts for why agreement is a
|
|
127
|
+
// non-detection and not a verification.
|
|
128
|
+
step('Asking the target its fingerprint TWICE (the stage lane reads state and authz in separate ops-Lambda invokes)...');
|
|
129
|
+
const pair = await readStageStateTwice(session);
|
|
130
|
+
if (!pair.ok) {
|
|
131
|
+
fail(pair.reason);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
warn(pair.warning);
|
|
135
|
+
({ snapshot, contract } = pair.state);
|
|
136
|
+
}
|
|
137
|
+
// Not part of the plan and not part of either fingerprint — the owner is
|
|
138
|
+
// environment-specific. It is read here so the REVIEW surface can name it.
|
|
139
|
+
const owners = await introspectTableOwners(runner);
|
|
98
140
|
// The modules' widened governed-role set. A barrel that exports only `models` has none,
|
|
99
141
|
// which is the greenfield default: govern exactly what the models name.
|
|
100
142
|
let declaredGovernedRoles: string[] = [];
|
|
@@ -134,7 +176,19 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
134
176
|
process.exit(1);
|
|
135
177
|
}
|
|
136
178
|
|
|
137
|
-
|
|
179
|
+
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
|
|
180
|
+
// column a view/matview/rule binds would fail MID-TRANSACTION at apply
|
|
181
|
+
// ("cannot alter type of a column used by a view or rule") — refuse at mint,
|
|
182
|
+
// with the dependents named, while there is still a live catalog to ask.
|
|
183
|
+
const alterTargets = alterTypeStatementTargets(plan.statements);
|
|
184
|
+
if (alterTargets.length > 0) {
|
|
185
|
+
const blockedAlters = await findAlterTypeDependents(runner, alterTargets);
|
|
186
|
+
if (blockedAlters.length > 0) {
|
|
187
|
+
fail(`Mint refused: ${renderAlterTypeRefusal(blockedAlters)}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
138
192
|
const body = JSON.stringify(plan, null, 2) + '\n';
|
|
139
193
|
if (out === '-') {
|
|
140
194
|
console.log(body);
|
|
@@ -145,6 +199,32 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
145
199
|
for (const line of buildPlanSummary(plan)) info(line);
|
|
146
200
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
147
201
|
|
|
202
|
+
// WHY does each authorization statement exist? — the same report db:generate prints,
|
|
203
|
+
// on the artifact a human actually reviews before applying.
|
|
204
|
+
//
|
|
205
|
+
// Re-DERIVED from live every mint, never stored: a recorded claim about what the models
|
|
206
|
+
// fail to capture goes stale the moment someone closes the gap, and a stale note is worse
|
|
207
|
+
// than none. `capability` is the count that says "this plan removes something the model
|
|
208
|
+
// cannot express" — the operator reading this is the last one who can catch it.
|
|
209
|
+
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
210
|
+
const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
|
|
211
|
+
if (adoptionTotal > 0) {
|
|
212
|
+
info(`${adoptionTotal} authorization statement(s), by why they exist:`);
|
|
213
|
+
for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
|
|
214
|
+
}
|
|
215
|
+
// The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
|
|
216
|
+
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
217
|
+
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
218
|
+
plan.adoption = adoption.statements;
|
|
219
|
+
|
|
220
|
+
// WHO owns the tables this plan authorizes, and does that owner obey the policies it
|
|
221
|
+
// is about to write? Re-derived live every mint, never stored — the owner is a fact
|
|
222
|
+
// about THIS target, and the target is the one thing a committed artifact cannot carry.
|
|
223
|
+
for (const line of renderOwnershipReport(buildOwnershipReport(owners, contract, { models }))) {
|
|
224
|
+
if (line.trimStart().startsWith('!')) warn(line.trim());
|
|
225
|
+
else info(line);
|
|
226
|
+
}
|
|
227
|
+
|
|
148
228
|
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
149
229
|
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
150
230
|
// work — say so on the review surface, where a human still reads it.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
|
-
* db:pull [--stage <name>
|
|
4
|
+
* db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
5
5
|
* [--derived-out <file.ts>] [--abilities public-read]
|
|
6
6
|
*
|
|
7
|
+
* `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
|
|
8
|
+
* baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
|
|
9
|
+
* `--database-url … --stage local`, which is the stage db:plan defaults to).
|
|
10
|
+
*
|
|
7
11
|
* The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
|
|
8
12
|
* database. It introspects the deployed schema through the read-only ops Lambda `db:query`
|
|
9
13
|
* action — or, for a database no Lambda can reach (the brownfield case where the target
|
|
@@ -34,14 +38,16 @@ import { fingerprintLive } from '../schema-fingerprint.js';
|
|
|
34
38
|
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
35
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
36
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
37
|
-
import { introspectContract, type TableContract } from '../authz-contract.js';
|
|
41
|
+
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
|
|
42
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
38
43
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
39
44
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
40
45
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
41
46
|
import type { QueryRunner } from '../authz-contract.js';
|
|
42
47
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
48
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
43
49
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
44
|
-
import { invokeAction } from '../aws.js';
|
|
50
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
45
51
|
import { fail } from '../output.js';
|
|
46
52
|
import { opsAdviceLines } from '../ops-advice.js';
|
|
47
53
|
|
|
@@ -101,15 +107,6 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
|
|
|
101
107
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
105
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
106
|
-
return async (sql: string) => {
|
|
107
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
108
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
109
|
-
return result?.rows ?? [];
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
110
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
114
111
|
const schema = flags.schema || 'public';
|
|
115
112
|
|
|
@@ -150,32 +147,42 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
150
147
|
/** Adoption observation — the foreign grantees present, and what the claim is true OF. */
|
|
151
148
|
let pulledExemptions: GrantExemption[] = [];
|
|
152
149
|
let pulledFingerprint = '';
|
|
150
|
+
/** Live ownership — reported, never rendered into a model (it is environment-specific). */
|
|
151
|
+
let liveContract: AuthzContract | undefined;
|
|
152
|
+
let liveOwners: TableOwner[] = [];
|
|
153
153
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
154
154
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
155
155
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
156
156
|
try {
|
|
157
157
|
let runner: QueryRunner;
|
|
158
|
+
let session: SessionRunner;
|
|
158
159
|
let end: (() => Promise<void>) | undefined;
|
|
159
160
|
if (dbSource.kind === 'url') {
|
|
160
161
|
note(connectingVia(dbSource));
|
|
161
|
-
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
162
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
162
163
|
} else {
|
|
163
164
|
note('Resolving deployed config...');
|
|
164
165
|
const config = await resolveConfig(flags.stage);
|
|
165
166
|
detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
166
|
-
runner =
|
|
167
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
168
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
167
169
|
}
|
|
168
170
|
note(`Introspecting live database (schema: ${schema})...`);
|
|
169
|
-
current = await introspectSchema(
|
|
171
|
+
current = await introspectSchema(session);
|
|
170
172
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
171
173
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
172
|
-
derivedCatalog = await introspectDerived(
|
|
174
|
+
derivedCatalog = await introspectDerived(session);
|
|
173
175
|
// --abilities live: the authz half of the on-ramp. Same connection, one more read —
|
|
174
176
|
// the grants and policies that already exist become the models' declared abilities,
|
|
175
177
|
// instead of a human transcribing them by hand.
|
|
176
178
|
if (abilities === 'live') {
|
|
177
179
|
note('Introspecting live authorization (grants + policies)...');
|
|
178
|
-
const contract = await introspectContract(
|
|
180
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
181
|
+
liveContract = contract;
|
|
182
|
+
// The owner is NOT rendered into the models — it is the dev user here and the
|
|
183
|
+
// operator role on a stage, so a model that declared it would drift everywhere.
|
|
184
|
+
// It is read to be SAID, on the one surface that is looking at the database.
|
|
185
|
+
liveOwners = await introspectTableOwners(runner);
|
|
179
186
|
liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
180
187
|
detail(`${liveAuthz.size} table(s) carry authorization.`);
|
|
181
188
|
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
@@ -243,6 +250,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
243
250
|
process.exit(1);
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
254
|
+
// write principal to contradict, and the models this pull is about to write do not exist
|
|
255
|
+
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
256
|
+
// from every artifact afterwards, because the contract excludes the owner on purpose.
|
|
257
|
+
if (liveContract) {
|
|
258
|
+
for (const line of renderOwnershipReport(
|
|
259
|
+
buildOwnershipReport(liveOwners, liveContract, { tables: pulled.map((t) => t.table) }),
|
|
260
|
+
)) note(line);
|
|
261
|
+
}
|
|
262
|
+
|
|
246
263
|
// The derived layer (B5): rendered from the live catalog with dependsOn from the real
|
|
247
264
|
// edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
|
|
248
265
|
const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
@@ -338,8 +355,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
338
355
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
339
356
|
}
|
|
340
357
|
|
|
341
|
-
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK
|
|
342
|
-
if (flagged) caution(`${flagged} inline comment(s) flag things to review (
|
|
358
|
+
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
|
|
359
|
+
if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
|
|
343
360
|
if (abilities === 'commented') {
|
|
344
361
|
note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
|
|
345
362
|
} else {
|