@everystack/cli 0.4.79 → 0.4.80
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 +28 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +40 -17
- package/src/cli/authz-contract.ts +68 -2
- package/src/cli/authz-reconcile.ts +68 -2
- package/src/cli/commands/db-check.ts +23 -2
- package/src/cli/commands/db-plan.ts +17 -11
- package/src/cli/edge-plan.ts +219 -28
- package/src/cli/migration-generate.ts +37 -3
- package/src/cli/schema-diff.ts +14 -4
- package/src/cli/schema-fingerprint.ts +12 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.80] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- apply-contract v3 — the default-privilege lock is a server-run gate; redeploy the stage before the first staged db:apply after upgrading
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- empty-edge summary names the ungoverned grantees, not 35 tables
|
|
16
|
+
- carryUngovernedAuthz — the full-prediction merge helper (N3 step 1)
|
|
17
|
+
- predictLiveState carryUngoverned — the full endpoint flavor (N3 step 2)
|
|
18
|
+
- the plan `to` is the full endpoint, verified and disclosed (N3 steps 3-5)
|
|
19
|
+
- a plan-created serial sequence is granted in the same plan
|
|
20
|
+
- the plan locks the default-privilege posture it predicted from
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- the full endpoint carries default privileges onto plan-created objects
|
|
25
|
+
- bump apply-contract to v2 for the full endpoint; sharpen the empty-edge summary
|
|
26
|
+
- db:check fails on a named artifact path with nothing at it
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
- the plan `to` flavor; test: condition (a) drop end-to-end (N3 step 6)
|
|
31
|
+
- every db verb has a reference entry, and a test says so
|
|
32
|
+
- the disclosed gap names what actually compiles, and a test pins it
|
|
33
|
+
- the last two rollback-point sentences say what the code does; the retargeting edge is named
|
|
34
|
+
|
|
7
35
|
## [0.4.79] - 2026-09-07
|
|
8
36
|
|
|
9
37
|
### Removed
|
package/package.json
CHANGED
package/src/cli/apply-execute.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
COLUMN_GRANTS_SQL,
|
|
39
39
|
EFFECTIVE_PRIVILEGES_SQL,
|
|
40
40
|
SCHEMA_ACL_SQL,
|
|
41
|
-
SEQUENCE_ACLS_SQL,
|
|
41
|
+
SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL,
|
|
42
42
|
type QueryRunner,
|
|
43
43
|
type AuthzContract,
|
|
44
44
|
} from './authz-contract';
|
|
@@ -56,10 +56,9 @@ import {
|
|
|
56
56
|
} from './schema-introspect';
|
|
57
57
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
58
58
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
59
|
-
import { governedLiveFingerprint } from './edge-plan';
|
|
60
59
|
import { applyGeneratedStatements } from './state-apply';
|
|
61
60
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
62
|
-
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan';
|
|
61
|
+
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
63
62
|
import type { DescentLedgerRow } from './git-descent';
|
|
64
63
|
|
|
65
64
|
/**
|
|
@@ -255,6 +254,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
255
254
|
[
|
|
256
255
|
COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
|
|
257
256
|
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
257
|
+
// The default-privilege posture the plan's `defaultAclHash` is checked against — read in
|
|
258
|
+
// the SAME session as the lock's state, so the two gates describe one instant.
|
|
259
|
+
DEFAULT_ACL_SQL,
|
|
258
260
|
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
259
261
|
],
|
|
260
262
|
INTROSPECTION_SESSION,
|
|
@@ -270,8 +272,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
270
272
|
columnGrants: results[9] as any[], effectivePrivileges: results[10] as any[],
|
|
271
273
|
schemaAcls: results[11] as any[], sequenceAcls: results[12] as any[],
|
|
272
274
|
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
275
|
+
defaultAcls: results[14] as any[],
|
|
273
276
|
});
|
|
274
|
-
const ledger = mapLedgerRows(rowsOrEmpty(results[
|
|
277
|
+
const ledger = mapLedgerRows(rowsOrEmpty(results[15]));
|
|
275
278
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
276
279
|
}
|
|
277
280
|
|
|
@@ -284,15 +287,17 @@ export async function executeApplyPlan(
|
|
|
284
287
|
): Promise<ApplyPlanResult> {
|
|
285
288
|
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
286
289
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// the
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
291
|
+
// the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
|
|
292
|
+
// the full endpoint (N3, milestone plan-endpoint-full): its `to` is a governed hash, and
|
|
293
|
+
// verifying the full live against it would false-fail AFTER the DDL landed. Refuse and re-mint.
|
|
294
|
+
if (plan.toFlavor !== 'full') {
|
|
295
|
+
const reason = 'this plan predates the full endpoint — its `to` is a governed fingerprint. Re-mint with db:plan.';
|
|
296
|
+
await recordRefusal(runner, plan, live, 'plan flavor', reason, options);
|
|
297
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
298
|
+
}
|
|
294
299
|
|
|
295
|
-
if (
|
|
300
|
+
if (live === plan.toFingerprint) {
|
|
296
301
|
return { status: 'already-applied', liveFingerprint: live };
|
|
297
302
|
}
|
|
298
303
|
const pre = checkPlanPrecondition(plan, live);
|
|
@@ -301,6 +306,24 @@ export async function executeApplyPlan(
|
|
|
301
306
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// The lock covers object state. The default-privilege posture is not object state — `from`
|
|
310
|
+
// never hashed it — yet the FULL prediction consumed it for every object this plan creates.
|
|
311
|
+
// Compare it BEFORE any write: an ALTER DEFAULT PRIVILEGES since minting used to pass the
|
|
312
|
+
// lock, land the DDL, and fail verify-after post-DDL. A plan without the field predates the
|
|
313
|
+
// gate and cannot be checked; plans are minutes old, so refusing costs one re-mint.
|
|
314
|
+
if (plan.defaultAclHash === undefined) {
|
|
315
|
+
const reason = 'this plan predates the default privileges lock — it carries no defaultAclHash. Re-mint with db:plan.';
|
|
316
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
317
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
318
|
+
}
|
|
319
|
+
if (defaultAclHash(beforeAuthz) !== plan.defaultAclHash) {
|
|
320
|
+
const reason =
|
|
321
|
+
'the default-privilege posture (ALTER DEFAULT PRIVILEGES) moved since minting — the plan predicted its endpoint ' +
|
|
322
|
+
'from the old one, so the objects it creates would land with grants it did not carry. Re-mint with db:plan.';
|
|
323
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
324
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
if (options.checkDescent) {
|
|
305
328
|
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
306
329
|
if (!descent.ok) {
|
|
@@ -347,19 +370,19 @@ export async function executeApplyPlan(
|
|
|
347
370
|
...(options.descentNote ? { descentNote: options.descentNote } : {}),
|
|
348
371
|
});
|
|
349
372
|
|
|
350
|
-
const { hash: landed
|
|
373
|
+
const { hash: landed } = await liveBaseFingerprint(session);
|
|
351
374
|
if (result.logId !== undefined) {
|
|
352
|
-
// The log stamps the RAW landed hash — the same flavor as `from`, so the
|
|
375
|
+
// The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
|
|
353
376
|
// schema_log chain reads as one consistent series of live states.
|
|
354
377
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
355
378
|
}
|
|
356
379
|
|
|
357
|
-
|
|
358
|
-
if (
|
|
380
|
+
// The full `to` equals the post-apply raw live hash, so verify-after compares them directly.
|
|
381
|
+
if (landed !== plan.toFingerprint) {
|
|
359
382
|
return {
|
|
360
383
|
status: 'verify-failed',
|
|
361
384
|
liveFingerprint: landed,
|
|
362
|
-
reason: `applied, but the target landed on ${
|
|
385
|
+
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
363
386
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
364
387
|
};
|
|
365
388
|
}
|
|
@@ -357,6 +357,15 @@ export interface AuthzContract {
|
|
|
357
357
|
* it did not read.
|
|
358
358
|
*/
|
|
359
359
|
sequenceGrants?: SequenceGrantContract[];
|
|
360
|
+
/**
|
|
361
|
+
* Default privileges (`pg_default_acl`) that fire for objects the current role CREATES —
|
|
362
|
+
* `{ tables, sequences }`, each schema (`''` = all schemas) → grantee → sorted privileges. The
|
|
363
|
+
* FULL prediction folds the ungoverned slice onto plan-created objects: Postgres auto-grants
|
|
364
|
+
* them at creation, so a `to` endpoint without them false-verify-fails a successful CREATE
|
|
365
|
+
* apply on a brownfield BI stage. Absent means NOT MEASURED (older recorder, hand-built
|
|
366
|
+
* fixture) — the prediction then models no default ACL, as before.
|
|
367
|
+
*/
|
|
368
|
+
defaultAcls?: { tables: DefaultAclScope; sequences: DefaultAclScope };
|
|
360
369
|
}
|
|
361
370
|
|
|
362
371
|
/**
|
|
@@ -682,6 +691,59 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
|
|
|
682
691
|
return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
|
|
683
692
|
}
|
|
684
693
|
|
|
694
|
+
/**
|
|
695
|
+
* The default privileges (`ALTER DEFAULT PRIVILEGES`) that fire for TABLES and SEQUENCES the
|
|
696
|
+
* CURRENT role creates — so the prediction can model what a plan-CREATED object will carry.
|
|
697
|
+
*
|
|
698
|
+
* `pg_default_acl` is keyed by (defaclrole, defaclnamespace, defaclobjtype). An object created by
|
|
699
|
+
* role R in schema S receives the ACL for (R, S) AND for (R, all-schemas). We read the current
|
|
700
|
+
* role's rows only: everystack applies as one operator, so the creator IS the introspector. A
|
|
701
|
+
* NULL `defaclnamespace` means "every schema" — reported as an empty schema string so the
|
|
702
|
+
* resolver can union it with any specific schema. Objtype 'r' is a table, 'S' a sequence.
|
|
703
|
+
*/
|
|
704
|
+
export const DEFAULT_ACL_SQL = `
|
|
705
|
+
SELECT
|
|
706
|
+
COALESCE(n.nspname, '') AS schema,
|
|
707
|
+
d.defaclobjtype AS objtype,
|
|
708
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
709
|
+
a.privilege_type AS privilege
|
|
710
|
+
FROM pg_default_acl d
|
|
711
|
+
LEFT JOIN pg_namespace n ON n.oid = d.defaclnamespace
|
|
712
|
+
CROSS JOIN LATERAL aclexplode(d.defaclacl) a
|
|
713
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
714
|
+
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
|
715
|
+
AND d.defaclobjtype IN ('r', 'S')
|
|
716
|
+
AND (n.nspname IS NULL OR (n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'))
|
|
717
|
+
ORDER BY 1, 2, 3, 4;
|
|
718
|
+
`.trim();
|
|
719
|
+
|
|
720
|
+
export interface DefaultAclRow {
|
|
721
|
+
schema: unknown;
|
|
722
|
+
/** 'r' = table, 'S' = sequence. */
|
|
723
|
+
objtype: unknown;
|
|
724
|
+
grantee: unknown;
|
|
725
|
+
privilege: unknown;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** schema (`''` = all schemas) → grantee → sorted privileges. */
|
|
729
|
+
export type DefaultAclScope = Record<string, Record<string, string[]>>;
|
|
730
|
+
|
|
731
|
+
/** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
|
|
732
|
+
export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
|
|
733
|
+
const tables: DefaultAclScope = {};
|
|
734
|
+
const sequences: DefaultAclScope = {};
|
|
735
|
+
for (const row of rows) {
|
|
736
|
+
if (row.grantee == null || row.privilege == null) continue;
|
|
737
|
+
const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
|
|
738
|
+
if (!scope) continue;
|
|
739
|
+
const schema = String(row.schema ?? '');
|
|
740
|
+
const grantee = String(row.grantee);
|
|
741
|
+
const byGrantee = scope[schema] ?? (scope[schema] = {});
|
|
742
|
+
byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
|
|
743
|
+
}
|
|
744
|
+
return { tables, sequences };
|
|
745
|
+
}
|
|
746
|
+
|
|
685
747
|
/** The two privileges a schema ACL can carry, in aclitem letter form. */
|
|
686
748
|
const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
|
|
687
749
|
|
|
@@ -872,6 +934,8 @@ export interface ContractRows {
|
|
|
872
934
|
schemaAcls?: SchemaAclRow[];
|
|
873
935
|
/** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
|
|
874
936
|
sequenceAcls?: SequenceAclRow[];
|
|
937
|
+
/** DEFAULT_ACL_SQL. Optional — absent means not measured, never "no default privileges". */
|
|
938
|
+
defaultAcls?: DefaultAclRow[];
|
|
875
939
|
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
876
940
|
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
|
|
877
941
|
}
|
|
@@ -966,6 +1030,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
966
1030
|
...(schemaAcls ? { schemaAcls } : {}),
|
|
967
1031
|
// Same absent-means-unmeasured contract as schemaAcls above.
|
|
968
1032
|
...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
|
|
1033
|
+
...(rows.defaultAcls ? { defaultAcls: assembleDefaultAcls(rows.defaultAcls) } : {}),
|
|
969
1034
|
};
|
|
970
1035
|
}
|
|
971
1036
|
|
|
@@ -978,8 +1043,8 @@ export async function introspectContract(
|
|
|
978
1043
|
// ONE session: the queries describe one moment under one pinned search_path.
|
|
979
1044
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
980
1045
|
// across connections can report authz drift that does not exist.
|
|
981
|
-
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
982
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
1046
|
+
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, defaultAcls, fnRows] = await session(
|
|
1047
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL, functionsSql],
|
|
983
1048
|
INTROSPECTION_SESSION,
|
|
984
1049
|
);
|
|
985
1050
|
return assembleContract({
|
|
@@ -990,6 +1055,7 @@ export async function introspectContract(
|
|
|
990
1055
|
effectivePrivileges: effectivePrivileges as EffectivePrivilegeRow[],
|
|
991
1056
|
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
992
1057
|
sequenceAcls: sequenceAcls as SequenceAclRow[],
|
|
1058
|
+
defaultAcls: defaultAcls as DefaultAclRow[],
|
|
993
1059
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
994
1060
|
});
|
|
995
1061
|
}
|
|
@@ -89,6 +89,61 @@ export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<stri
|
|
|
89
89
|
return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A declared table's authz AFTER the edge lands — the FULL prediction's per-table building block.
|
|
94
|
+
*
|
|
95
|
+
* The reconciler converges the GOVERNED roles onto the declared contract and leaves every other
|
|
96
|
+
* (foreign) grantee alone. So the post-apply truth for a declared table is: the declared contract
|
|
97
|
+
* PLUS the live authz of the ungoverned roles. The governed prediction drops that second half; on
|
|
98
|
+
* a brownfield stage the foreign grants live on, so a `to` endpoint without them can never equal
|
|
99
|
+
* the post-apply full live hash (milestone plan-endpoint-full).
|
|
100
|
+
*
|
|
101
|
+
* Carried ONLY onto objects the declared target still has (condition (a) of the N2 ruling):
|
|
102
|
+
* - table-level grants ride whole — the table is present by construction;
|
|
103
|
+
* - a column grant rides only for the columns in `presentColumns`; a grant on a column the edge
|
|
104
|
+
* DROPS is gone after apply, so carrying it would predict an endpoint the apply cannot reach —
|
|
105
|
+
* a false verify-failed after the DDL already landed;
|
|
106
|
+
* - a policy rides only when EVERY role it names is ungoverned. A policy touching a governed role
|
|
107
|
+
* is the reconciler's to manage, so it is not carried here (if the reconciler leaves it, the
|
|
108
|
+
* verify-after fails LOUD rather than being hidden).
|
|
109
|
+
*
|
|
110
|
+
* The declared (governed) grants always win over any live grant to the same role: a governed role
|
|
111
|
+
* whose live grant the models dropped must be REVOKED by the plan, never carried through.
|
|
112
|
+
*/
|
|
113
|
+
export function carryUngovernedAuthz(
|
|
114
|
+
declared: TableContract,
|
|
115
|
+
live: TableContract,
|
|
116
|
+
governed: ReadonlySet<string>,
|
|
117
|
+
presentColumns: ReadonlySet<string>,
|
|
118
|
+
): TableContract {
|
|
119
|
+
const foreign = (role: string): boolean => !isGoverned(governed, role);
|
|
120
|
+
|
|
121
|
+
const grants: Record<string, string[]> = { ...declared.grants };
|
|
122
|
+
for (const [role, privileges] of Object.entries(live.grants)) {
|
|
123
|
+
if (foreign(role)) grants[role] = [...privileges];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const columnGrants: Record<string, Record<string, string[]>> = { ...(declared.columnGrants ?? {}) };
|
|
127
|
+
for (const [role, byPrivilege] of Object.entries(live.columnGrants ?? {})) {
|
|
128
|
+
if (!foreign(role)) continue;
|
|
129
|
+
const kept: Record<string, string[]> = {};
|
|
130
|
+
for (const [privilege, columns] of Object.entries(byPrivilege)) {
|
|
131
|
+
const present = columns.filter((c) => presentColumns.has(c));
|
|
132
|
+
if (present.length > 0) kept[privilege] = present;
|
|
133
|
+
}
|
|
134
|
+
if (Object.keys(kept).length > 0) columnGrants[role] = kept;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const carriedPolicies = live.policies.filter((p) => p.roles.length > 0 && p.roles.every(foreign));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...declared,
|
|
141
|
+
grants,
|
|
142
|
+
...(Object.keys(columnGrants).length > 0 ? { columnGrants } : {}),
|
|
143
|
+
policies: [...declared.policies, ...carriedPolicies],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
/**
|
|
93
148
|
* The exemption report, one line per grantee — the shared rendering every surface prints.
|
|
94
149
|
*
|
|
@@ -307,7 +362,18 @@ function reconcileSequenceGrants(
|
|
|
307
362
|
export function emitReconcileSql(
|
|
308
363
|
declared: AuthzContract,
|
|
309
364
|
live: AuthzContract,
|
|
310
|
-
opts: {
|
|
365
|
+
opts: {
|
|
366
|
+
governedRoles?: ReadonlySet<string>;
|
|
367
|
+
/**
|
|
368
|
+
* The serial sequences the SAME plan's data phase CREATES (a new serial table, a serial
|
|
369
|
+
* column added to an existing one) — reconciled as if live with no grants. Without them a
|
|
370
|
+
* created sequence got no governed grant until the NEXT plan: db:apply verified green and
|
|
371
|
+
* every INSERT by the governed holder failed `permission denied for sequence` for one plan
|
|
372
|
+
* cycle (milestone finish-0-4-x F1). They are emitted even when the live side never read
|
|
373
|
+
* sequences: the plan creates these, so there is no unread live state to misjudge.
|
|
374
|
+
*/
|
|
375
|
+
createdSequences?: readonly SequenceGrantContract[];
|
|
376
|
+
} = {},
|
|
311
377
|
): string[] {
|
|
312
378
|
// Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
|
|
313
379
|
// (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
|
|
@@ -325,7 +391,7 @@ export function emitReconcileSql(
|
|
|
325
391
|
// Absent = the recorder never read sequences; skip the pass entirely rather than diff
|
|
326
392
|
// against a set nobody measured. Grouped once, not re-scanned per table.
|
|
327
393
|
const sequencesByTable = new Map<string, SequenceGrantContract[]>();
|
|
328
|
-
for (const seq of live.sequenceGrants ?? []) {
|
|
394
|
+
for (const seq of [...(live.sequenceGrants ?? []), ...(opts.createdSequences ?? [])]) {
|
|
329
395
|
(sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
|
|
330
396
|
}
|
|
331
397
|
|
|
@@ -80,6 +80,15 @@ export interface StaticCheckInput {
|
|
|
80
80
|
artifactPath: string;
|
|
81
81
|
/** null = no generated schema artifact on disk. */
|
|
82
82
|
artifactSource: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Where `artifactPath` came from — the same resolution db:generate records. An ABSENT artifact
|
|
85
|
+
* is only "nothing to match" at the untouched default (an app that never generated one). A path
|
|
86
|
+
* the operator named with `--schema-out`, or one the journal recorded, is a claim that an
|
|
87
|
+
* artifact lives there; its absence is the artifact gate vanishing, and must FAIL. Measured:
|
|
88
|
+
* `db:check --schema-out db/does-not-exist.ts` printed the note and passed with exit 0.
|
|
89
|
+
* Absent = default (older callers).
|
|
90
|
+
*/
|
|
91
|
+
artifactOrigin?: 'flag' | 'recorded' | 'default';
|
|
83
92
|
/** Set when a retired db/sql directory still carries .sql files — the tombstone
|
|
84
93
|
* message (with the migration path); a hard fail, never a shrug. */
|
|
85
94
|
sqlDirRetired?: string | null;
|
|
@@ -250,7 +259,19 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
|
|
253
|
-
if (input.artifactSource === null) {
|
|
262
|
+
if (input.artifactSource === null && input.artifactOrigin === 'flag') {
|
|
263
|
+
findings.push({
|
|
264
|
+
level: 'fail',
|
|
265
|
+
area: 'artifact',
|
|
266
|
+
message: `no generated schema at ${input.artifactPath} — the path came from --schema-out, so an artifact is expected there. Fix the path, or run db:generate to write it. A named path with nothing at it is a gate that guards nothing.`,
|
|
267
|
+
});
|
|
268
|
+
} else if (input.artifactSource === null && input.artifactOrigin === 'recorded') {
|
|
269
|
+
findings.push({
|
|
270
|
+
level: 'fail',
|
|
271
|
+
area: 'artifact',
|
|
272
|
+
message: `no generated schema at ${input.artifactPath} — the migration journal records that path as the artifact (db:generate wrote it there). Restore it, run db:generate, or re-record with --schema-out.`,
|
|
273
|
+
});
|
|
274
|
+
} else if (input.artifactSource === null) {
|
|
254
275
|
findings.push({
|
|
255
276
|
level: 'note',
|
|
256
277
|
area: 'artifact',
|
|
@@ -393,7 +414,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
393
414
|
} catch { /* no baseline — see above */ }
|
|
394
415
|
|
|
395
416
|
const findings = runStaticChecks({
|
|
396
|
-
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
|
|
417
|
+
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, artifactOrigin: artifactRes.source, sqlDirRetired,
|
|
397
418
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
398
419
|
moduleModels: declaredDb?.models ?? null,
|
|
399
420
|
baselineSource,
|
|
@@ -49,7 +49,7 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } fr
|
|
|
49
49
|
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
50
50
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
51
51
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
52
|
-
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
52
|
+
import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy } from '../edge-plan';
|
|
53
53
|
import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
54
|
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
55
|
import { predictLiveState } from '../edge-plan';
|
|
@@ -337,16 +337,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
337
337
|
await fs.writeFile(out, body, 'utf8');
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
// An EMPTY edge
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
340
|
+
// An EMPTY edge still has a story to tell, and the two cases tell DIFFERENT ones:
|
|
341
|
+
// - `from == to`: the full endpoint carried the foreign grants through, so the plan is empty
|
|
342
|
+
// AND equal. Name the ungoverned roles (condition (c)) — the operator must still see what
|
|
343
|
+
// rides untouched and under which baseline.
|
|
344
|
+
// - `from != to`: the FULL prediction failed to reproduce raw live — a real, unexplained
|
|
345
|
+
// divergence. The foreign grants are ABSORBED into `to`, so they are NOT the cause; naming
|
|
346
|
+
// them would misdirect. Name the divergent surface of the FULL prediction vs live instead.
|
|
347
|
+
const emptyEdge = plan.statements.length === 0;
|
|
348
|
+
const carriedBy = !emptyEdge
|
|
349
|
+
? undefined
|
|
350
|
+
: plan.fromFingerprint === plan.toFingerprint
|
|
351
|
+
? explainCarriedBy(exemptions, [])
|
|
352
|
+
: divergentSurfaces(
|
|
353
|
+
predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles), carryUngoverned: true }).canonical,
|
|
354
|
+
fingerprintLive(snapshot, contract).canonical,
|
|
355
|
+
);
|
|
350
356
|
for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
|
|
351
357
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
352
358
|
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -27,13 +27,16 @@
|
|
|
27
27
|
import { createHash } from 'node:crypto';
|
|
28
28
|
import type { ModelDescriptor } from '@everystack/model';
|
|
29
29
|
import type { SchemaSnapshot } from './schema-introspect';
|
|
30
|
-
import type { AuthzContract } from './authz-contract';
|
|
30
|
+
import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
|
|
31
|
+
import { ownSequenceName } from './schema-diff';
|
|
31
32
|
import { generateMigrationSql, unmodeledTables } from './migration-generate';
|
|
32
33
|
import { compileTableContract } from './authz-compile';
|
|
34
|
+
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
35
|
+
import { BASELINE_FILE } from './authz-baseline';
|
|
33
36
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
|
|
34
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
37
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
35
38
|
import { compileDeclaredState } from './declared-diff';
|
|
36
|
-
import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
39
|
+
import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
|
|
37
40
|
|
|
38
41
|
export const PLAN_VERSION = 2;
|
|
39
42
|
|
|
@@ -48,8 +51,20 @@ export const PLAN_VERSION = 2;
|
|
|
48
51
|
* runs, or a gate's semantics. Both packages import THIS binding (the server from the cli it
|
|
49
52
|
* bundles), so a version skew is a hard, exact-equality refusal in both directions — never a
|
|
50
53
|
* silently dropped gate.
|
|
54
|
+
*
|
|
55
|
+
* v2 (milestone plan-endpoint-full): `plan.toFingerprint` became the FULL endpoint (carries the
|
|
56
|
+
* ungoverned live authz through) and the server verifies the raw landed hash against it. An
|
|
57
|
+
* un-redeployed v1 ops Lambda would verify a GOVERNED landed hash against the full `to` and report
|
|
58
|
+
* verify-failed on a SUCCESSFUL brownfield apply — so the meaning of a gate changed and the peers
|
|
59
|
+
* must not mix. The handshake now forces a stage redeploy before an apply crosses versions.
|
|
60
|
+
*
|
|
61
|
+
* v3 (milestone finish-0-4-x): the payload gained `plan.defaultAclHash` and the server runs a new
|
|
62
|
+
* gate on it (`default privileges`, pre-dispatch). A v2 ops Lambda would ignore the key and skip
|
|
63
|
+
* the gate — not a false pass of the apply (it degrades to the post-DDL verify failure), but a
|
|
64
|
+
* silently dropped gate the operator was told is in force. The BUMP RULE says a gate the server
|
|
65
|
+
* runs is part of the contract, so the peers refuse until the stage is redeployed.
|
|
51
66
|
*/
|
|
52
|
-
export const APPLY_CONTRACT_VERSION =
|
|
67
|
+
export const APPLY_CONTRACT_VERSION = 3;
|
|
53
68
|
|
|
54
69
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
55
70
|
export interface PlanClassification {
|
|
@@ -82,13 +97,29 @@ export interface EdgePlan {
|
|
|
82
97
|
*/
|
|
83
98
|
fpVersion?: number;
|
|
84
99
|
/**
|
|
85
|
-
* The predicted live fingerprint after apply — exact
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
100
|
+
* The predicted live fingerprint after apply — exact and unmodeled-aware. Its FLAVOR is
|
|
101
|
+
* `toFlavor`: a `full` endpoint (every plan minted since N3) carries the ungoverned live authz
|
|
102
|
+
* through, so it equals the post-apply RAW live hash and db:apply compares `from`-flavor live
|
|
103
|
+
* against it directly. A `governed`/absent endpoint predates the full flavor and db:apply
|
|
104
|
+
* REFUSES it — comparing a full live hash against a governed `to` would false-verify-fail after
|
|
105
|
+
* the DDL already landed.
|
|
90
106
|
*/
|
|
91
107
|
toFingerprint: string;
|
|
108
|
+
/**
|
|
109
|
+
* Which flavor `toFingerprint` is (milestone plan-endpoint-full). `full` carries the ungoverned
|
|
110
|
+
* live grants/policies/sequence ACLs through the prediction and is what db:apply verifies
|
|
111
|
+
* against. Absent means a pre-N3 plan whose `to` was governed — db:apply refuses it (re-mint).
|
|
112
|
+
*/
|
|
113
|
+
toFlavor?: 'governed' | 'full';
|
|
114
|
+
/**
|
|
115
|
+
* The content address of the default-privilege posture (`pg_default_acl`, tables + sequences,
|
|
116
|
+
* every schema and grantee) the FULL prediction consumed at mint. `from` never hashed it —
|
|
117
|
+
* default ACLs are not object state — so an `ALTER DEFAULT PRIVILEGES` between mint and apply
|
|
118
|
+
* passed the concurrency lock, the DDL landed, and verify-after failed POST-DDL. db:apply
|
|
119
|
+
* compares this against the live posture BEFORE dispatch and refuses on a difference; absent
|
|
120
|
+
* means a plan minted before the lock existed, and db:apply refuses that too (re-mint).
|
|
121
|
+
*/
|
|
122
|
+
defaultAclHash?: string;
|
|
92
123
|
/**
|
|
93
124
|
* The governed-role set `to` was hashed under, sorted — carried on the plan
|
|
94
125
|
* so every apply venue verifies with the exact set the mint used. Absent on
|
|
@@ -162,7 +193,7 @@ export function predictLiveFingerprint(
|
|
|
162
193
|
models: ModelDescriptor[],
|
|
163
194
|
snapshot: SchemaSnapshot,
|
|
164
195
|
contract: AuthzContract,
|
|
165
|
-
opts: { schema?: string; governedRoles?: ReadonlySet<string
|
|
196
|
+
opts: { schema?: string; governedRoles?: ReadonlySet<string>; carryUngoverned?: boolean } = {},
|
|
166
197
|
): string {
|
|
167
198
|
return predictLiveState(models, snapshot, contract, opts).hash;
|
|
168
199
|
}
|
|
@@ -176,14 +207,20 @@ export function predictLiveState(
|
|
|
176
207
|
models: ModelDescriptor[],
|
|
177
208
|
snapshot: SchemaSnapshot,
|
|
178
209
|
contract: AuthzContract,
|
|
179
|
-
opts: { schema?: string; governedRoles?: ReadonlySet<string
|
|
210
|
+
opts: { schema?: string; governedRoles?: ReadonlySet<string>; carryUngoverned?: boolean } = {},
|
|
180
211
|
): Fingerprint {
|
|
181
|
-
//
|
|
182
|
-
// models' governed set
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
212
|
+
// Two FLAVORS (milestone plan-endpoint-full):
|
|
213
|
+
// - GOVERNED (default): hash through the models' governed set. A DECLARED-vs-live comparison —
|
|
214
|
+
// the descent declares-test and db:check — must ignore the foreign grants the reconciler
|
|
215
|
+
// leaves alone, or it reads "drifted" against a state the models can never reach.
|
|
216
|
+
// - FULL (`carryUngoverned`): the plan's `to` endpoint. The reconciler converges the governed
|
|
217
|
+
// roles and leaves the foreign ones, so the post-apply truth is the declared contract PLUS
|
|
218
|
+
// the live ungoverned authz. Carry it through and hash UNFILTERED, so `to` equals the
|
|
219
|
+
// post-apply full live hash and an empty plan means the two fingerprints are equal.
|
|
220
|
+
const carry = opts.carryUngoverned === true;
|
|
186
221
|
const governedRoles = opts.governedRoles ?? governedRolesForModels(models);
|
|
222
|
+
const foreignGrantee = (role: string): boolean =>
|
|
223
|
+
!governedRoles.has(role) && !(role.toUpperCase() === 'PUBLIC' && governedRoles.has('PUBLIC'));
|
|
187
224
|
const declared = compileDeclaredState(models, { schema: opts.schema });
|
|
188
225
|
const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
|
|
189
226
|
const currentNames = new Set(snapshot.tables.map((t) => t.table));
|
|
@@ -214,16 +251,122 @@ export function predictLiveState(
|
|
|
214
251
|
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
215
252
|
],
|
|
216
253
|
};
|
|
254
|
+
// The live counterpart of a declared table: its rename or move source when the edge brings it
|
|
255
|
+
// in under a new name, else its own name. Grants, policies and owned sequences survive RENAME
|
|
256
|
+
// TABLE and SET SCHEMA alike (they attach by OID), so the foreign authz carries across both.
|
|
257
|
+
// A model cannot declare both, so the two maps never collide.
|
|
258
|
+
const priorNameOf: Record<string, string> = {
|
|
259
|
+
...compileTableMoves(models, { schema: opts.schema }),
|
|
260
|
+
...compileTableRenames(models, { schema: opts.schema }),
|
|
261
|
+
};
|
|
262
|
+
const liveNameFor = (declaredTable: string): string =>
|
|
263
|
+
!currentNames.has(declaredTable) && currentNames.has(priorNameOf[declaredTable] ?? '')
|
|
264
|
+
? priorNameOf[declaredTable]
|
|
265
|
+
: declaredTable;
|
|
266
|
+
const liveByTable = new Map(contract.tables.map((t) => [t.table, t]));
|
|
267
|
+
const declaredColumnsByTable = new Map(
|
|
268
|
+
declared.snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// The UNGOVERNED default-privilege grants that fire for an object created in `schema` — the
|
|
272
|
+
// schema-specific rows unioned with the all-schemas ('') rows, foreign grantees only. Postgres
|
|
273
|
+
// applies these at CREATE, so a plan-created table/sequence lands with them; the reconciler
|
|
274
|
+
// never touches a foreign role, so they persist. Governed roles are excluded — the reconciler
|
|
275
|
+
// sets those explicitly and the declared contract already models them.
|
|
276
|
+
const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
|
|
277
|
+
if (!scope) return {};
|
|
278
|
+
const out: Record<string, string[]> = {};
|
|
279
|
+
for (const key of ['', schema]) {
|
|
280
|
+
for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
|
|
281
|
+
if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// FULL flavor: each declared table's authz AFTER apply = declared contract + the live authz of
|
|
288
|
+
// the ungoverned roles, carried onto the columns the declared target keeps (carryUngovernedAuthz).
|
|
289
|
+
// A CREATED table (no live counterpart) has nothing to carry — but Postgres applies the schema's
|
|
290
|
+
// default privileges to it at creation, so fold the ungoverned default ACL in, or the endpoint
|
|
291
|
+
// omits a grant the apply lands and verify-after false-fails.
|
|
292
|
+
const declaredAuthz = carry
|
|
293
|
+
? declared.contract.tables.map((d) => {
|
|
294
|
+
const liveTable = liveByTable.get(liveNameFor(d.table));
|
|
295
|
+
if (liveTable) return carryUngovernedAuthz(d, liveTable, governedRoles, declaredColumnsByTable.get(d.table) ?? new Set());
|
|
296
|
+
const defaultGrants = defaultAclFor(contract.defaultAcls?.tables, schemaOf(d.table));
|
|
297
|
+
return Object.keys(defaultGrants).length > 0 ? { ...d, grants: { ...d.grants, ...defaultGrants } } : d;
|
|
298
|
+
})
|
|
299
|
+
: declared.contract.tables;
|
|
217
300
|
const mergedAuthz = [
|
|
218
|
-
...
|
|
301
|
+
...declaredAuthz,
|
|
219
302
|
...contract.tables.filter((t) => ridesThrough(t.table)),
|
|
220
303
|
];
|
|
304
|
+
|
|
221
305
|
// Sequence ACLs (v11): declared tables DERIVE their reconciled (inherited) target, so pass
|
|
222
306
|
// measured ACLs ONLY for rides-through tables — the ones reconciliation never touches, whose
|
|
223
307
|
// live ACLs carry through unchanged. Passing all measured would make a declared table's
|
|
224
308
|
// predicted endpoint read its pre-reconcile ACL and never converge with the plan it mints.
|
|
225
309
|
const ridesThroughSeqGrants = (contract.sequenceGrants ?? []).filter((s) => ridesThrough(s.table));
|
|
226
|
-
|
|
310
|
+
let sequenceGrants = ridesThroughSeqGrants;
|
|
311
|
+
if (carry) {
|
|
312
|
+
// FULL flavor, the sequence combine: a declared table's sequence lands with its GOVERNED
|
|
313
|
+
// grants DERIVED (the reconciler converges them onto the A6 inheritance) AND its FOREIGN
|
|
314
|
+
// grants MEASURED (the reconciler skips ungoverned roles, so their direct grants are
|
|
315
|
+
// unchanged). Deriving alone drops the foreign grant; passing the live measured grants reads
|
|
316
|
+
// a stale (pre-reconcile) governed shape. So synthesize one entry per live sequence whose
|
|
317
|
+
// grants = inherited-governed ∪ that-sequence's-foreign-live-grants, and hash UNFILTERED.
|
|
318
|
+
const liveSeqByTable = new Map<string, SequenceGrantContract[]>();
|
|
319
|
+
for (const s of contract.sequenceGrants ?? []) {
|
|
320
|
+
(liveSeqByTable.get(s.table) ?? liveSeqByTable.set(s.table, []).get(s.table)!).push(s);
|
|
321
|
+
}
|
|
322
|
+
const declaredColumnsFor = new Map(declared.snapshot.tables.map((t) => [t.table, t.columns]));
|
|
323
|
+
const liveColumnsFor = new Map(snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]));
|
|
324
|
+
const columnRenames = compileRenames(models, { schema: opts.schema });
|
|
325
|
+
const declaredSeqGrants: SequenceGrantContract[] = [];
|
|
326
|
+
for (const d of declaredAuthz) {
|
|
327
|
+
if (ridesThrough(d.table)) continue;
|
|
328
|
+
const inherited = inheritedSequenceGrants(d);
|
|
329
|
+
const liveName = liveNameFor(d.table);
|
|
330
|
+
for (const s of liveSeqByTable.get(liveName) ?? []) {
|
|
331
|
+
const foreign: Record<string, string[]> = {};
|
|
332
|
+
for (const [role, privileges] of Object.entries(s.grants)) {
|
|
333
|
+
if (foreignGrantee(role)) foreign[role] = privileges;
|
|
334
|
+
}
|
|
335
|
+
declaredSeqGrants.push({
|
|
336
|
+
sequence: s.sequence,
|
|
337
|
+
table: d.table,
|
|
338
|
+
grants: { ...inherited, ...foreign },
|
|
339
|
+
...(s.identity !== undefined ? { identity: s.identity } : {}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// The sequences THIS plan creates — every serial column of a created table, or a serial
|
|
343
|
+
// column ADDED to a live one. `plannedSerialSequences` grants by the diff's `createTable` /
|
|
344
|
+
// `addColumn` changes; this side has no diff in hand, so it reads the same facts off the
|
|
345
|
+
// live snapshot: a column is added when neither its own name nor its `renamedFrom` source
|
|
346
|
+
// exists live (a rename is a `renameColumn` change and creates nothing). Each lands with the
|
|
347
|
+
// governed inheritance the same plan grants (finish-0-4-x F1) PLUS the SEQUENCE default ACL
|
|
348
|
+
// Postgres applied at creation for the foreign roles. Identity columns own no nextval
|
|
349
|
+
// default and are excluded on both sides, so only serial columns count.
|
|
350
|
+
const liveColumns = liveColumnsFor.get(liveName);
|
|
351
|
+
const renamedFromOf = columnRenames[d.table] ?? {};
|
|
352
|
+
const existsLive = (column: string): boolean =>
|
|
353
|
+
liveColumns?.has(column) === true || (renamedFromOf[column] !== undefined && liveColumns?.has(renamedFromOf[column]) === true);
|
|
354
|
+
const seqDefaultGrants = defaultAclFor(contract.defaultAcls?.sequences, schemaOf(d.table));
|
|
355
|
+
(declaredColumnsFor.get(d.table) ?? [])
|
|
356
|
+
.filter((c) => ownSequenceName(d.table, c) != null && !existsLive(c.name))
|
|
357
|
+
.forEach((_, i) => declaredSeqGrants.push({
|
|
358
|
+
sequence: `${d.table}#created_seq_${i}`,
|
|
359
|
+
table: d.table,
|
|
360
|
+
grants: { ...seqDefaultGrants, ...inherited },
|
|
361
|
+
identity: false,
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
sequenceGrants = [...ridesThroughSeqGrants, ...declaredSeqGrants];
|
|
365
|
+
}
|
|
366
|
+
return fingerprintState(mergedSnapshot, mergedAuthz, {
|
|
367
|
+
governedRoles: carry ? undefined : governedRoles,
|
|
368
|
+
sequenceGrants,
|
|
369
|
+
});
|
|
227
370
|
}
|
|
228
371
|
|
|
229
372
|
/**
|
|
@@ -283,19 +426,22 @@ export function mintEdgePlan(
|
|
|
283
426
|
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
284
427
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
285
428
|
|
|
286
|
-
// The plan's two endpoints are
|
|
287
|
-
// - `from` is the RAW live hash (everything introspected) — the concurrency
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
429
|
+
// The plan's two endpoints are both FULL live hashes, deliberately:
|
|
430
|
+
// - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
|
|
431
|
+
// backup-stamp chain (backup.fp == plan.from) compare live-vs-live.
|
|
432
|
+
// - `to` is the FULL predicted endpoint: the declared state PLUS the ungoverned live authz the
|
|
433
|
+
// reconciler leaves alone (carryUngoverned). So `to` equals the post-apply raw live hash, an
|
|
434
|
+
// empty plan means `from == to`, and db:apply verifies the full landed hash against it.
|
|
435
|
+
// The governed set still rides on the plan — the descent declares-test and db:check use it — but
|
|
436
|
+
// the endpoint itself is full.
|
|
293
437
|
const governedRoles = governedRolesForModels(models, opts.governedRoles);
|
|
294
438
|
return {
|
|
295
439
|
v: PLAN_VERSION,
|
|
296
440
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
297
441
|
fpVersion: FINGERPRINT_VERSION,
|
|
298
|
-
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles }),
|
|
442
|
+
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true }),
|
|
443
|
+
toFlavor: 'full',
|
|
444
|
+
defaultAclHash: defaultAclHash(contract),
|
|
299
445
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
300
446
|
governedRoles: [...governedRoles].sort(),
|
|
301
447
|
statements,
|
|
@@ -384,6 +530,24 @@ export function tablesLeftWithoutARead(statements: readonly string[], live?: Aut
|
|
|
384
530
|
return dark.sort();
|
|
385
531
|
}
|
|
386
532
|
|
|
533
|
+
/**
|
|
534
|
+
* The content address of a contract's default-privilege posture — what `plan.defaultAclHash`
|
|
535
|
+
* carries and what db:apply recomputes over the live contract before dispatch. The WHOLE
|
|
536
|
+
* posture, not just the foreign slice the prediction folds in: a governed default ACL changes
|
|
537
|
+
* what a created object lands with too, and re-minting is free. A contract that never read
|
|
538
|
+
* default ACLs (an older recorder, a hand-built fixture) hashes as "not measured" — equal to
|
|
539
|
+
* itself, never to a measured one.
|
|
540
|
+
*
|
|
541
|
+
* `current_user`-relative, like the read it hashes (DEFAULT_ACL_SQL keys on the role that will
|
|
542
|
+
* create the objects). Mint and apply as DIFFERENT database roles that own different default
|
|
543
|
+
* ACLs, and the hashes differ with no change in between — a false refusal whose escape is one
|
|
544
|
+
* re-mint. The stage lanes share one operator role, so this only bites a mint over a local DSN
|
|
545
|
+
* applied through `--stage`, or two local DSNs — and a plan should be minted where it is applied.
|
|
546
|
+
*/
|
|
547
|
+
export function defaultAclHash(contract: Pick<AuthzContract, 'defaultAcls'>): string {
|
|
548
|
+
return createHash('sha256').update(stableStringify(contract.defaultAcls ?? null)).digest('hex');
|
|
549
|
+
}
|
|
550
|
+
|
|
387
551
|
/** The plan's content address — recorded as `plan_ref` on the schema_log row. */
|
|
388
552
|
export function planHash(plan: EdgePlan): string {
|
|
389
553
|
return createHash('sha256').update(stableStringify(plan)).digest('hex');
|
|
@@ -420,15 +584,42 @@ const short = (hash: string): string => hash.slice(0, 12);
|
|
|
420
584
|
* cutover, cli 0.4.77). The caller computes it at mint from the canonical forms; it is never
|
|
421
585
|
* stored on the plan.
|
|
422
586
|
*/
|
|
587
|
+
/**
|
|
588
|
+
* What accounts for an EMPTY edge between two DIFFERENT fingerprints — the `carriedBy` the
|
|
589
|
+
* summary prints. The `from` half is the full live fingerprint (the concurrency lock), the `to`
|
|
590
|
+
* half the declared endpoint through the governed set, and on a brownfield adopter they differ by
|
|
591
|
+
* exactly the roles the models do not govern: the reconciler leaves those grants alone. So when
|
|
592
|
+
* any ungoverned grantee is present, it IS the difference — name the roles and the baseline that
|
|
593
|
+
* admitted them (`db/authz-baseline.json`). Naming "35 tables differ" instead sent a consumer
|
|
594
|
+
* hunting a schema change that was not there (0.4.78 re-plan). Fall back to the raw divergent
|
|
595
|
+
* surfaces only when NO ungoverned grantee explains it — a live object the vocabulary cannot
|
|
596
|
+
* declare, say a policy on a governed role. Interim: the full fix carries the ungoverned authz
|
|
597
|
+
* through so the edge is truly empty (milestone plan-endpoint-full, N3).
|
|
598
|
+
*/
|
|
599
|
+
export function explainCarriedBy(
|
|
600
|
+
exemptions: readonly GrantExemption[],
|
|
601
|
+
divergent: readonly string[],
|
|
602
|
+
): string[] {
|
|
603
|
+
const grantees = [...new Set(exemptions.map((e) => e.grantee))].sort();
|
|
604
|
+
if (grantees.length > 0) {
|
|
605
|
+
return [`grants to baseline-adopted role(s) ${grantees.join(', ')} (${BASELINE_FILE})`];
|
|
606
|
+
}
|
|
607
|
+
return [...divergent];
|
|
608
|
+
}
|
|
609
|
+
|
|
423
610
|
export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] } = {}): string[] {
|
|
424
611
|
const lines: string[] = [];
|
|
425
612
|
lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
|
|
426
613
|
if (plan.statements.length === 0) {
|
|
614
|
+
const carried = opts.carriedBy?.length ? `; carried by: ${opts.carriedBy.join('; ')}` : '';
|
|
427
615
|
if (plan.fromFingerprint === plan.toFingerprint) {
|
|
428
|
-
|
|
616
|
+
// Condition (c), milestone plan-endpoint-full: the full endpoint carries the foreign grants
|
|
617
|
+
// through, so a brownfield adopter reaches from == to — but the operator must still see WHICH
|
|
618
|
+
// ungoverned roles ride through untouched, or the baseline they were admitted under is invisible.
|
|
619
|
+
lines.push('empty — the target already declares this state' + carried);
|
|
429
620
|
} else {
|
|
430
621
|
lines.push('empty — no statement to emit. The fingerprints differ by what the plan leaves alone: `from` is the target\'s full fingerprint (the concurrency lock), `to` the declared endpoint through the governed set'
|
|
431
|
-
+
|
|
622
|
+
+ carried + '.');
|
|
432
623
|
}
|
|
433
624
|
return lines;
|
|
434
625
|
}
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
19
|
-
import type { SchemaSnapshot } from './schema-introspect';
|
|
20
|
-
import type { AuthzContract } from './authz-contract';
|
|
19
|
+
import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
|
|
20
|
+
import type { AuthzContract, SequenceGrantContract } from './authz-contract';
|
|
21
21
|
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
|
|
22
22
|
import { compileTableContract } from './authz-compile';
|
|
23
23
|
import { emitReconcileSql, governedRoleSet } from './authz-reconcile';
|
|
24
|
-
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff';
|
|
24
|
+
import { diffSchema, emitSchemaSql, ownSequenceName, type SchemaChange } from './schema-diff';
|
|
25
|
+
import { schemaOf } from './schema-fingerprint';
|
|
25
26
|
|
|
26
27
|
export interface GenerateOptions {
|
|
27
28
|
/** The Postgres schema the Models live in. Default: `public`. */
|
|
@@ -233,12 +234,22 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
233
234
|
.filter(([, from]) => pendingMoveSources.has(from))
|
|
234
235
|
.map(([to, from]) => [from, to] as const),
|
|
235
236
|
]);
|
|
237
|
+
// The owned sequences ride a RENAME/SET SCHEMA with their table (the sequence keeps its
|
|
238
|
+
// own name), so their live ACLs are keyed under the new table name too — else the sequence
|
|
239
|
+
// reconcile finds nothing for a renamed table and its inheritance lags a plan behind.
|
|
236
240
|
const liveAuthzRenamed = opts.liveAuthz && pendingByFrom.size > 0
|
|
237
241
|
? {
|
|
238
242
|
...opts.liveAuthz,
|
|
239
243
|
tables: opts.liveAuthz.tables.map((t) =>
|
|
240
244
|
pendingByFrom.has(t.table) ? { ...t, table: pendingByFrom.get(t.table)! } : t,
|
|
241
245
|
),
|
|
246
|
+
...(opts.liveAuthz.sequenceGrants
|
|
247
|
+
? {
|
|
248
|
+
sequenceGrants: opts.liveAuthz.sequenceGrants.map((s) =>
|
|
249
|
+
pendingByFrom.has(s.table) ? { ...s, table: pendingByFrom.get(s.table)! } : s,
|
|
250
|
+
),
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
242
253
|
}
|
|
243
254
|
: opts.liveAuthz;
|
|
244
255
|
const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
|
|
@@ -246,6 +257,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
246
257
|
const authzPhase = liveAuthzRenamed
|
|
247
258
|
? emitReconcileSql(desiredAuthz, liveAuthzRenamed, {
|
|
248
259
|
governedRoles: governedRoleSet(desiredAuthz, opts.governedRoles ?? []),
|
|
260
|
+
createdSequences: plannedSerialSequences(changes),
|
|
249
261
|
})
|
|
250
262
|
: [];
|
|
251
263
|
|
|
@@ -295,6 +307,28 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
295
307
|
return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
296
308
|
}
|
|
297
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The serial sequences a diff's data phase CREATES — one per serial column in a `createTable`
|
|
312
|
+
* or `addColumn` change whose default draws the column's OWN conventional `<table>_<col>_seq`
|
|
313
|
+
* (the `serial` shorthand is what creates it). A foreign `nextval` draws an existing sequence
|
|
314
|
+
* and creates none; an identity column has no nextval default; a rename/move creates nothing
|
|
315
|
+
* (the live sequence rides along under its old name). Returned as live-shaped entries with NO
|
|
316
|
+
* grants, for `emitReconcileSql`'s `createdSequences` — the reconcile then grants the same
|
|
317
|
+
* plan's INSERT-holders their inheritance right after the CREATE that makes the sequence.
|
|
318
|
+
*/
|
|
319
|
+
export function plannedSerialSequences(changes: readonly SchemaChange[]): SequenceGrantContract[] {
|
|
320
|
+
const out: SequenceGrantContract[] = [];
|
|
321
|
+
const push = (table: string, c: ColumnSchema): void => {
|
|
322
|
+
const seq = ownSequenceName(table, c);
|
|
323
|
+
if (seq) out.push({ sequence: `${schemaOf(table)}.${seq}`, table, grants: {}, identity: false });
|
|
324
|
+
};
|
|
325
|
+
for (const change of changes) {
|
|
326
|
+
if (change.kind === 'createTable') for (const c of change.schema.columns) push(change.table, c);
|
|
327
|
+
else if (change.kind === 'addColumn') push(change.table, change.column);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
298
332
|
/** The marker drizzle migration files put between statements. */
|
|
299
333
|
const BREAKPOINT = '\n--> statement-breakpoint\n';
|
|
300
334
|
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -208,10 +208,21 @@ export function sequenceCreateSql(seq: SequenceSchema): string {
|
|
|
208
208
|
return `${s};`;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The bare name of the sequence a column OWNS through the `serial` shorthand — its conventional
|
|
213
|
+
* `<table>_<col>_seq` — or null: a foreign `nextval` draws an existing sequence and owns none,
|
|
214
|
+
* and an identity column has no nextval default. This is the one place that spells the
|
|
215
|
+
* convention; the CREATE/ADD renderer, the created-sequence grant, and the endpoint prediction
|
|
216
|
+
* all read it here so they cannot disagree about which columns make a sequence.
|
|
217
|
+
*/
|
|
218
|
+
export function ownSequenceName(table: string, col: ColumnSchema): string | null {
|
|
219
|
+
const seq = nextvalSequence(col.default);
|
|
220
|
+
return seq != null && seq === `${bareTable(table)}_${col.name}_seq` ? seq : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
/** True when a column's default draws a sequence that is NOT its own conventional one. */
|
|
212
224
|
function isForeignSequenceDefault(table: string, col: ColumnSchema): boolean {
|
|
213
|
-
|
|
214
|
-
return seq != null && seq !== `${bareTable(table)}_${col.name}_seq`;
|
|
225
|
+
return nextvalSequence(col.default) != null && ownSequenceName(table, col) == null;
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
// ---------------------------------------------------------------------------
|
|
@@ -888,8 +899,7 @@ function serialSpelling(col: ColumnSchema, table: string): string | null {
|
|
|
888
899
|
// ONLY the column's own conventional sequence is a serial declaration — the shorthand
|
|
889
900
|
// creates a sequence, so spelling it for a foreign `nextval` would mint a duplicate
|
|
890
901
|
// and silently detach the column from the sequence it declared.
|
|
891
|
-
|
|
892
|
-
if (seq == null || seq !== `${bareTable(table)}_${col.name}_seq`) return null;
|
|
902
|
+
if (ownSequenceName(table, col) == null) return null;
|
|
893
903
|
if (col.type === 'integer') return 'serial';
|
|
894
904
|
if (col.type === 'bigint') return 'bigserial';
|
|
895
905
|
return null;
|
|
@@ -301,8 +301,10 @@ export interface CanonicalizeOptions {
|
|
|
301
301
|
* The MEASURED serial-sequence ACLs (v11). A table present here is hashed from its live ACLs
|
|
302
302
|
* (so a REVOKE moves the fingerprint); a table absent DERIVES its ACLs from the A6 inheritance
|
|
303
303
|
* rule (the reconciled target). The live path passes every measured table; the declared path
|
|
304
|
-
* passes nothing; the plan's predicted endpoint passes
|
|
305
|
-
*
|
|
304
|
+
* passes nothing; the plan's predicted endpoint passes the rides-through tables measured — and
|
|
305
|
+
* in its FULL flavor (`carryUngoverned`) also one synthesized entry per declared table's
|
|
306
|
+
* sequence: its live sequences combined with the reconciled governed shape, plus the sequences
|
|
307
|
+
* the plan creates. See {@link canonicalSequenceAuthz}.
|
|
306
308
|
*/
|
|
307
309
|
sequenceGrants?: SequenceGrantContract[];
|
|
308
310
|
}
|
|
@@ -568,13 +570,14 @@ WHERE p.prosecdef
|
|
|
568
570
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
569
571
|
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
570
572
|
UNION ALL
|
|
571
|
-
-- A14
|
|
572
|
-
--
|
|
573
|
-
--
|
|
574
|
-
--
|
|
575
|
-
--
|
|
576
|
-
-- dropped or flipped ALWAYS to BY DEFAULT leaves
|
|
577
|
-
--
|
|
573
|
+
-- A14. A column's GENERATED … AS IDENTITY is READ, DECLARED and BUILT, but it is NOT hashed:
|
|
574
|
+
-- the fingerprint's coverage is a version contract, and a property entering the hash inside a
|
|
575
|
+
-- version makes two operators on different CLI builds disagree about the same database. v11
|
|
576
|
+
-- took the sequence ACLs and deliberately left this and the schema posture out; both stay
|
|
577
|
+
-- named here until a canon bump (a v12 candidate) carries them together. The gap is real and
|
|
578
|
+
-- narrow: an identity that is silently dropped or flipped ALWAYS to BY DEFAULT leaves the MATCH
|
|
579
|
+
-- claim green. It is not silent on the write path — db:generate/db:plan diff it (the differ
|
|
580
|
+
-- compares identity) — and the round-trip oracle's pg_dump comparison catches it.
|
|
578
581
|
SELECT 'identity column',
|
|
579
582
|
n.nspname || '.' || c.relname || '.' || a.attname ||
|
|
580
583
|
' → ' || CASE a.attidentity WHEN 'a' THEN 'always' ELSE 'by default' END
|