@everystack/cli 0.4.40 → 0.4.43
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/apply-execute.ts +20 -1
- package/src/cli/authz-baseline.ts +227 -0
- package/src/cli/authz-compile.ts +46 -4
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-reconcile.ts +131 -22
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-apply.ts +18 -0
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-check.ts +45 -0
- package/src/cli/commands/db-generate.ts +29 -3
- package/src/cli/commands/db-plan.ts +54 -0
- package/src/cli/commands/db-pull.ts +40 -0
- package/src/cli/commands/db-sync.ts +4 -0
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/edge-plan.ts +84 -6
- package/src/cli/migration-generate.ts +12 -2
- package/src/cli/model-render.ts +55 -20
- package/src/cli/schema-compile.ts +25 -2
- package/src/cli/schema-diff.ts +26 -1
- package/src/cli/state-apply.ts +127 -2
package/src/cli/edge-plan.ts
CHANGED
|
@@ -29,21 +29,32 @@ import type { ModelDescriptor } from '@everystack/model';
|
|
|
29
29
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
30
30
|
import type { AuthzContract } from './authz-contract.js';
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
|
-
import {
|
|
32
|
+
import { compileTableContract } from './authz-compile.js';
|
|
33
|
+
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
|
|
33
34
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
35
|
import { compileDeclaredState } from './declared-diff.js';
|
|
35
36
|
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
36
37
|
|
|
37
38
|
export const PLAN_VERSION = 2;
|
|
38
39
|
|
|
39
|
-
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
|
|
40
|
+
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
40
41
|
export interface PlanClassification {
|
|
41
|
-
/**
|
|
42
|
+
/** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
|
|
43
|
+
* computing it by subtraction made "safe" the default for anything unrecognized. */
|
|
42
44
|
additive: number;
|
|
43
45
|
/** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
|
|
44
46
|
drops: number;
|
|
45
47
|
/** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
|
|
46
48
|
narrowings: number;
|
|
49
|
+
/** REVOKEs against a grantee no model declares — nothing re-derives that access.
|
|
50
|
+
* Optional: a plan minted before this classification existed has no field for it. */
|
|
51
|
+
strips?: number;
|
|
52
|
+
/** `DROP POLICY` / `REVOKE` / `DISABLE RLS` — authorization removed, no row lost. Not
|
|
53
|
+
* destructive (it re-declares from the models) and emphatically not additive. */
|
|
54
|
+
authzRemovals?: number;
|
|
55
|
+
/** Statements the classifier does not recognize. Never additive — a plan carrying these
|
|
56
|
+
* has not been fully described and a human should read them before it is applied. */
|
|
57
|
+
unclassified?: number;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
export interface EdgePlan {
|
|
@@ -72,6 +83,12 @@ export interface MintOptions {
|
|
|
72
83
|
allowDrops?: boolean;
|
|
73
84
|
gitRef?: string | null;
|
|
74
85
|
actor?: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* Roles the modules declare as governed beyond the ones the models name. A live grantee
|
|
88
|
+
* outside the governed set is left untouched rather than revoked — without this the
|
|
89
|
+
* MINTED PLAN carries those revokes, which is where they would actually be applied.
|
|
90
|
+
*/
|
|
91
|
+
governedRoles?: string[];
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
/**
|
|
@@ -145,6 +162,7 @@ export function mintEdgePlan(
|
|
|
145
162
|
schema: opts.schema,
|
|
146
163
|
allowDrops: opts.allowDrops,
|
|
147
164
|
liveAuthz: contract,
|
|
165
|
+
governedRoles: opts.governedRoles,
|
|
148
166
|
});
|
|
149
167
|
const classified = classifyGeneratedStatements(statements);
|
|
150
168
|
if (classified.heldDrops.length > 0) {
|
|
@@ -158,8 +176,16 @@ export function mintEdgePlan(
|
|
|
158
176
|
// (the authz phase is data-safe by construction); dropping a table,
|
|
159
177
|
// column, or type is not — and neither is a narrowing type change, which
|
|
160
178
|
// is data loss wearing an ALTER.
|
|
161
|
-
|
|
162
|
-
|
|
179
|
+
// The grantees the models actually declare — the discriminator for a STRIP (a revoke
|
|
180
|
+
// against a role no model grants to has nothing to restore it from, so it carries the
|
|
181
|
+
// same ceremony as a dropped column).
|
|
182
|
+
const declaredGrantees = new Set<string>();
|
|
183
|
+
for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
|
|
184
|
+
for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
|
|
185
|
+
for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
|
|
186
|
+
}
|
|
187
|
+
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
188
|
+
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
163
189
|
|
|
164
190
|
return {
|
|
165
191
|
v: PLAN_VERSION,
|
|
@@ -170,9 +196,14 @@ export function mintEdgePlan(
|
|
|
170
196
|
executable: classified.executable.length,
|
|
171
197
|
destructive,
|
|
172
198
|
classification: {
|
|
173
|
-
|
|
199
|
+
// Counted, never inferred by subtraction — see partitionStatements. A statement nobody
|
|
200
|
+
// recognized is `unclassified`, and it must never be able to present as additive.
|
|
201
|
+
additive: breakdown.additive.length,
|
|
174
202
|
drops: breakdown.drops.length,
|
|
175
203
|
narrowings: breakdown.narrowings.length,
|
|
204
|
+
...(breakdown.strips.length > 0 ? { strips: breakdown.strips.length } : {}),
|
|
205
|
+
...(breakdown.authzRemovals.length > 0 ? { authzRemovals: breakdown.authzRemovals.length } : {}),
|
|
206
|
+
...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
|
|
176
207
|
},
|
|
177
208
|
notices: classified.notices.length,
|
|
178
209
|
unmodeled: unmodeledTables(models, snapshot),
|
|
@@ -181,6 +212,35 @@ export function mintEdgePlan(
|
|
|
181
212
|
};
|
|
182
213
|
}
|
|
183
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Tables this plan leaves with no SELECT-admitting policy — the "goes dark" set.
|
|
217
|
+
*
|
|
218
|
+
* A `DROP POLICY` is cheap to reverse and loses no row, which is why it is not gated. But when
|
|
219
|
+
* a plan drops every policy that admitted a read and creates none in its place, RLS is still
|
|
220
|
+
* enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
|
|
221
|
+
* adoption plan that was 11 tables, including the users table, and it printed no notice at all.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately conservative: it only names a table when the plan drops a read-admitting policy
|
|
224
|
+
* and adds none back for that table. It cannot know what other policies exist live, so it
|
|
225
|
+
* under-reports rather than crying wolf.
|
|
226
|
+
*/
|
|
227
|
+
export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
|
|
228
|
+
const dropped = new Map<string, number>();
|
|
229
|
+
const created = new Set<string>();
|
|
230
|
+
for (const statement of statements) {
|
|
231
|
+
const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
|
|
232
|
+
let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?\S+\s+ON\s+(\S+?);?$/i.exec(head);
|
|
233
|
+
if (m) {
|
|
234
|
+
dropped.set(m[1], (dropped.get(m[1]) ?? 0) + 1);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
|
|
238
|
+
// Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
|
|
239
|
+
if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) created.add(m[1]);
|
|
240
|
+
}
|
|
241
|
+
return [...dropped.keys()].filter((t) => !created.has(t)).sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
184
244
|
/** The plan's content address — recorded as `plan_ref` on the schema_log row. */
|
|
185
245
|
export function planHash(plan: EdgePlan): string {
|
|
186
246
|
return createHash('sha256').update(stableStringify(plan)).digest('hex');
|
|
@@ -223,6 +283,24 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
223
283
|
lines.push(`! ${ddl.trim()}`);
|
|
224
284
|
}
|
|
225
285
|
}
|
|
286
|
+
// Authorization removed loses no row, so it is not gated — but it decides who can SEE the
|
|
287
|
+
// rows, and a plan that drops a table's only read policy leaves that table returning nothing.
|
|
288
|
+
// 11 tables went dark in a real adoption plan that printed "0 notice(s)".
|
|
289
|
+
const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
|
|
290
|
+
const dark = tablesLeftWithoutARead(plan.statements);
|
|
291
|
+
if (authzRemovals.length > 0) {
|
|
292
|
+
lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
|
|
293
|
+
if (dark.length > 0) {
|
|
294
|
+
lines.push(`! ${dark.length} table(s) end this plan with NO read policy and RLS still on — they will return zero rows: ${dark.join(', ')}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (unclassified.length > 0) {
|
|
298
|
+
lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
|
|
299
|
+
for (const statement of unclassified) {
|
|
300
|
+
const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
|
|
301
|
+
lines.push(`! ${ddl.trim()}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
226
304
|
if (plan.unmodeled.length > 0) {
|
|
227
305
|
lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
|
|
228
306
|
}
|
|
@@ -20,7 +20,7 @@ import type { SchemaSnapshot } from './schema-introspect.js';
|
|
|
20
20
|
import type { AuthzContract } from './authz-contract.js';
|
|
21
21
|
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
|
|
22
22
|
import { compileTableContract } from './authz-compile.js';
|
|
23
|
-
import { emitReconcileSql } from './authz-reconcile.js';
|
|
23
|
+
import { emitReconcileSql, governedRoleSet } from './authz-reconcile.js';
|
|
24
24
|
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
25
25
|
|
|
26
26
|
export interface GenerateOptions {
|
|
@@ -44,6 +44,13 @@ export interface GenerateOptions {
|
|
|
44
44
|
* removed table/enum becomes a drop (still held by the `allowDrops` gate).
|
|
45
45
|
*/
|
|
46
46
|
scope?: 'declared' | 'full';
|
|
47
|
+
/**
|
|
48
|
+
* Roles the modules declare as governed BEYOND the ones the models name
|
|
49
|
+
* (`moduleGovernedRoles`). A live grantee outside the governed set is left untouched
|
|
50
|
+
* rather than revoked — see authz-reconcile's `governedRoleSet`. Widen-only: this can
|
|
51
|
+
* never stop a model-named role being governed.
|
|
52
|
+
*/
|
|
53
|
+
governedRoles?: string[];
|
|
47
54
|
/**
|
|
48
55
|
* The live authorization contract (`introspectContract`). When provided, the migration
|
|
49
56
|
* carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
|
|
@@ -208,8 +215,11 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
208
215
|
}
|
|
209
216
|
: opts.liveAuthz;
|
|
210
217
|
const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
|
|
218
|
+
const desiredAuthz: AuthzContract = { tables: desiredContracts, functions: [] };
|
|
211
219
|
const authzPhase = liveAuthzRenamed
|
|
212
|
-
? emitReconcileSql(
|
|
220
|
+
? emitReconcileSql(desiredAuthz, liveAuthzRenamed, {
|
|
221
|
+
governedRoles: governedRoleSet(desiredAuthz, opts.governedRoles ?? []),
|
|
222
|
+
})
|
|
213
223
|
: [];
|
|
214
224
|
|
|
215
225
|
// 0a-bis (the diff analog of compileMigration's). A role cannot reach a table in a
|
package/src/cli/model-render.ts
CHANGED
|
@@ -88,40 +88,74 @@ export interface RenderOptions {
|
|
|
88
88
|
* dominant brownfield shape (public reference data, admin-managed). Anything else is a
|
|
89
89
|
* per-model edit — the decision belongs in the file, not in a flag grammar.
|
|
90
90
|
*/
|
|
91
|
-
export const ABILITY_PRESETS: Record<string, string> = {
|
|
92
|
-
'public-read': `
|
|
91
|
+
export const ABILITY_PRESETS: Record<string, string[]> = {
|
|
92
|
+
'public-read': [`can('read')`, `can('manage', { role: 'admin' })`],
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* A rendered ability that is a PUBLIC read — anon-visible, the only shape the soft-delete
|
|
97
|
+
* guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
|
|
98
|
+
* `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
|
|
99
|
+
* so one ability's `role:` can never mask another's public read.
|
|
99
100
|
*/
|
|
100
|
-
function
|
|
101
|
+
function isPublicReadAbility(expr: string): boolean {
|
|
102
|
+
return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The scaffold stanza for one model, plus whether it declares a public read — the renderer
|
|
107
|
+
* needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
|
|
108
|
+
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
109
|
+
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
110
|
+
*/
|
|
111
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
|
|
101
112
|
if (mode === 'live') {
|
|
102
113
|
const contract = table && liveAuthz?.get(table.table);
|
|
103
114
|
if (!contract) {
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
return {
|
|
116
|
+
text: [
|
|
117
|
+
' // no live authorization found for this table — nothing was granted, so nothing is',
|
|
118
|
+
' // rendered. Author the read model deliberately, or leave it internal:',
|
|
119
|
+
' // private: true,',
|
|
120
|
+
].join('\n'),
|
|
121
|
+
publicRead: false,
|
|
122
|
+
};
|
|
109
123
|
}
|
|
110
|
-
|
|
124
|
+
const derived = deriveAbilities(contract);
|
|
125
|
+
return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
|
|
111
126
|
}
|
|
112
127
|
if (mode === 'commented') {
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
return {
|
|
129
|
+
text: [
|
|
130
|
+
' // Declare the read model — db:check fails this model until its authz is authored:',
|
|
131
|
+
` // abilities: [can('read')], // public data`,
|
|
132
|
+
` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
|
|
133
|
+
' // private: true, // not part of the data API',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
// Nothing is stamped uncommented, so the model declares no read at all yet.
|
|
136
|
+
publicRead: false,
|
|
137
|
+
};
|
|
119
138
|
}
|
|
120
139
|
const preset = ABILITY_PRESETS[mode];
|
|
121
140
|
if (!preset) {
|
|
122
141
|
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
123
142
|
}
|
|
124
|
-
return ` ${preset}
|
|
143
|
+
return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The `softDelete` line, when the model would otherwise fail to define.
|
|
148
|
+
*
|
|
149
|
+
* `defineModel` refuses to guess for a table that has `deleted_at` AND a public read: the
|
|
150
|
+
* guard decides what anonymous users can see, and neither default is safe. A pull renders
|
|
151
|
+
* `false` — LIVE reality is the truth being transcribed, and no live policy carries a guard
|
|
152
|
+
* nobody wrote. The comment says how to get the other one, so the decision is visible in the
|
|
153
|
+
* file rather than buried in a compiler convention.
|
|
154
|
+
*/
|
|
155
|
+
function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
|
|
156
|
+
const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
|
|
157
|
+
if (!hasColumn || !publicRead) return '';
|
|
158
|
+
return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
|
|
125
159
|
}
|
|
126
160
|
|
|
127
161
|
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
@@ -448,8 +482,9 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
448
482
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
449
483
|
// put it, proving the position is mechanical-edit-friendly).
|
|
450
484
|
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
485
|
+
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
451
486
|
|
|
452
|
-
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
487
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
453
488
|
}
|
|
454
489
|
|
|
455
490
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -223,12 +223,35 @@ function defaultExpr(spec: FieldSpec): string | null {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
226
|
+
* True when PostgreSQL's own `array_out` would quote this element: it is empty, it is the
|
|
227
|
+
* literal `NULL` (which unquoted means the SQL null), or it contains a delimiter, a brace, a
|
|
228
|
+
* quote, a backslash, or whitespace.
|
|
229
|
+
*/
|
|
230
|
+
function needsArrayQuote(v: string): boolean {
|
|
231
|
+
return v === '' || /^NULL$/i.test(v) || /[{},"\\\s]/.test(v);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A Postgres array literal for a default — `[]` → `'{}'`, `['User']` → `'{User}'`,
|
|
236
|
+
* `['a b']` → `'{"a b"}'`.
|
|
237
|
+
*
|
|
238
|
+
* QUOTING MATTERS: this must match `array_out` exactly, because the value round-trips
|
|
239
|
+
* through the live catalog. Quoting every element unconditionally produced `'{"User"}'`
|
|
240
|
+
* where PostgreSQL deparses `'{User}'`, so the declared and live defaults never compared
|
|
241
|
+
* equal and `db:generate` re-emitted the same `SET DEFAULT` on every run, forever.
|
|
242
|
+
*
|
|
243
|
+
* Backslashes escape before quotes, or `\` would become `\\"` — the escaping bug the
|
|
244
|
+
* previous version also carried (it escaped `"` and left `\` alone).
|
|
245
|
+
*
|
|
227
246
|
* The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
|
|
228
247
|
* strips, so the bare literal round-trips.
|
|
229
248
|
*/
|
|
230
249
|
function arrayLiteral(arr: unknown[]): string {
|
|
231
|
-
const elems = arr.map((v) =>
|
|
250
|
+
const elems = arr.map((v) => {
|
|
251
|
+
if (typeof v !== 'string') return String(v);
|
|
252
|
+
if (!needsArrayQuote(v)) return v;
|
|
253
|
+
return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
254
|
+
});
|
|
232
255
|
return `'{${elems.join(',')}}'`;
|
|
233
256
|
}
|
|
234
257
|
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -220,6 +220,20 @@ function argsWiden(from: number[], to: number[]): boolean {
|
|
|
220
220
|
return to.every((v, i) => from[i] == null || v >= from[i]);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Types whose entire meaning survives a trip through `text` — scalars and strings whose
|
|
225
|
+
* printed form IS their value. Everything else (postgis geometry, tsvector, hstore, ltree,
|
|
226
|
+
* arrays, composites, ranges, and any extension type) carries structure the cast discards,
|
|
227
|
+
* so it is deliberately NOT listed: the default for an unknown base is "not free".
|
|
228
|
+
*/
|
|
229
|
+
const TEXT_ROUND_TRIPS = new Set([
|
|
230
|
+
'text', 'character varying', 'character', 'citext', 'name', 'uuid', 'boolean',
|
|
231
|
+
'smallint', 'integer', 'bigint', 'numeric', 'decimal', 'real', 'double precision',
|
|
232
|
+
'date', 'time', 'time without time zone', 'time with time zone',
|
|
233
|
+
'timestamp', 'timestamp without time zone', 'timestamp with time zone',
|
|
234
|
+
'json', 'jsonb', 'inet', 'cidr', 'macaddr', 'bytea', 'interval',
|
|
235
|
+
]);
|
|
236
|
+
|
|
223
237
|
/**
|
|
224
238
|
* Classify a column type change. Conservative by construction — anything not provably safe
|
|
225
239
|
* or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
|
|
@@ -230,7 +244,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
|
|
|
230
244
|
const f = parseType(from);
|
|
231
245
|
const t = parseType(to);
|
|
232
246
|
|
|
233
|
-
|
|
247
|
+
// `→ text` is only free from a type text can round-trip. It IS the universal sink for the
|
|
248
|
+
// printable VALUE — but a structured type loses its meaning, not its characters: postgis
|
|
249
|
+
// `geometry` → text takes every spatial index and `ST_*` call with it, `tsvector` → text
|
|
250
|
+
// kills the full-text index, `hstore` → text destroys the key/value structure. None of that
|
|
251
|
+
// is recoverable by casting back.
|
|
252
|
+
//
|
|
253
|
+
// This mattered far past taxonomy. A `safe` verdict emits a BARE `SET DATA TYPE` with no
|
|
254
|
+
// WARNING, and the plan classifier only counts a narrowing when it sees the `USING` cast a
|
|
255
|
+
// non-safe verdict adds — so 48 postgis/tsvector/hstore/`timestamp(6)` rewrites in a real
|
|
256
|
+
// adoption plan were reported as additive with `destructive: 0`, which is also the number
|
|
257
|
+
// that gates `--confirm` + snapshot. Calling these lossy is what arms the gate.
|
|
258
|
+
if (t.base === 'text') return TEXT_ROUND_TRIPS.has(f.base) ? 'safe' : 'lossy';
|
|
234
259
|
|
|
235
260
|
const fr = NUMERIC_RANK[f.base];
|
|
236
261
|
const tr = NUMERIC_RANK[t.base];
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -71,22 +71,147 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
|
|
|
71
71
|
*
|
|
72
72
|
* Recoverable metadata is never destructive: policies, grants, constraints,
|
|
73
73
|
* defaults, nullability all re-declare from the models without touching a row.
|
|
74
|
+
*
|
|
75
|
+
* strips — the ONE exception to that rule. A REVOKE against a grantee NO model
|
|
76
|
+
* declares is not recoverable from the models: there is no declaration to
|
|
77
|
+
* re-derive it from, and the role simply loses its access. This is the
|
|
78
|
+
* deliberate case left over once ungoverned grantees stopped being revoked
|
|
79
|
+
* by accident (authz-reconcile's governedRoleSet) — someone put a role in
|
|
80
|
+
* `governedRoles` and then granted it nothing, which reads as "strip it".
|
|
81
|
+
* Deliberate removal deserves the same ceremony as a dropped column.
|
|
82
|
+
* Framework roles (anon/authenticated/admin/PUBLIC) are excluded: revoking
|
|
83
|
+
* from those is ordinary authz churn and re-declaring an ability restores it.
|
|
74
84
|
*/
|
|
75
85
|
export interface DestructiveBreakdown {
|
|
76
86
|
drops: string[];
|
|
77
87
|
narrowings: string[];
|
|
88
|
+
/** REVOKEs that leave an undeclared grantee with nothing to restore it from. */
|
|
89
|
+
strips: string[];
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Statements that positively ADD — the only ones a plan may call additive.
|
|
94
|
+
*
|
|
95
|
+
* `additive` used to be computed as `executable.length - destructive`, which made it the
|
|
96
|
+
* DEFAULT rather than a finding: any statement the classifier did not recognize was reported
|
|
97
|
+
* as safe. A real adoption plan carrying 38 DROP POLICY, 43 REVOKE and 48 column rewrites
|
|
98
|
+
* described itself as `additive: 158, destructive: 0`, and since `destructive > 0` is what
|
|
99
|
+
* gates `--confirm` + snapshot + the approver set, the false zero did not merely mislabel the
|
|
100
|
+
* plan — it disarmed the gate.
|
|
101
|
+
*
|
|
102
|
+
* So the default is inverted. A statement is additive only when it matches one of these; a
|
|
103
|
+
* statement nobody recognized is `unclassified`, and `unclassified` is never additive. This is
|
|
104
|
+
* the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
|
|
105
|
+
* ALONE and say so, never "assume it is fine".
|
|
106
|
+
*/
|
|
107
|
+
const ADDITIVE_MATCHERS: RegExp[] = [
|
|
108
|
+
/^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
|
|
109
|
+
/^GRANT\b/,
|
|
110
|
+
/^COMMENT\s+ON\b/,
|
|
111
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
|
|
112
|
+
// Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
|
|
113
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
|
|
114
|
+
// Relaxing nullability and setting a default add capability; they remove nothing.
|
|
115
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
|
|
116
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Statements that REMOVE an authorization without losing a row.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately NOT folded into `destructive`, which means data loss and whose taxonomy is
|
|
123
|
+
* defended above — a dropped policy or grant re-declares from the models without touching a
|
|
124
|
+
* row. But it is emphatically not ADDITIVE either, and that was the lie: a `DROP POLICY` that
|
|
125
|
+
* removes a table's only read policy leaves the table returning zero rows, which is the single
|
|
126
|
+
* highest-consequence thing an adoption plan can carry and it was reported as an addition.
|
|
127
|
+
*/
|
|
128
|
+
const AUTHZ_REMOVAL_MATCHERS: RegExp[] = [
|
|
129
|
+
/^DROP\s+POLICY\b/,
|
|
130
|
+
/^REVOKE\b/,
|
|
131
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\s(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** The full partition of an executable stream. Every statement lands in exactly one bucket. */
|
|
135
|
+
export interface StatementPartition {
|
|
136
|
+
additive: string[];
|
|
137
|
+
drops: string[];
|
|
138
|
+
narrowings: string[];
|
|
139
|
+
strips: string[];
|
|
140
|
+
/** Authorization removed, no data lost — visible, never additive. */
|
|
141
|
+
authzRemovals: string[];
|
|
142
|
+
/** Not positively recognized. NEVER additive; needs a human before this plan is applied. */
|
|
143
|
+
unclassified: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
|
|
147
|
+
function statementHead(statement: string): string {
|
|
148
|
+
return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
|
|
149
|
+
.trim()
|
|
150
|
+
.toUpperCase();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Partition an executable stream into exactly one bucket per statement.
|
|
155
|
+
*
|
|
156
|
+
* Order matters: the data-loss buckets are consulted FIRST so a statement that both drops and
|
|
157
|
+
* revokes is counted at its most severe reading, and `unclassified` is the fallthrough rather
|
|
158
|
+
* than `additive`.
|
|
159
|
+
*/
|
|
160
|
+
export function partitionStatements(
|
|
161
|
+
executable: string[],
|
|
162
|
+
opts: { declaredGrantees?: ReadonlySet<string> } = {},
|
|
163
|
+
): StatementPartition {
|
|
164
|
+
const { drops, narrowings, strips } = classifyDestructive(executable, opts);
|
|
165
|
+
const destructive = new Set([...drops, ...narrowings, ...strips]);
|
|
166
|
+
const additive: string[] = [];
|
|
167
|
+
const authzRemovals: string[] = [];
|
|
168
|
+
const unclassified: string[] = [];
|
|
169
|
+
|
|
170
|
+
for (const statement of executable) {
|
|
171
|
+
if (destructive.has(statement)) continue;
|
|
172
|
+
const head = statementHead(statement);
|
|
173
|
+
if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
|
|
174
|
+
else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
|
|
175
|
+
else unclassified.push(statement);
|
|
176
|
+
}
|
|
177
|
+
return { additive, drops, narrowings, strips, authzRemovals, unclassified };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
|
|
181
|
+
export function revokeTarget(statement: string): string | null {
|
|
182
|
+
// A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
|
|
183
|
+
// reading one as null would silently UNDER-classify a strip — the failure direction that
|
|
184
|
+
// matters, since it skips the ceremony rather than adding one.
|
|
185
|
+
const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statement.trim());
|
|
186
|
+
return m ? m[1].replace(/^"|"$/g, '') : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function classifyDestructive(
|
|
190
|
+
executable: string[],
|
|
191
|
+
opts: { declaredGrantees?: ReadonlySet<string> } = {},
|
|
192
|
+
): DestructiveBreakdown {
|
|
81
193
|
const drops: string[] = [];
|
|
82
194
|
const narrowings: string[] = [];
|
|
195
|
+
const strips: string[] = [];
|
|
83
196
|
for (const statement of executable) {
|
|
84
197
|
if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
|
|
85
198
|
else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
|
|
199
|
+
else if (opts.declaredGrantees) {
|
|
200
|
+
// Only classified when the caller supplied the declared side — without it there is
|
|
201
|
+
// no way to tell a strip from ordinary churn, and guessing would newly force
|
|
202
|
+
// --confirm on plans that never needed it.
|
|
203
|
+
const grantee = revokeTarget(statement);
|
|
204
|
+
if (grantee && !opts.declaredGrantees.has(grantee) && !FRAMEWORK_ROLES.has(grantee.toUpperCase())) {
|
|
205
|
+
strips.push(statement);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
86
208
|
}
|
|
87
|
-
return { drops, narrowings };
|
|
209
|
+
return { drops, narrowings, strips };
|
|
88
210
|
}
|
|
89
211
|
|
|
212
|
+
/** Roles the framework itself owns — revoking from these re-declares from an ability. */
|
|
213
|
+
const FRAMEWORK_ROLES = new Set(['PUBLIC', 'ANON', 'AUTHENTICATED', 'ADMIN']);
|
|
214
|
+
|
|
90
215
|
/**
|
|
91
216
|
* The kind histogram — what a plan IS, before anyone reads its SQL. A real edge can
|
|
92
217
|
* run to four digits of statements (a consumer's adoption edge was 1,671), and the
|