@everystack/cli 0.4.100 → 0.4.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +32 -13
- package/src/cli/commands/db-pull.ts +155 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +5 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +92 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/src/cli/authz-derive.ts
CHANGED
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
import { EXTRA_PRIVILEGES, sessionlessAudience } from '@everystack/model';
|
|
29
29
|
import { holdsPrivilege, holdsTablePrivilege } from './authz-contract';
|
|
30
30
|
import type { TableContract, PolicyContract } from './authz-contract';
|
|
31
|
+
import { templateBody, tsString, commentLines } from './ts-template';
|
|
32
|
+
import { refuseProtoKey, liveObject } from './generated-names';
|
|
33
|
+
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
31
34
|
|
|
32
35
|
/** One rendered decision: either an ability line, or a comment explaining the omission. */
|
|
33
36
|
export interface DerivedAbilities {
|
|
@@ -55,6 +58,13 @@ export interface DerivedAbilities {
|
|
|
55
58
|
* it once, for the whole pull.
|
|
56
59
|
*/
|
|
57
60
|
unmappedRoles: string[];
|
|
61
|
+
/**
|
|
62
|
+
* What a GOVERNED custom role (--govern-roles) holds that `can()` cannot state exactly, one
|
|
63
|
+
* line each, naming the table and the role. db:pull prints them as WARNINGs and the model
|
|
64
|
+
* carries them as comments: a grant kept as a raw `privileges` entry, or a policy the model
|
|
65
|
+
* does not declare (so the next db:plan drops it). Never silently approximated.
|
|
66
|
+
*/
|
|
67
|
+
warnings: string[];
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
@@ -260,13 +270,43 @@ export function parsePublicOrOwn(
|
|
|
260
270
|
*/
|
|
261
271
|
const granted = holdsTablePrivilege;
|
|
262
272
|
|
|
263
|
-
/** The policies that
|
|
264
|
-
function
|
|
273
|
+
/** The policies that name a role for a command (an `ALL` policy covers every command). */
|
|
274
|
+
function policiesNaming(contract: TableContract, role: string, command: string): PolicyContract[] {
|
|
265
275
|
return contract.policies.filter(
|
|
266
276
|
(p) => (p.command === command || p.command === 'ALL') && (p.roles.includes(role) || p.roles.includes('public')),
|
|
267
277
|
);
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Can this policy admit any row for the command? PostgreSQL skips a policy whose expression is
|
|
282
|
+
* null: SELECT, UPDATE and DELETE read USING; INSERT reads WITH CHECK, and an ALL policy with no
|
|
283
|
+
* WITH CHECK falls back to its USING. A policy with neither admits NO row, and an ability with
|
|
284
|
+
* no predicate compiles USING (true): EVERY row.
|
|
285
|
+
*/
|
|
286
|
+
function admitsRows(p: PolicyContract, command: string): boolean {
|
|
287
|
+
return (command === 'INSERT' ? p.check ?? p.using : p.using) != null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The policies that apply to a role for a command and can admit a row. Every ability site reads
|
|
292
|
+
* this, so a policy that admits no row never becomes an ability; its grant is declared raw.
|
|
293
|
+
*/
|
|
294
|
+
function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
|
|
295
|
+
return policiesNaming(contract, role, command).filter((p) => admitsRows(p, command));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Notes naming the permissive policies a role's dead grants sit under that admit no row. */
|
|
299
|
+
function noRowPolicyNotes(contract: TableContract, role: string, privs: readonly string[]): string[] {
|
|
300
|
+
const out: string[] = [];
|
|
301
|
+
for (const priv of privs) {
|
|
302
|
+
for (const p of policiesNaming(contract, role, priv)) {
|
|
303
|
+
if (!p.permissive || admitsRows(p, priv)) continue;
|
|
304
|
+
out.push(` policy "${p.name}" (${priv}) has no ${priv === 'INSERT' ? 'WITH CHECK' : 'USING'}, so it admits no row; an ability would admit every row.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
270
310
|
/**
|
|
271
311
|
* Does this grant actually give the role rows?
|
|
272
312
|
*
|
|
@@ -336,7 +376,7 @@ function sqlFragment(pred: string | null): string | null {
|
|
|
336
376
|
if (!pred) return null;
|
|
337
377
|
const t = pred.trim();
|
|
338
378
|
if (t === 'true' || t === '(true)') return null;
|
|
339
|
-
return `sql\`${t
|
|
379
|
+
return `sql\`${templateBody(t)}\``;
|
|
340
380
|
}
|
|
341
381
|
|
|
342
382
|
/** The policy catalog spells the PUBLIC pseudo-role lower-case; grants spell it upper-case. */
|
|
@@ -391,7 +431,7 @@ function renderColumnAbility(
|
|
|
391
431
|
// Owner-only uses the model's authenticated default. Every other grantee must be named so the
|
|
392
432
|
// compiler emits the policy TO the live role, not a role-less public ability — and a differing
|
|
393
433
|
// check names it too, to escape the F-A12 refusal above.
|
|
394
|
-
if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role:
|
|
434
|
+
if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: ${tsString(role)}`);
|
|
395
435
|
if (owner) {
|
|
396
436
|
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
397
437
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
@@ -422,10 +462,141 @@ function renderColumnAbility(
|
|
|
422
462
|
notes.push(` ${policy.check}`);
|
|
423
463
|
}
|
|
424
464
|
}
|
|
425
|
-
parts.push(`columns: [${columns.map((column) =>
|
|
465
|
+
parts.push(`columns: [${columns.map((column) => tsString(modelFieldKey(column))).join(', ')}]`);
|
|
426
466
|
return `can('${action}', { ${parts.join(', ')} })`;
|
|
427
467
|
}
|
|
428
468
|
|
|
469
|
+
/**
|
|
470
|
+
* A named owner read emits both SELECT policies. Recover that declaration only when
|
|
471
|
+
* the complete pair agrees. Differing rules remain two reads; unsupported pairs refuse.
|
|
472
|
+
*/
|
|
473
|
+
function deriveOwnerReadPair(contract: TableContract): string[] | null {
|
|
474
|
+
const table = contract.table.split('.').pop()!;
|
|
475
|
+
const named = contract.policies.find(p => p.name === `${table}_select_authenticated`);
|
|
476
|
+
const own = contract.policies.find(p => p.name === `${table}_select_own`);
|
|
477
|
+
if (!named || !own) return null;
|
|
478
|
+
|
|
479
|
+
const refuse = (): never => {
|
|
480
|
+
throw new Error(`${contract.table}: owner-read pair cannot be represented faithfully; keep the live policies and normalize them before pulling.`);
|
|
481
|
+
};
|
|
482
|
+
const reads = policiesNaming(contract, 'authenticated', 'SELECT');
|
|
483
|
+
if (reads.length !== 2 || [named, own].some(p => p.command !== 'SELECT' || !p.permissive
|
|
484
|
+
|| p.roles.length !== 1 || p.roles[0] !== 'authenticated' || p.check != null || p.using == null)) {
|
|
485
|
+
return refuse();
|
|
486
|
+
}
|
|
487
|
+
const namedPred = normalizeDeparsedExpr(named.using!);
|
|
488
|
+
const ownPred = normalizeDeparsedExpr(own.using!);
|
|
489
|
+
const exactOwner = parseOwnerPredicate(ownPred);
|
|
490
|
+
const split = splitOwnerPredicate(ownPred);
|
|
491
|
+
const owner = exactOwner ?? split.owner;
|
|
492
|
+
if (!owner) return refuse();
|
|
493
|
+
const parts = [`owner: '${modelFieldKey(owner.column)}'`];
|
|
494
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
495
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${templateBody(owner.expr)}\``);
|
|
496
|
+
if (namedPred === ownPred && exactOwner) {
|
|
497
|
+
return [`can('read', { role: 'authenticated', ${parts.join(', ')} })`];
|
|
498
|
+
}
|
|
499
|
+
// A named SQL read does not inherit the model's owner gate. The own read does,
|
|
500
|
+
// so carry only its remaining guard there, avoiding a second copy of the gate.
|
|
501
|
+
return [
|
|
502
|
+
`can('read', { role: 'authenticated', sql: sql\`${templateBody(namedPred)}\` })`,
|
|
503
|
+
`can('read', { ${parts.join(', ')}, sql: sql\`${templateBody(split.guard ?? 'true')}\` })`,
|
|
504
|
+
];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A GOVERNED custom role (`--govern-roles`) on a table with row security ON: each table-level
|
|
509
|
+
* CRUD grant renders as a role-scoped `can()` when exactly one permissive policy TO that role
|
|
510
|
+
* alone states its row rule in a spelling the compiler emits back unchanged.
|
|
511
|
+
*
|
|
512
|
+
* Why: governing a role reconciles its policies as well as its grants. Transcribing only the
|
|
513
|
+
* grants as `privileges` (all governing did before F4) declared no policy, so the next plan
|
|
514
|
+
* DROPPED every live policy TO the role. An ability declares the grant and the policy together.
|
|
515
|
+
*
|
|
516
|
+
* Exact or not at all. Each shape compiles back to the live rule, never to a wider one:
|
|
517
|
+
* - manage: all four grants, ONE `FOR ALL` policy with USING `true` and WITH CHECK `true` or
|
|
518
|
+
* none, and no per-command policy. Compiles to one ALL policy USING true WITH CHECK true.
|
|
519
|
+
* - read / delete: the USING text rides verbatim as `sql:` (none when it is `true`).
|
|
520
|
+
* - update: as read, and the WITH CHECK must be absent or equal to USING, because the
|
|
521
|
+
* compiler sets WITH CHECK to the same predicate.
|
|
522
|
+
* - create: the WITH CHECK must be exactly `true`.
|
|
523
|
+
* A policy with no USING (or an INSERT policy with no WITH CHECK) admits NO row. An ability
|
|
524
|
+
* without a predicate admits EVERY row, so that shape falls back like any other.
|
|
525
|
+
*
|
|
526
|
+
* Fallback: the grant stays a raw `privileges` entry (kept, never revoked) and a warning says
|
|
527
|
+
* why. A role with no policy of its own for a grant needs no warning: the raw grant is exact.
|
|
528
|
+
*/
|
|
529
|
+
function deriveGovernedRole(contract: TableContract, role: string): {
|
|
530
|
+
abilities: string[];
|
|
531
|
+
/** The CRUD privileges stated by an ability, so they are NOT also transcribed raw. */
|
|
532
|
+
asAbility: Set<string>;
|
|
533
|
+
/** The policies those abilities declare. */
|
|
534
|
+
usedPolicies: Set<string>;
|
|
535
|
+
warnings: string[];
|
|
536
|
+
} {
|
|
537
|
+
const out = { abilities: [] as string[], asAbility: new Set<string>(), usedPolicies: new Set<string>(), warnings: [] as string[] };
|
|
538
|
+
if (!contract.rls.enabled) return out;
|
|
539
|
+
const own = (command: string): PolicyContract[] => contract.policies.filter(
|
|
540
|
+
(p) => p.permissive && p.roles.length === 1 && p.roles[0] === role && p.command === command,
|
|
541
|
+
);
|
|
542
|
+
const held = CRUD_PRIVS.filter((p) => granted(contract, role, p));
|
|
543
|
+
const fallback = (priv: string, why: string): void => {
|
|
544
|
+
out.warnings.push(`${contract.table}: --govern-roles ${role}: ${priv} is kept as privileges (a raw grant), not can() — ${why}.`);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const all = own('ALL');
|
|
548
|
+
if (all.length) {
|
|
549
|
+
const a = all[0];
|
|
550
|
+
if (all.length === 1 && held.length === CRUD_PRIVS.length && CRUD_PRIVS.every((c) => own(c).length === 0)
|
|
551
|
+
&& a.using === 'true' && (a.check == null || a.check === 'true')) {
|
|
552
|
+
out.abilities.push(`can('manage', { role: ${tsString(role)} })`);
|
|
553
|
+
for (const p of CRUD_PRIVS) out.asAbility.add(p);
|
|
554
|
+
out.usedPolicies.add(a.name);
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
const names = all.map((p) => `"${p.name}"`).join(', ');
|
|
558
|
+
for (const priv of held) {
|
|
559
|
+
fallback(priv, `FOR ALL policy ${names} is not the plain manage shape (all four grants, one FOR ALL USING true, no per-command policy)`);
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
for (const priv of held) {
|
|
565
|
+
const pols = own(priv);
|
|
566
|
+
if (pols.length === 0) continue;
|
|
567
|
+
if (pols.length > 1) {
|
|
568
|
+
fallback(priv, `${pols.length} permissive ${priv} policies TO ${role} (${pols.map((p) => `"${p.name}"`).join(', ')}); can() states one`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const p = pols[0];
|
|
572
|
+
if (priv === 'INSERT') {
|
|
573
|
+
if (p.check !== 'true') {
|
|
574
|
+
fallback(priv, p.check == null
|
|
575
|
+
? `INSERT policy "${p.name}" has no WITH CHECK, so it admits no row; can('create') would admit every row`
|
|
576
|
+
: `INSERT policy "${p.name}" has WITH CHECK ${p.check}; can('create', { role }) states only WITH CHECK (true)`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
out.abilities.push(`can('create', { role: ${tsString(role)} })`);
|
|
580
|
+
} else {
|
|
581
|
+
if (p.using == null) {
|
|
582
|
+
fallback(priv, `${priv} policy "${p.name}" has no USING, so it admits no row; can('${DML[priv]}') would admit every row`);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (priv === 'UPDATE' && p.check != null && p.check !== p.using) {
|
|
586
|
+
fallback(priv, `UPDATE policy "${p.name}": WITH CHECK differs from USING (USING ${p.using}; WITH CHECK ${p.check})`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const frag = sqlFragment(p.using);
|
|
590
|
+
// A lifecycle read without the guard (or with deleted_at IS NOT NULL), marked by the fold.
|
|
591
|
+
const deleted = priv === 'SELECT' && p.deleted ? `, deleted: '${p.deleted}'` : '';
|
|
592
|
+
out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag}${deleted} })` : `can('${DML[priv]}', { role: ${tsString(role)}${deleted} })`);
|
|
593
|
+
}
|
|
594
|
+
out.asAbility.add(priv);
|
|
595
|
+
out.usedPolicies.add(p.name);
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
429
600
|
/**
|
|
430
601
|
* Derive the ability stanza for ONE table from its live contract.
|
|
431
602
|
*
|
|
@@ -433,18 +604,25 @@ function renderColumnAbility(
|
|
|
433
604
|
* through to `notes` rather than guessing — the caller renders those as comments beside
|
|
434
605
|
* the model, so the human sees the real rule and decides.
|
|
435
606
|
*
|
|
436
|
-
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
607
|
+
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to GOVERN a foreign
|
|
608
|
+
* role, per the doctrine on GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently
|
|
609
|
+
* convert a rendering decision into an access decision. Its grants and policies render as
|
|
610
|
+
* role-scoped `can()` where they pair exactly (see deriveGovernedRole), else as raw
|
|
611
|
+
* `privileges` with a warning. Complete-or-refuse where the model cannot keep live access:
|
|
612
|
+
* a column-scoped grant with no exactly-paired policy THROWS (the next plan would REVOKE it),
|
|
613
|
+
* and so does a RESTRICTIVE policy TO the role (the next plan would DROP it, a widening).
|
|
442
614
|
*/
|
|
443
615
|
export function deriveAbilities(
|
|
444
616
|
contract: TableContract,
|
|
445
617
|
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
446
618
|
): DerivedAbilities {
|
|
447
619
|
const govern = opts.governRoles ?? new Set<string>();
|
|
620
|
+
// A role is a KEY in every accumulator below (and in the rendered `privileges`), and a key
|
|
621
|
+
// named __proto__ sets the prototype instead. Introspection keeps such a role (null-prototype
|
|
622
|
+
// maps), so a live grant to "__proto__" reaches here and the pull refuses, naming it.
|
|
623
|
+
for (const role of [...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]) {
|
|
624
|
+
refuseProtoKey(role, liveObject('role', role));
|
|
625
|
+
}
|
|
448
626
|
/** One classification pass; every ability-vs-grant decision below reads this tag. */
|
|
449
627
|
const abilitySurfaces = classifyAbilitySurfaces(contract);
|
|
450
628
|
const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
|
|
@@ -505,6 +683,7 @@ export function deriveAbilities(
|
|
|
505
683
|
notes.push(
|
|
506
684
|
` an ability: an ability would ADD the policy the database does not have.`,
|
|
507
685
|
);
|
|
686
|
+
notes.push(...noRowPolicyNotes(contract, 'admin', adminDead));
|
|
508
687
|
}
|
|
509
688
|
|
|
510
689
|
// The same accounting for the app roles. A grant they hold with no policy behind it is
|
|
@@ -516,6 +695,7 @@ export function deriveAbilities(
|
|
|
516
695
|
deadGrants[role] = dead;
|
|
517
696
|
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
518
697
|
notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
|
|
698
|
+
notes.push(...noRowPolicyNotes(contract, role, dead));
|
|
519
699
|
}
|
|
520
700
|
|
|
521
701
|
// --- policed but not granted at the table level ---------------------------------------
|
|
@@ -555,8 +735,11 @@ export function deriveAbilities(
|
|
|
555
735
|
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
556
736
|
const anonRead = isAbilitySurface('anon', 'SELECT');
|
|
557
737
|
const authedRead = isAbilitySurface('authenticated', 'SELECT');
|
|
738
|
+
const ownerPair = !anonRead && authedRead ? deriveOwnerReadPair(contract) : null;
|
|
558
739
|
|
|
559
|
-
if (
|
|
740
|
+
if (ownerPair) {
|
|
741
|
+
abilities.push(...ownerPair);
|
|
742
|
+
} else if (anonRead) {
|
|
560
743
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
561
744
|
// carry that predicate — a public read with a filter is still a public read.
|
|
562
745
|
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
@@ -643,7 +826,7 @@ export function deriveAbilities(
|
|
|
643
826
|
// DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
|
|
644
827
|
// reached zero through escape hatches can be counted apart from one that reached zero
|
|
645
828
|
// through vocabulary. Fidelity achieved is not expressiveness achieved.
|
|
646
|
-
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred
|
|
829
|
+
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${templateBody(authedPred!)}\` })`);
|
|
647
830
|
notes.push(
|
|
648
831
|
`verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
|
|
649
832
|
);
|
|
@@ -753,27 +936,74 @@ export function deriveAbilities(
|
|
|
753
936
|
// exactly one permissive policy TO the same role proves the row gate. Anything else stays a
|
|
754
937
|
// note: guessing the pairing could grant the right columns under the wrong row policy.
|
|
755
938
|
const renderedColumnRoles = new Set<string>();
|
|
939
|
+
|
|
940
|
+
// --- custom roles the operator chose to GOVERN (--govern-roles): their row rules (F4) ---
|
|
941
|
+
const governedCustom = [...govern].filter((r) => !GOVERNED_VOCABULARY.has(r)).sort();
|
|
942
|
+
const governedAsAbility = new Map<string, Set<string>>();
|
|
943
|
+
const usedPolicies = new Set<string>();
|
|
944
|
+
const warnings: string[] = [];
|
|
945
|
+
for (const role of governedCustom) {
|
|
946
|
+
const g = deriveGovernedRole(contract, role);
|
|
947
|
+
abilities.push(...g.abilities);
|
|
948
|
+
warnings.push(...g.warnings);
|
|
949
|
+
governedAsAbility.set(role, g.asAbility);
|
|
950
|
+
for (const name of g.usedPolicies) usedPolicies.add(name);
|
|
951
|
+
}
|
|
952
|
+
|
|
756
953
|
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
757
954
|
const byPriv = contract.columnGrants![grantee];
|
|
758
955
|
if (govern.has(grantee)) {
|
|
956
|
+
// A governed role's column grant renders ONLY as an exact column ability: SELECT or
|
|
957
|
+
// UPDATE, row security on, exactly one permissive policy TO the role alone, and no
|
|
958
|
+
// table-level ability for the same privilege (two policies of one name would collide).
|
|
959
|
+
// `columns:` keeps it column-scoped; it never becomes a table-level grant.
|
|
960
|
+
// A paired policy with NO USING admits no row (PostgreSQL 16: a SELECT policy without
|
|
961
|
+
// USING, or an UPDATE policy with only a WITH CHECK); a column ability compiles USING (true),
|
|
962
|
+
// every row. `privileges` has no column axis, so there is no raw fallback that keeps the
|
|
963
|
+
// grant without widening it to the table: this refuses, as main did.
|
|
964
|
+
const tableLevel = governedAsAbility.get(grantee) ?? new Set<string>();
|
|
965
|
+
const noUsing: string[] = [];
|
|
966
|
+
const pairs = Object.keys(byPriv).sort().filter((priv) => (byPriv[priv] ?? []).length).map((priv) => {
|
|
967
|
+
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
968
|
+
let policy = action && contract.rls.enabled && !tableLevel.has(priv) ? columnPolicyFor(contract, grantee, priv) : null;
|
|
969
|
+
if (policy && policy.using == null) {
|
|
970
|
+
noUsing.push(`its ${priv} policy "${policy.name}" has no USING, so it admits no row and a column ability would admit every row`);
|
|
971
|
+
policy = null;
|
|
972
|
+
}
|
|
973
|
+
return { policy, ability: action && policy ? renderColumnAbility(action, grantee, byPriv[priv]!, policy, notes) : null };
|
|
974
|
+
});
|
|
975
|
+
if (pairs.length && pairs.every((p) => p.ability)) {
|
|
976
|
+
for (const p of pairs) {
|
|
977
|
+
abilities.push(p.ability!);
|
|
978
|
+
usedPolicies.add(p.policy!.name);
|
|
979
|
+
}
|
|
980
|
+
renderedColumnRoles.add(grantee);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
759
983
|
// Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
|
|
760
|
-
// has no column axis
|
|
761
|
-
//
|
|
984
|
+
// has no column axis — a partial transcription would leave this grant undeclared on a
|
|
985
|
+
// governed role, and the very next plan would REVOKE it.
|
|
762
986
|
const held = Object.entries(byPriv)
|
|
763
987
|
.filter(([, cols]) => (cols ?? []).length)
|
|
764
988
|
.map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
|
|
765
989
|
throw new Error(
|
|
766
990
|
`${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
|
|
767
|
-
`Governing a role
|
|
768
|
-
`column
|
|
769
|
-
`
|
|
991
|
+
`Governing a role reconciles ALL of its grants, and the model states a column-scoped grant only as a ` +
|
|
992
|
+
`column ability: SELECT or UPDATE, row security on, and exactly one permissive policy of that command ` +
|
|
993
|
+
`TO ${grantee} alone, with a USING. Without it the next plan would revoke the grant. ` +
|
|
994
|
+
`${noUsing.length ? `Here ${noUsing.join('; ')}. ` : ''}` +
|
|
995
|
+
`Add that policy or normalize the grant in the database first, or leave the role ungoverned.`,
|
|
770
996
|
);
|
|
771
997
|
}
|
|
772
998
|
for (const priv of Object.keys(byPriv).sort()) {
|
|
773
999
|
const cols = byPriv[priv] ?? [];
|
|
774
1000
|
if (!cols.length) continue;
|
|
775
1001
|
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
776
|
-
const
|
|
1002
|
+
const paired = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
1003
|
+
// The governed rule, here too: a paired policy with no USING admits no row, and a column
|
|
1004
|
+
// ability compiles USING (true), every row. Not rendered; the note below names the policy.
|
|
1005
|
+
const noUsing = paired && paired.using == null ? paired : null;
|
|
1006
|
+
const policy = noUsing ? null : paired;
|
|
777
1007
|
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
|
|
778
1008
|
if (ability) {
|
|
779
1009
|
abilities.push(ability);
|
|
@@ -782,11 +1012,33 @@ export function deriveAbilities(
|
|
|
782
1012
|
}
|
|
783
1013
|
notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
|
|
784
1014
|
notes.push(
|
|
785
|
-
|
|
786
|
-
? ` NOT rendered —
|
|
787
|
-
:
|
|
1015
|
+
noUsing
|
|
1016
|
+
? ` NOT rendered — its ${priv} policy "${noUsing.name}" has no USING, so it admits no row; a column ability would admit every row.`
|
|
1017
|
+
: action
|
|
1018
|
+
? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
|
|
1019
|
+
: ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// --- every policy TO a governed custom role is declared, warned, or refused -------------
|
|
1025
|
+
// The plan reconciles a governed role's policies, so a policy no ability above declares is
|
|
1026
|
+
// DROPPED. Dropping a permissive policy can only narrow, so it is a warning. Dropping a
|
|
1027
|
+
// RESTRICTIVE one widens (it subtracts rows), and the model cannot declare one: refuse.
|
|
1028
|
+
for (const p of contract.policies) {
|
|
1029
|
+
const roles = p.roles.filter((r) => governedCustom.includes(r));
|
|
1030
|
+
if (!roles.length || usedPolicies.has(p.name)) continue;
|
|
1031
|
+
if (!p.permissive) {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`${table}: --govern-roles ${roles.join(', ')} refused — RESTRICTIVE policy "${p.name}" (${p.command}) applies to it. ` +
|
|
1034
|
+
`The model cannot declare a restrictive policy, so governing the role would make the next plan DROP it ` +
|
|
1035
|
+
`and WIDEN the role's access. Leave the role ungoverned.`,
|
|
788
1036
|
);
|
|
789
1037
|
}
|
|
1038
|
+
warnings.push(
|
|
1039
|
+
`${table}: --govern-roles ${roles.join(', ')}: policy "${p.name}" (${p.command} TO ${p.roles.join(', ')}) is not ` +
|
|
1040
|
+
`declared by the model; the next db:plan DROPS policy "${p.name}".`,
|
|
1041
|
+
);
|
|
790
1042
|
}
|
|
791
1043
|
|
|
792
1044
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
@@ -794,7 +1046,7 @@ export function deriveAbilities(
|
|
|
794
1046
|
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
|
|
795
1047
|
);
|
|
796
1048
|
|
|
797
|
-
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
1049
|
+
if (!abilities.length && !notes.length && !unmappedRoles.length && !warnings.length) {
|
|
798
1050
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
799
1051
|
}
|
|
800
1052
|
|
|
@@ -808,17 +1060,18 @@ export function deriveAbilities(
|
|
|
808
1060
|
|
|
809
1061
|
// --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
|
|
810
1062
|
//
|
|
811
|
-
//
|
|
812
|
-
// build must recreate (the migrations being deleted are what used to
|
|
813
|
-
// declared grant with no policy stays subject to the naked-grant WARN,
|
|
814
|
-
// rls: false table correctly names it as live, deliberate, table-wide access.
|
|
1063
|
+
// Every grant an ability above did not state, verbatim — DML and beyond-CRUD alike. These
|
|
1064
|
+
// are the grants a fresh build must recreate (the migrations being deleted are what used to
|
|
1065
|
+
// create them); a declared grant with no policy stays subject to the naked-grant WARN,
|
|
1066
|
+
// which on an rls: false table correctly names it as live, deliberate, table-wide access.
|
|
815
1067
|
for (const role of Object.keys(contract.grants).sort()) {
|
|
816
1068
|
if (!govern.has(role)) continue;
|
|
817
|
-
const
|
|
1069
|
+
const asAbility = governedAsAbility.get(role) ?? new Set<string>();
|
|
1070
|
+
const held = (contract.grants[role] ?? []).filter((p) => !asAbility.has(p)).sort();
|
|
818
1071
|
if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
|
|
819
1072
|
}
|
|
820
1073
|
|
|
821
|
-
return { abilities, notes, unmappedRoles, privileges };
|
|
1074
|
+
return { abilities, notes, unmappedRoles, privileges, warnings };
|
|
822
1075
|
}
|
|
823
1076
|
|
|
824
1077
|
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
|
@@ -833,7 +1086,8 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
|
833
1086
|
// deparses a subquery across several lines. One `//` for the first line left the rest
|
|
834
1087
|
// as bare SQL in the middle of a TypeScript object — a generated file that would not
|
|
835
1088
|
// parse. Commenting per line here means no note, present or future, can do that.
|
|
836
|
-
for (const n of d.notes) for (const line of String(n)
|
|
1089
|
+
for (const n of d.notes) for (const line of commentLines(String(n))) out.push(` // ${line}`);
|
|
1090
|
+
for (const w of d.warnings) for (const line of commentLines(String(w))) out.push(` // WARNING: ${line}`);
|
|
837
1091
|
if (d.abilities.length) {
|
|
838
1092
|
out.push(` abilities: [${d.abilities.join(', ')}],`);
|
|
839
1093
|
} else {
|
|
@@ -118,15 +118,17 @@ export function carryUngovernedAuthz(
|
|
|
118
118
|
): TableContract {
|
|
119
119
|
const foreign = (role: string): boolean => !isGoverned(governed, role);
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
// Null-prototype copies: a carried role is a live name, and `grants['__proto__'] = …` on a
|
|
122
|
+
// plain object would set the prototype and drop the grant from the predicted endpoint.
|
|
123
|
+
const grants: Record<string, string[]> = Object.assign(Object.create(null), declared.grants);
|
|
122
124
|
for (const [role, privileges] of Object.entries(live.grants)) {
|
|
123
125
|
if (foreign(role)) grants[role] = [...privileges];
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
const columnGrants: Record<string, Record<string, string[]>> =
|
|
128
|
+
const columnGrants: Record<string, Record<string, string[]>> = Object.assign(Object.create(null), declared.columnGrants);
|
|
127
129
|
for (const [role, byPrivilege] of Object.entries(live.columnGrants ?? {})) {
|
|
128
130
|
if (!foreign(role)) continue;
|
|
129
|
-
const kept: Record<string, string[]> =
|
|
131
|
+
const kept: Record<string, string[]> = Object.create(null);
|
|
130
132
|
for (const [privilege, columns] of Object.entries(byPrivilege)) {
|
|
131
133
|
const present = columns.filter((c) => presentColumns.has(c));
|
|
132
134
|
if (present.length > 0) kept[privilege] = present;
|
|
@@ -296,7 +298,7 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
296
298
|
export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['USAGE'] as const;
|
|
297
299
|
|
|
298
300
|
export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
|
|
299
|
-
const out: Record<string, string[]> =
|
|
301
|
+
const out: Record<string, string[]> = Object.create(null); // keyed by a role name
|
|
300
302
|
for (const [grantee, privileges] of Object.entries(table.grants)) {
|
|
301
303
|
if (privileges.includes('INSERT')) out[grantee] = [...SEQUENCE_PRIVILEGES_FOR_INSERT];
|
|
302
304
|
}
|
|
@@ -34,6 +34,7 @@ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
|
34
34
|
import { compileDeclaredState } from '../declared-diff';
|
|
35
35
|
import { compileMigration } from '../migration-compile';
|
|
36
36
|
import { compileDrizzleSource } from '../schema-source';
|
|
37
|
+
import { modelMapPath, renderModelMap } from '../model-map';
|
|
37
38
|
import { currentGitRef } from '../state-apply';
|
|
38
39
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build';
|
|
39
40
|
import { resolveModelsPath } from '../models-path';
|
|
@@ -115,6 +116,15 @@ export interface StaticCheckInput {
|
|
|
115
116
|
/** Raw `db/authz-baseline.json`, or null when there is none. The ARTIFACT half of the
|
|
116
117
|
* ungoverned-grantee gate — see the check below for what it deliberately does not do. */
|
|
117
118
|
baselineSource?: string | null;
|
|
119
|
+
/** Where the model map lives: beside `artifactPath` (model-map.ts `modelMapPath`). */
|
|
120
|
+
modelMapPath?: string;
|
|
121
|
+
/**
|
|
122
|
+
* The committed model map, read raw. null = no file there (a NOTE, like the schema artifact at
|
|
123
|
+
* its default path: an app that upgraded and has not run db:generate yet is not broken; the
|
|
124
|
+
* map's path is never named on its own, so its absence is never a claim that vanished).
|
|
125
|
+
* Absent (undefined) = the caller did not read it (older callers): no map finding at all.
|
|
126
|
+
*/
|
|
127
|
+
modelMapSource?: string | null;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
@@ -303,6 +313,27 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
303
313
|
});
|
|
304
314
|
}
|
|
305
315
|
|
|
316
|
+
// F8 — the model map, the same regenerate-and-compare gate as the drizzle schema. Pure: the
|
|
317
|
+
// map is a function of the models, so nothing here reads a database (T2).
|
|
318
|
+
if (input.modelMapSource !== undefined) {
|
|
319
|
+
const mapPath = input.modelMapPath ?? modelMapPath(input.artifactPath);
|
|
320
|
+
if (input.modelMapSource === null) {
|
|
321
|
+
findings.push({
|
|
322
|
+
level: 'note',
|
|
323
|
+
area: 'artifact',
|
|
324
|
+
message: `no model map at ${mapPath} — nothing to match (\`everystack db:generate\` writes it beside the drizzle schema)`,
|
|
325
|
+
});
|
|
326
|
+
} else if (input.modelMapSource === renderModelMap(input.models)) {
|
|
327
|
+
findings.push({ level: 'ok', area: 'artifact', message: `${mapPath} matches regeneration byte-for-byte` });
|
|
328
|
+
} else {
|
|
329
|
+
findings.push({
|
|
330
|
+
level: 'fail',
|
|
331
|
+
area: 'artifact',
|
|
332
|
+
message: `${mapPath} does NOT match regeneration — the models moved without db:generate, or the artifact was hand-edited (generated artifacts are never edited). Run \`everystack db:generate\` (it rewrites the map from the models before it reads any database) and commit ${mapPath}.`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
306
337
|
// B7 — db/sql is retired. Files still present would be silently unmanaged: fail
|
|
307
338
|
// with the migration path. (Absence is the normal state; nothing to report.)
|
|
308
339
|
if (input.sqlDirRetired) {
|
|
@@ -407,6 +438,14 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
407
438
|
} catch {
|
|
408
439
|
artifactSource = null;
|
|
409
440
|
}
|
|
441
|
+
// F8 — the model map lives beside the drizzle schema, so it follows the same resolution.
|
|
442
|
+
const mapPath = modelMapPath(artifactPath);
|
|
443
|
+
let modelMapSource: string | null = null;
|
|
444
|
+
try {
|
|
445
|
+
modelMapSource = await fs.readFile(mapPath, 'utf8');
|
|
446
|
+
} catch {
|
|
447
|
+
modelMapSource = null;
|
|
448
|
+
}
|
|
410
449
|
// B7 tombstone — a retired db/sql directory still carrying .sql files is a FAIL
|
|
411
450
|
// finding (the message carries the migration path), never a silent skip. The retired
|
|
412
451
|
// --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
|
|
@@ -438,6 +477,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
438
477
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
439
478
|
moduleModels: declaredDb?.models ?? null,
|
|
440
479
|
baselineSource,
|
|
480
|
+
modelMapPath: mapPath, modelMapSource,
|
|
441
481
|
});
|
|
442
482
|
// Per-finding detail is the --verbose report. The quiet default answers the one question a
|
|
443
483
|
// human asked — is anything pending? — and names the verb that shows it.
|
|
@@ -25,7 +25,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
25
25
|
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
26
26
|
import { linkModels } from '@everystack/model';
|
|
27
27
|
import { introspectSchema } from '../schema-introspect';
|
|
28
|
-
import {
|
|
28
|
+
import { writeGeneratedArtifacts } from '../generated-artifacts';
|
|
29
|
+
import { modelMapPath } from '../model-map';
|
|
29
30
|
import { compileModuleMigration } from '../migration-compile';
|
|
30
31
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate';
|
|
31
32
|
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract';
|
|
@@ -103,8 +104,8 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
103
104
|
const models = modules.flatMap((m) => m.models);
|
|
104
105
|
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
105
106
|
|
|
106
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
107
|
-
await
|
|
107
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
108
|
+
await writeGeneratedArtifacts(schemaOut, models, modules.flatMap((m) => m.derived));
|
|
108
109
|
|
|
109
110
|
const statements = compileModuleMigration(modules);
|
|
110
111
|
|
|
@@ -252,13 +253,13 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
252
253
|
models = await loadModels(modelsPath);
|
|
253
254
|
declaredDb = await loadDeclaredDerived(flags.models);
|
|
254
255
|
info(`${models.length} model(s).`);
|
|
255
|
-
// The drizzle runtime schema
|
|
256
|
-
// every run (
|
|
257
|
-
// updates
|
|
258
|
-
// typed derived relations — B6a). A dry run refreshes NOTHING
|
|
256
|
+
// The drizzle runtime schema and the model map are derived artifacts — refresh them
|
|
257
|
+
// from the models every run (they need no DB), so a relation-only change with no DDL
|
|
258
|
+
// diff still updates them. db:check asserts both match the models byte for byte (the
|
|
259
|
+
// schema with the typed derived relations — B6a). A dry run refreshes NOTHING.
|
|
259
260
|
if (!dryRun) {
|
|
260
|
-
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
261
|
-
await
|
|
261
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
|
|
262
|
+
await writeGeneratedArtifacts(schemaOut, models, declaredDb?.derived);
|
|
262
263
|
}
|
|
263
264
|
const dbSource: DbSource = resolveDbSource(flags);
|
|
264
265
|
if (apply && dbSource.kind !== 'url') {
|