@everystack/cli 0.4.41 → 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/authz-compile.ts +46 -4
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-reconcile.ts +10 -19
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-plan.ts +24 -0
- package/src/cli/edge-plan.ts +62 -4
- 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 +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.43",
|
|
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.8"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ModelDescriptor, Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
|
+
import { parenthesizeOnce } from './authz-contract.js';
|
|
25
26
|
|
|
26
27
|
export interface CompileOptions {
|
|
27
28
|
/** Schema the table lives in. Default: `public`. */
|
|
@@ -176,9 +177,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* The soft-delete column SQL name, when the model DECLARES one.
|
|
182
|
+
*
|
|
183
|
+
* Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
|
|
184
|
+
* statement of visibility intent, and inferring the guard from it made the compiler author a
|
|
185
|
+
* security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
|
|
186
|
+
* automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
|
|
187
|
+
* refuses to guess: a model with the field and a public read must say which it means.
|
|
188
|
+
*/
|
|
180
189
|
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
181
|
-
return
|
|
190
|
+
return model.softDelete ? 'deleted_at' : null;
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
/**
|
|
@@ -228,7 +237,11 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
228
237
|
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
|
|
229
238
|
);
|
|
230
239
|
}
|
|
231
|
-
|
|
240
|
+
// Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
|
|
241
|
+
// (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
|
|
242
|
+
// differ from the live one by a single layer of parens — enough for the reconciler to
|
|
243
|
+
// plan a DROP + CREATE that changed nothing.
|
|
244
|
+
return parenthesizeOnce(text.trim());
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
@@ -266,11 +279,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
266
279
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
267
280
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
268
281
|
|
|
282
|
+
/**
|
|
283
|
+
* A read scoped to a named role — `can('read', { role })`. It compiles to its own
|
|
284
|
+
* `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
|
|
285
|
+
* public read's predicate: a rule written for one role would otherwise narrow what
|
|
286
|
+
* every other role can see.
|
|
287
|
+
*/
|
|
288
|
+
const isRoleRead = (a: Ability): boolean =>
|
|
289
|
+
a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
|
|
290
|
+
|
|
269
291
|
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
270
292
|
// it is the admin bypass — so only the specific verbs are consulted.
|
|
271
293
|
const predFor = (action: Ability['action']): string | null => {
|
|
272
294
|
for (const a of model.abilities) {
|
|
273
|
-
if (a.action !== action) continue;
|
|
295
|
+
if (a.action !== action || isRoleRead(a)) continue;
|
|
274
296
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
275
297
|
if (p) return p;
|
|
276
298
|
}
|
|
@@ -336,6 +358,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
336
358
|
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
337
359
|
}
|
|
338
360
|
|
|
361
|
+
// Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
|
|
362
|
+
// none of the branches above: the public read requires NO role, and the owner/column reads
|
|
363
|
+
// require a row-scoping condition. Before this they compiled to a SELECT grant and no
|
|
364
|
+
// policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
|
|
365
|
+
// the model read as though it could see its own. The declared predicate was computed and
|
|
366
|
+
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
367
|
+
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
368
|
+
for (const a of abilities.filter(isRoleRead)) {
|
|
369
|
+
const role = a.condition.role!;
|
|
370
|
+
const name = `${t}_select_${role}`;
|
|
371
|
+
const where = `${table}: can('read', { role: '${role}' })`;
|
|
372
|
+
if (policies.some((p) => p.name === name)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${where} collides with the ${name} policy already compiled from this model's public read. `
|
|
375
|
+
+ `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
|
|
379
|
+
}
|
|
380
|
+
|
|
339
381
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
340
382
|
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
341
383
|
//
|
|
@@ -82,6 +82,51 @@ export interface PolicyContract {
|
|
|
82
82
|
check: string | null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The WITH CHECK expression PostgreSQL will actually enforce.
|
|
87
|
+
*
|
|
88
|
+
* For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
|
|
89
|
+
* `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
|
|
90
|
+
* (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
|
|
91
|
+
* WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
|
|
92
|
+
* different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
|
|
93
|
+
* defeats a zero-statement bar even when the SQL is identical.
|
|
94
|
+
*
|
|
95
|
+
* `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
|
|
96
|
+
* and no defaulting applies.
|
|
97
|
+
*/
|
|
98
|
+
export function effectivePolicyCheck(p: PolicyContract): string | null {
|
|
99
|
+
if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
|
|
100
|
+
return p.check;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
104
|
+
export function isWrappedExpression(s: string): boolean {
|
|
105
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
106
|
+
let depth = 0;
|
|
107
|
+
for (let i = 0; i < s.length; i++) {
|
|
108
|
+
if (s[i] === '(') depth++;
|
|
109
|
+
else if (s[i] === ')') {
|
|
110
|
+
depth--;
|
|
111
|
+
if (depth === 0 && i < s.length - 1) return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return depth === 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A predicate parenthesized exactly once — the normal form both producers must agree on.
|
|
119
|
+
*
|
|
120
|
+
* `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
|
|
121
|
+
* one into a model verbatim. Wrapping it again produced a compiled predicate identical to
|
|
122
|
+
* live but for one layer of parens, which the equality check called a difference and turned
|
|
123
|
+
* into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
|
|
124
|
+
* comparison did not, so both go through this.
|
|
125
|
+
*/
|
|
126
|
+
export function parenthesizeOnce(expr: string): string {
|
|
127
|
+
return isWrappedExpression(expr) ? expr : `(${expr})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* One function, enumerated. The cross-table authorization that RLS does not hold lives
|
|
87
132
|
* in SECURITY DEFINER functions as imperative gates; the contract records that they
|
|
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
|
|
|
581
626
|
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
|
|
582
627
|
if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
|
|
583
628
|
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
|
|
584
|
-
|
|
629
|
+
// Compared through the server's own defaulting rule, so this agrees with
|
|
630
|
+
// emitReconcileSql — otherwise db:check reports drift the plan does not carry.
|
|
631
|
+
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
|
|
585
632
|
if (changes.length) {
|
|
586
633
|
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
587
634
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
|
+
import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
|
|
19
20
|
import { quoteQualified } from './pg-ident.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -116,24 +117,8 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
|
|
|
116
117
|
return lines;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
120
|
-
function isWrapped(s: string): boolean {
|
|
121
|
-
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
122
|
-
let depth = 0;
|
|
123
|
-
for (let i = 0; i < s.length; i++) {
|
|
124
|
-
if (s[i] === '(') depth++;
|
|
125
|
-
else if (s[i] === ')') {
|
|
126
|
-
depth--;
|
|
127
|
-
if (depth === 0 && i < s.length - 1) return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return depth === 0;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
120
|
/** A USING/WITH CHECK clause expression, parenthesized exactly once. */
|
|
134
|
-
|
|
135
|
-
return isWrapped(expr) ? expr : `(${expr})`;
|
|
136
|
-
}
|
|
121
|
+
const clause = parenthesizeOnce;
|
|
137
122
|
|
|
138
123
|
/** A CREATE POLICY statement from a policy descriptor. */
|
|
139
124
|
function policyCreateSql(table: string, p: PolicyContract): string {
|
|
@@ -150,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
|
|
|
150
135
|
return `DROP POLICY IF EXISTS ${name} ON ${table};`;
|
|
151
136
|
}
|
|
152
137
|
|
|
153
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Two policies are the same authorization when every diffed field matches. The check is
|
|
140
|
+
* compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule —
|
|
141
|
+
* a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
|
|
142
|
+
* authorize identically, and reconciling them would emit a DROP + CREATE that changes
|
|
143
|
+
* nothing.
|
|
144
|
+
*/
|
|
154
145
|
function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
155
146
|
return a.command === b.command
|
|
156
147
|
&& a.permissive === b.permissive
|
|
157
148
|
&& a.roles.join(',') === b.roles.join(',')
|
|
158
149
|
&& (a.using ?? '') === (b.using ?? '')
|
|
159
|
-
&& (a
|
|
150
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
160
151
|
}
|
|
161
152
|
|
|
162
153
|
function tableMap(c: AuthzContract): Map<string, TableContract> {
|
package/src/cli/authz-redteam.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface ProbeResult {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface RedTeamFinding {
|
|
43
|
-
severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
|
|
43
|
+
severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited' | 'elevated';
|
|
44
44
|
role: string;
|
|
45
45
|
table: string;
|
|
46
46
|
command: ProbeCommand;
|
|
@@ -244,6 +244,9 @@ export interface InheritedGrant {
|
|
|
244
244
|
table: string; // schema-qualified
|
|
245
245
|
command: ProbeCommand;
|
|
246
246
|
via: string; // the ancestor role(s) supplying it
|
|
247
|
+
/** True when an ancestor is SUPERUSER or BYPASSRLS — the role bypasses RLS entirely,
|
|
248
|
+
* which makes every probe result for it vacuous. Drives the severity split. */
|
|
249
|
+
elevated: boolean;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
/**
|
|
@@ -273,7 +276,8 @@ WITH RECURSIVE anc AS (
|
|
|
273
276
|
SELECT r.rolname AS role,
|
|
274
277
|
(c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
|
|
275
278
|
p.priv AS command,
|
|
276
|
-
string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
|
|
279
|
+
string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via,
|
|
280
|
+
bool_or(ar.rolsuper OR ar.rolbypassrls) AS elevated
|
|
277
281
|
FROM pg_class c
|
|
278
282
|
CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
|
|
279
283
|
JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
|
|
@@ -296,6 +300,10 @@ export function toInheritedGrant(row: any): InheritedGrant {
|
|
|
296
300
|
table: String(row.table),
|
|
297
301
|
command: String(row.command).toUpperCase() as ProbeCommand,
|
|
298
302
|
via: String(row.via),
|
|
303
|
+
// An ancestor that is SUPERUSER or BYPASSRLS makes every probe result for this role
|
|
304
|
+
// vacuous — default-deny cannot be falsified and RLS assertions are void. That is a
|
|
305
|
+
// different finding from inheriting an ordinary role's grants, and it is graded so.
|
|
306
|
+
elevated: row.elevated === true || row.elevated === 't' || row.elevated === 'true',
|
|
299
307
|
};
|
|
300
308
|
}
|
|
301
309
|
|
|
@@ -329,8 +337,8 @@ export function evaluateRedTeam(
|
|
|
329
337
|
const findings: RedTeamFinding[] = [];
|
|
330
338
|
// Privileges explained by role membership rather than a direct grant. Keyed so the
|
|
331
339
|
// per-result lookup is exact; reported once per role, not once per table × command.
|
|
332
|
-
const inheritedBy = new Map(inherited.map((g) => [`${g.role}
|
|
333
|
-
const inheritedRoles = new Map<string, { via: string; count: number }>();
|
|
340
|
+
const inheritedBy = new Map(inherited.map((g) => [`${g.role}\u0000${g.table}\u0000${g.command}`, g]));
|
|
341
|
+
const inheritedRoles = new Map<string, { via: string; count: number; elevated: boolean }>();
|
|
334
342
|
|
|
335
343
|
for (const r of results) {
|
|
336
344
|
const table = tablesByName.get(r.table);
|
|
@@ -347,12 +355,12 @@ export function evaluateRedTeam(
|
|
|
347
355
|
findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
|
|
348
356
|
detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
|
|
349
357
|
} else if (allowed && !granted) {
|
|
350
|
-
const via = inheritedBy.get(`${r.role}
|
|
358
|
+
const via = inheritedBy.get(`${r.role}\u0000${r.table}\u0000${r.command}`);
|
|
351
359
|
if (via) {
|
|
352
360
|
// Real, but it is one fact about a role, not N facts about N tables.
|
|
353
361
|
const seen = inheritedRoles.get(r.role);
|
|
354
|
-
if (seen) seen.count += 1;
|
|
355
|
-
else inheritedRoles.set(r.role, { via: via.via, count: 1 });
|
|
362
|
+
if (seen) { seen.count += 1; seen.elevated ||= via.elevated; }
|
|
363
|
+
else inheritedRoles.set(r.role, { via: via.via, count: 1, elevated: via.elevated });
|
|
356
364
|
} else {
|
|
357
365
|
findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
|
|
358
366
|
detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
|
|
@@ -366,9 +374,23 @@ export function evaluateRedTeam(
|
|
|
366
374
|
// One line per inheriting role. The membership IS the finding — a role that reaches
|
|
367
375
|
// tables through `GRANT parent TO child` is worth stating out loud once, and worth not
|
|
368
376
|
// stating N times.
|
|
369
|
-
for (const [role, { via, count }] of inheritedRoles) {
|
|
370
|
-
|
|
371
|
-
|
|
377
|
+
for (const [role, { via, count, elevated }] of inheritedRoles) {
|
|
378
|
+
// The severity axis is WHAT the ancestor is, not that inheritance happened.
|
|
379
|
+
//
|
|
380
|
+
// An ordinary ancestor's grants are finite, enumerable, and still RLS-subject: the
|
|
381
|
+
// database enforces declared ∪ inherited, which is a completeness note.
|
|
382
|
+
//
|
|
383
|
+
// A SUPERUSER/BYPASSRLS ancestor is categorically different. The role bypasses every
|
|
384
|
+
// policy, its effective access is unbounded, and every probe result for it is vacuous —
|
|
385
|
+
// this tool cannot vouch for the role at all. Reporting that at the same level as
|
|
386
|
+
// "could not SET ROLE into this role" is what let an adopter read the run as clean.
|
|
387
|
+
if (elevated) {
|
|
388
|
+
findings.push({ severity: 'elevated', role, table: '', command: 'SELECT',
|
|
389
|
+
detail: `${role} inherits ${via}, which is SUPERUSER or BYPASSRLS — it bypasses every RLS policy and holds privileges no ACL lists (${count} seen here). Every probe result for ${role} is vacuous: this run cannot vouch for it` });
|
|
390
|
+
} else {
|
|
391
|
+
findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
|
|
392
|
+
detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
|
|
393
|
+
}
|
|
372
394
|
}
|
|
373
395
|
return findings;
|
|
374
396
|
}
|
|
@@ -252,12 +252,20 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
252
252
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
253
253
|
const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
|
|
254
254
|
const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
|
|
255
|
+
// An ancestor that is SUPERUSER/BYPASSRLS makes every probe for that role vacuous. It is
|
|
256
|
+
// NOT an enforcement hole — the database enforces exactly what the grants say — so it does
|
|
257
|
+
// not fail the gate, which would go red on the many legitimate elevated migration roles
|
|
258
|
+
// and teach adopters to stop running it. But it is warn-grade, and it must qualify the
|
|
259
|
+
// success line: people read the checkmark and stop, which is exactly how the first adopter
|
|
260
|
+
// came away thinking a real finding had been dismissed.
|
|
261
|
+
const elevatedFindings = findings.filter((f) => f.severity === 'elevated');
|
|
255
262
|
const gaps = gapRows.map(toGrantGap);
|
|
256
263
|
|
|
257
264
|
console.log('');
|
|
258
265
|
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
259
266
|
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
260
267
|
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
268
|
+
for (const f of elevatedFindings) warn(`[ELEVATED] ${f.detail}`);
|
|
261
269
|
for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
|
|
262
270
|
if (inconclusive.length) {
|
|
263
271
|
// Never a pass and never a failure — a probe that proved nothing, said out loud so it
|
|
@@ -273,7 +281,15 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
273
281
|
console.log('');
|
|
274
282
|
|
|
275
283
|
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
276
|
-
|
|
284
|
+
// "default-deny holds" is a FALSE UNIVERSAL when a role bypasses RLS. Any claim this run
|
|
285
|
+
// could not verify for a role is excluded from the claim in the sentence that makes it,
|
|
286
|
+
// so a reader who sees only the green line still learns one role is outside it.
|
|
287
|
+
const exempt = elevatedFindings.map((f) => f.role).sort();
|
|
288
|
+
const scope = exempt.length
|
|
289
|
+
? `${contract.tables.length} tables probed; default-deny holds EXCEPT for ${exempt.join(', ')} — `
|
|
290
|
+
+ `${exempt.length === 1 ? 'that role bypasses RLS by inheritance and this run cannot vouch for it' : 'those roles bypass RLS by inheritance and this run cannot vouch for them'}`
|
|
291
|
+
: `${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete`;
|
|
292
|
+
success(`db:authz:test — ${venueLabel} enforces the contract (${scope})`);
|
|
277
293
|
process.exit(0);
|
|
278
294
|
}
|
|
279
295
|
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import fs from 'node:fs/promises';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import type { ModelDescriptor } from '@everystack/model';
|
|
23
24
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
24
25
|
import { introspectSchema } from '../schema-introspect.js';
|
|
@@ -42,6 +43,23 @@ import { step, success, fail, info, warn } from '../output.js';
|
|
|
42
43
|
|
|
43
44
|
const DEFAULT_OUT = 'db.plan.json';
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* True when git would ignore `file` — false when it would happily commit it, and ALSO false
|
|
48
|
+
* outside a repo or when git is unavailable, because the warning is only ever advice and a
|
|
49
|
+
* missing git must not turn a plan mint into a failure.
|
|
50
|
+
*
|
|
51
|
+
* `check-ignore` exits 0 for ignored, 1 for not-ignored, 128 for "not a repo".
|
|
52
|
+
*/
|
|
53
|
+
function isGitIgnored(file: string): boolean {
|
|
54
|
+
try {
|
|
55
|
+
const r = spawnSync('git', ['check-ignore', '-q', file], { stdio: 'ignore' });
|
|
56
|
+
if (r.error || r.status === 128) return true; // no repo / no git — nothing to warn about
|
|
57
|
+
return r.status === 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
|
|
46
64
|
let dbSource: DbSource;
|
|
47
65
|
try {
|
|
@@ -161,6 +179,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
161
179
|
if (out !== '-') {
|
|
162
180
|
success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
|
|
163
181
|
warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
|
|
182
|
+
// "Do NOT commit" is advice; git is what enforces it. The default lands in the app
|
|
183
|
+
// root, so an adopter following the happy path gets an untracked, unignored artifact
|
|
184
|
+
// sitting next to their source with only a log line between it and a `git add .`.
|
|
185
|
+
if (!isGitIgnored(out)) {
|
|
186
|
+
warn(`${out} is NOT gitignored — add it, or the next \`git add .\` commits the plan: echo '${out}' >> .gitignore`);
|
|
187
|
+
}
|
|
164
188
|
}
|
|
165
189
|
} finally {
|
|
166
190
|
await end?.();
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -30,7 +30,7 @@ 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
32
|
import { compileTableContract } from './authz-compile.js';
|
|
33
|
-
import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
|
|
33
|
+
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
|
|
34
34
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
35
35
|
import { compileDeclaredState } from './declared-diff.js';
|
|
36
36
|
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
@@ -39,7 +39,8 @@ export const PLAN_VERSION = 2;
|
|
|
39
39
|
|
|
40
40
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
41
41
|
export interface PlanClassification {
|
|
42
|
-
/**
|
|
42
|
+
/** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
|
|
43
|
+
* computing it by subtraction made "safe" the default for anything unrecognized. */
|
|
43
44
|
additive: number;
|
|
44
45
|
/** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
|
|
45
46
|
drops: number;
|
|
@@ -48,6 +49,12 @@ export interface PlanClassification {
|
|
|
48
49
|
/** REVOKEs against a grantee no model declares — nothing re-derives that access.
|
|
49
50
|
* Optional: a plan minted before this classification existed has no field for it. */
|
|
50
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;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
export interface EdgePlan {
|
|
@@ -177,7 +184,7 @@ export function mintEdgePlan(
|
|
|
177
184
|
for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
|
|
178
185
|
for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
|
|
179
186
|
}
|
|
180
|
-
const breakdown =
|
|
187
|
+
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
181
188
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
182
189
|
|
|
183
190
|
return {
|
|
@@ -189,10 +196,14 @@ export function mintEdgePlan(
|
|
|
189
196
|
executable: classified.executable.length,
|
|
190
197
|
destructive,
|
|
191
198
|
classification: {
|
|
192
|
-
|
|
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,
|
|
193
202
|
drops: breakdown.drops.length,
|
|
194
203
|
narrowings: breakdown.narrowings.length,
|
|
195
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 } : {}),
|
|
196
207
|
},
|
|
197
208
|
notices: classified.notices.length,
|
|
198
209
|
unmodeled: unmodeledTables(models, snapshot),
|
|
@@ -201,6 +212,35 @@ export function mintEdgePlan(
|
|
|
201
212
|
};
|
|
202
213
|
}
|
|
203
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
|
+
|
|
204
244
|
/** The plan's content address — recorded as `plan_ref` on the schema_log row. */
|
|
205
245
|
export function planHash(plan: EdgePlan): string {
|
|
206
246
|
return createHash('sha256').update(stableStringify(plan)).digest('hex');
|
|
@@ -243,6 +283,24 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
|
|
|
243
283
|
lines.push(`! ${ddl.trim()}`);
|
|
244
284
|
}
|
|
245
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
|
+
}
|
|
246
304
|
if (plan.unmodeled.length > 0) {
|
|
247
305
|
lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
|
|
248
306
|
}
|
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
|
@@ -89,6 +89,94 @@ export interface DestructiveBreakdown {
|
|
|
89
89
|
strips: string[];
|
|
90
90
|
}
|
|
91
91
|
|
|
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
|
+
|
|
92
180
|
/** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
|
|
93
181
|
export function revokeTarget(statement: string): string | null {
|
|
94
182
|
// A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
|