@everystack/cli 0.4.99 → 0.4.102
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 +105 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +15 -1
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/catalog-rename.ts +115 -0
- 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 +30 -13
- package/src/cli/commands/db-pull.ts +153 -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 +4 -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 +57 -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.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +243 -77
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +57 -18
- package/src/cli/schema-diff.ts +184 -41
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +66 -2
- 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,139 @@ 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
|
+
out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag} })` : `can('${DML[priv]}', { role: ${tsString(role)} })`);
|
|
591
|
+
}
|
|
592
|
+
out.asAbility.add(priv);
|
|
593
|
+
out.usedPolicies.add(p.name);
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
429
598
|
/**
|
|
430
599
|
* Derive the ability stanza for ONE table from its live contract.
|
|
431
600
|
*
|
|
@@ -433,18 +602,25 @@ function renderColumnAbility(
|
|
|
433
602
|
* through to `notes` rather than guessing — the caller renders those as comments beside
|
|
434
603
|
* the model, so the human sees the real rule and decides.
|
|
435
604
|
*
|
|
436
|
-
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
605
|
+
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to GOVERN a foreign
|
|
606
|
+
* role, per the doctrine on GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently
|
|
607
|
+
* convert a rendering decision into an access decision. Its grants and policies render as
|
|
608
|
+
* role-scoped `can()` where they pair exactly (see deriveGovernedRole), else as raw
|
|
609
|
+
* `privileges` with a warning. Complete-or-refuse where the model cannot keep live access:
|
|
610
|
+
* a column-scoped grant with no exactly-paired policy THROWS (the next plan would REVOKE it),
|
|
611
|
+
* and so does a RESTRICTIVE policy TO the role (the next plan would DROP it, a widening).
|
|
442
612
|
*/
|
|
443
613
|
export function deriveAbilities(
|
|
444
614
|
contract: TableContract,
|
|
445
615
|
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
446
616
|
): DerivedAbilities {
|
|
447
617
|
const govern = opts.governRoles ?? new Set<string>();
|
|
618
|
+
// A role is a KEY in every accumulator below (and in the rendered `privileges`), and a key
|
|
619
|
+
// named __proto__ sets the prototype instead. Introspection keeps such a role (null-prototype
|
|
620
|
+
// maps), so a live grant to "__proto__" reaches here and the pull refuses, naming it.
|
|
621
|
+
for (const role of [...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]) {
|
|
622
|
+
refuseProtoKey(role, liveObject('role', role));
|
|
623
|
+
}
|
|
448
624
|
/** One classification pass; every ability-vs-grant decision below reads this tag. */
|
|
449
625
|
const abilitySurfaces = classifyAbilitySurfaces(contract);
|
|
450
626
|
const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
|
|
@@ -505,6 +681,7 @@ export function deriveAbilities(
|
|
|
505
681
|
notes.push(
|
|
506
682
|
` an ability: an ability would ADD the policy the database does not have.`,
|
|
507
683
|
);
|
|
684
|
+
notes.push(...noRowPolicyNotes(contract, 'admin', adminDead));
|
|
508
685
|
}
|
|
509
686
|
|
|
510
687
|
// The same accounting for the app roles. A grant they hold with no policy behind it is
|
|
@@ -516,6 +693,7 @@ export function deriveAbilities(
|
|
|
516
693
|
deadGrants[role] = dead;
|
|
517
694
|
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
518
695
|
notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
|
|
696
|
+
notes.push(...noRowPolicyNotes(contract, role, dead));
|
|
519
697
|
}
|
|
520
698
|
|
|
521
699
|
// --- policed but not granted at the table level ---------------------------------------
|
|
@@ -555,8 +733,11 @@ export function deriveAbilities(
|
|
|
555
733
|
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
556
734
|
const anonRead = isAbilitySurface('anon', 'SELECT');
|
|
557
735
|
const authedRead = isAbilitySurface('authenticated', 'SELECT');
|
|
736
|
+
const ownerPair = !anonRead && authedRead ? deriveOwnerReadPair(contract) : null;
|
|
558
737
|
|
|
559
|
-
if (
|
|
738
|
+
if (ownerPair) {
|
|
739
|
+
abilities.push(...ownerPair);
|
|
740
|
+
} else if (anonRead) {
|
|
560
741
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
561
742
|
// carry that predicate — a public read with a filter is still a public read.
|
|
562
743
|
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
@@ -643,7 +824,7 @@ export function deriveAbilities(
|
|
|
643
824
|
// DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
|
|
644
825
|
// reached zero through escape hatches can be counted apart from one that reached zero
|
|
645
826
|
// through vocabulary. Fidelity achieved is not expressiveness achieved.
|
|
646
|
-
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred
|
|
827
|
+
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${templateBody(authedPred!)}\` })`);
|
|
647
828
|
notes.push(
|
|
648
829
|
`verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
|
|
649
830
|
);
|
|
@@ -753,27 +934,74 @@ export function deriveAbilities(
|
|
|
753
934
|
// exactly one permissive policy TO the same role proves the row gate. Anything else stays a
|
|
754
935
|
// note: guessing the pairing could grant the right columns under the wrong row policy.
|
|
755
936
|
const renderedColumnRoles = new Set<string>();
|
|
937
|
+
|
|
938
|
+
// --- custom roles the operator chose to GOVERN (--govern-roles): their row rules (F4) ---
|
|
939
|
+
const governedCustom = [...govern].filter((r) => !GOVERNED_VOCABULARY.has(r)).sort();
|
|
940
|
+
const governedAsAbility = new Map<string, Set<string>>();
|
|
941
|
+
const usedPolicies = new Set<string>();
|
|
942
|
+
const warnings: string[] = [];
|
|
943
|
+
for (const role of governedCustom) {
|
|
944
|
+
const g = deriveGovernedRole(contract, role);
|
|
945
|
+
abilities.push(...g.abilities);
|
|
946
|
+
warnings.push(...g.warnings);
|
|
947
|
+
governedAsAbility.set(role, g.asAbility);
|
|
948
|
+
for (const name of g.usedPolicies) usedPolicies.add(name);
|
|
949
|
+
}
|
|
950
|
+
|
|
756
951
|
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
757
952
|
const byPriv = contract.columnGrants![grantee];
|
|
758
953
|
if (govern.has(grantee)) {
|
|
954
|
+
// A governed role's column grant renders ONLY as an exact column ability: SELECT or
|
|
955
|
+
// UPDATE, row security on, exactly one permissive policy TO the role alone, and no
|
|
956
|
+
// table-level ability for the same privilege (two policies of one name would collide).
|
|
957
|
+
// `columns:` keeps it column-scoped; it never becomes a table-level grant.
|
|
958
|
+
// A paired policy with NO USING admits no row (PostgreSQL 16: a SELECT policy without
|
|
959
|
+
// USING, or an UPDATE policy with only a WITH CHECK); a column ability compiles USING (true),
|
|
960
|
+
// every row. `privileges` has no column axis, so there is no raw fallback that keeps the
|
|
961
|
+
// grant without widening it to the table: this refuses, as main did.
|
|
962
|
+
const tableLevel = governedAsAbility.get(grantee) ?? new Set<string>();
|
|
963
|
+
const noUsing: string[] = [];
|
|
964
|
+
const pairs = Object.keys(byPriv).sort().filter((priv) => (byPriv[priv] ?? []).length).map((priv) => {
|
|
965
|
+
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
966
|
+
let policy = action && contract.rls.enabled && !tableLevel.has(priv) ? columnPolicyFor(contract, grantee, priv) : null;
|
|
967
|
+
if (policy && policy.using == null) {
|
|
968
|
+
noUsing.push(`its ${priv} policy "${policy.name}" has no USING, so it admits no row and a column ability would admit every row`);
|
|
969
|
+
policy = null;
|
|
970
|
+
}
|
|
971
|
+
return { policy, ability: action && policy ? renderColumnAbility(action, grantee, byPriv[priv]!, policy, notes) : null };
|
|
972
|
+
});
|
|
973
|
+
if (pairs.length && pairs.every((p) => p.ability)) {
|
|
974
|
+
for (const p of pairs) {
|
|
975
|
+
abilities.push(p.ability!);
|
|
976
|
+
usedPolicies.add(p.policy!.name);
|
|
977
|
+
}
|
|
978
|
+
renderedColumnRoles.add(grantee);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
759
981
|
// Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
|
|
760
|
-
// has no column axis
|
|
761
|
-
//
|
|
982
|
+
// has no column axis — a partial transcription would leave this grant undeclared on a
|
|
983
|
+
// governed role, and the very next plan would REVOKE it.
|
|
762
984
|
const held = Object.entries(byPriv)
|
|
763
985
|
.filter(([, cols]) => (cols ?? []).length)
|
|
764
986
|
.map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
|
|
765
987
|
throw new Error(
|
|
766
988
|
`${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
|
|
767
|
-
`Governing a role
|
|
768
|
-
`column
|
|
769
|
-
`
|
|
989
|
+
`Governing a role reconciles ALL of its grants, and the model states a column-scoped grant only as a ` +
|
|
990
|
+
`column ability: SELECT or UPDATE, row security on, and exactly one permissive policy of that command ` +
|
|
991
|
+
`TO ${grantee} alone, with a USING. Without it the next plan would revoke the grant. ` +
|
|
992
|
+
`${noUsing.length ? `Here ${noUsing.join('; ')}. ` : ''}` +
|
|
993
|
+
`Add that policy or normalize the grant in the database first, or leave the role ungoverned.`,
|
|
770
994
|
);
|
|
771
995
|
}
|
|
772
996
|
for (const priv of Object.keys(byPriv).sort()) {
|
|
773
997
|
const cols = byPriv[priv] ?? [];
|
|
774
998
|
if (!cols.length) continue;
|
|
775
999
|
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
776
|
-
const
|
|
1000
|
+
const paired = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
1001
|
+
// The governed rule, here too: a paired policy with no USING admits no row, and a column
|
|
1002
|
+
// ability compiles USING (true), every row. Not rendered; the note below names the policy.
|
|
1003
|
+
const noUsing = paired && paired.using == null ? paired : null;
|
|
1004
|
+
const policy = noUsing ? null : paired;
|
|
777
1005
|
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
|
|
778
1006
|
if (ability) {
|
|
779
1007
|
abilities.push(ability);
|
|
@@ -782,11 +1010,33 @@ export function deriveAbilities(
|
|
|
782
1010
|
}
|
|
783
1011
|
notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
|
|
784
1012
|
notes.push(
|
|
785
|
-
|
|
786
|
-
? ` NOT rendered —
|
|
787
|
-
:
|
|
1013
|
+
noUsing
|
|
1014
|
+
? ` NOT rendered — its ${priv} policy "${noUsing.name}" has no USING, so it admits no row; a column ability would admit every row.`
|
|
1015
|
+
: action
|
|
1016
|
+
? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
|
|
1017
|
+
: ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// --- every policy TO a governed custom role is declared, warned, or refused -------------
|
|
1023
|
+
// The plan reconciles a governed role's policies, so a policy no ability above declares is
|
|
1024
|
+
// DROPPED. Dropping a permissive policy can only narrow, so it is a warning. Dropping a
|
|
1025
|
+
// RESTRICTIVE one widens (it subtracts rows), and the model cannot declare one: refuse.
|
|
1026
|
+
for (const p of contract.policies) {
|
|
1027
|
+
const roles = p.roles.filter((r) => governedCustom.includes(r));
|
|
1028
|
+
if (!roles.length || usedPolicies.has(p.name)) continue;
|
|
1029
|
+
if (!p.permissive) {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`${table}: --govern-roles ${roles.join(', ')} refused — RESTRICTIVE policy "${p.name}" (${p.command}) applies to it. ` +
|
|
1032
|
+
`The model cannot declare a restrictive policy, so governing the role would make the next plan DROP it ` +
|
|
1033
|
+
`and WIDEN the role's access. Leave the role ungoverned.`,
|
|
788
1034
|
);
|
|
789
1035
|
}
|
|
1036
|
+
warnings.push(
|
|
1037
|
+
`${table}: --govern-roles ${roles.join(', ')}: policy "${p.name}" (${p.command} TO ${p.roles.join(', ')}) is not ` +
|
|
1038
|
+
`declared by the model; the next db:plan DROPS policy "${p.name}".`,
|
|
1039
|
+
);
|
|
790
1040
|
}
|
|
791
1041
|
|
|
792
1042
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
@@ -794,7 +1044,7 @@ export function deriveAbilities(
|
|
|
794
1044
|
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
|
|
795
1045
|
);
|
|
796
1046
|
|
|
797
|
-
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
1047
|
+
if (!abilities.length && !notes.length && !unmappedRoles.length && !warnings.length) {
|
|
798
1048
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
799
1049
|
}
|
|
800
1050
|
|
|
@@ -808,17 +1058,18 @@ export function deriveAbilities(
|
|
|
808
1058
|
|
|
809
1059
|
// --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
|
|
810
1060
|
//
|
|
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.
|
|
1061
|
+
// Every grant an ability above did not state, verbatim — DML and beyond-CRUD alike. These
|
|
1062
|
+
// are the grants a fresh build must recreate (the migrations being deleted are what used to
|
|
1063
|
+
// create them); a declared grant with no policy stays subject to the naked-grant WARN,
|
|
1064
|
+
// which on an rls: false table correctly names it as live, deliberate, table-wide access.
|
|
815
1065
|
for (const role of Object.keys(contract.grants).sort()) {
|
|
816
1066
|
if (!govern.has(role)) continue;
|
|
817
|
-
const
|
|
1067
|
+
const asAbility = governedAsAbility.get(role) ?? new Set<string>();
|
|
1068
|
+
const held = (contract.grants[role] ?? []).filter((p) => !asAbility.has(p)).sort();
|
|
818
1069
|
if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
|
|
819
1070
|
}
|
|
820
1071
|
|
|
821
|
-
return { abilities, notes, unmappedRoles, privileges };
|
|
1072
|
+
return { abilities, notes, unmappedRoles, privileges, warnings };
|
|
822
1073
|
}
|
|
823
1074
|
|
|
824
1075
|
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
|
@@ -833,7 +1084,8 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
|
833
1084
|
// deparses a subquery across several lines. One `//` for the first line left the rest
|
|
834
1085
|
// as bare SQL in the middle of a TypeScript object — a generated file that would not
|
|
835
1086
|
// 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)
|
|
1087
|
+
for (const n of d.notes) for (const line of commentLines(String(n))) out.push(` // ${line}`);
|
|
1088
|
+
for (const w of d.warnings) for (const line of commentLines(String(w))) out.push(` // WARNING: ${line}`);
|
|
837
1089
|
if (d.abilities.length) {
|
|
838
1090
|
out.push(` abilities: [${d.abilities.join(', ')}],`);
|
|
839
1091
|
} 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
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog-rename — the catalog-only renames the planner emits, their parser, and the verdict
|
|
3
|
+
* db:apply reads from them.
|
|
4
|
+
*
|
|
5
|
+
* A rename of a check, a foreign key or an index changes no fingerprint: a check is hashed by its
|
|
6
|
+
* predicate and an index or key by its content. So a plan whose ONLY change is such a rename has
|
|
7
|
+
* from == to == live, and the endpoint cannot tell a rename that ran from one that did not. The
|
|
8
|
+
* live names can. The renderer and the parser live side by side here so they cannot drift; the
|
|
9
|
+
* parser accepts exactly what the renderer writes and nothing else.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SchemaSnapshot } from './schema-introspect';
|
|
13
|
+
import { isPureComment } from './sql-statement';
|
|
14
|
+
|
|
15
|
+
/** The same quoting schema-diff uses for every identifier it emits. */
|
|
16
|
+
const quote = (name: string): string => `"${name}"`;
|
|
17
|
+
|
|
18
|
+
/** `public.orders` → `public.`; an unqualified table carries no qualifier. */
|
|
19
|
+
const schemaPrefix = (table: string): string => (table.includes('.') ? `${table.split('.')[0]}.` : '');
|
|
20
|
+
|
|
21
|
+
export interface RenameChange {
|
|
22
|
+
/** Schema-qualified, as the snapshot spells it (`public.orders`). */
|
|
23
|
+
table: string;
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `ALTER TABLE … RENAME CONSTRAINT` — catalog-only and instant; the constraint keeps its identity. */
|
|
29
|
+
export function renderConstraintRename(change: RenameChange): string {
|
|
30
|
+
return `ALTER TABLE ${change.table} RENAME CONSTRAINT ${quote(change.from)} TO ${quote(change.to)};`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `ALTER INDEX … RENAME TO` — no rebuild. The schema qualifies the OLD name only. */
|
|
34
|
+
export function renderIndexRename(change: RenameChange): string {
|
|
35
|
+
return `ALTER INDEX ${schemaPrefix(change.table)}${quote(change.from)} RENAME TO ${quote(change.to)};`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CatalogRename =
|
|
39
|
+
| { kind: 'constraint'; table: string; from: string; to: string }
|
|
40
|
+
/** ALTER INDEX names no table — an index name is unique within its schema, so the schema is the scope. */
|
|
41
|
+
| { kind: 'index'; schema: string; from: string; to: string };
|
|
42
|
+
|
|
43
|
+
const IDENT = '"([^"]*)"';
|
|
44
|
+
const CONSTRAINT_RE = new RegExp(`^ALTER TABLE (\\S+) RENAME CONSTRAINT ${IDENT} TO ${IDENT};$`);
|
|
45
|
+
const INDEX_RE = new RegExp(`^ALTER INDEX (?:([^\\s."]+)\\.)?${IDENT} RENAME TO ${IDENT};$`);
|
|
46
|
+
|
|
47
|
+
/** The rename a statement is, if it is exactly one the renderers above produce; otherwise null. */
|
|
48
|
+
export function parseCatalogRename(sql: string): CatalogRename | null {
|
|
49
|
+
const c = CONSTRAINT_RE.exec(sql);
|
|
50
|
+
if (c) return { kind: 'constraint', table: c[1], from: c[2], to: c[3] };
|
|
51
|
+
const i = INDEX_RE.exec(sql);
|
|
52
|
+
// An unqualified ALTER INDEX resolves through search_path; the renderer only writes one for an
|
|
53
|
+
// unqualified table, which the snapshot spells as `public`.
|
|
54
|
+
if (i) return { kind: 'index', schema: i[1] ?? 'public', from: i[2], to: i[3] };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type RenameEdgeVerdict =
|
|
59
|
+
/** Empty, or some statement is not a recognized rename — the names have nothing to say. */
|
|
60
|
+
| { verdict: 'not-renames' }
|
|
61
|
+
/** Every rename already landed: `to` present, `from` absent. */
|
|
62
|
+
| { verdict: 'settled' }
|
|
63
|
+
/** No rename has landed: `from` present, `to` absent. The edge still has to run. */
|
|
64
|
+
| { verdict: 'unsettled' }
|
|
65
|
+
/** Anything else — the plan describes neither the database before it nor after it. */
|
|
66
|
+
| { verdict: 'stale'; reason: string };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where each rename in the edge stands in the BEFORE snapshot (the one the lock was computed
|
|
70
|
+
* from). Only an edge made entirely of recognized renames gets a verdict other than
|
|
71
|
+
* `not-renames`; one rename in an odd state, or a mix of landed and not, makes the plan stale.
|
|
72
|
+
*/
|
|
73
|
+
export function renameEdgeVerdict(statements: readonly string[], before: SchemaSnapshot): RenameEdgeVerdict {
|
|
74
|
+
// A comment is never run, and the planner writes them beside real changes (an inert
|
|
75
|
+
// renamedFrom notice, a held drop). They must not hide a rename edge from the names.
|
|
76
|
+
const real = statements.filter((sql) => !isPureComment(sql));
|
|
77
|
+
if (real.length === 0) return { verdict: 'not-renames' };
|
|
78
|
+
const renames: CatalogRename[] = [];
|
|
79
|
+
for (const sql of real) {
|
|
80
|
+
const rename = parseCatalogRename(sql);
|
|
81
|
+
if (!rename) return { verdict: 'not-renames' };
|
|
82
|
+
renames.push(rename);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const settled: string[] = [];
|
|
86
|
+
const unsettled: string[] = [];
|
|
87
|
+
for (const rename of renames) {
|
|
88
|
+
let present: Set<string>;
|
|
89
|
+
let where: string;
|
|
90
|
+
if (rename.kind === 'constraint') {
|
|
91
|
+
const table = before.tables.find((t) => t.table === rename.table);
|
|
92
|
+
if (!table) return { verdict: 'stale', reason: `table ${rename.table} does not exist` };
|
|
93
|
+
present = new Set([...table.checks, ...table.foreignKeys, ...table.uniques].map((c) => c.name));
|
|
94
|
+
where = `on ${rename.table}`;
|
|
95
|
+
} else {
|
|
96
|
+
present = new Set(before.tables
|
|
97
|
+
.filter((t) => t.table.startsWith(`${rename.schema}.`))
|
|
98
|
+
.flatMap((t) => t.indexes.map((ix) => ix.name)));
|
|
99
|
+
where = `in schema ${rename.schema}`;
|
|
100
|
+
}
|
|
101
|
+
const hasFrom = present.has(rename.from);
|
|
102
|
+
const hasTo = present.has(rename.to);
|
|
103
|
+
const label = `"${rename.from}" → "${rename.to}"`;
|
|
104
|
+
if (hasTo && !hasFrom) settled.push(label);
|
|
105
|
+
else if (hasFrom && !hasTo) unsettled.push(label);
|
|
106
|
+
else if (hasFrom && hasTo) return { verdict: 'stale', reason: `both "${rename.from}" and "${rename.to}" exist ${where}` };
|
|
107
|
+
else return { verdict: 'stale', reason: `neither "${rename.from}" nor "${rename.to}" exists ${where}` };
|
|
108
|
+
}
|
|
109
|
+
if (unsettled.length === 0) return { verdict: 'settled' };
|
|
110
|
+
if (settled.length === 0) return { verdict: 'unsettled' };
|
|
111
|
+
return {
|
|
112
|
+
verdict: 'stale',
|
|
113
|
+
reason: `some of its renames are already applied (${settled.join(', ')}) and some are not (${unsettled.join(', ')})`,
|
|
114
|
+
};
|
|
115
|
+
}
|