@everystack/cli 0.4.41 → 0.4.44
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 +128 -16
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-derive.ts +156 -9
- 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/commands/db-pull.ts +32 -3
- package/src/cli/edge-plan.ts +62 -4
- package/src/cli/model-render.ts +116 -32
- 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/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?.();
|
|
@@ -83,6 +83,24 @@ export function keyCandidates(row: Record<string, unknown>, cols: string[]): str
|
|
|
83
83
|
return cols.filter((_, i) => Number(row[`c${i}`]) === n && Number(row[`d${i}`]) === n);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The specifier the generated `index.ts` uses to import the `--derived-out` file.
|
|
88
|
+
*
|
|
89
|
+
* Both paths are resolved against CWD first, so this works whichever way either was written
|
|
90
|
+
* (`db/models` + `db/models/derived.ts`, or absolute, or `./db/models/`). Extension stripped
|
|
91
|
+
* (the barrel's own imports are extensionless), separators normalized for Windows, and a
|
|
92
|
+
* same-directory result gets the explicit `./` a bare `derived` would lack.
|
|
93
|
+
*
|
|
94
|
+
* `--out` may also name a single `.ts` file, in which case the barrel IS that file and the
|
|
95
|
+
* specifier is relative to its directory.
|
|
96
|
+
*/
|
|
97
|
+
export function derivedImportSpecifier(out: string, derivedOut: string): string {
|
|
98
|
+
const barrelDir = out.endsWith('.ts') ? path.dirname(path.resolve(out)) : path.resolve(out);
|
|
99
|
+
const target = path.resolve(derivedOut).replace(/\.ts$/, '');
|
|
100
|
+
const rel = path.relative(barrelDir, target).split(path.sep).join('/');
|
|
101
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
87
105
|
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
88
106
|
return async (sql: string) => {
|
|
@@ -269,15 +287,26 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
269
287
|
process.exit(0);
|
|
270
288
|
}
|
|
271
289
|
}
|
|
272
|
-
// With --derived-out, the embedded copy would duplicate every descriptor — the
|
|
273
|
-
//
|
|
290
|
+
// With --derived-out, the embedded copy would duplicate every descriptor — the models
|
|
291
|
+
// output carries models only, and the barrel IMPORTS the layer from its own file. It used
|
|
292
|
+
// to carry neither, so the same command that wrote 120 descriptors emitted a
|
|
293
|
+
// `defineModule({ models })` that excluded every one of them, and db:plan then compared
|
|
294
|
+
// against a fraction of the database while printing a confident statement count.
|
|
274
295
|
const embeddedDerived = derivedOut ? undefined : derived;
|
|
296
|
+
const externalDerived = derivedOut && flags.out
|
|
297
|
+
? {
|
|
298
|
+
specifier: derivedImportSpecifier(flags.out, derivedOut),
|
|
299
|
+
sequences: derived.sequenceNames.length > 0,
|
|
300
|
+
materializedTables: derived.materializedTableNames.length > 0,
|
|
301
|
+
derived: derived.names.length > 0,
|
|
302
|
+
}
|
|
303
|
+
: undefined;
|
|
275
304
|
|
|
276
305
|
let source: string;
|
|
277
306
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
278
307
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
279
308
|
const dir = path.resolve(flags.out);
|
|
280
|
-
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
309
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
|
|
281
310
|
try {
|
|
282
311
|
await fs.mkdir(dir, { recursive: true });
|
|
283
312
|
const written = new Set(files.map((f) => f.file));
|
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
|
@@ -81,6 +81,10 @@ export interface RenderOptions {
|
|
|
81
81
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
82
82
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
83
83
|
derived?: DerivedRenderResult;
|
|
84
|
+
/** The `--derived-out` case: the layer lives in its own file, so the barrel imports and
|
|
85
|
+
* wires it instead of embedding it. Mutually exclusive with `derived` in practice —
|
|
86
|
+
* passing neither is what silently produced `defineModule({ models })`. */
|
|
87
|
+
externalDerived?: ExternalDerived;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/**
|
|
@@ -88,40 +92,74 @@ export interface RenderOptions {
|
|
|
88
92
|
* dominant brownfield shape (public reference data, admin-managed). Anything else is a
|
|
89
93
|
* per-model edit — the decision belongs in the file, not in a flag grammar.
|
|
90
94
|
*/
|
|
91
|
-
export const ABILITY_PRESETS: Record<string, string> = {
|
|
92
|
-
'public-read': `
|
|
95
|
+
export const ABILITY_PRESETS: Record<string, string[]> = {
|
|
96
|
+
'public-read': [`can('read')`, `can('manage', { role: 'admin' })`],
|
|
93
97
|
};
|
|
94
98
|
|
|
95
99
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
100
|
+
* A rendered ability that is a PUBLIC read — anon-visible, the only shape the soft-delete
|
|
101
|
+
* guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
|
|
102
|
+
* `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
|
|
103
|
+
* so one ability's `role:` can never mask another's public read.
|
|
99
104
|
*/
|
|
100
|
-
function
|
|
105
|
+
function isPublicReadAbility(expr: string): boolean {
|
|
106
|
+
return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The scaffold stanza for one model, plus whether it declares a public read — the renderer
|
|
111
|
+
* needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
|
|
112
|
+
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
113
|
+
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
114
|
+
*/
|
|
115
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
|
|
101
116
|
if (mode === 'live') {
|
|
102
117
|
const contract = table && liveAuthz?.get(table.table);
|
|
103
118
|
if (!contract) {
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
return {
|
|
120
|
+
text: [
|
|
121
|
+
' // no live authorization found for this table — nothing was granted, so nothing is',
|
|
122
|
+
' // rendered. Author the read model deliberately, or leave it internal:',
|
|
123
|
+
' // private: true,',
|
|
124
|
+
].join('\n'),
|
|
125
|
+
publicRead: false,
|
|
126
|
+
};
|
|
109
127
|
}
|
|
110
|
-
|
|
128
|
+
const derived = deriveAbilities(contract);
|
|
129
|
+
return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
|
|
111
130
|
}
|
|
112
131
|
if (mode === 'commented') {
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
return {
|
|
133
|
+
text: [
|
|
134
|
+
' // Declare the read model — db:check fails this model until its authz is authored:',
|
|
135
|
+
` // abilities: [can('read')], // public data`,
|
|
136
|
+
` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
|
|
137
|
+
' // private: true, // not part of the data API',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
// Nothing is stamped uncommented, so the model declares no read at all yet.
|
|
140
|
+
publicRead: false,
|
|
141
|
+
};
|
|
119
142
|
}
|
|
120
143
|
const preset = ABILITY_PRESETS[mode];
|
|
121
144
|
if (!preset) {
|
|
122
145
|
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
123
146
|
}
|
|
124
|
-
return ` ${preset}
|
|
147
|
+
return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `softDelete` line, when the model would otherwise fail to define.
|
|
152
|
+
*
|
|
153
|
+
* `defineModel` refuses to guess for a table that has `deleted_at` AND a public read: the
|
|
154
|
+
* guard decides what anonymous users can see, and neither default is safe. A pull renders
|
|
155
|
+
* `false` — LIVE reality is the truth being transcribed, and no live policy carries a guard
|
|
156
|
+
* nobody wrote. The comment says how to get the other one, so the decision is visible in the
|
|
157
|
+
* file rather than buried in a compiler convention.
|
|
158
|
+
*/
|
|
159
|
+
function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
|
|
160
|
+
const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
|
|
161
|
+
if (!hasColumn || !publicRead) return '';
|
|
162
|
+
return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
|
|
125
163
|
}
|
|
126
164
|
|
|
127
165
|
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
@@ -448,8 +486,9 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
448
486
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
449
487
|
// put it, proving the position is mechanical-edit-friendly).
|
|
450
488
|
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
489
|
+
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
451
490
|
|
|
452
|
-
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
491
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
453
492
|
}
|
|
454
493
|
|
|
455
494
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -470,22 +509,42 @@ function importHeader(body: string, extra: string[] = []): string {
|
|
|
470
509
|
return imports.join('\n');
|
|
471
510
|
}
|
|
472
511
|
|
|
473
|
-
/**
|
|
474
|
-
|
|
512
|
+
/**
|
|
513
|
+
* The barrel's module wrapper: models always; sequences/derived when the pull found any (B5).
|
|
514
|
+
*
|
|
515
|
+
* `external` is the `--derived-out` case — the descriptors live in their own file, so the
|
|
516
|
+
* barrel IMPORTS the arrays rather than re-declaring them. Before this the barrel simply
|
|
517
|
+
* omitted them, which meant the same pull that wrote 120 descriptors emitted a
|
|
518
|
+
* `defineModule({ models })` that excluded every one, and `db:plan` then compared against a
|
|
519
|
+
* fraction of the database while reporting a confident statement count. A plan scoped to
|
|
520
|
+
* something narrower than the reader assumes is the same false-completeness failure as a
|
|
521
|
+
* classification that defaults to safe.
|
|
522
|
+
*/
|
|
523
|
+
function moduleFooter(
|
|
524
|
+
modelNames: string[],
|
|
525
|
+
derived?: DerivedRenderResult,
|
|
526
|
+
multiline = false,
|
|
527
|
+
external?: ExternalDerived,
|
|
528
|
+
): string {
|
|
475
529
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
476
530
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
531
|
+
const hasMaterialized = external ? external.materializedTables : Boolean(derived?.materializedTableNames.length);
|
|
532
|
+
const inlineNames = external ? [] : (derived?.materializedTableNames ?? []);
|
|
533
|
+
const allModels = [...modelNames, ...inlineNames];
|
|
534
|
+
const rendered = multiline
|
|
535
|
+
? `export const models = [\n${allModels.map((n) => ` ${n},`).join('\n')}\n${external && hasMaterialized ? ' ...materializedTables,\n' : ''}];`
|
|
536
|
+
: `export const models = [${[...allModels, ...(external && hasMaterialized ? ['...materializedTables'] : [])].join(', ')}];`;
|
|
537
|
+
const parts = [rendered];
|
|
482
538
|
const keys = ['models'];
|
|
483
|
-
|
|
484
|
-
|
|
539
|
+
|
|
540
|
+
const hasSequences = external ? external.sequences : Boolean(derived?.sequenceNames.length);
|
|
541
|
+
const hasDerived = external ? external.derived : Boolean(derived?.names.length);
|
|
542
|
+
if (hasSequences) {
|
|
543
|
+
if (!external) parts.push(`export const sequences = [${derived!.sequenceNames.join(', ')}];`);
|
|
485
544
|
keys.push('sequences');
|
|
486
545
|
}
|
|
487
|
-
if (
|
|
488
|
-
parts.push(`export const derived = [${derived
|
|
546
|
+
if (hasDerived) {
|
|
547
|
+
if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
|
|
489
548
|
keys.push('derived');
|
|
490
549
|
}
|
|
491
550
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
@@ -493,6 +552,24 @@ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multi
|
|
|
493
552
|
return parts.join('\n\n');
|
|
494
553
|
}
|
|
495
554
|
|
|
555
|
+
/** What an external (`--derived-out`) derived file exports, so the barrel can wire it. */
|
|
556
|
+
export interface ExternalDerived {
|
|
557
|
+
/** Import specifier as written in the barrel, e.g. `'./derived'`. */
|
|
558
|
+
specifier: string;
|
|
559
|
+
sequences: boolean;
|
|
560
|
+
materializedTables: boolean;
|
|
561
|
+
derived: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The named exports to pull out of an external derived file, in a stable order. */
|
|
565
|
+
function externalDerivedNames(e: ExternalDerived): string[] {
|
|
566
|
+
const names: string[] = [];
|
|
567
|
+
if (e.derived) names.push('derived');
|
|
568
|
+
if (e.materializedTables) names.push('materializedTables');
|
|
569
|
+
if (e.sequences) names.push('sequences');
|
|
570
|
+
return names;
|
|
571
|
+
}
|
|
572
|
+
|
|
496
573
|
/**
|
|
497
574
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
498
575
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -575,14 +652,21 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
575
652
|
// their own model.
|
|
576
653
|
const names = tables.map((t) => modelVarName(t.table));
|
|
577
654
|
const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
|
|
655
|
+
const ext = opts.externalDerived;
|
|
656
|
+
const extNames = ext ? externalDerivedNames(ext) : [];
|
|
578
657
|
const index = [
|
|
579
658
|
[
|
|
580
659
|
`import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
|
|
660
|
+
// The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
|
|
661
|
+
// or db:plan silently compares against models only.
|
|
662
|
+
...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
|
|
581
663
|
...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
|
|
582
664
|
].join('\n'),
|
|
583
665
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
666
|
+
// Re-exported so the barrel remains the one place that describes the database.
|
|
667
|
+
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
584
668
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
585
|
-
moduleFooter(names, opts.derived, true),
|
|
669
|
+
moduleFooter(names, opts.derived, true, ext),
|
|
586
670
|
].join('\n\n');
|
|
587
671
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
588
672
|
|
|
@@ -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
|
|