@everystack/cli 0.4.44 → 0.4.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.46",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.11"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* alter-type-dependents — the mint-time gate for a hard PostgreSQL rule:
|
|
3
|
+
* `ALTER COLUMN … SET DATA TYPE` fails when a view, materialized view, or
|
|
4
|
+
* rewrite rule references the column ("cannot alter type of a column used by
|
|
5
|
+
* a view or rule"), and it fails MID-TRANSACTION, at apply time.
|
|
6
|
+
*
|
|
7
|
+
* The plan lane's contract is "what it mints, it can apply" — so the mint
|
|
8
|
+
* refuses the plan up front, with the dependents NAMED, instead of letting the
|
|
9
|
+
* operator discover them from a rolled-back apply. (Proven live: the first-ever
|
|
10
|
+
* Z1a apply of a brownfield adoption plan failed exactly this way.) The route
|
|
11
|
+
* for a real type migration under dependents is deliberate: rebuild through
|
|
12
|
+
* `db:swap`, or drop and re-create the derived objects around the change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
16
|
+
|
|
17
|
+
export interface AlterTypeTarget {
|
|
18
|
+
table: string;
|
|
19
|
+
column: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BlockedAlterType extends AlterTypeTarget {
|
|
23
|
+
/** Qualified names of the views / matviews / rules that bind the column. */
|
|
24
|
+
dependents: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The (table, column) pairs a plan's `SET DATA TYPE` statements touch — read
|
|
29
|
+
* from the emitted artifact itself, the same way the destructive classifier
|
|
30
|
+
* reads it: the statement is what gets applied, so it is what gets checked.
|
|
31
|
+
*/
|
|
32
|
+
export function alterTypeStatementTargets(statements: readonly string[]): AlterTypeTarget[] {
|
|
33
|
+
const targets: AlterTypeTarget[] = [];
|
|
34
|
+
for (const s of statements) {
|
|
35
|
+
const m = s.match(/ALTER TABLE\s+(\S+)\s+ALTER COLUMN\s+"([^"]+)"\s+SET DATA TYPE\s/);
|
|
36
|
+
if (m) targets.push({ table: m[1], column: m[2] });
|
|
37
|
+
}
|
|
38
|
+
return targets;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const quoteLiteral = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The dependents PostgreSQL itself would refuse over: rewrite-rule entries
|
|
45
|
+
* (views, matviews, rules) that depend on the specific column. Whole-table
|
|
46
|
+
* dependencies (refobjsubid 0 — every view's FROM clause) are deliberately
|
|
47
|
+
* excluded: a view that never touches the column does not block the change.
|
|
48
|
+
*/
|
|
49
|
+
export function dependentsSql(target: AlterTypeTarget): string {
|
|
50
|
+
const rel = quoteLiteral(target.table);
|
|
51
|
+
return `
|
|
52
|
+
SELECT DISTINCT
|
|
53
|
+
CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
|
|
54
|
+
n.nspname || '.' || cl.relname AS name
|
|
55
|
+
FROM pg_depend d
|
|
56
|
+
JOIN pg_rewrite r ON r.oid = d.objid
|
|
57
|
+
JOIN pg_class cl ON cl.oid = r.ev_class
|
|
58
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
59
|
+
WHERE d.classid = 'pg_rewrite'::regclass
|
|
60
|
+
AND d.refclassid = 'pg_class'::regclass
|
|
61
|
+
AND d.refobjid = ${rel}::regclass
|
|
62
|
+
AND d.refobjsubid = (
|
|
63
|
+
SELECT attnum FROM pg_attribute
|
|
64
|
+
WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(target.column)}
|
|
65
|
+
)
|
|
66
|
+
AND cl.oid <> ${rel}::regclass
|
|
67
|
+
ORDER BY name;
|
|
68
|
+
`.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Query the live catalog for each target; return only the blocked ones. */
|
|
72
|
+
export async function findAlterTypeDependents(
|
|
73
|
+
runner: QueryRunner,
|
|
74
|
+
targets: readonly AlterTypeTarget[],
|
|
75
|
+
): Promise<BlockedAlterType[]> {
|
|
76
|
+
const blocked: BlockedAlterType[] = [];
|
|
77
|
+
for (const t of targets) {
|
|
78
|
+
const rows = await runner(dependentsSql(t));
|
|
79
|
+
if (rows.length > 0) {
|
|
80
|
+
blocked.push({ ...t, dependents: rows.map((r: any) => `${r.kind} ${r.name}`) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return blocked;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The refusal, with every dependent named — actionable, not archaeological. */
|
|
87
|
+
export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): string {
|
|
88
|
+
const lines = blocked.map(
|
|
89
|
+
(b) => ` ${b.table}."${b.column}" is bound by: ${b.dependents.join(', ')}`,
|
|
90
|
+
);
|
|
91
|
+
return [
|
|
92
|
+
`This plan changes the type of ${blocked.length} column(s) that a view or rule depends on — PostgreSQL would refuse the ALTER mid-transaction ("cannot alter type of a column used by a view or rule"), so the mint refuses it now:`,
|
|
93
|
+
...lines,
|
|
94
|
+
`Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { introspectContract, type QueryRunner, type AuthzContract } from './authz-contract.js';
|
|
33
|
+
import type { SessionRunner } from './session.js';
|
|
33
34
|
import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
|
|
34
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
|
|
35
36
|
import { fingerprintLive } from './schema-fingerprint.js';
|
|
37
|
+
import { governedLiveFingerprint } from './edge-plan.js';
|
|
36
38
|
import { applyGeneratedStatements } from './state-apply.js';
|
|
37
39
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
|
|
38
40
|
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan.js';
|
|
@@ -134,21 +136,30 @@ export interface LiveBaseFingerprint {
|
|
|
134
136
|
* destructive-apply safety gate (backup.fp == plan.from). A second copy would silently drift the
|
|
135
137
|
* fingerprint chain — the whole point of the gate.
|
|
136
138
|
*/
|
|
137
|
-
export async function liveBaseFingerprint(
|
|
138
|
-
const snapshot = await introspectSchema(
|
|
139
|
-
const contract = await introspectContract(
|
|
139
|
+
export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveBaseFingerprint> {
|
|
140
|
+
const snapshot = await introspectSchema(session);
|
|
141
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
140
142
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
144
146
|
export async function executeApplyPlan(
|
|
145
147
|
runner: QueryRunner,
|
|
148
|
+
session: SessionRunner,
|
|
146
149
|
plan: EdgePlan,
|
|
147
150
|
options: ApplyPlanOptions = {},
|
|
148
151
|
): Promise<ApplyPlanResult> {
|
|
149
|
-
const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(
|
|
152
|
+
const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(session);
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
// Two flavors, matched to what each check compares (see EdgePlan): the RAW
|
|
155
|
+
// hash for live-vs-live (the lock, the backup chain), the GOVERNED hash for
|
|
156
|
+
// declared-vs-live (already-applied here, verify-after below) — computed with
|
|
157
|
+
// the exact set the mint stamped on the plan. A plan minted before the field
|
|
158
|
+
// existed verifies raw, as it was minted.
|
|
159
|
+
const governed = plan.governedRoles ? new Set(plan.governedRoles) : undefined;
|
|
160
|
+
const governedLive = governed ? governedLiveFingerprint(before, beforeAuthz, governed) : live;
|
|
161
|
+
|
|
162
|
+
if (governedLive === plan.toFingerprint) {
|
|
152
163
|
return { status: 'already-applied', liveFingerprint: live };
|
|
153
164
|
}
|
|
154
165
|
const pre = checkPlanPrecondition(plan, live);
|
|
@@ -200,16 +211,19 @@ export async function executeApplyPlan(
|
|
|
200
211
|
now: options.now,
|
|
201
212
|
});
|
|
202
213
|
|
|
203
|
-
const { hash: landed } = await liveBaseFingerprint(
|
|
214
|
+
const { hash: landed, snapshot: after, contract: afterAuthz } = await liveBaseFingerprint(session);
|
|
204
215
|
if (result.logId !== undefined) {
|
|
216
|
+
// The log stamps the RAW landed hash — the same flavor as `from`, so the
|
|
217
|
+
// schema_log chain reads as one consistent series of live states.
|
|
205
218
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
206
219
|
}
|
|
207
220
|
|
|
208
|
-
|
|
221
|
+
const governedLanded = governed ? governedLiveFingerprint(after, afterAuthz, governed) : landed;
|
|
222
|
+
if (governedLanded !== plan.toFingerprint) {
|
|
209
223
|
return {
|
|
210
224
|
status: 'verify-failed',
|
|
211
225
|
liveFingerprint: landed,
|
|
212
|
-
reason: `applied, but the target landed on ${
|
|
226
|
+
reason: `applied, but the target landed on ${governedLanded.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
213
227
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
214
228
|
};
|
|
215
229
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-adoption-class — WHY does this statement exist?
|
|
3
|
+
*
|
|
4
|
+
* A brownfield adopter's first plan is long, and the length is not the problem. The problem is
|
|
5
|
+
* that nobody can tell which statements are the tool imposing its own spelling and which are
|
|
6
|
+
* real differences between the model and the database. This classifier answers that, per
|
|
7
|
+
* statement, mechanically.
|
|
8
|
+
*
|
|
9
|
+
* FOUR CLASSES, and the whole point is that the first one should reach zero:
|
|
10
|
+
*
|
|
11
|
+
* - `convention` — exists ONLY because we spell a semantically identical rule differently.
|
|
12
|
+
* Every one of these is our bug. Phase A's success criterion is zero.
|
|
13
|
+
* - `capability` — the model cannot express what is live, so the plan would remove it.
|
|
14
|
+
* Real, and a gap in everystack, not in their schema.
|
|
15
|
+
* - `narrowing` — a genuine reduction of access. Must ship, labeled, and be approved by a
|
|
16
|
+
* human before it is applied.
|
|
17
|
+
* - `dead` — the live object is dead code: policed but never granted, so it authorizes
|
|
18
|
+
* nothing today.
|
|
19
|
+
*
|
|
20
|
+
* NAME COLLISION, stated once so nobody conflates them. `edge-plan.ts` already has
|
|
21
|
+
* `PlanClassification.narrowings`, which means a DATA-LOSSY `ALTER COLUMN … TYPE`. This
|
|
22
|
+
* `narrowing` means an ACCESS reduction. They are two orthogonal axes on the same statement —
|
|
23
|
+
* one asks "what does it cost?", the other asks "why is it here?" — and both are correct in
|
|
24
|
+
* their own frame. This module deliberately does NOT extend `PlanClassification`.
|
|
25
|
+
*
|
|
26
|
+
* READ-ONLY. Nothing here changes what is emitted. It reasons over the same declared and live
|
|
27
|
+
* contracts the emitter does, and a test pins its statement list to the emitter's actual
|
|
28
|
+
* output so the two can never drift apart.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
32
|
+
import { governedRoleSet } from './authz-reconcile.js';
|
|
33
|
+
import { effectivePolicyCheck, holdsPrivilege, isPolicyDead, policyCommands } from './authz-contract.js';
|
|
34
|
+
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
|
|
35
|
+
|
|
36
|
+
export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
|
|
37
|
+
|
|
38
|
+
export interface ClassifiedStatement {
|
|
39
|
+
table: string;
|
|
40
|
+
/** The statement's subject — a policy name, or `grantee` for a privilege change. */
|
|
41
|
+
subject: string;
|
|
42
|
+
cls: AdoptionClass;
|
|
43
|
+
/** One line, in plain words, for the plan's report. */
|
|
44
|
+
why: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AdoptionCounts {
|
|
48
|
+
convention: number;
|
|
49
|
+
capability: number;
|
|
50
|
+
narrowing: number;
|
|
51
|
+
dead: number;
|
|
52
|
+
unclassified: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The reconciler's own exemption rule, mirrored: a grantee the declared authz does not govern
|
|
57
|
+
* is left alone. `PUBLIC` compares case-insensitively because it is a pseudo-role with two
|
|
58
|
+
* catalog spellings; every other name compares exactly.
|
|
59
|
+
*/
|
|
60
|
+
function isGovernedGrantee(governed: ReadonlySet<string>, grantee: string): boolean {
|
|
61
|
+
return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Does `outer` cover every role in `inner`?
|
|
68
|
+
*
|
|
69
|
+
* PUBLIC (`['public']`) covers everything — it is an open set. That fact is used HERE ONLY, to
|
|
70
|
+
* decide whether a live policy's replacement is narrower (a label). It is deliberately NOT
|
|
71
|
+
* available to the matcher: letting PUBLIC compare equal to an enumerated set there would be
|
|
72
|
+
* the false match the whole phase exists to prevent. Labeling cannot widen access; matching
|
|
73
|
+
* can.
|
|
74
|
+
*/
|
|
75
|
+
function covers(outer: readonly string[], inner: readonly string[]): boolean {
|
|
76
|
+
if (outer.includes('public')) return true;
|
|
77
|
+
const o = new Set(outer);
|
|
78
|
+
return inner.every((r) => o.has(r));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Same authorization on every axis EXCEPT which roles it names — the role-split test. */
|
|
82
|
+
function sameRule(a: PolicyContract, b: PolicyContract): boolean {
|
|
83
|
+
return a.command === b.command
|
|
84
|
+
&& a.permissive === b.permissive
|
|
85
|
+
&& (a.using ?? '') === (b.using ?? '')
|
|
86
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A policy's predicate, collapsed to one line for the report.
|
|
91
|
+
*
|
|
92
|
+
* Live predicates arrive from `pg_get_expr` with real newlines and indentation (an EXISTS
|
|
93
|
+
* subquery spans several lines), and the adoption report is one line per statement. Collapsing
|
|
94
|
+
* keeps the rule READABLE in place rather than referring the operator elsewhere to find it.
|
|
95
|
+
*/
|
|
96
|
+
function pred(p: PolicyContract): string {
|
|
97
|
+
const using = (p.using ?? 'true').replace(/\s+/g, ' ').trim();
|
|
98
|
+
return using === 'true' ? 'true (unfiltered)' : using;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `commandsOf` and the dead-policy test live in authz-contract — ONE definition, shared with
|
|
102
|
+
// the reconciler, the canonical form, and db:pull.
|
|
103
|
+
const commandsOf = policyCommands;
|
|
104
|
+
const isDead = isPolicyDead;
|
|
105
|
+
|
|
106
|
+
/** Classify the policy statements one table would emit. */
|
|
107
|
+
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
|
|
108
|
+
const table = d.table;
|
|
109
|
+
const m = matchPolicies(d.policies, l.policies);
|
|
110
|
+
const liveByName = new Map(l.policies.map((p) => [p.name, p]));
|
|
111
|
+
|
|
112
|
+
// Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
|
|
113
|
+
// covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
|
|
114
|
+
// This is what driving `convention` toward zero looks like: the statements stop existing
|
|
115
|
+
// rather than getting a nicer label.
|
|
116
|
+
|
|
117
|
+
const declaredByName = new Map(m.toCreate.map((p) => [p.name, p]));
|
|
118
|
+
const consumed = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const name of m.toDrop) {
|
|
121
|
+
const live = l.policies.find((p) => p.name === name)!;
|
|
122
|
+
|
|
123
|
+
// The reconciler LEAVES a policy scoped entirely to ungoverned roles alone, so no statement
|
|
124
|
+
// is emitted for it and there is nothing here to classify. Counting one would describe a
|
|
125
|
+
// plan nobody is going to apply — the same reason classifyGrants skips those grantees.
|
|
126
|
+
if (live.roles.length > 0 && live.roles.every((r) => !isGovernedGrantee(governed, r))) continue;
|
|
127
|
+
|
|
128
|
+
// A DEAD policy is left alone by the reconciler now (it authorizes nothing, so dropping it
|
|
129
|
+
// changes no access), which means no statement exists to classify — same as the ungoverned
|
|
130
|
+
// case above, and counting one would describe a plan nobody is going to apply. The adopter
|
|
131
|
+
// still hears about it: db:pull writes the finding into the model file, where it persists.
|
|
132
|
+
if (isDead(l, live)) continue;
|
|
133
|
+
|
|
134
|
+
// A replacement exists when some declared policy governs the same command. If those
|
|
135
|
+
// replacements together cover FEWER roles than the live policy did, the plan is reducing
|
|
136
|
+
// access — a narrowing, which must be approved rather than merely applied.
|
|
137
|
+
const replacements = m.toCreate.filter(
|
|
138
|
+
(c) => commandsOf(c).some((cmd) => commandsOf(live).includes(cmd)),
|
|
139
|
+
);
|
|
140
|
+
if (!replacements.length) {
|
|
141
|
+
out.push({ table, subject: name, cls: 'capability', why: `no declared policy governs ${commandsOf(live).join('/')} — the model cannot express this rule` });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
|
|
146
|
+
|
|
147
|
+
// The role split: one live policy covering several roles, against the per-role policies the
|
|
148
|
+
// compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
|
|
149
|
+
// so the two authorize identically for every session. Pure spelling — ours to fix (A5).
|
|
150
|
+
// Note this is recognized here for LABELING before the matcher can act on it; the two must
|
|
151
|
+
// agree once A5 lands, which is what the emitter-parity test pins.
|
|
152
|
+
if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
|
|
153
|
+
out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
|
|
154
|
+
for (const r of replacements) {
|
|
155
|
+
if (consumed.has(r.name)) continue;
|
|
156
|
+
consumed.add(r.name);
|
|
157
|
+
out.push({ table, subject: r.name, cls: 'convention', why: `the per-role half of "${name}"` });
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (covers(live.roles, replacementRoles) && !roleSetEqual(live.roles, replacementRoles)) {
|
|
163
|
+
out.push({ table, subject: name, cls: 'narrowing', why: `live applies to ${live.roles.join(', ')}; the declared replacement covers only ${replacementRoles.join(', ')}` });
|
|
164
|
+
for (const r of replacements) {
|
|
165
|
+
if (consumed.has(r.name)) continue;
|
|
166
|
+
consumed.add(r.name);
|
|
167
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The PREDICATE axis. Everything above this line decides on ROLE SETS alone, and that was
|
|
173
|
+
// the blind spot: a replacement holding the SAME roles with a different USING is a real
|
|
174
|
+
// access change that no role comparison can see. It fell to `unclassified` — a bucket with
|
|
175
|
+
// a count and, until E3, no detail — so the most dangerous shape this tool can emit was
|
|
176
|
+
// also its quietest. A live `(guard) AND (mine OR published)` read replaced by a
|
|
177
|
+
// `published`-only one is not silence; it is a replacement that READS as equivalent.
|
|
178
|
+
//
|
|
179
|
+
// Narrower vs. wider is not decidable here without a predicate lattice, so this takes the
|
|
180
|
+
// most severe reading, exactly as the destructive-statement partition does: an access change
|
|
181
|
+
// we cannot prove is identical is reported as a reduction, and BOTH predicates are carried
|
|
182
|
+
// so the operator reads the rule rather than the label.
|
|
183
|
+
const sameRoleReplacements = replacements.filter((r) => r.roles.some((role) => live.roles.includes(role)));
|
|
184
|
+
if (sameRoleReplacements.length && !sameRoleReplacements.some((r) => sameRule(r, live))) {
|
|
185
|
+
const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
|
|
186
|
+
out.push({
|
|
187
|
+
table,
|
|
188
|
+
subject: name,
|
|
189
|
+
cls: 'narrowing',
|
|
190
|
+
why:
|
|
191
|
+
`live applies to ${shared.join(', ')} USING ${pred(live)}; the declared replacement `
|
|
192
|
+
+ `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
|
|
193
|
+
+ `${sameRoleReplacements.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
|
|
194
|
+
});
|
|
195
|
+
for (const r of sameRoleReplacements) {
|
|
196
|
+
if (consumed.has(r.name)) continue;
|
|
197
|
+
consumed.add(r.name);
|
|
198
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const [name] of declaredByName) {
|
|
207
|
+
if (consumed.has(name)) continue;
|
|
208
|
+
out.push({ table, subject: name, cls: 'unclassified', why: `created with no live counterpart` });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Classify the privilege statements one table would emit. */
|
|
213
|
+
function classifyGrants(
|
|
214
|
+
d: TableContract,
|
|
215
|
+
l: TableContract,
|
|
216
|
+
governed: ReadonlySet<string>,
|
|
217
|
+
out: ClassifiedStatement[],
|
|
218
|
+
): void {
|
|
219
|
+
const table = d.table;
|
|
220
|
+
|
|
221
|
+
for (const grantee of Object.keys(l.grants).sort()) {
|
|
222
|
+
// The reconciler LEAVES ungoverned grantees alone — a migrator or BI reader the model
|
|
223
|
+
// vocabulary cannot name keeps its access, reported instead of revoked. It emits nothing
|
|
224
|
+
// for them, so neither may we: counting statements that are never emitted would describe
|
|
225
|
+
// a plan nobody is going to apply.
|
|
226
|
+
if (!isGovernedGrantee(governed, grantee)) continue;
|
|
227
|
+
const declared = new Set(d.grants[grantee] ?? []);
|
|
228
|
+
const live = l.grants[grantee] ?? [];
|
|
229
|
+
const revoked = live.filter((p) => !declared.has(p));
|
|
230
|
+
if (!revoked.length) continue;
|
|
231
|
+
|
|
232
|
+
// Every revoke here is now a NARROWING, including the beyond-CRUD trio.
|
|
233
|
+
//
|
|
234
|
+
// It used to be `convention` for REFERENCES/TRIGGER/TRUNCATE: `manage` means exactly CRUD,
|
|
235
|
+
// so live admin holding those three read as drift and the plan revoked them — 28 statements
|
|
236
|
+
// on the reference schema, none of which anybody asked for. The model has a word for them
|
|
237
|
+
// now (`privileges`), and `db:pull` transcribes what is live, so a pulled model emits
|
|
238
|
+
// nothing. A model that does NOT declare them is choosing to remove them, which is a real
|
|
239
|
+
// access reduction and gets labeled as one. The fix is at the source, not in the label.
|
|
240
|
+
out.push({ table, subject: grantee, cls: 'narrowing', why: `revokes ${revoked.join(', ')} from ${grantee}` });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Column grants: the model expresses column scoping for READ only, so a live column-scoped
|
|
244
|
+
// UPDATE has no declaration and the plan revokes it. That is a gap in everystack.
|
|
245
|
+
const lcg = l.columnGrants ?? {};
|
|
246
|
+
const dcg = d.columnGrants ?? {};
|
|
247
|
+
for (const grantee of Object.keys(lcg).sort()) {
|
|
248
|
+
if (!isGovernedGrantee(governed, grantee)) continue;
|
|
249
|
+
for (const priv of Object.keys(lcg[grantee]).sort()) {
|
|
250
|
+
const declaredCols = new Set(dcg[grantee]?.[priv] ?? []);
|
|
251
|
+
const revoked = (lcg[grantee][priv] ?? []).filter((c) => !declaredCols.has(c));
|
|
252
|
+
if (!revoked.length) continue;
|
|
253
|
+
out.push({ table, subject: `${grantee}/${priv}`, cls: 'capability', why: `column-scoped ${priv} on ${revoked.length} column(s) — the model declares columns for read only` });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Classify every authorization statement the plan would emit, per table.
|
|
260
|
+
*
|
|
261
|
+
* Live tables no model declares are skipped: the plan does not touch them, so they contribute
|
|
262
|
+
* no statements to classify.
|
|
263
|
+
*/
|
|
264
|
+
export function classifyAdoption(
|
|
265
|
+
declared: AuthzContract,
|
|
266
|
+
live: AuthzContract,
|
|
267
|
+
opts: { governedRoles?: ReadonlySet<string> } = {},
|
|
268
|
+
): {
|
|
269
|
+
statements: ClassifiedStatement[];
|
|
270
|
+
counts: AdoptionCounts;
|
|
271
|
+
} {
|
|
272
|
+
const governed = opts.governedRoles ?? governedRoleSet(declared);
|
|
273
|
+
const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
|
|
274
|
+
const statements: ClassifiedStatement[] = [];
|
|
275
|
+
|
|
276
|
+
for (const d of declared.tables) {
|
|
277
|
+
const l = liveByTable.get(d.table);
|
|
278
|
+
if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
|
|
279
|
+
classifyPolicies(d, l, statements, governed);
|
|
280
|
+
classifyGrants(d, l, governed, statements);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const counts: AdoptionCounts = { convention: 0, capability: 0, narrowing: 0, dead: 0, unclassified: 0 };
|
|
284
|
+
for (const s of statements) counts[s.cls]++;
|
|
285
|
+
return { statements, counts };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The report block for the plan surface. Terse: one line per class, then the offenders.
|
|
290
|
+
*
|
|
291
|
+
* `unclassified` is the one class whose count cannot be acted on by itself — its whole meaning
|
|
292
|
+
* is "read these before applying", and a bare number gives the reader nothing to read. The
|
|
293
|
+
* adopter who hit this said it best: a count with no way to act on it is the same shape as the
|
|
294
|
+
* problem the bucket was created to solve. So every unclassified statement is NAMED here, and
|
|
295
|
+
* the per-statement detail rides on the artifact too (`adoption` on the plan).
|
|
296
|
+
*
|
|
297
|
+
* Only `unclassified` is expanded. The others are counts on purpose — 19 narrowings listed
|
|
298
|
+
* inline is a wall nobody reads, and they are each described by their class.
|
|
299
|
+
*/
|
|
300
|
+
export function renderAdoptionReport(counts: AdoptionCounts, statements: readonly ClassifiedStatement[] = []): string[] {
|
|
301
|
+
const lines = [
|
|
302
|
+
` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
|
|
303
|
+
` capability ${counts.capability} — the model cannot express what is live`,
|
|
304
|
+
` narrowing ${counts.narrowing} — real access reduction, approve before applying`,
|
|
305
|
+
` dead ${counts.dead} — policed but never granted`,
|
|
306
|
+
];
|
|
307
|
+
if (counts.unclassified) {
|
|
308
|
+
lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying:`);
|
|
309
|
+
for (const s of statements.filter((s) => s.cls === 'unclassified')) {
|
|
310
|
+
lines.push(` ${s.table} "${s.subject}" — ${s.why}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import fs from 'node:fs/promises';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
+
import { FINGERPRINT_VERSION } from './schema-fingerprint.js';
|
|
39
40
|
import type { GrantExemption } from './authz-reconcile.js';
|
|
40
41
|
|
|
41
42
|
/** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
|
|
@@ -58,6 +59,15 @@ export interface BaselineStage {
|
|
|
58
59
|
observedAt: string;
|
|
59
60
|
/** The live base fingerprint at observation time — what the claim was true OF. */
|
|
60
61
|
fingerprint: string;
|
|
62
|
+
/**
|
|
63
|
+
* The FINGERPRINT_VERSION that hash was computed under.
|
|
64
|
+
*
|
|
65
|
+
* Stored beside the hash rather than only mixed into it, because a bare hash cannot say WHY
|
|
66
|
+
* it differs. Without this a baseline recorded under an older canonical form reads as drift,
|
|
67
|
+
* and an operator goes looking for a change nobody made instead of running one re-baseline.
|
|
68
|
+
* Absent on baselines written before v4.
|
|
69
|
+
*/
|
|
70
|
+
fpVersion?: number;
|
|
61
71
|
grantees: Record<string, BaselineGrantee>;
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -71,7 +81,7 @@ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
|
|
|
71
81
|
/** Fold the observed exemptions into one entry per grantee. */
|
|
72
82
|
export function buildStageBaseline(
|
|
73
83
|
exemptions: readonly GrantExemption[],
|
|
74
|
-
meta: { observedAt: string; fingerprint: string },
|
|
84
|
+
meta: { observedAt: string; fingerprint: string; fpVersion?: number },
|
|
75
85
|
): BaselineStage {
|
|
76
86
|
const grantees: Record<string, BaselineGrantee> = {};
|
|
77
87
|
for (const e of exemptions) {
|
|
@@ -83,7 +93,12 @@ export function buildStageBaseline(
|
|
|
83
93
|
const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
|
|
84
94
|
g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
|
|
85
95
|
}
|
|
86
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
observedAt: meta.observedAt,
|
|
98
|
+
fingerprint: meta.fingerprint,
|
|
99
|
+
fpVersion: meta.fpVersion ?? FINGERPRINT_VERSION,
|
|
100
|
+
grantees,
|
|
101
|
+
};
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
/** Write one stage's entry into the file, leaving every other stage untouched. */
|
|
@@ -105,7 +120,14 @@ export function renderBaseline(baseline: AuthzBaseline): string {
|
|
|
105
120
|
const { privileges, tables, write } = s.grantees[g];
|
|
106
121
|
grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
|
|
107
122
|
}
|
|
108
|
-
|
|
123
|
+
// `fpVersion` rides through the render, or the stamp is lost on the first rewrite and the
|
|
124
|
+
// baseline silently becomes unstamped — which reads as a format change forever after.
|
|
125
|
+
stages[stage] = {
|
|
126
|
+
observedAt: s.observedAt,
|
|
127
|
+
fingerprint: s.fingerprint,
|
|
128
|
+
...(s.fpVersion != null ? { fpVersion: s.fpVersion } : {}),
|
|
129
|
+
grantees,
|
|
130
|
+
};
|
|
109
131
|
}
|
|
110
132
|
return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
|
|
111
133
|
}
|