@everystack/cli 0.4.98 → 0.4.100
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 +42 -0
- package/package.json +13 -3
- package/src/cli/apply-execute.ts +60 -1
- package/src/cli/authz-manifest.ts +93 -0
- package/src/cli/authz-redteam.ts +19 -10
- package/src/cli/backfill.ts +10 -4
- package/src/cli/backup.ts +21 -0
- package/src/cli/catalog-rename.ts +115 -0
- package/src/cli/commands/db-backup.ts +56 -17
- package/src/cli/commands/db-check.ts +16 -3
- package/src/cli/commands/db-fork.ts +12 -4
- package/src/cli/commands/db-generate.ts +7 -3
- package/src/cli/commands/db.ts +3 -2
- package/src/cli/commands/runbook.ts +3 -0
- package/src/cli/connection-fence.ts +351 -0
- package/src/cli/db-source.ts +11 -7
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/derived-apply.ts +20 -5
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +19 -3
- package/src/cli/mutation-lease.ts +5 -0
- package/src/cli/plain-output.ts +32 -10
- package/src/cli/runbook/model-section.ts +5 -2
- package/src/cli/schema-compile.ts +22 -1
- package/src/cli/schema-diff.ts +18 -10
- package/src/cli/schema-introspect.ts +2 -0
- package/src/cli/select-grant-advisory.ts +132 -0
- package/src/cli/stage-backup.ts +57 -16
package/src/cli/model-render.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { generatedIndexName, generatedForeignKeyName } from './schema-compile';
|
|
|
24
24
|
// The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
|
|
25
25
|
// Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
|
|
26
26
|
// the rule is not, so the two surfaces cannot drift on when a declaration is required.
|
|
27
|
-
import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
|
|
27
|
+
import { needsSoftDeleteDeclaration, privilegesGrantDelete, positionalCheckName } from '@everystack/model';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
@@ -599,8 +599,18 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
599
599
|
for (const u of table.uniques) {
|
|
600
600
|
if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
|
|
601
601
|
}
|
|
602
|
+
// R9 — a check keeps the name the database has. Said only when it is NOT the positional name
|
|
603
|
+
// the compiler would give it: position counts only the checks rendered bare, exactly as the
|
|
604
|
+
// compiler counts only unnamed ones, so every pulled check compiles back to its live name.
|
|
605
|
+
let position = 0;
|
|
602
606
|
for (const ck of checks) {
|
|
603
|
-
|
|
607
|
+
const predicate = `sql\`${normalizeCheck(ck.expr)}\``;
|
|
608
|
+
if (ck.name === positionalCheckName(bareName(table.table), position)) {
|
|
609
|
+
items.push(`check(${predicate})`);
|
|
610
|
+
position++;
|
|
611
|
+
} else {
|
|
612
|
+
items.push(`check(${predicate}, { name: ${tsLiteral(ck.name)} })`);
|
|
613
|
+
}
|
|
604
614
|
}
|
|
605
615
|
for (const ix of table.indexes) {
|
|
606
616
|
// Plain entries render as field keys; canonical-text entries (expressions, DESC,
|
|
@@ -655,9 +665,15 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
655
665
|
const validates = new Map<string, string>();
|
|
656
666
|
const columnNames = new Set(table.columns.map((c) => c.name));
|
|
657
667
|
const tableChecks: CheckConstraint[] = [];
|
|
668
|
+
// A `.validate()` compiles to a CHECK named `<table>_<column>_check`. When a DIFFERENT live
|
|
669
|
+
// check already holds that name, folding this one into the field would give the model two
|
|
670
|
+
// constraints under one name, so it stays a table-level check and keeps its own.
|
|
671
|
+
const liveCheckNames = new Set(table.checks.map((ck) => ck.name));
|
|
658
672
|
for (const ck of table.checks) {
|
|
659
673
|
const v = checkToValidate(ck.expr);
|
|
660
|
-
|
|
674
|
+
const fieldCheckName = v ? `${bareName(table.table)}_${v.column}_check` : '';
|
|
675
|
+
const nameFree = ck.name === fieldCheckName || !liveCheckNames.has(fieldCheckName);
|
|
676
|
+
if (v && columnNames.has(v.column) && !validates.has(v.column) && nameFree) validates.set(v.column, v.zod);
|
|
661
677
|
else tableChecks.push(ck);
|
|
662
678
|
}
|
|
663
679
|
|
|
@@ -179,6 +179,11 @@ export function leaseSession(): string {
|
|
|
179
179
|
* write of the verb. A verb that opens a SECOND connection mid-body has a writer the lease
|
|
180
180
|
* does not cover.
|
|
181
181
|
*
|
|
182
|
+
* That connection must fail CLOSED when its backend dies: hand this a fenced runner
|
|
183
|
+
* (connection-fence.ts — `createUrlRunner` and the server's mutation connection both are).
|
|
184
|
+
* An unfenced postgres.js pool reconnects onto a new backend after a loss, so the rest of the
|
|
185
|
+
* body would run without the lease; the lease's own release below would too.
|
|
186
|
+
*
|
|
182
187
|
* HONEST LIMITATION (do not overstate): this is CONVENTIONALLY enforced, not runtime-enforced.
|
|
183
188
|
* A PostgreSQL connection carries no read/write intent the connection layer can inspect, so
|
|
184
189
|
* "reject a second connection" is unrealizable in-process. The counterexample is already in the
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -77,6 +77,16 @@ function head(statement: string): string {
|
|
|
77
77
|
return body === '' ? statement.replace(/\s+/g, ' ').trim() : body;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Match report marks by the whole statement with ONLY outer whitespace removed, on both sides.
|
|
82
|
+
* `head` is a description, not an identity: collapsing whitespace can change literals and
|
|
83
|
+
* function bodies. Internal formatting, comments, case and terminators must still match exactly.
|
|
84
|
+
* This key never replaces the original SQL passed to descriptions or returned to callers.
|
|
85
|
+
*/
|
|
86
|
+
function statementKey(statement: string): string {
|
|
87
|
+
return statement.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
/** The whole statement as one line, comments stripped and truncated — for the fallback description. */
|
|
81
91
|
function oneLine(statement: string): string {
|
|
82
92
|
const body = head(statement).replace(/;$/, '');
|
|
@@ -339,7 +349,8 @@ function objectKey(s: string): { verb: 'drop' | 'create'; key: string } | null {
|
|
|
339
349
|
* definition edit. So the drop is removed and its create is marked `rebuilt`, which renders as
|
|
340
350
|
* the update line it always was.
|
|
341
351
|
*
|
|
342
|
-
* Statements this does not recognise pass through untouched, in order.
|
|
352
|
+
* Statements this does not recognise pass through untouched, in order. Returned statements keep
|
|
353
|
+
* their original text; `rebuilt` holds statementKey values (outer whitespace removed).
|
|
343
354
|
*/
|
|
344
355
|
export function collapseRebuilds(statements: readonly string[]): {
|
|
345
356
|
statements: string[];
|
|
@@ -355,7 +366,7 @@ export function collapseRebuilds(statements: readonly string[]): {
|
|
|
355
366
|
for (const s of statements) {
|
|
356
367
|
const k = objectKey(head(s));
|
|
357
368
|
if (k?.verb === 'drop' && created.has(k.key)) {
|
|
358
|
-
rebuilt.add(created.get(k.key)!);
|
|
369
|
+
rebuilt.add(statementKey(created.get(k.key)!));
|
|
359
370
|
continue;
|
|
360
371
|
}
|
|
361
372
|
kept.push(s);
|
|
@@ -378,8 +389,9 @@ const plural = (n: number, one: string, many: string): string => (n === 1 ? one
|
|
|
378
389
|
|
|
379
390
|
/** One change line, indented, already carrying its mark. */
|
|
380
391
|
function changeLine(statement: string, destructive: ReadonlySet<string>, rebuilt?: ReadonlySet<string>): string {
|
|
381
|
-
|
|
382
|
-
|
|
392
|
+
const key = statementKey(statement);
|
|
393
|
+
if (destructive.has(key)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
|
|
394
|
+
const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(key) === true });
|
|
383
395
|
return ` ${symbol} ${text}`;
|
|
384
396
|
}
|
|
385
397
|
|
|
@@ -398,7 +410,7 @@ function changeLines(
|
|
|
398
410
|
export interface PlanReportInput {
|
|
399
411
|
/** The real DDL the plan will run — notices and held drops are not changes. */
|
|
400
412
|
executable: readonly string[];
|
|
401
|
-
/** The
|
|
413
|
+
/** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
|
|
402
414
|
destructive: readonly string[];
|
|
403
415
|
/** What the plan is aimed at, in the reader's words: a stage name, or `local`. */
|
|
404
416
|
target: string;
|
|
@@ -415,7 +427,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
|
|
|
415
427
|
const { executable, target, stage, planPath, verbose = false } = input;
|
|
416
428
|
if (executable.length === 0) return ['Up to date. Nothing to plan.'];
|
|
417
429
|
|
|
418
|
-
const destructive = new Set(input.destructive);
|
|
430
|
+
const destructive = new Set(input.destructive.map(statementKey));
|
|
419
431
|
const lines: string[] = [
|
|
420
432
|
`Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
|
|
421
433
|
'',
|
|
@@ -440,6 +452,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
|
|
|
440
452
|
|
|
441
453
|
export interface ApplyReceiptInput {
|
|
442
454
|
executable: readonly string[];
|
|
455
|
+
/** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
|
|
443
456
|
destructive: readonly string[];
|
|
444
457
|
/** The target was already where the plan ends — an idempotent re-apply. */
|
|
445
458
|
alreadyApplied?: boolean;
|
|
@@ -461,7 +474,7 @@ export function renderApplyReceipt(input: ApplyReceiptInput): string[] {
|
|
|
461
474
|
const n = executable.length;
|
|
462
475
|
const tally = `Applied ${n} ${plural(n, 'change', 'changes')}.`;
|
|
463
476
|
if (n === 0 || n > MAX_RECEIPT_CHANGES) return [tally];
|
|
464
|
-
return [...changeLines(executable, new Set(input.destructive), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
|
|
477
|
+
return [...changeLines(executable, new Set(input.destructive.map(statementKey)), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
|
|
465
478
|
}
|
|
466
479
|
|
|
467
480
|
export interface CatchUpFailureInput {
|
|
@@ -529,7 +542,16 @@ function sentence(text: string): string {
|
|
|
529
542
|
}
|
|
530
543
|
|
|
531
544
|
/** db:check's default output: one line, always. */
|
|
532
|
-
export function renderCheckVerdict(pending: number): string[] {
|
|
533
|
-
if (
|
|
534
|
-
|
|
545
|
+
export function renderCheckVerdict(pending: number, warnings = 0): string[] {
|
|
546
|
+
if (warnings === 0) {
|
|
547
|
+
if (pending === 0) return ['Up to date.'];
|
|
548
|
+
return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
|
|
549
|
+
}
|
|
550
|
+
// Warnings never fail the gate, but a clean-looking line would hide them.
|
|
551
|
+
const warned = `${warnings} ${plural(warnings, 'warning', 'warnings')}`;
|
|
552
|
+
const readThem = `db:check --verbose to read ${warnings === 1 ? 'it' : 'them'}`;
|
|
553
|
+
if (pending === 0) return [`Up to date, ${warned}. Run ${readThem}.`];
|
|
554
|
+
return [
|
|
555
|
+
`${pending} ${plural(pending, 'change', 'changes')} pending, ${warned}. Run db:plan to review, db:check --verbose to read the ${plural(warnings, 'warning', 'warnings')}.`,
|
|
556
|
+
];
|
|
535
557
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* have — and an ability change shows up as runbook drift (`runbook --check`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { ModelLinkError } from '@everystack/model';
|
|
10
11
|
import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
|
|
11
12
|
|
|
12
13
|
function typeLabel(spec: FieldSpec): string {
|
|
@@ -35,7 +36,8 @@ function referenceTarget(spec: FieldSpec): string | null {
|
|
|
35
36
|
try {
|
|
36
37
|
const target = spec.references() as { table?: string } | undefined;
|
|
37
38
|
return target?.table ?? 'unknown';
|
|
38
|
-
} catch {
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err instanceof ModelLinkError) throw err;
|
|
39
41
|
return 'unknown';
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -73,7 +75,8 @@ function relationLine(name: string, rel: Relation): string {
|
|
|
73
75
|
let target = 'unknown';
|
|
74
76
|
try {
|
|
75
77
|
target = rel.target().table;
|
|
76
|
-
} catch {
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof ModelLinkError) throw err;
|
|
77
80
|
/* forward reference that can't resolve here — label stays unknown */
|
|
78
81
|
}
|
|
79
82
|
const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
23
|
+
import { positionalCheckName } from '@everystack/model';
|
|
23
24
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
|
|
24
25
|
import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
|
|
25
26
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
@@ -59,6 +60,13 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
59
60
|
const indexes: IndexSchema[] = [];
|
|
60
61
|
let checkIndex = 0;
|
|
61
62
|
const usedIndexNames = new Set<string>();
|
|
63
|
+
// A field's `.validate()` CHECK is named `<table>_<column>_check`; a declared table check may
|
|
64
|
+
// not take that name, or CREATE TABLE fails with two constraints under one name.
|
|
65
|
+
const fieldCheckNames = new Map<string, string>();
|
|
66
|
+
for (const [key, field] of Object.entries(model.fields)) {
|
|
67
|
+
const col = toSnakeCase(key);
|
|
68
|
+
if (fieldCheckPredicate(col, field.spec)) fieldCheckNames.set(`${model.table}_${col}_check`, key);
|
|
69
|
+
}
|
|
62
70
|
for (const con of model.constraints) {
|
|
63
71
|
if (con.kind === 'unique') {
|
|
64
72
|
const cols = con.columns.map(toSnakeCase);
|
|
@@ -67,7 +75,20 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
67
75
|
// Normalized like the live producer (schema-introspect): a model that transcribes a
|
|
68
76
|
// pulled predicate must compare equal to the database it came from, whichever of the
|
|
69
77
|
// two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
|
|
70
|
-
|
|
78
|
+
//
|
|
79
|
+
// A DECLARED name is used verbatim and takes no position, so naming one check never
|
|
80
|
+
// renames its unnamed siblings; defineModel already refused duplicates and collisions
|
|
81
|
+
// with a positional name.
|
|
82
|
+
if (con.name !== undefined && fieldCheckNames.has(con.name)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`model '${model.table}': check(sql\`${con.predicate}\`) is named '${con.name}', the name of the CHECK that field '${fieldCheckNames.get(con.name)}'.validate() compiles to. Choose another name.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
checks.push({
|
|
88
|
+
name: con.name ?? positionalCheckName(model.table, checkIndex++),
|
|
89
|
+
expr: normalizeDeparsedExpr(con.predicate),
|
|
90
|
+
...(con.name !== undefined ? { nameDeclared: true } : {}),
|
|
91
|
+
});
|
|
71
92
|
} else if (con.kind === 'index') {
|
|
72
93
|
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
73
94
|
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
IndexSchema,
|
|
34
34
|
SequenceSchema,
|
|
35
35
|
} from './schema-introspect';
|
|
36
|
+
import { renderConstraintRename, renderIndexRename } from './catalog-rename';
|
|
36
37
|
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// The change set.
|
|
@@ -849,8 +850,12 @@ export function foreignKeyContentKey(
|
|
|
849
850
|
* same predicate is the same check no matter what it's called, so a pulled check (whose DB
|
|
850
851
|
* name we didn't choose) re-generates clean. A changed predicate drops the old (by its real
|
|
851
852
|
* name) and adds the new.
|
|
853
|
+
*
|
|
854
|
+
* R9, the CHECK half of B3/A8: a predicate match whose DECLARED name disagrees with the live
|
|
855
|
+
* one is the same check under another name — `RENAME CONSTRAINT`, catalog-only, no re-scan.
|
|
856
|
+
* An undeclared (positional) name never renames the database.
|
|
852
857
|
*/
|
|
853
|
-
function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
|
|
858
|
+
function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], renames: SchemaChange[]): void {
|
|
854
859
|
const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
|
|
855
860
|
const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
|
|
856
861
|
// Paren-blind lookup, diagnostic only (see diffIndexes): a compound CHECK churns for the
|
|
@@ -860,7 +865,11 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
|
|
|
860
865
|
if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
|
|
861
866
|
}
|
|
862
867
|
for (const [pred, ck] of desiredByPred) {
|
|
863
|
-
|
|
868
|
+
const live = currentByPred.get(pred);
|
|
869
|
+
if (live) {
|
|
870
|
+
if (ck.nameDeclared && live.name !== ck.name) renames.push({ kind: 'renameConstraint', table: d.table, from: live.name, to: ck.name });
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
864
873
|
const twin = currentByParenBlind.get(stripAllParens(ck.expr));
|
|
865
874
|
// Annotate only a twin that is itself being dropped (no exact desired match) — a real churn.
|
|
866
875
|
const churning = twin && !desiredByPred.has(normalizeCheck(twin.expr));
|
|
@@ -921,7 +930,7 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
|
|
|
921
930
|
for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
|
|
922
931
|
}
|
|
923
932
|
|
|
924
|
-
diffChecks(d, c, dropConstraints, addConstraints);
|
|
933
|
+
diffChecks(d, c, dropConstraints, addConstraints, renames);
|
|
925
934
|
|
|
926
935
|
// Primary key: a first-time add is safe; any other change needs the existing
|
|
927
936
|
// constraint's name to drop it (which the snapshot discards), so it is surfaced.
|
|
@@ -1170,18 +1179,17 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
|
|
|
1170
1179
|
}
|
|
1171
1180
|
case 'renameConstraint':
|
|
1172
1181
|
// Catalog-only and instant — the constraint and its backing index keep their identity.
|
|
1173
|
-
|
|
1182
|
+
// Rendered beside its parser: db:apply reads this exact text back (catalog-rename).
|
|
1183
|
+
return renderConstraintRename(change);
|
|
1174
1184
|
case 'redefineIndex': {
|
|
1175
1185
|
// Same statement as a removal; a DIFFERENT decision, which is why it is a different kind.
|
|
1176
1186
|
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|
|
1177
1187
|
return `DROP INDEX ${schema}${quote(change.name)};`;
|
|
1178
1188
|
}
|
|
1179
|
-
case 'renameIndex':
|
|
1180
|
-
// Non-destructive and instant — a catalog rename, no rebuild.
|
|
1181
|
-
//
|
|
1182
|
-
|
|
1183
|
-
return `ALTER INDEX ${schema}${quote(change.from)} RENAME TO ${quote(change.to)};`;
|
|
1184
|
-
}
|
|
1189
|
+
case 'renameIndex':
|
|
1190
|
+
// Non-destructive and instant — a catalog rename, no rebuild. Rendered beside its parser:
|
|
1191
|
+
// db:apply reads this exact text back (catalog-rename).
|
|
1192
|
+
return renderIndexRename(change);
|
|
1185
1193
|
case 'enumValuesChanged':
|
|
1186
1194
|
return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
|
|
1187
1195
|
case 'createSequence':
|
|
@@ -88,6 +88,8 @@ export interface CheckConstraint {
|
|
|
88
88
|
name: string;
|
|
89
89
|
/** The predicate text, as `pg_get_constraintdef` deparses it. */
|
|
90
90
|
expr: string;
|
|
91
|
+
/** The name was DECLARED (`check(sql, { name })`), not positional — same rule as {@link IndexSchema.nameDeclared}. */
|
|
92
|
+
nameDeclared?: boolean;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role that can write a table but holds no SELECT grant on it.
|
|
3
|
+
*
|
|
4
|
+
* `can('update', { role: 'moderator' })` alone compiles `GRANT UPDATE` and an UPDATE policy for
|
|
5
|
+
* moderator, but no SELECT grant. PostgreSQL needs SELECT to read the columns a WHERE or RETURNING
|
|
6
|
+
* names, so every `UPDATE t ... WHERE id = $1` by moderator fails with "permission denied for
|
|
7
|
+
* table t". The ability reads as working and is dead. The same holds for DELETE ... WHERE and
|
|
8
|
+
* INSERT ... RETURNING (measured: __tests__/integration/select-grant-advisory.test.ts).
|
|
9
|
+
*
|
|
10
|
+
* The rule reads the COMPILED grants (compileTableContract), not the abilities, so it judges what
|
|
11
|
+
* the database will hold: `manage` carries its SELECT, a bare `can('read')` covers anon and
|
|
12
|
+
* authenticated only, an owner read is authenticated's table SELECT, and a column-scoped SELECT
|
|
13
|
+
* counts. Two exceptions: a role with any ability carrying a raw `sql:` condition is skipped (a
|
|
14
|
+
* hand-written predicate is outside what this rule judges), and a write granted only through the
|
|
15
|
+
* model's `privileges:` is left to findNakedGrants, which owns that case.
|
|
16
|
+
*
|
|
17
|
+
* The rule cannot see role membership: a role that inherits SELECT from another role it is a
|
|
18
|
+
* member of is still reported, since no model declares memberships.
|
|
19
|
+
*
|
|
20
|
+
* The advice is a read SCOPED to the rows the write touches, never broader than the write: for an
|
|
21
|
+
* owner write a role-wide `can('read', { role })` would be a USING (true) policy for every
|
|
22
|
+
* signed-in user. A role-wide read is offered only for a role-wide write.
|
|
23
|
+
*
|
|
24
|
+
* Known limits: a table with `rls: false` whose `privileges:` grant a write but no SELECT is
|
|
25
|
+
* skipped here (findNakedGrants owns `privileges:` grants and does not name the missing SELECT);
|
|
26
|
+
* and a write scoped by `where:` alone gets the generic advice, which can be wider than the write.
|
|
27
|
+
*
|
|
28
|
+
* Advisory only — db:check reports it as a warning and never fails on it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Ability, ModelDescriptor } from '@everystack/model';
|
|
32
|
+
import { compileTableContract } from './authz-compile';
|
|
33
|
+
import type { TableContract } from './authz-contract';
|
|
34
|
+
|
|
35
|
+
export type WriteVerb = 'create' | 'delete' | 'update';
|
|
36
|
+
|
|
37
|
+
export interface SelectGrantGap {
|
|
38
|
+
/** `schema.table`. */
|
|
39
|
+
table: string;
|
|
40
|
+
role: string;
|
|
41
|
+
/** The ability verb whose grant is unusable without SELECT. */
|
|
42
|
+
verb: WriteVerb;
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Ability verb -> the privilege it compiles to, in report order. */
|
|
47
|
+
const WRITE_PRIVILEGES: ReadonlyArray<[WriteVerb, string]> = [
|
|
48
|
+
['create', 'INSERT'],
|
|
49
|
+
['delete', 'DELETE'],
|
|
50
|
+
['update', 'UPDATE'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** The statement shape that needs SELECT, per privilege. INSERT has no WHERE. */
|
|
54
|
+
const NEEDS_SELECT: Record<string, string> = {
|
|
55
|
+
INSERT: 'INSERT ... RETURNING',
|
|
56
|
+
DELETE: 'DELETE ... WHERE/RETURNING',
|
|
57
|
+
UPDATE: 'UPDATE ... WHERE/RETURNING',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Does the role hold this privilege on the table, table-wide or on any column? */
|
|
61
|
+
function holds(contract: TableContract, role: string, privilege: string): boolean {
|
|
62
|
+
return (contract.grants[role] ?? []).includes(privilege)
|
|
63
|
+
|| Boolean(contract.columnGrants?.[role]?.[privilege]?.length);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The grantee an ability lands on — what compileGrants keys it under. */
|
|
67
|
+
function granteeOf(role: string | undefined): string {
|
|
68
|
+
if (!role) return 'authenticated';
|
|
69
|
+
return role.toLowerCase() === 'public' ? 'PUBLIC' : role;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The table privileges an ability compiles to (`manage` is all four). */
|
|
73
|
+
function privilegesOf(action: Ability['action']): string[] {
|
|
74
|
+
switch (action) {
|
|
75
|
+
case 'manage': return ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
76
|
+
case 'read': return ['SELECT'];
|
|
77
|
+
case 'create': return ['INSERT'];
|
|
78
|
+
case 'update': return ['UPDATE'];
|
|
79
|
+
case 'delete': return ['DELETE'];
|
|
80
|
+
default: return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The abilities whose grant puts `privilege` on `role` — none means it came from `privileges:`. */
|
|
85
|
+
function sources(model: ModelDescriptor, role: string, privilege: string): Ability[] {
|
|
86
|
+
return model.abilities.filter((a) => granteeOf(a.condition.role) === role && privilegesOf(a.action).includes(privilege));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The read to suggest: scoped like the write when the write is owner/via-scoped, else generic. */
|
|
90
|
+
function remedy(role: string, from: readonly Ability[]): string {
|
|
91
|
+
const scoped = from.find((a) => a.condition.owner || a.condition.via);
|
|
92
|
+
const lead = `Add a read ability for ${role} scoped to the rows it may touch`;
|
|
93
|
+
if (!scoped) {
|
|
94
|
+
return `${lead} (for example can('read', { owner: '<column>' }) for an owner write, `
|
|
95
|
+
+ `or can('read', { role: '${role}' }) for a role-wide one).`;
|
|
96
|
+
}
|
|
97
|
+
const keys = [
|
|
98
|
+
...(scoped.condition.role ? [`role: '${scoped.condition.role}'`] : []),
|
|
99
|
+
scoped.condition.owner ? `owner: '${scoped.condition.owner}'` : `via: '${scoped.condition.via}'`,
|
|
100
|
+
];
|
|
101
|
+
return `${lead}, for example can('read', { ${keys.join(', ')} }).`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function findSelectGrantGaps(models: readonly ModelDescriptor[]): SelectGrantGap[] {
|
|
105
|
+
const out: SelectGrantGap[] = [];
|
|
106
|
+
for (const model of models) {
|
|
107
|
+
const contract = compileTableContract(model);
|
|
108
|
+
const suppressed = new Set(
|
|
109
|
+
model.abilities.filter((a) => a.condition.sql != null).map((a) => granteeOf(a.condition.role)),
|
|
110
|
+
);
|
|
111
|
+
const roles = new Set([...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]);
|
|
112
|
+
for (const role of [...roles].sort()) {
|
|
113
|
+
if (suppressed.has(role)) continue;
|
|
114
|
+
// A PUBLIC SELECT reaches every role, so it satisfies any of them.
|
|
115
|
+
if (holds(contract, role, 'SELECT') || holds(contract, 'PUBLIC', 'SELECT')) continue;
|
|
116
|
+
for (const [verb, privilege] of WRITE_PRIVILEGES) {
|
|
117
|
+
if (!holds(contract, role, privilege)) continue;
|
|
118
|
+
const from = sources(model, role, privilege);
|
|
119
|
+
if (from.length === 0) continue;
|
|
120
|
+
out.push({
|
|
121
|
+
table: contract.table,
|
|
122
|
+
role,
|
|
123
|
+
verb,
|
|
124
|
+
message: `${contract.table}: ${role} can ${privilege} but has no SELECT grant; `
|
|
125
|
+
+ `${NEEDS_SELECT[privilege]} by ${role} fails with "permission denied for table ${model.table}". `
|
|
126
|
+
+ remedy(role, from),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
package/src/cli/stage-backup.ts
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
|
|
25
25
|
import { invokeAction, lambdaSessionRunner } from './aws';
|
|
26
26
|
import { pollTaskUntilStopped } from './task-poll';
|
|
27
|
+
import type { BackupLiveRead } from './apply-execute';
|
|
28
|
+
import type { AuthzManifest } from './authz-manifest';
|
|
27
29
|
|
|
28
30
|
/** The verdict on a completed dump's fingerprint pair. Pure — the heart of the sandwich. */
|
|
29
31
|
export type SandwichVerdict = { ok: true } | { ok: false; reason: string };
|
|
@@ -46,10 +48,14 @@ export function judgeSandwich(fpBefore: string, fpAfter: string): SandwichVerdic
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export interface StageBackupDeps {
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Read the live base-schema fingerprint and the target's origin commit, in one session. Called
|
|
53
|
+
* twice, bracketing the dump; the sandwich judges the fingerprints, and the OPENING read is what
|
|
54
|
+
* the dispatch carries.
|
|
55
|
+
*/
|
|
56
|
+
readLive: () => Promise<BackupLiveRead>;
|
|
57
|
+
/** Dispatch `db:backup` with the operator-computed fingerprint and origin commit. */
|
|
58
|
+
dispatch: (live: BackupLiveRead) => Promise<{ runId: string; taskArn: string; id: string; key: string; warning?: string }>;
|
|
53
59
|
/**
|
|
54
60
|
* Wait for the task to stop; resolve ok, or a human reason it did not.
|
|
55
61
|
*
|
|
@@ -59,6 +65,13 @@ export interface StageBackupDeps {
|
|
|
59
65
|
awaitTask: (ids: { runId: string; taskArn: string; id: string }) => Promise<{ ok: true; bytes?: number; stampedFingerprint?: string } | { ok: false; reason: string }>;
|
|
60
66
|
/** Delete a retracted artifact (the ops Lambda's db:backup:retract). */
|
|
61
67
|
retract: (key: string) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Stamp the declared-authz manifest onto the finished backup (the ops Lambda's
|
|
70
|
+
* db:backup:stamp-authz). Optional: only a caller that rendered a manifest supplies it. Runs only
|
|
71
|
+
* on a PROVEN backup, and its failure is a warning, never a failed backup — the dump is valid, the
|
|
72
|
+
* manifest is metadata layered on top, and a restore treats a missing one as "no manifest".
|
|
73
|
+
*/
|
|
74
|
+
stampAuthz?: (key: string) => Promise<void>;
|
|
62
75
|
/** Progress output. Optional so tests stay quiet. */
|
|
63
76
|
onStep?: (message: string) => void;
|
|
64
77
|
}
|
|
@@ -78,18 +91,19 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
|
|
|
78
91
|
const step = deps.onStep ?? (() => {});
|
|
79
92
|
|
|
80
93
|
step('Reading the live schema fingerprint (before the dump)...');
|
|
81
|
-
let
|
|
94
|
+
let before: BackupLiveRead;
|
|
82
95
|
try {
|
|
83
|
-
|
|
96
|
+
before = await deps.readLive();
|
|
84
97
|
} catch (err: any) {
|
|
85
98
|
// Refuse BEFORE dispatching: a dump whose fingerprint cannot be proven is not a safety net, and
|
|
86
99
|
// taking one anyway would burn the dump window and still leave nothing trustworthy.
|
|
87
100
|
return { ok: false, reason: `the schema fingerprint could not be read, so no backup was dispatched: ${err?.message ?? String(err)}` };
|
|
88
101
|
}
|
|
102
|
+
const fpBefore = before.fingerprint;
|
|
89
103
|
|
|
90
104
|
let dispatched: { runId: string; taskArn: string; id: string; key: string; warning?: string };
|
|
91
105
|
try {
|
|
92
|
-
dispatched = await deps.dispatch(
|
|
106
|
+
dispatched = await deps.dispatch(before);
|
|
93
107
|
} catch (err: any) {
|
|
94
108
|
return { ok: false, reason: `the backup would not dispatch: ${err?.message ?? String(err)}` };
|
|
95
109
|
}
|
|
@@ -127,7 +141,7 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
|
|
|
127
141
|
step('Re-reading the schema fingerprint (after the dump)...');
|
|
128
142
|
let fpAfter: string;
|
|
129
143
|
try {
|
|
130
|
-
fpAfter = await deps.
|
|
144
|
+
fpAfter = (await deps.readLive()).fingerprint;
|
|
131
145
|
} catch (err: any) {
|
|
132
146
|
// The dump exists but is unproven. Do NOT retract — an unreadable fingerprint is not evidence
|
|
133
147
|
// the schema moved, and silently destroying a good backup is the worse error.
|
|
@@ -146,7 +160,16 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
|
|
|
146
160
|
const unverified = finished.stampedFingerprint === undefined
|
|
147
161
|
? 'the task did not report the fingerprint it stamped, so the artifact could not be verified against what the operator sent (the result-ledger write is best-effort)'
|
|
148
162
|
: undefined;
|
|
149
|
-
|
|
163
|
+
let unstamped: string | undefined;
|
|
164
|
+
if (deps.stampAuthz) {
|
|
165
|
+
step('Recording the declared authz manifest on the backup...');
|
|
166
|
+
try {
|
|
167
|
+
await deps.stampAuthz(dispatched.key);
|
|
168
|
+
} catch (err: any) {
|
|
169
|
+
unstamped = `the backup is valid, but its authz manifest was NOT recorded (${err?.message ?? String(err)}) — a restore from it cannot converge authz from the manifest; take another backup if you need one`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const warning = [dispatched.warning, unverified, unstamped].filter(Boolean).join('; ') || undefined;
|
|
150
173
|
return { ok: true, id: dispatched.id, key: dispatched.key, bytes: finished.bytes, fingerprint: fpBefore, warning };
|
|
151
174
|
}
|
|
152
175
|
|
|
@@ -178,22 +201,31 @@ export function stageBackupDeps(
|
|
|
178
201
|
config: { region: string },
|
|
179
202
|
opsFn: string,
|
|
180
203
|
stage: string | undefined,
|
|
181
|
-
hooks: {
|
|
204
|
+
hooks: {
|
|
205
|
+
onStep?: (message: string) => void;
|
|
206
|
+
onDispatch?: (d: { runId: string; taskArn: string; id: string; warning?: string }) => void;
|
|
207
|
+
/** The declared-authz manifest to stamp onto the finished backup (db:backup renders it). */
|
|
208
|
+
authzManifest?: AuthzManifest;
|
|
209
|
+
} = {},
|
|
182
210
|
): StageBackupDeps {
|
|
211
|
+
const { authzManifest } = hooks;
|
|
183
212
|
return {
|
|
184
213
|
// BOTH ends of the sandwich read over this ONE closure, so they cannot land on different lanes.
|
|
185
214
|
// That matters: a before-read over a direct session and an after-read over the two-invoke
|
|
186
215
|
// ops-Lambda blend could manufacture a false mismatch — or, worse, a false match. The symmetry
|
|
187
216
|
// is structural here rather than asserted; if a caller ever needs to override one end, it has to
|
|
188
217
|
// override both.
|
|
189
|
-
|
|
190
|
-
// The SAME
|
|
191
|
-
|
|
192
|
-
|
|
218
|
+
readLive: async () => {
|
|
219
|
+
// The SAME one-session read db:apply's concurrency lock and descent proof use — the
|
|
220
|
+
// fingerprint and the ledger anchor come from one snapshot, one round trip.
|
|
221
|
+
const { readBackupLive } = await import('./apply-execute');
|
|
222
|
+
return readBackupLive(lambdaSessionRunner(config.region, opsFn));
|
|
193
223
|
},
|
|
194
|
-
dispatch: async (fingerprint) => {
|
|
224
|
+
dispatch: async ({ fingerprint, originCommit }) => {
|
|
225
|
+
// `originCommit` is sent as read: a string, `null` (proven none), or left out when the ledger
|
|
226
|
+
// read failed (JSON drops undefined), so the artifact never claims what the read did not prove.
|
|
195
227
|
const dispatched: any = await invokeAction(config.region, opsFn, 'db:backup', {
|
|
196
|
-
stage, actor: process.env.USER ?? null, fingerprint,
|
|
228
|
+
stage, actor: process.env.USER ?? null, fingerprint, originCommit,
|
|
197
229
|
});
|
|
198
230
|
if (dispatched?.error) throw new Error(dispatched.error);
|
|
199
231
|
hooks.onDispatch?.(dispatched);
|
|
@@ -224,6 +256,15 @@ export function stageBackupDeps(
|
|
|
224
256
|
if (res?.error) throw new Error(res.error);
|
|
225
257
|
if (!res?.retracted) throw new Error(`the ops function did not confirm the retraction (returned ${JSON.stringify(res)})`);
|
|
226
258
|
},
|
|
259
|
+
// Sent AFTER the dump, never in the dispatch: the task job is one ECS env override capped at
|
|
260
|
+
// 8192 chars, which a real manifest outgrows. Scoped server-side like the retraction.
|
|
261
|
+
...(authzManifest ? {
|
|
262
|
+
stampAuthz: async (key: string) => {
|
|
263
|
+
const res: any = await invokeAction(config.region, opsFn, 'db:backup:stamp-authz', { key, stage, authzManifest });
|
|
264
|
+
if (res?.error) throw new Error(res.error);
|
|
265
|
+
if (!res?.stamped) throw new Error(`the ops function did not confirm the stamp (returned ${JSON.stringify(res)}) — deploy a server build that ships db:backup:stamp-authz`);
|
|
266
|
+
},
|
|
267
|
+
} : {}),
|
|
227
268
|
onStep: hooks.onStep,
|
|
228
269
|
};
|
|
229
270
|
}
|