@everystack/cli 0.4.72 → 0.4.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/cli/adopt.ts +58 -0
- package/src/cli/apply-execute.ts +8 -4
- package/src/cli/authz-canonical.ts +146 -54
- package/src/cli/authz-contract.ts +151 -7
- package/src/cli/authz-reconcile.ts +58 -29
- package/src/cli/commands/db-adopt.ts +227 -0
- package/src/cli/commands/db-fingerprint.ts +24 -5
- package/src/cli/derived-grants.ts +20 -13
- package/src/cli/derived-plan.ts +15 -5
- package/src/cli/edge-plan.ts +24 -3
- package/src/cli/git-descent.ts +17 -4
- package/src/cli/index.ts +5 -0
- package/src/cli/live-models.ts +65 -0
- package/src/cli/schema-fingerprint-v10.ts +152 -0
- package/src/cli/schema-fingerprint.ts +150 -31
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract';
|
|
19
19
|
import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract';
|
|
20
20
|
import { matchPolicies } from './authz-identity';
|
|
21
|
+
import { tablePoliciesEquivalent } from './authz-canonical';
|
|
21
22
|
import { quoteQualified } from './pg-ident';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -247,7 +248,8 @@ export function inheritedSequenceGrants(table: TableContract): Record<string, st
|
|
|
247
248
|
function reconcileSequenceGrants(
|
|
248
249
|
d: TableContract,
|
|
249
250
|
liveSequences: readonly SequenceGrantContract[],
|
|
250
|
-
|
|
251
|
+
narrow: string[],
|
|
252
|
+
widen: string[],
|
|
251
253
|
governed: ReadonlySet<string>,
|
|
252
254
|
): void {
|
|
253
255
|
const declared = inheritedSequenceGrants(d);
|
|
@@ -275,8 +277,8 @@ function reconcileSequenceGrants(
|
|
|
275
277
|
const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
276
278
|
const toRevoke = [...have].filter((p) => !want.has(p)).sort();
|
|
277
279
|
const toGrant = [...want].filter((p) => !have.has(p)).sort();
|
|
278
|
-
if (toRevoke.length)
|
|
279
|
-
if (toGrant.length)
|
|
280
|
+
if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
|
|
281
|
+
if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
|
|
280
282
|
}
|
|
281
283
|
}
|
|
282
284
|
}
|
|
@@ -293,6 +295,13 @@ export function emitReconcileSql(
|
|
|
293
295
|
live: AuthzContract,
|
|
294
296
|
opts: { governedRoles?: ReadonlySet<string> } = {},
|
|
295
297
|
): string[] {
|
|
298
|
+
// Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
|
|
299
|
+
// (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
|
|
300
|
+
// RLS DISABLE/NO FORCE). The batch is atomic, so this is defence in depth — but it makes every
|
|
301
|
+
// prefix of the plan fail-safe: a partial application is never broader than both endpoints. A
|
|
302
|
+
// same-name policy replacement stays a DROP (narrow) then CREATE (widen), never co-existing.
|
|
303
|
+
// Per table, not global: grants and policies are table-scoped, so a widen on one table can
|
|
304
|
+
// never be exploited through another's narrow — and tables stay grouped, as the plan reads.
|
|
296
305
|
const sql: string[] = [];
|
|
297
306
|
const lTables = tableMap(live);
|
|
298
307
|
// Default: govern exactly what the models name (plus the always-governed set). A caller
|
|
@@ -311,12 +320,15 @@ export function emitReconcileSql(
|
|
|
311
320
|
// The contract keys on the bare `<schema>.<table>` (so the diff matches both producers);
|
|
312
321
|
// the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
|
|
313
322
|
const table = quoteQualified(d.table);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
323
|
+
const narrow: string[] = [];
|
|
324
|
+
const widen: string[] = [];
|
|
325
|
+
reconcileRls(table, d, l, narrow, widen);
|
|
326
|
+
reconcilePolicies(table, d, l, narrow, widen, governed);
|
|
327
|
+
reconcileGrants(table, d, l, narrow, widen, governed);
|
|
328
|
+
reconcileColumnGrants(table, d, l, narrow, widen, governed);
|
|
318
329
|
// A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
|
|
319
|
-
reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [],
|
|
330
|
+
reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], narrow, widen, governed);
|
|
331
|
+
sql.push(...narrow, ...widen);
|
|
320
332
|
}
|
|
321
333
|
|
|
322
334
|
return sql;
|
|
@@ -339,29 +351,37 @@ export function emitReconcileSql(
|
|
|
339
351
|
* already-enabled table is a no-op; the dump carried no grants).
|
|
340
352
|
*/
|
|
341
353
|
export function emitSwapAuthzSql(declared: AuthzContract): string[] {
|
|
354
|
+
// Same narrow-before-widen shape as emitReconcileSql (Condition C), per table: RLS enable/force
|
|
355
|
+
// and the idempotent policy DROPs narrow; the CREATEs and GRANTs widen. Drop-BEFORE-create per
|
|
356
|
+
// policy is preserved (drop in the narrow phase, create in the widen phase of the same table),
|
|
357
|
+
// so no CREATE lands on a policy the dump restored.
|
|
342
358
|
const sql: string[] = [];
|
|
343
359
|
const governed = governedRoleSet(declared);
|
|
344
360
|
for (const d of declared.tables) {
|
|
345
361
|
const table = quoteQualified(d.table);
|
|
346
|
-
|
|
347
|
-
|
|
362
|
+
const narrow: string[] = [];
|
|
363
|
+
const widen: string[] = [];
|
|
364
|
+
if (d.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
|
|
365
|
+
if (d.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
|
|
348
366
|
for (const p of d.policies) {
|
|
349
|
-
|
|
350
|
-
|
|
367
|
+
narrow.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
|
|
368
|
+
widen.push(policyCreateSql(table, p));
|
|
351
369
|
}
|
|
352
370
|
// No live side here, so every grantee comes from the DECLARED contract and is
|
|
353
371
|
// governed by definition — the exemption can never fire on this path.
|
|
354
|
-
reconcileGrants(table, d, undefined,
|
|
355
|
-
reconcileColumnGrants(table, d, undefined,
|
|
372
|
+
reconcileGrants(table, d, undefined, narrow, widen, governed);
|
|
373
|
+
reconcileColumnGrants(table, d, undefined, narrow, widen, governed);
|
|
374
|
+
sql.push(...narrow, ...widen);
|
|
356
375
|
}
|
|
357
376
|
return sql;
|
|
358
377
|
}
|
|
359
378
|
|
|
360
|
-
function reconcileRls(table: string, d: TableContract, l: TableContract | undefined,
|
|
361
|
-
|
|
362
|
-
if (d.rls.
|
|
363
|
-
if (
|
|
364
|
-
if (!d.rls.
|
|
379
|
+
function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[]): void {
|
|
380
|
+
// ENABLE / FORCE deny by default — narrowing. DISABLE / NO FORCE lift a restriction — widening.
|
|
381
|
+
if (d.rls.enabled && !l?.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
|
|
382
|
+
if (d.rls.forced && !l?.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
|
|
383
|
+
if (!d.rls.enabled && l?.rls.enabled) widen.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
|
|
384
|
+
if (!d.rls.forced && l?.rls.forced) widen.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
|
|
365
385
|
}
|
|
366
386
|
|
|
367
387
|
/**
|
|
@@ -375,12 +395,20 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
|
|
|
375
395
|
* The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
|
|
376
396
|
* no-op — never emit DDL the name-keyed version would not have emitted.
|
|
377
397
|
*/
|
|
378
|
-
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined,
|
|
398
|
+
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
|
|
399
|
+
// Condition B — gate on the canonical equivalence the fingerprint uses, so this surface and
|
|
400
|
+
// that one can never disagree about the same database. A live `FOR ALL` with a dead DELETE arm
|
|
401
|
+
// and the granular pull that omits DELETE are equal here; emitting the DROP + CREATE the
|
|
402
|
+
// per-command matcher would produce is exactly the MATCH-but-nonzero split this closes. Only
|
|
403
|
+
// when the states genuinely differ do we fall back to the per-policy matcher below, which can
|
|
404
|
+
// only over-emit churn, never under-emit a real authorization difference.
|
|
405
|
+
if (l && tablePoliciesEquivalent(d, l, governed)) return;
|
|
379
406
|
const live = l?.policies ?? [];
|
|
380
407
|
const m = matchPolicies(d.policies, live);
|
|
381
408
|
const byName = new Map(live.map((p) => [p.name, p]));
|
|
382
409
|
|
|
383
|
-
//
|
|
410
|
+
// Condition C: DROPs go to the narrow phase, CREATEs to the widen phase — a replaced policy is
|
|
411
|
+
// dropped (narrow) before it is re-created (widen), so it never co-exists with its old form.
|
|
384
412
|
for (const name of m.toDrop) {
|
|
385
413
|
// A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
|
|
386
414
|
// as reconcileGrants leaves that role's privileges alone. Without this the two lanes
|
|
@@ -411,7 +439,7 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
|
|
|
411
439
|
// drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
|
|
412
440
|
// the moment the plan lands — and leaving it would keep access the model never declared.
|
|
413
441
|
if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
|
|
414
|
-
|
|
442
|
+
narrow.push(policyDropSql(table, name));
|
|
415
443
|
}
|
|
416
444
|
// Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
|
|
417
445
|
// the declared grants do not back is inert — it would authorize nothing the moment it
|
|
@@ -420,11 +448,11 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
|
|
|
420
448
|
// moment the model grants the privilege it polices.
|
|
421
449
|
for (const p of m.toCreate) {
|
|
422
450
|
if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
|
|
423
|
-
|
|
451
|
+
widen.push(policyCreateSql(table, p));
|
|
424
452
|
}
|
|
425
453
|
}
|
|
426
454
|
|
|
427
|
-
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined,
|
|
455
|
+
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
|
|
428
456
|
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
|
|
429
457
|
for (const grantee of [...grantees].sort()) {
|
|
430
458
|
// A live grantee the declared authz does not govern is LEFT ALONE. The union below
|
|
@@ -439,8 +467,9 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
|
|
|
439
467
|
|
|
440
468
|
const toRevoke = [...live].filter((p) => !declared.has(p)).sort();
|
|
441
469
|
const toGrant = [...declared].filter((p) => !live.has(p)).sort();
|
|
442
|
-
|
|
443
|
-
if (
|
|
470
|
+
// REVOKE narrows, GRANT widens (Condition C).
|
|
471
|
+
if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
|
|
472
|
+
if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
|
|
444
473
|
}
|
|
445
474
|
}
|
|
446
475
|
|
|
@@ -450,7 +479,7 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
|
|
|
450
479
|
* those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
|
|
451
480
|
* to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
|
|
452
481
|
*/
|
|
453
|
-
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined,
|
|
482
|
+
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
|
|
454
483
|
const dcg = d.columnGrants ?? {};
|
|
455
484
|
const lcg = l?.columnGrants ?? {};
|
|
456
485
|
for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
|
|
@@ -465,8 +494,8 @@ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract
|
|
|
465
494
|
const live = new Set(lPriv[priv] ?? []);
|
|
466
495
|
const toRevoke = [...live].filter((c) => !declared.has(c)).sort();
|
|
467
496
|
const toGrant = [...declared].filter((c) => !live.has(c)).sort();
|
|
468
|
-
if (toRevoke.length)
|
|
469
|
-
if (toGrant.length)
|
|
497
|
+
if (toRevoke.length) narrow.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
|
|
498
|
+
if (toGrant.length) widen.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
|
|
470
499
|
}
|
|
471
500
|
}
|
|
472
501
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:adopt` — first-time adoption of a pre-Models database, by PROOF.
|
|
3
|
+
*
|
|
4
|
+
* db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models]
|
|
5
|
+
* [--govern-roles r1,r2]
|
|
6
|
+
*
|
|
7
|
+
* The problem it solves: a production database that never went through Models has no committed
|
|
8
|
+
* ancestor declaring it, so db:apply refuses it as unverifiable drift — and no waiver fits (there
|
|
9
|
+
* is nothing to attest a lineage FROM). db:adopt is the deliberate, auditable on-ramp. It renders
|
|
10
|
+
* Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
|
|
11
|
+
* the compiled Models declare the live state EXACTLY — predicted == governed live, the same
|
|
12
|
+
* declares-test descent and db:check use. Only then does it write the model files and print the
|
|
13
|
+
* `chore(db): adopt live baseline <fp>` commit: that commit legitimately declares the live state,
|
|
14
|
+
* so a later db:apply descends for real instead of hitting the drift refusal.
|
|
15
|
+
*
|
|
16
|
+
* The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
|
|
17
|
+
* round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
|
|
18
|
+
* db:generate edge — because a baseline that papered over a difference is the bypass under another
|
|
19
|
+
* name. It writes NOTHING on refusal; --out is left untouched.
|
|
20
|
+
*
|
|
21
|
+
* The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
|
|
22
|
+
* (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
|
|
23
|
+
* and either finalize or refuse.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
30
|
+
import { introspectContract, type AuthzContract, type QueryRunner } from '../authz-contract';
|
|
31
|
+
import { introspectDerived } from '../derived-introspect';
|
|
32
|
+
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
33
|
+
import { renderLiveModelFiles } from '../live-models';
|
|
34
|
+
import { proveAdoption, type AdoptionProof } from '../adopt';
|
|
35
|
+
import { generateMigrationSql } from '../migration-generate';
|
|
36
|
+
import { loadModels } from './db-generate';
|
|
37
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
38
|
+
import type { RenderedModelFile } from '../model-render';
|
|
39
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
40
|
+
import type { SessionRunner } from '../session';
|
|
41
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
42
|
+
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
43
|
+
import { step, success, fail, info, warn } from '../output';
|
|
44
|
+
import { opsAdviceLines } from '../ops-advice';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_OUT = 'db/models';
|
|
47
|
+
|
|
48
|
+
export interface AdoptResult {
|
|
49
|
+
/** The proof: clean means the compiled Models declare the live state exactly. */
|
|
50
|
+
proof: AdoptionProof;
|
|
51
|
+
/** The rendered model files (directory shape) — written to --out only when the proof is clean. */
|
|
52
|
+
files: RenderedModelFile[];
|
|
53
|
+
/** The live schema, as introspected. */
|
|
54
|
+
snapshot: SchemaSnapshot;
|
|
55
|
+
/** The live authorization contract, as introspected. */
|
|
56
|
+
contract: AuthzContract;
|
|
57
|
+
/** `chore(db): adopt live baseline <fp>` when clean; null on refusal. */
|
|
58
|
+
commitMessage: string | null;
|
|
59
|
+
/** On refusal, the db:generate edge (what the Models and the live database still differ by). */
|
|
60
|
+
refusalDiff: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The adopt CORE: introspect the live database, render Models with the shared renderer, compile
|
|
65
|
+
* them in `sandboxDir`, and prove they declare the live state. Pure of policy — it does not decide
|
|
66
|
+
* to write --out or to exit; the caller does that from the verdict. The sandbox is where the
|
|
67
|
+
* rendered barrel is written so it can be dynamically imported (the compile the proof needs);
|
|
68
|
+
* --out is written by the shell only after the proof is clean.
|
|
69
|
+
*/
|
|
70
|
+
export async function adoptFromLive(opts: {
|
|
71
|
+
session: SessionRunner;
|
|
72
|
+
runner: QueryRunner;
|
|
73
|
+
schema?: string | string[];
|
|
74
|
+
governRoles?: string[];
|
|
75
|
+
sandboxDir: string;
|
|
76
|
+
/** How to compile the rendered barrel back to descriptors. Defaults to loadModels (the barrel
|
|
77
|
+
* loader db:generate uses). Injectable so a test runner that cannot dynamic-import a `.ts` via
|
|
78
|
+
* a file:// URL (jest) can supply its own transforming import. */
|
|
79
|
+
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
80
|
+
}): Promise<AdoptResult> {
|
|
81
|
+
const { session, sandboxDir } = opts;
|
|
82
|
+
const schema = opts.schema ?? 'public';
|
|
83
|
+
const load = opts.loadBarrel ?? loadModels;
|
|
84
|
+
|
|
85
|
+
const snapshot = await introspectSchema(session);
|
|
86
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
87
|
+
const derivedCatalog = await introspectDerived(session);
|
|
88
|
+
|
|
89
|
+
const rendered = renderLiveModelFiles(snapshot, contract, derivedCatalog, { schema, governRoles: opts.governRoles });
|
|
90
|
+
|
|
91
|
+
// Compile the rendered barrel: proveAdoption needs the loaded ModelDescriptors, and loading is a
|
|
92
|
+
// dynamic import, so the files must exist on disk under a tree where @everystack/model resolves.
|
|
93
|
+
await fs.mkdir(sandboxDir, { recursive: true });
|
|
94
|
+
for (const f of rendered.files) await fs.writeFile(path.join(sandboxDir, f.file), f.source, 'utf8');
|
|
95
|
+
const models = await load(path.join(sandboxDir, 'index.ts'));
|
|
96
|
+
|
|
97
|
+
const proof = proveAdoption(models, snapshot, contract, { schema: typeof schema === 'string' ? schema : undefined });
|
|
98
|
+
const commitMessage = proof.clean ? `chore(db): adopt live baseline ${proof.fingerprint}` : null;
|
|
99
|
+
// On refusal, show WHAT still differs — the same edge db:generate would mint against the live DB.
|
|
100
|
+
const refusalDiff = proof.clean ? [] : generateMigrationSql(models, snapshot, { liveAuthz: contract });
|
|
101
|
+
|
|
102
|
+
return { proof, files: rendered.files, snapshot, contract, commitMessage, refusalDiff };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn the adopt verdict into action — the SECURITY BOUNDARY, isolated so it is testable without a
|
|
107
|
+
* database. This is where "PROVE not attest, no bypass" is actually enforced: on refusal it writes
|
|
108
|
+
* NOTHING to --out and reports; on a clean proof it writes the baseline and prints the commit/push
|
|
109
|
+
* runbook. It never calls process.exit (the caller maps the returned code) — so a test can assert,
|
|
110
|
+
* against a synthetic verdict, that a dirty result leaves --out untouched and a clean one finalizes.
|
|
111
|
+
* A future edit that let the refusal path fall through to the write is caught by that test, red.
|
|
112
|
+
*/
|
|
113
|
+
export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Promise<{ exitCode: 0 | 1; wrote: boolean }> {
|
|
114
|
+
if (!result.proof.clean) {
|
|
115
|
+
// REFUSAL — the round trip is not exact. Name what diverged and show the edge; write NOTHING.
|
|
116
|
+
console.log('');
|
|
117
|
+
fail('db:adopt refused — the rendered Models do NOT declare the live database exactly.');
|
|
118
|
+
warn('A baseline that papered over this difference would be a bypass. Nothing was written to --out.');
|
|
119
|
+
info('Surfaces that diverge:');
|
|
120
|
+
for (const s of result.proof.divergentSurfaces) info(` • ${s}`);
|
|
121
|
+
if (result.refusalDiff.length) {
|
|
122
|
+
console.log('');
|
|
123
|
+
info(`What still differs (the db:generate edge, ${result.refusalDiff.length} statement(s)):`);
|
|
124
|
+
for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
|
|
125
|
+
}
|
|
126
|
+
console.log('');
|
|
127
|
+
info('The renderer could not yet capture the surfaces above. Refine by hand until db:generate is empty, then commit the Models — a later db:apply will descend normally.');
|
|
128
|
+
return { exitCode: 1, wrote: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// CLEAN — finalize. Write the rendered barrel to --out, then print the runbook.
|
|
132
|
+
try {
|
|
133
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
134
|
+
const written = new Set(result.files.map((f) => f.file));
|
|
135
|
+
const existing = (await fs.readdir(outDir)).filter((f) => f.endsWith('.ts') && !written.has(f));
|
|
136
|
+
for (const f of result.files) await fs.writeFile(path.join(outDir, f.file), f.source, 'utf8');
|
|
137
|
+
if (existing.length) {
|
|
138
|
+
warn(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), outDir)} were not part of this adoption and were left untouched: ${existing.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
fail(`Could not write ${outDir}: ${err.message}`);
|
|
142
|
+
return { exitCode: 1, wrote: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('');
|
|
146
|
+
success(`db:adopt — the rendered Models declare the live database exactly (${result.files.length - 1} model(s)).`);
|
|
147
|
+
info(`Wrote ${path.relative(process.cwd(), outDir)} — the adopted baseline.`);
|
|
148
|
+
console.log('');
|
|
149
|
+
info('Finalize the baseline — commit, then PUSH before you apply:');
|
|
150
|
+
info(` git add ${path.relative(process.cwd(), outDir)}`);
|
|
151
|
+
info(` git commit -m "${result.commitMessage}"`);
|
|
152
|
+
info(' git push # an unpushed baseline is an unknown commit to teammates and the ops Lambda');
|
|
153
|
+
info(' everystack db:plan --stage <name> # empty — the live database is now declared');
|
|
154
|
+
return { exitCode: 0, wrote: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function dbAdoptCommand(
|
|
158
|
+
flags: Record<string, string>,
|
|
159
|
+
opts: {
|
|
160
|
+
/** Injected barrel loader (jest cannot file:// import a .ts). Default: adoptFromLive's loadModels. */
|
|
161
|
+
loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
|
|
162
|
+
/** Injected sandbox dir, so a test can assert it is cleaned up. Default: a fresh mkdtemp. */
|
|
163
|
+
sandboxDir?: string;
|
|
164
|
+
} = {},
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
|
|
167
|
+
if (schemas.length === 0) fail('--schema needs at least one schema name.');
|
|
168
|
+
const schema = schemas.length === 1 ? schemas[0] : schemas;
|
|
169
|
+
const outDir = path.resolve(flags.out || DEFAULT_OUT);
|
|
170
|
+
|
|
171
|
+
// --govern-roles: the operator's EXPLICIT decision to transcribe foreign roles' grants as
|
|
172
|
+
// privileges (so a fresh build recreates them). Same vocabulary guard as db:pull.
|
|
173
|
+
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
174
|
+
for (const r of governRoles) {
|
|
175
|
+
if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
|
|
176
|
+
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let dbSource: DbSource;
|
|
182
|
+
try {
|
|
183
|
+
dbSource = resolveDbSource(flags);
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
fail(err.message);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sandboxDir = opts.sandboxDir ?? await fs.mkdtemp(path.join(os.tmpdir(), 'everystack-adopt-'));
|
|
190
|
+
let exitCode: 0 | 1 = 1;
|
|
191
|
+
let end: (() => Promise<void>) | undefined;
|
|
192
|
+
try {
|
|
193
|
+
let runner: QueryRunner;
|
|
194
|
+
let session: SessionRunner;
|
|
195
|
+
if (dbSource.kind === 'url') {
|
|
196
|
+
step(connectingVia(dbSource));
|
|
197
|
+
({ runner, session, end } = await createUrlRunner(dbSource.url));
|
|
198
|
+
} else {
|
|
199
|
+
step('Resolving deployed config...');
|
|
200
|
+
const config = await resolveConfig(flags.stage);
|
|
201
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
202
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
203
|
+
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
204
|
+
}
|
|
205
|
+
step(`Introspecting live database (schema: ${schemas.join(', ')})...`);
|
|
206
|
+
const result = await adoptFromLive({ session, runner, schema, governRoles, sandboxDir, loadBarrel: opts.loadBarrel });
|
|
207
|
+
// Only after a proof do we act. A throw before here (connect, introspect, render, compile)
|
|
208
|
+
// never reaches applyAdoptVerdict, so --out is untouched — a bad baseline is impossible.
|
|
209
|
+
({ exitCode } = await applyAdoptVerdict(result, outDir));
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
fail(err.message);
|
|
212
|
+
if (dbSource.kind === 'url') {
|
|
213
|
+
info('Check the connection string and that the database is accepting connections.');
|
|
214
|
+
} else {
|
|
215
|
+
for (const line of opsAdviceLines(err, [
|
|
216
|
+
'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
|
|
217
|
+
'Adopting a local database instead? Pass --database-url or set DATABASE_URL.',
|
|
218
|
+
])) info(line);
|
|
219
|
+
}
|
|
220
|
+
exitCode = 1;
|
|
221
|
+
} finally {
|
|
222
|
+
// The sandbox is the compile scratch, never the baseline — remove it on EVERY exit path.
|
|
223
|
+
await end?.();
|
|
224
|
+
await fs.rm(sandboxDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
process.exit(exitCode);
|
|
227
|
+
}
|
|
@@ -21,12 +21,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
|
21
21
|
import {
|
|
22
22
|
fingerprintLive,
|
|
23
23
|
fingerprintModels,
|
|
24
|
+
fingerprintState,
|
|
24
25
|
governedRolesForModels,
|
|
26
|
+
divergentSurfaces,
|
|
25
27
|
mapUnfingerprintedRows,
|
|
26
28
|
UNFINGERPRINTED_SQL,
|
|
27
29
|
type UnfingerprintedObject,
|
|
28
30
|
} from '../schema-fingerprint';
|
|
29
|
-
import {
|
|
31
|
+
import { predictLiveState, governedLiveFingerprint } from '../edge-plan';
|
|
30
32
|
import { resolveModelsPath } from '../models-path';
|
|
31
33
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
|
|
32
34
|
import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session';
|
|
@@ -56,6 +58,9 @@ export interface FingerprintStatus {
|
|
|
56
58
|
*/
|
|
57
59
|
predicted?: string;
|
|
58
60
|
match: boolean | null;
|
|
61
|
+
/** On a MISMATCH, the surfaces that diverge (base schema / authorization / sequence ACLs, with
|
|
62
|
+
* the tables named) — so the report says WHERE to look. Absent when MATCH or no models. */
|
|
63
|
+
divergentSurfaces?: string[];
|
|
59
64
|
unfingerprinted: UnfingerprintedObject[];
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -77,8 +82,14 @@ export async function computeFingerprintStatus(
|
|
|
77
82
|
const live = fingerprintLive(snapshot, contract).hash;
|
|
78
83
|
const declared = models ? fingerprintModels(models, { sequences }).hash : null;
|
|
79
84
|
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
// Compute the governed-live and predicted states as full canonical forms, so a MISMATCH can
|
|
86
|
+
// name the diverging surface — not just their hashes.
|
|
87
|
+
const governedLiveState = governedRoles
|
|
88
|
+
? fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants })
|
|
89
|
+
: undefined;
|
|
90
|
+
const governedLive = governedLiveState?.hash;
|
|
91
|
+
const predictedState = models ? predictLiveState(models, snapshot, contract, { governedRoles }) : undefined;
|
|
92
|
+
const predicted = predictedState?.hash;
|
|
82
93
|
// B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
|
|
83
94
|
// unfingerprinted would be a false claim in the one report whose whole job is honesty about
|
|
84
95
|
// coverage. SQL cannot know what the models declare; the filter belongs here.
|
|
@@ -89,12 +100,17 @@ export async function computeFingerprintStatus(
|
|
|
89
100
|
);
|
|
90
101
|
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
|
|
91
102
|
.filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
|
|
103
|
+
const match = declared === null ? null : predicted === governedLive;
|
|
104
|
+
const surfaces = match === false && predictedState && governedLiveState
|
|
105
|
+
? divergentSurfaces(predictedState.canonical, governedLiveState.canonical)
|
|
106
|
+
: undefined;
|
|
92
107
|
return {
|
|
93
108
|
live,
|
|
94
109
|
...(governedLive !== undefined ? { governedLive } : {}),
|
|
95
110
|
declared,
|
|
96
111
|
...(predicted !== undefined ? { predicted } : {}),
|
|
97
|
-
match
|
|
112
|
+
match,
|
|
113
|
+
...(surfaces && surfaces.length ? { divergentSurfaces: surfaces } : {}),
|
|
98
114
|
unfingerprinted,
|
|
99
115
|
};
|
|
100
116
|
}
|
|
@@ -172,7 +188,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
172
188
|
info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
|
|
173
189
|
}
|
|
174
190
|
if (status.match) success('MATCH — the database is the state the models declare.');
|
|
175
|
-
else
|
|
191
|
+
else {
|
|
192
|
+
const where = status.divergentSurfaces?.length ? ` — diverges on ${status.divergentSurfaces.join('; ')}` : '';
|
|
193
|
+
fail(`MISMATCH${where}. Run \`everystack db:generate\` to see the difference as SQL.`);
|
|
194
|
+
}
|
|
176
195
|
}
|
|
177
196
|
if (status.unfingerprinted.length > 0) {
|
|
178
197
|
warn(`unfingerprinted (${status.unfingerprinted.length}) — outside the base fingerprint and the derived manifest:`);
|
|
@@ -106,24 +106,30 @@ export interface ObjectGrantDiff {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* Ownership context for
|
|
109
|
+
* Ownership context for an object's grant diff — passed for EVERY kind (functions AND relations).
|
|
110
110
|
*
|
|
111
|
-
* Owner-implicit
|
|
112
|
-
* correct in isolation — an owner's privileges
|
|
113
|
-
* non-owner grants is computed RELATIVE TO THE OWNER, so
|
|
114
|
-
* produce two different sets for the same
|
|
115
|
-
* that is purely ownership.
|
|
111
|
+
* Owner-implicit privileges are excluded from both grant reads (`grantee <> proowner` for
|
|
112
|
+
* functions, `<> relowner` for relations), which is correct in isolation — an owner's privileges
|
|
113
|
+
* are not grants. But it means the SET of non-owner grants is computed RELATIVE TO THE OWNER, so
|
|
114
|
+
* two sides with different owners produce two different sets for the same object, and the differ
|
|
115
|
+
* reconciles a difference that is purely ownership.
|
|
116
116
|
*
|
|
117
|
-
*
|
|
117
|
+
* First measured on functions, 2026-08-10: a consumer's local `resolve_user_slug` is owned by
|
|
118
118
|
* `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
|
|
119
119
|
* same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
|
|
120
120
|
* The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
|
|
121
121
|
* access removal minted from an ownership difference. They did not apply it.
|
|
122
|
+
*
|
|
123
|
+
* The relation half surfaced 2026-09-04: after `db:provision ADOPT` made `outbound_migrator` own a
|
|
124
|
+
* VIEW the model also lists in its grants, the live read excluded the owner's own rows, so the
|
|
125
|
+
* declared self-grant was minted on every reconcile and never converged. The relation call site
|
|
126
|
+
* used to pass no ownership context on the belief that a relation's grant set does not shift with
|
|
127
|
+
* its owner — true only while relacl is NULL. Both kinds now share this context.
|
|
122
128
|
*/
|
|
123
129
|
export interface GrantOwnerContext {
|
|
124
130
|
/** The owner the DECLARED grant set was computed against, when the models declare one. */
|
|
125
131
|
declared?: string;
|
|
126
|
-
/** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
|
|
132
|
+
/** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner|relowner)`). */
|
|
127
133
|
live?: string;
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -131,8 +137,9 @@ export interface GrantOwnerContext {
|
|
|
131
137
|
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
132
138
|
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
133
139
|
*
|
|
134
|
-
* When `owners` is supplied (functions) and the two sides cannot be
|
|
135
|
-
* computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs
|
|
140
|
+
* When `owners` is supplied (every kind — functions and relations) and the two sides cannot be
|
|
141
|
+
* shown to have been computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs
|
|
142
|
+
* still flow: a
|
|
136
143
|
* redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
|
|
137
144
|
* revoke is the recoverable error and it is never silent.
|
|
138
145
|
*
|
|
@@ -198,10 +205,10 @@ export function diffObjectGrants(
|
|
|
198
205
|
out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
|
|
199
206
|
} else {
|
|
200
207
|
const why = grantee === owners!.live
|
|
201
|
-
? `'${grantee}' is the
|
|
202
|
-
: `'${grantee}' is the
|
|
208
|
+
? `'${grantee}' is the object's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
|
|
209
|
+
: `'${grantee}' is the object's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
|
|
203
210
|
suppressed.push(
|
|
204
|
-
`${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the
|
|
211
|
+
`${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the object's owner to restore revokes.`,
|
|
205
212
|
);
|
|
206
213
|
}
|
|
207
214
|
}
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -482,11 +482,21 @@ export function planReconcile(
|
|
|
482
482
|
);
|
|
483
483
|
} else {
|
|
484
484
|
const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
485
|
+
// Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded
|
|
486
|
+
// from the live read for relations too (`a.grantee <> c.relowner`), so a model that both
|
|
487
|
+
// owns an object and lists that owner in its grants declares a self-grant the read can
|
|
488
|
+
// never confirm — diffObjectGrants normalizes the owner grantee away. The earlier belief
|
|
489
|
+
// that a relation's grant set "does not shift with its owner" holds only while relacl is
|
|
490
|
+
// NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
|
|
491
|
+
// exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
|
|
492
|
+
// grants, looped forever — 2026-09-04.)
|
|
493
|
+
// KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
|
|
494
|
+
// is a failure to ADD access (availability), never to remove it, and unnormalized code did
|
|
495
|
+
// not converge on it either.
|
|
496
|
+
const owners = {
|
|
497
|
+
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
498
|
+
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
499
|
+
};
|
|
490
500
|
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
|
|
491
501
|
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
492
502
|
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract';
|
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate';
|
|
32
32
|
import { compileTableContract } from './authz-compile';
|
|
33
33
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
|
|
34
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
34
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
35
35
|
import { compileDeclaredState } from './declared-diff';
|
|
36
36
|
import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
37
37
|
|
|
@@ -164,6 +164,20 @@ export function predictLiveFingerprint(
|
|
|
164
164
|
contract: AuthzContract,
|
|
165
165
|
opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
|
|
166
166
|
): string {
|
|
167
|
+
return predictLiveState(models, snapshot, contract, opts).hash;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
|
|
172
|
+
* the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
|
|
173
|
+
* surface diverged (base schema, authorization, sequence ACLs) against the live one.
|
|
174
|
+
*/
|
|
175
|
+
export function predictLiveState(
|
|
176
|
+
models: ModelDescriptor[],
|
|
177
|
+
snapshot: SchemaSnapshot,
|
|
178
|
+
contract: AuthzContract,
|
|
179
|
+
opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
|
|
180
|
+
): Fingerprint {
|
|
167
181
|
// The prediction is a DECLARED-vs-live comparison, so it must hash through the
|
|
168
182
|
// models' governed set — the live side of any comparison against it must use
|
|
169
183
|
// the same set (see governedLiveFingerprint). Unfiltered, every ride-through
|
|
@@ -204,7 +218,12 @@ export function predictLiveFingerprint(
|
|
|
204
218
|
...declared.contract.tables,
|
|
205
219
|
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
206
220
|
];
|
|
207
|
-
|
|
221
|
+
// Sequence ACLs (v11): declared tables DERIVE their reconciled (inherited) target, so pass
|
|
222
|
+
// measured ACLs ONLY for rides-through tables — the ones reconciliation never touches, whose
|
|
223
|
+
// live ACLs carry through unchanged. Passing all measured would make a declared table's
|
|
224
|
+
// predicted endpoint read its pre-reconcile ACL and never converge with the plan it mints.
|
|
225
|
+
const ridesThroughSeqGrants = (contract.sequenceGrants ?? []).filter((s) => ridesThrough(s.table));
|
|
226
|
+
return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles, sequenceGrants: ridesThroughSeqGrants });
|
|
208
227
|
}
|
|
209
228
|
|
|
210
229
|
/**
|
|
@@ -218,7 +237,9 @@ export function governedLiveFingerprint(
|
|
|
218
237
|
contract: AuthzContract,
|
|
219
238
|
governedRoles: ReadonlySet<string>,
|
|
220
239
|
): string {
|
|
221
|
-
|
|
240
|
+
// A true live read: measure every table's serial-sequence ACLs (v11), so this half sees a
|
|
241
|
+
// sequence REVOKE the predicted endpoint's derived target does not.
|
|
242
|
+
return fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).hash;
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
/**
|