@everystack/cli 0.4.73 → 0.4.75
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 +238 -0
- package/src/cli/commands/db-fingerprint.ts +24 -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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderLiveModelFiles — the render db:pull and db:adopt SHARE.
|
|
3
|
+
*
|
|
4
|
+
* Both commands turn a live database into `field()` Models with the same posture: derive each
|
|
5
|
+
* model's authz from the grants and policies already there (`abilities: 'live'`), render the
|
|
6
|
+
* derived layer from the live catalog, and write the directory shape (one file per model + an
|
|
7
|
+
* index barrel). Factoring it here is not only DRY — it is the round-trip's correctness. db:adopt
|
|
8
|
+
* PROVES the rendered Models declare the live state; if adopt rendered even slightly differently
|
|
9
|
+
* from pull, the proof could refuse on a cosmetic difference pull produces fine. One renderer,
|
|
10
|
+
* one result.
|
|
11
|
+
*
|
|
12
|
+
* Pure: introspection results in, rendered files out. The command shells do the IO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SchemaSnapshot } from './schema-introspect';
|
|
16
|
+
import type { AuthzContract } from './authz-contract';
|
|
17
|
+
import type { DerivedCatalog } from './derived-introspect';
|
|
18
|
+
import { renderModelFiles, pullableTables, modelVarName, type RenderedModelFile } from './model-render';
|
|
19
|
+
import { renderDerivedSource } from './derived-render';
|
|
20
|
+
import { fingerprintLive } from './schema-fingerprint';
|
|
21
|
+
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from './authz-reconcile';
|
|
22
|
+
import { deriveAbilities } from './authz-derive';
|
|
23
|
+
|
|
24
|
+
export interface LiveModelRender {
|
|
25
|
+
/** The directory shape: one file per model + `index.ts` (the barrel that exports `models`). */
|
|
26
|
+
files: RenderedModelFile[];
|
|
27
|
+
/** The RAW live fingerprint — the baseline id db:adopt names the commit by. */
|
|
28
|
+
fingerprint: string;
|
|
29
|
+
/** Foreign grantees present at pull, exempt from revocation (the adoption baseline observation). */
|
|
30
|
+
exemptions: GrantExemption[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render `field()` Models from a live database in the live-abilities, directory shape. The
|
|
35
|
+
* `--govern-roles` complete-or-refuse gate runs here (pure re-derivation on the pulled tables),
|
|
36
|
+
* before any file is written, so a listed role holding a column-scoped grant throws instead of
|
|
37
|
+
* leaving a half-written tree.
|
|
38
|
+
*/
|
|
39
|
+
export function renderLiveModelFiles(
|
|
40
|
+
snapshot: SchemaSnapshot,
|
|
41
|
+
contract: AuthzContract,
|
|
42
|
+
derivedCatalog: DerivedCatalog,
|
|
43
|
+
opts: { schema?: string | string[]; governRoles?: string[] } = {},
|
|
44
|
+
): LiveModelRender {
|
|
45
|
+
const schema = opts.schema ?? 'public';
|
|
46
|
+
const governRoles = opts.governRoles ?? [];
|
|
47
|
+
const pulled = pullableTables(snapshot, schema);
|
|
48
|
+
const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
|
49
|
+
const derived = renderDerivedSource(derivedCatalog, { knownTables, sequences: snapshot.sequences });
|
|
50
|
+
const liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
51
|
+
|
|
52
|
+
// The --govern-roles gate, BEFORE any file is written: a governed role holding a column-scoped
|
|
53
|
+
// grant fails the render (deriveAbilities throws) rather than producing a half-owned barrel.
|
|
54
|
+
if (governRoles.length) {
|
|
55
|
+
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
56
|
+
const govern = new Set(governRoles);
|
|
57
|
+
for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const files = renderModelFiles(snapshot, { schema, abilities: 'live', liveAuthz, governRoles, derived });
|
|
61
|
+
// A governed role's grants are DECLARED (transcribed as privileges), so they are not also
|
|
62
|
+
// recorded as exemptions — that would double-book them, declared and exempted at once.
|
|
63
|
+
const exemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
64
|
+
return { files, fingerprint: fingerprintLive(snapshot, contract).hash, exemptions };
|
|
65
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FROZEN v10 canonicalizer — the exact live-side hash the CLI produced under
|
|
3
|
+
* FINGERPRINT_VERSION 10, before the command fan (Bug 2), effective-privilege deadness
|
|
4
|
+
* (Condition A), and serial-sequence ACLs (Bug 3) landed at v11.
|
|
5
|
+
*
|
|
6
|
+
* DO NOT EDIT this to track new behaviour. It exists for ONE purpose: `db:apply`'s
|
|
7
|
+
* fast-forward proof recomputes a `schema_log` row recorded at `fp_version = 10` against a
|
|
8
|
+
* CURRENT live read, and comparing an old-format hash against a current-format one reports a
|
|
9
|
+
* phantom mismatch. So the descent registry recomputes the LIVE side under the ROW's canon —
|
|
10
|
+
* this function — per the standing rule in `RETAINED_CANONS`.
|
|
11
|
+
*
|
|
12
|
+
* It is a SELF-CONTAINED copy of the v10 authz canonical form: the command dimension is NOT
|
|
13
|
+
* fanned, deadness reads the DIRECT grants (never the v11 effective-privilege matrix), and there
|
|
14
|
+
* is no `sequenceAuthz` slice. It reuses only the stable leaf normalizers (deparse + structure),
|
|
15
|
+
* whose drift is the documented deparse caveat and is equal across every version.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import type { PolicyContract, TableContract } from './authz-contract';
|
|
20
|
+
import { effectivePolicyCheck, holdsPrivilege, policyCommands, isPolicySubsumed } from './authz-contract';
|
|
21
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
22
|
+
import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
23
|
+
import { stableStringify, schemaOf, type CanonicalizeOptions } from './schema-fingerprint';
|
|
24
|
+
|
|
25
|
+
const PUBLIC_ROLE = 'public';
|
|
26
|
+
|
|
27
|
+
const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
|
|
28
|
+
[...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
29
|
+
|
|
30
|
+
// --- v10 authz canonical form (frozen: the command dimension is NOT fanned) ---
|
|
31
|
+
|
|
32
|
+
/** v10 deadness: a policy governing a privilege none of its roles holds by DIRECT grant. */
|
|
33
|
+
function isPolicyDeadV10(t: TableContract, p: PolicyContract): boolean {
|
|
34
|
+
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC') ? Object.keys(t.grants) : p.roles;
|
|
35
|
+
if (!roles.length) return true;
|
|
36
|
+
return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stableRuleV10(p: PolicyContract): string {
|
|
40
|
+
return JSON.stringify([p.command, p.permissive, p.using ?? '', effectivePolicyCheck(p) ?? '']);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expandPolicyV10(p: PolicyContract): string[] {
|
|
44
|
+
const rule = stableRuleV10(p);
|
|
45
|
+
if (p.roles.includes(PUBLIC_ROLE)) return [`${PUBLIC_ROLE}|${rule}`];
|
|
46
|
+
return [...p.roles].sort().map((r) => `${r}|${rule}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function multiset(entries: string[]): Array<[string, number]> {
|
|
50
|
+
const counts = new Map<string, number>();
|
|
51
|
+
for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
52
|
+
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canonicalPoliciesV10(policies: readonly PolicyContract[], governed?: ReadonlySet<string>): Array<[string, number]> {
|
|
56
|
+
const entries = policies.flatMap(expandPolicyV10);
|
|
57
|
+
const kept = governed
|
|
58
|
+
? entries.filter((e) => {
|
|
59
|
+
const role = e.slice(0, e.indexOf('|'));
|
|
60
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
61
|
+
})
|
|
62
|
+
: entries;
|
|
63
|
+
return multiset(kept);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function governedOnly<T>(map: Record<string, T> | undefined, governed: ReadonlySet<string> | undefined): Record<string, T> {
|
|
67
|
+
if (!map) return {};
|
|
68
|
+
if (!governed) return map;
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
Object.entries(map).filter(([grantee]) => governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function canonicalAuthzV10(contract: TableContract, governed?: ReadonlySet<string>): Record<string, unknown> {
|
|
75
|
+
const grants = governedOnly(contract.grants, governed);
|
|
76
|
+
const columnGrants = governedOnly(contract.columnGrants, governed);
|
|
77
|
+
return {
|
|
78
|
+
table: contract.table,
|
|
79
|
+
rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
|
|
80
|
+
grants: Object.fromEntries(
|
|
81
|
+
Object.entries(grants).map(([role, privs]) => [role, [...(privs as string[])].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
82
|
+
),
|
|
83
|
+
...(Object.keys(columnGrants).length > 0
|
|
84
|
+
? {
|
|
85
|
+
columnGrants: Object.fromEntries(
|
|
86
|
+
Object.entries(columnGrants)
|
|
87
|
+
.map(([role, byPriv]) => [
|
|
88
|
+
role,
|
|
89
|
+
Object.fromEntries(
|
|
90
|
+
Object.entries(byPriv as Record<string, string[]>).map(([priv, cols]) => [priv, [...cols].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
91
|
+
),
|
|
92
|
+
] as const)
|
|
93
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
97
|
+
policies: canonicalPoliciesV10(
|
|
98
|
+
contract.policies.filter((p) => !isPolicyDeadV10(contract, p) && !isPolicySubsumed(contract, p)),
|
|
99
|
+
governed,
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- v10 base-schema canonical form (identical to v11; frozen here for self-containment) ---
|
|
105
|
+
|
|
106
|
+
function canonicalTableV10(table: TableSchema): Record<string, unknown> {
|
|
107
|
+
return {
|
|
108
|
+
table: table.table,
|
|
109
|
+
columns: byKey(
|
|
110
|
+
table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
|
|
111
|
+
(c) => c.name,
|
|
112
|
+
),
|
|
113
|
+
primaryKey: table.primaryKey,
|
|
114
|
+
uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
|
|
115
|
+
checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
|
|
116
|
+
foreignKeys: byKey(
|
|
117
|
+
table.foreignKeys.map((f) => ({
|
|
118
|
+
columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
|
|
119
|
+
...(f.onDelete ? { onDelete: f.onDelete } : {}),
|
|
120
|
+
...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
|
|
121
|
+
})),
|
|
122
|
+
(f) => stableStringify(f),
|
|
123
|
+
),
|
|
124
|
+
indexes: [...new Set(table.indexes.map(indexKey))].sort(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The frozen v10 live hash for (snapshot, authzTables, opts). Sequence ACLs are NOT hashed. */
|
|
129
|
+
export function fingerprintV10(snapshot: SchemaSnapshot, authzTables: TableContract[], opts: CanonicalizeOptions = {}): string {
|
|
130
|
+
const inScope = opts.schemas ? new Set(opts.schemas) : null;
|
|
131
|
+
const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
|
|
132
|
+
const tables = snapshot.tables.filter((t) => keepTable(t.table));
|
|
133
|
+
const usedEnum = new Set<string>();
|
|
134
|
+
for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
|
|
135
|
+
const sequences = byKey(
|
|
136
|
+
(snapshot.sequences ?? [])
|
|
137
|
+
.filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
|
|
138
|
+
.map((s) => ({ name: s.name, as: s.as, ...(s.start != null ? { start: s.start } : {}), ...(s.increment != null ? { increment: s.increment } : {}) })),
|
|
139
|
+
(s) => String(s.name),
|
|
140
|
+
);
|
|
141
|
+
const canonical = {
|
|
142
|
+
v: 10,
|
|
143
|
+
tables: byKey(tables.map(canonicalTableV10), (t) => String(t.table)),
|
|
144
|
+
enums: byKey(
|
|
145
|
+
(snapshot.enums ?? []).filter((e) => inScope === null || usedEnum.has(e.name)).map((e) => ({ name: e.name, values: e.values })),
|
|
146
|
+
(e) => e.name,
|
|
147
|
+
),
|
|
148
|
+
authz: byKey(authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzV10(t, opts.governedRoles)), (t) => String(t.table)),
|
|
149
|
+
...(sequences.length ? { sequences } : {}),
|
|
150
|
+
};
|
|
151
|
+
return createHash('sha256').update(stableStringify(canonical)).digest('hex');
|
|
152
|
+
}
|
|
@@ -43,21 +43,23 @@
|
|
|
43
43
|
* Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
|
|
44
44
|
* base fingerprint does not see (triggers — drift-checked separately by the
|
|
45
45
|
* reconciler's provenance — domains, composite types, foreign/partitioned
|
|
46
|
-
* tables, extensions,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* tables, extensions, and the OWNER of a SECURITY DEFINER function that declares
|
|
47
|
+
* none) so partial coverage reads as a report, never as "covered everything".
|
|
48
|
+
* Standalone sequences left this list in v3: covered. SEQUENCE ACLs left it at
|
|
49
|
+
* v11: covered (derived per table from the A6 inheritance rule, measured live).
|
|
50
|
+
* The schema posture and identity columns remain deferred.
|
|
50
51
|
*/
|
|
51
52
|
|
|
52
53
|
import { createHash } from 'node:crypto';
|
|
53
54
|
import { canonicalAuthz } from './authz-canonical';
|
|
54
55
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
55
56
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
56
|
-
import type { AuthzContract, TableContract } from './authz-contract';
|
|
57
|
+
import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
|
|
57
58
|
import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
|
|
58
59
|
import { compileTableContract } from './authz-compile';
|
|
59
|
-
import { governedRoleSet } from './authz-reconcile';
|
|
60
|
-
import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
60
|
+
import { governedRoleSet, inheritedSequenceGrants } from './authz-reconcile';
|
|
61
|
+
import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
|
|
62
|
+
import { fingerprintV10 } from './schema-fingerprint-v10';
|
|
61
63
|
|
|
62
64
|
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
63
65
|
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
@@ -114,7 +116,21 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff';
|
|
|
114
116
|
// it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
|
|
115
117
|
// policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
|
|
116
118
|
// exactly including the effective WITH CHECK.
|
|
117
|
-
|
|
119
|
+
// v11 — Bug 2 + Bug 3, one bump. (a) The AUTHZ canonical form FANS THE COMMAND DIMENSION: a
|
|
120
|
+
// policy is one entry per (role, command) with per-command predicate slots (INSERT using='';
|
|
121
|
+
// SELECT/DELETE check=''; INSERT/UPDATE check = check ?? using), so a live FOR ALL and the
|
|
122
|
+
// granular per-command policies a pull produces hash equal when they authorize the same thing —
|
|
123
|
+
// and a FOR ALL that still holds a command a granular set denies stays distinct. Deadness and
|
|
124
|
+
// subsumption move to the ARM: a dead (role,command) arm and a PUBLIC-covered one leave the state.
|
|
125
|
+
// (b) DEADNESS folds ROLE MEMBERSHIP on the live side — a role holding a command through a parent
|
|
126
|
+
// role or PUBLIC is alive, measured by has_table_privilege (the declared side stays on its direct
|
|
127
|
+
// grants). (c) SERIAL-SEQUENCE ACLs enter the hash: per table, the multiset of its serial
|
|
128
|
+
// sequences' grant shapes, derived from the A6 inheritance rule on the declared side and measured
|
|
129
|
+
// on the live side, so a sequence REVOKE moves the fingerprint. Same consequence as every bump: a
|
|
130
|
+
// plan or baseline minted under v10 refuses as a FORMAT CHANGE and must be re-minted (db:plan) or
|
|
131
|
+
// re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The v10
|
|
132
|
+
// canon is frozen in RETAINED_CANONS so a schema_log row recorded under it still descends.
|
|
133
|
+
export const FINGERPRINT_VERSION = 11;
|
|
118
134
|
|
|
119
135
|
// ---------------------------------------------------------------------------
|
|
120
136
|
// Canonical form.
|
|
@@ -191,6 +207,73 @@ export interface CanonicalState {
|
|
|
191
207
|
/** Standalone sequences — present ONLY when any exist, so a sequence-free state's
|
|
192
208
|
* canonical form (and hash) is byte-identical to the pre-coverage form. */
|
|
193
209
|
sequences?: Array<Record<string, unknown>>;
|
|
210
|
+
/** Serial-sequence ACLs, per table, as `[table, sorted multiset of grant-shape strings]`
|
|
211
|
+
* (v11). Present ONLY when a table owns a serial sequence with a governed shape. */
|
|
212
|
+
sequenceAuthz?: Array<[string, string[]]>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Is a grantee governed? PUBLIC compares case-insensitively; every other name exactly. */
|
|
216
|
+
function isGovernedGrantee(governed: ReadonlySet<string> | undefined, grantee: string): boolean {
|
|
217
|
+
if (!governed) return true;
|
|
218
|
+
return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One sequence's grant shape as a stable string: governed grantees → sorted privileges. */
|
|
222
|
+
function seqShape(grants: Record<string, string[]>, governed?: ReadonlySet<string>): string {
|
|
223
|
+
const kept = Object.entries(grants)
|
|
224
|
+
.filter(([grantee, privs]) => privs.length > 0 && isGovernedGrantee(governed, grantee))
|
|
225
|
+
.map(([grantee, privs]) => [grantee, [...privs].sort()] as const);
|
|
226
|
+
return stableStringify(Object.fromEntries(kept));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The canonical sequence-ACL slice (v11): per table, the sorted MULTISET of its serial
|
|
231
|
+
* sequences' grant shapes.
|
|
232
|
+
*
|
|
233
|
+
* Keyed by TABLE, not by pg's generated sequence name — that name truncates at 63 chars and
|
|
234
|
+
* collision-suffixes, and the DECLARED side (which never introspected) could not reproduce it.
|
|
235
|
+
* A multiset, not a union: a REVOKE on ONE of a table's several sequences still moves the hash.
|
|
236
|
+
*
|
|
237
|
+
* MEASURED vs DERIVED, PER TABLE — the axis the plan's predicted endpoint needs. A table present
|
|
238
|
+
* in the measured set uses its live ACLs (so a REVOKE is seen); a table absent DERIVES from its
|
|
239
|
+
* serial-column count × the A6-inherited grants (the state reconciliation targets). The declared
|
|
240
|
+
* side passes nothing and derives every table; a plain live read passes every measured table;
|
|
241
|
+
* the plan's predicted endpoint passes ONLY the rides-through tables, which reconciliation never
|
|
242
|
+
* touches, and lets the declared tables derive their reconciled target. Identity sequences are
|
|
243
|
+
* excluded on both sides — they inherit nothing, and the reconciler exempts them.
|
|
244
|
+
*/
|
|
245
|
+
function canonicalSequenceAuthz(
|
|
246
|
+
tables: TableSchema[],
|
|
247
|
+
authzTables: TableContract[],
|
|
248
|
+
measured: SequenceGrantContract[] | undefined,
|
|
249
|
+
governed: ReadonlySet<string> | undefined,
|
|
250
|
+
keepTable: (name: string) => boolean,
|
|
251
|
+
): Array<[string, string[]]> {
|
|
252
|
+
const snapshotByTable = new Map(tables.map((t) => [t.table, t]));
|
|
253
|
+
const measuredByTable = new Map<string, SequenceGrantContract[]>();
|
|
254
|
+
for (const s of measured ?? []) {
|
|
255
|
+
(measuredByTable.get(s.table) ?? measuredByTable.set(s.table, []).get(s.table)!).push(s);
|
|
256
|
+
}
|
|
257
|
+
const out: Array<[string, string[]]> = [];
|
|
258
|
+
for (const az of authzTables) {
|
|
259
|
+
if (!keepTable(az.table)) continue;
|
|
260
|
+
let shapes: string[];
|
|
261
|
+
if (measuredByTable.has(az.table)) {
|
|
262
|
+
// LIVE: measure each non-identity sequence's actual ACL — this is what sees a REVOKE.
|
|
263
|
+
shapes = measuredByTable.get(az.table)!.filter((s) => !s.identity).map((s) => seqShape(s.grants, governed));
|
|
264
|
+
} else {
|
|
265
|
+
// DERIVED: one inherited shape per serial column (nextval default); identity columns,
|
|
266
|
+
// which carry no such default, are excluded exactly as the reconciler exempts them.
|
|
267
|
+
const snap = snapshotByTable.get(az.table);
|
|
268
|
+
const serialCount = (snap?.columns ?? []).filter((c) => nextvalSequence(normalizeDefault(c.default)) != null).length;
|
|
269
|
+
if (serialCount === 0) continue;
|
|
270
|
+
const inherited = seqShape(inheritedSequenceGrants(az), governed);
|
|
271
|
+
shapes = Array.from({ length: serialCount }, () => inherited);
|
|
272
|
+
}
|
|
273
|
+
if (shapes.length === 0) continue;
|
|
274
|
+
out.push([az.table, shapes.sort()]);
|
|
275
|
+
}
|
|
276
|
+
return out.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
194
277
|
}
|
|
195
278
|
|
|
196
279
|
/** The schema of a (possibly) qualified name — `stats.entities` → `stats`, a bare name → `public`. */
|
|
@@ -214,6 +297,14 @@ export interface CanonicalizeOptions {
|
|
|
214
297
|
* every grantee is governed by construction.
|
|
215
298
|
*/
|
|
216
299
|
governedRoles?: ReadonlySet<string>;
|
|
300
|
+
/**
|
|
301
|
+
* The MEASURED serial-sequence ACLs (v11). A table present here is hashed from its live ACLs
|
|
302
|
+
* (so a REVOKE moves the fingerprint); a table absent DERIVES its ACLs from the A6 inheritance
|
|
303
|
+
* rule (the reconciled target). The live path passes every measured table; the declared path
|
|
304
|
+
* passes nothing; the plan's predicted endpoint passes only rides-through tables. See
|
|
305
|
+
* {@link canonicalSequenceAuthz}.
|
|
306
|
+
*/
|
|
307
|
+
sequenceGrants?: SequenceGrantContract[];
|
|
217
308
|
}
|
|
218
309
|
|
|
219
310
|
export function canonicalizeState(
|
|
@@ -256,6 +347,10 @@ export function canonicalizeState(
|
|
|
256
347
|
(t) => String(t.table),
|
|
257
348
|
),
|
|
258
349
|
...(sequences.length ? { sequences } : {}),
|
|
350
|
+
...((() => {
|
|
351
|
+
const sequenceAuthz = canonicalSequenceAuthz(tables, authzTables, opts.sequenceGrants, opts.governedRoles, keepTable);
|
|
352
|
+
return sequenceAuthz.length ? { sequenceAuthz } : {};
|
|
353
|
+
})()),
|
|
259
354
|
};
|
|
260
355
|
}
|
|
261
356
|
|
|
@@ -266,6 +361,37 @@ export interface Fingerprint {
|
|
|
266
361
|
canonical: CanonicalState;
|
|
267
362
|
}
|
|
268
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Which SURFACES of two canonical states diverge — so a MISMATCH names WHERE to look instead of
|
|
366
|
+
* only "run db:generate". Compares each top-level slice (base schema, authorization, sequence
|
|
367
|
+
* ACLs, enums, standalone sequences) as its stable string, and for the per-table slices lists the
|
|
368
|
+
* specific tables whose entry differs. Empty when the two states are identical.
|
|
369
|
+
*/
|
|
370
|
+
export function divergentSurfaces(a: CanonicalState, b: CanonicalState): string[] {
|
|
371
|
+
const out: string[] = [];
|
|
372
|
+
const named = (label: string, av: unknown, bv: unknown): void => {
|
|
373
|
+
if (stableStringify(av) !== stableStringify(bv)) out.push(label);
|
|
374
|
+
};
|
|
375
|
+
// Per-table slices: name the tables that differ, keyed by their `table` field.
|
|
376
|
+
const tablesDiff = (rows: Array<Record<string, unknown>> = []): Map<string, string> =>
|
|
377
|
+
new Map(rows.map((r) => [String(r.table), stableStringify(r)]));
|
|
378
|
+
const tableSurface = (label: string, ar: Array<Record<string, unknown>>, br: Array<Record<string, unknown>>): void => {
|
|
379
|
+
const am = tablesDiff(ar); const bm = tablesDiff(br);
|
|
380
|
+
const differ = [...new Set([...am.keys(), ...bm.keys()])].filter((t) => am.get(t) !== bm.get(t)).sort();
|
|
381
|
+
if (differ.length) out.push(`${label} (${differ.join(', ')})`);
|
|
382
|
+
};
|
|
383
|
+
tableSurface('base schema', a.tables, b.tables);
|
|
384
|
+
tableSurface('authorization', a.authz, b.authz);
|
|
385
|
+
named('enums', a.enums, b.enums);
|
|
386
|
+
named('standalone sequences', a.sequences, b.sequences);
|
|
387
|
+
// sequenceAuthz entries are [table, shapes] — name the tables.
|
|
388
|
+
const seqTables = (rows: Array<[string, string[]]> = []) => new Map(rows.map(([t, s]) => [t, stableStringify(s)]));
|
|
389
|
+
const as = seqTables(a.sequenceAuthz); const bs = seqTables(b.sequenceAuthz);
|
|
390
|
+
const seqDiffer = [...new Set([...as.keys(), ...bs.keys()])].filter((t) => as.get(t) !== bs.get(t)).sort();
|
|
391
|
+
if (seqDiffer.length) out.push(`sequence ACLs (${seqDiffer.join(', ')})`);
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
269
395
|
export function fingerprintState(
|
|
270
396
|
snapshot: SchemaSnapshot,
|
|
271
397
|
authzTables: TableContract[],
|
|
@@ -315,13 +441,15 @@ export function governedRolesForModels(
|
|
|
315
441
|
return governedRoleSet(declared, extra ?? []);
|
|
316
442
|
}
|
|
317
443
|
|
|
318
|
-
/** Convenience: the live side, from the two existing introspections.
|
|
444
|
+
/** Convenience: the live side, from the two existing introspections. Passes the measured
|
|
445
|
+
* sequence ACLs so a live serial-sequence REVOKE moves the hash (v11); when the contract never
|
|
446
|
+
* measured them (older recorder), they stay absent and the sequence ACLs derive, as before. */
|
|
319
447
|
export function fingerprintLive(
|
|
320
448
|
snapshot: SchemaSnapshot,
|
|
321
449
|
contract: AuthzContract,
|
|
322
450
|
opts: CanonicalizeOptions = {},
|
|
323
451
|
): Fingerprint {
|
|
324
|
-
return fingerprintState(snapshot, contract.tables, opts);
|
|
452
|
+
return fingerprintState(snapshot, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants });
|
|
325
453
|
}
|
|
326
454
|
|
|
327
455
|
// ---------------------------------------------------------------------------
|
|
@@ -359,10 +487,11 @@ export type CanonHasher = (
|
|
|
359
487
|
* absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
|
|
360
488
|
* SKIPS that row (fails closed), never guesses a hash.
|
|
361
489
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
490
|
+
* v11 is the FIRST bump, so v10 is the first retired entry: `fingerprintV10`, a self-contained
|
|
491
|
+
* frozen copy of the v10 canonical form (no command fan, direct-grant deadness, no sequence ACLs).
|
|
364
492
|
*/
|
|
365
493
|
export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
|
|
494
|
+
10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
|
|
366
495
|
[FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
|
|
367
496
|
};
|
|
368
497
|
|
|
@@ -381,7 +510,10 @@ export function fingerprintLiveAtVersion(
|
|
|
381
510
|
): string | null {
|
|
382
511
|
if (version == null) return null;
|
|
383
512
|
const canon = canons[version];
|
|
384
|
-
|
|
513
|
+
// Thread the measured serial-sequence ACLs the way `fingerprintLive` does, so a v11 canon
|
|
514
|
+
// recomputes the SAME hash the apply recorded. A retired canon that predates sequence ACLs
|
|
515
|
+
// (v10) simply ignores the field — its self-contained copy never reads `sequenceGrants`.
|
|
516
|
+
return canon ? canon(snapshot, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants }) : null;
|
|
385
517
|
}
|
|
386
518
|
|
|
387
519
|
// ---------------------------------------------------------------------------
|
|
@@ -390,12 +522,11 @@ export function fingerprintLiveAtVersion(
|
|
|
390
522
|
|
|
391
523
|
/**
|
|
392
524
|
* Objects outside both the base fingerprint (this module) and the derived
|
|
393
|
-
* manifest (derived-introspect):
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* Both of those last two enter the hash at v11.
|
|
525
|
+
* manifest (derived-introspect): triggers, domains, composite types, foreign
|
|
526
|
+
* tables, partitioned tables, installed extensions — and identity columns, which
|
|
527
|
+
* the models can now say and the builder now builds, but which the hash does not
|
|
528
|
+
* yet cover (deferred past v11 by scope). Serial-sequence ACLs left this list at
|
|
529
|
+
* v11: they are fingerprinted now (see canonicalSequenceAuthz).
|
|
399
530
|
*/
|
|
400
531
|
export const UNFINGERPRINTED_SQL = `
|
|
401
532
|
SELECT 'trigger' AS kind, n.nspname || '.' || c.relname || '.' || t.tgname AS identity
|
|
@@ -421,18 +552,6 @@ JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
|
421
552
|
WHERE c.relkind IN ('f', 'p')
|
|
422
553
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
423
554
|
UNION ALL
|
|
424
|
-
-- A6/B4, the interim before v11. A serial/identity sequence's SHAPE is fingerprinted (it rides
|
|
425
|
-
-- the column default), but its ACL is not — and that ACL is load-bearing: a role that may INSERT
|
|
426
|
-
-- into the table and holds nothing on the sequence fails every INSERT with "permission denied for
|
|
427
|
-
-- sequence". Only sequences that actually CARRY an ACL are named; an ungranted one has nothing
|
|
428
|
-
-- unfingerprinted to say. Enters the hash at v11, batched with the schema posture.
|
|
429
|
-
SELECT 'sequence ACL', n.nspname || '.' || c.relname
|
|
430
|
-
FROM pg_class c
|
|
431
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
432
|
-
WHERE c.relkind = 'S' AND c.relacl IS NOT NULL
|
|
433
|
-
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
434
|
-
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
435
|
-
UNION ALL
|
|
436
555
|
-- B5. A SECURITY DEFINER function executes with its OWNER's rights, so ownership is a privilege
|
|
437
556
|
-- boundary. It is hashed ONLY WHEN DECLARED (derived-source's hashSourceContent prefixes the
|
|
438
557
|
-- owner, so an object without one hashes byte-identically to before the feature existed), and
|