@everystack/cli 0.4.44 → 0.4.45
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-adoption-class.ts +265 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +146 -0
- package/src/cli/authz-compile.ts +46 -6
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +99 -14
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +19 -27
- package/src/cli/commands/db-generate.ts +50 -0
- package/src/cli/commands/db-plan.ts +34 -2
- package/src/cli/commands/db-pull.ts +20 -1
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +19 -2
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { parsePgArray } from './security-catalog.js';
|
|
25
25
|
import { withCanonicalSearchPath } from './search-path.js';
|
|
26
|
+
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
@@ -100,6 +101,42 @@ export function effectivePolicyCheck(p: PolicyContract): string | null {
|
|
|
100
101
|
return p.check;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Does a role hold a privilege at the TABLE level? — the question "may we emit an ability?"
|
|
106
|
+
*
|
|
107
|
+
* `can('update')` compiles to a policy PLUS a table-wide `GRANT UPDATE`. So a role that holds
|
|
108
|
+
* UPDATE only on three columns does NOT satisfy this: rendering the ability would widen a
|
|
109
|
+
* column grant into a whole-table one. PUBLIC counts, in both catalog spellings, because a
|
|
110
|
+
* grant to PUBLIC really is held by every role.
|
|
111
|
+
*/
|
|
112
|
+
export function holdsTablePrivilege(t: TableContract, role: string, privilege: string): boolean {
|
|
113
|
+
const direct = t.grants[role] ?? [];
|
|
114
|
+
const pub = t.grants.PUBLIC ?? t.grants.public ?? [];
|
|
115
|
+
return direct.includes(privilege) || pub.includes(privilege);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does a role hold a privilege AT ALL? — the question "does this policy authorize anything?"
|
|
120
|
+
*
|
|
121
|
+
* The same question one layer wider: a COLUMN-scoped grant counts. `GRANT UPDATE (body) ON t
|
|
122
|
+
* TO authenticated` really does let that role update, so a policy governing UPDATE is doing
|
|
123
|
+
* live work even though the role holds no table-level UPDATE.
|
|
124
|
+
*
|
|
125
|
+
* TWO PREDICATES ON PURPOSE, and they answer different questions — that is why they are here
|
|
126
|
+
* together rather than one being written twice. `db:pull` used the narrow one to answer the
|
|
127
|
+
* wide question and told adopters a live, column-granted UPDATE policy was "dead code today,
|
|
128
|
+
* declaring it would ADD a privilege the database does not give". Both halves were false, and
|
|
129
|
+
* it was printed beside the model they were about to trust. Use {@link holdsTablePrivilege} to
|
|
130
|
+
* decide what to EMIT; use this to decide what is DEAD.
|
|
131
|
+
*/
|
|
132
|
+
export function holdsPrivilege(t: TableContract, role: string, privilege: string): boolean {
|
|
133
|
+
if (holdsTablePrivilege(t, role, privilege)) return true;
|
|
134
|
+
const cols = t.columnGrants ?? {};
|
|
135
|
+
return Boolean(cols[role]?.[privilege]?.length)
|
|
136
|
+
|| Boolean(cols.PUBLIC?.[privilege]?.length)
|
|
137
|
+
|| Boolean(cols.public?.[privilege]?.length);
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
104
141
|
export function isWrappedExpression(s: string): boolean {
|
|
105
142
|
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
@@ -509,6 +546,24 @@ export async function introspectContract(
|
|
|
509
546
|
|
|
510
547
|
export type DriftSeverity = 'drift';
|
|
511
548
|
|
|
549
|
+
/** A live policy kept under its own name because it already carries the declared rule. */
|
|
550
|
+
export interface PolicyAdoption {
|
|
551
|
+
/** Schema-qualified table. */
|
|
552
|
+
subject: string;
|
|
553
|
+
/** The policy name(s) the model would have created. */
|
|
554
|
+
declared: string[];
|
|
555
|
+
/** The name the database uses, and keeps. */
|
|
556
|
+
live: string;
|
|
557
|
+
/** Why it was accepted, in plain words. */
|
|
558
|
+
reason: string;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Drift, plus the adoptions that are deliberately NOT drift. */
|
|
562
|
+
export interface ContractComparison {
|
|
563
|
+
findings: DriftFinding[];
|
|
564
|
+
adoptions: PolicyAdoption[];
|
|
565
|
+
}
|
|
566
|
+
|
|
512
567
|
export interface DriftFinding {
|
|
513
568
|
/** Schema-qualified table, or `fn:<name>` for a function finding. */
|
|
514
569
|
subject: string;
|
|
@@ -528,7 +583,20 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
528
583
|
* Returns every discrepancy; an empty list means the live DB matches the declaration.
|
|
529
584
|
*/
|
|
530
585
|
export function diffContracts(declared: AuthzContract, live: AuthzContract): DriftFinding[] {
|
|
586
|
+
return compareContracts(declared, live).findings;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The full comparison: drift AND the name divergences accepted by rule.
|
|
591
|
+
*
|
|
592
|
+
* `diffContracts` returns only the findings, because every caller treats a finding as drift
|
|
593
|
+
* and exits non-zero. An adoption is the opposite of drift, so it cannot travel in that list —
|
|
594
|
+
* but it must still be SHOWN, or the mapping between the model's name and the database's name
|
|
595
|
+
* becomes tribal knowledge.
|
|
596
|
+
*/
|
|
597
|
+
export function compareContracts(declared: AuthzContract, live: AuthzContract): ContractComparison {
|
|
531
598
|
const findings: DriftFinding[] = [];
|
|
599
|
+
const adoptions: PolicyAdoption[] = [];
|
|
532
600
|
const dTables = tableMap(declared);
|
|
533
601
|
const lTables = tableMap(live);
|
|
534
602
|
|
|
@@ -546,7 +614,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
|
|
|
546
614
|
}
|
|
547
615
|
diffGrants(name, d, l, findings);
|
|
548
616
|
diffColumnGrants(name, d, l, findings);
|
|
549
|
-
diffPolicies(name, d, l, findings);
|
|
617
|
+
diffPolicies(name, d, l, findings, adoptions);
|
|
550
618
|
}
|
|
551
619
|
for (const name of lTables.keys()) {
|
|
552
620
|
if (!dTables.has(name)) {
|
|
@@ -575,7 +643,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
|
|
|
575
643
|
}
|
|
576
644
|
}
|
|
577
645
|
|
|
578
|
-
return findings;
|
|
646
|
+
return { findings, adoptions };
|
|
579
647
|
}
|
|
580
648
|
|
|
581
649
|
function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
@@ -612,31 +680,58 @@ function diffColumnGrants(table: string, d: TableContract, l: TableContract, out
|
|
|
612
680
|
}
|
|
613
681
|
}
|
|
614
682
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
683
|
+
/**
|
|
684
|
+
* Diff policies through the SHARED matcher — the same equivalence `emitReconcileSql` and the
|
|
685
|
+
* fingerprint use. A rule-identical live policy under a different name is not drift: the
|
|
686
|
+
* database already authorizes exactly what the model declares.
|
|
687
|
+
*
|
|
688
|
+
* Adoptions travel on their own channel, not as findings. A finding means drift and drift
|
|
689
|
+
* means exit 1, while an adoption is agreement. It is never SILENT though — once a live policy
|
|
690
|
+
* keeps its own name, the name the model shows and the name a human greps for have diverged,
|
|
691
|
+
* and that mapping has to stay machine-derived and visible.
|
|
692
|
+
*/
|
|
693
|
+
function diffPolicies(
|
|
694
|
+
table: string,
|
|
695
|
+
d: TableContract,
|
|
696
|
+
l: TableContract,
|
|
697
|
+
out: DriftFinding[],
|
|
698
|
+
adoptions: PolicyAdoption[],
|
|
699
|
+
): void {
|
|
700
|
+
const m = matchPolicies(d.policies, l.policies);
|
|
701
|
+
|
|
702
|
+
for (const a of m.adopted) {
|
|
703
|
+
adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
|
|
704
|
+
}
|
|
705
|
+
for (const g of m.adoptedGroups) {
|
|
706
|
+
adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
|
|
710
|
+
// field moved is the difference between a finding an operator can act on and one they have
|
|
711
|
+
// to go read the catalog to understand.
|
|
712
|
+
const createdByName = new Map(m.toCreate.map((p) => [p.name, p]));
|
|
713
|
+
const changed = new Set<string>();
|
|
714
|
+
for (const name of m.toDrop) {
|
|
715
|
+
const dp = createdByName.get(name);
|
|
716
|
+
if (!dp) continue;
|
|
717
|
+
const lp = l.policies.find((p) => p.name === name)!;
|
|
718
|
+
changed.add(name);
|
|
624
719
|
const changes: string[] = [];
|
|
625
|
-
if (dp.command !== lp.command) changes.push(`command ${dp.command}
|
|
626
|
-
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}
|
|
627
|
-
if (dp.roles
|
|
628
|
-
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
|
|
632
|
-
if (changes.length) {
|
|
633
|
-
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
634
|
-
}
|
|
720
|
+
if (dp.command !== lp.command) changes.push(`command ${dp.command}\u2192${lp.command}`);
|
|
721
|
+
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}\u2192${lp.permissive}`);
|
|
722
|
+
if (!roleSetEqual(dp.roles, lp.roles)) changes.push(`roles [${dp.roles}]\u2192[${lp.roles}]`);
|
|
723
|
+
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push('USING changed');
|
|
724
|
+
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push('WITH CHECK changed');
|
|
725
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
635
726
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
727
|
+
|
|
728
|
+
for (const p of m.toCreate) {
|
|
729
|
+
if (changed.has(p.name)) continue;
|
|
730
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${p.name}" declared but missing from the live database` });
|
|
731
|
+
}
|
|
732
|
+
for (const name of m.toDrop) {
|
|
733
|
+
if (changed.has(name)) continue;
|
|
734
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
|
|
640
735
|
}
|
|
641
736
|
}
|
|
642
737
|
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -25,12 +25,24 @@
|
|
|
25
25
|
* comment: the comment costs a human five minutes, the ability costs them a privilege.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { EXTRA_PRIVILEGES } from '@everystack/model';
|
|
29
|
+
import { holdsPrivilege, holdsTablePrivilege } from './authz-contract.js';
|
|
28
30
|
import type { TableContract, PolicyContract } from './authz-contract.js';
|
|
29
31
|
|
|
30
32
|
/** One rendered decision: either an ability line, or a comment explaining the omission. */
|
|
31
33
|
export interface DerivedAbilities {
|
|
32
34
|
/** `can(...)` lines, ready to sit inside `abilities: [ ... ]`. */
|
|
33
35
|
abilities: string[];
|
|
36
|
+
/**
|
|
37
|
+
* The beyond-CRUD privileges (REFERENCES/TRIGGER/TRUNCATE) a GOVERNED role holds live —
|
|
38
|
+
* rendered as the model's `privileges` key.
|
|
39
|
+
*
|
|
40
|
+
* `can()` has no verb for these, so before they were rendered the model could not say a
|
|
41
|
+
* live admin held them, and the first plan revoked all three on every table. Present only
|
|
42
|
+
* when the live database actually has some; empty means the key is omitted entirely, so a
|
|
43
|
+
* greenfield model is byte-identical to what it was.
|
|
44
|
+
*/
|
|
45
|
+
privileges: Record<string, string[]>;
|
|
34
46
|
/** `//` comment lines naming what could not be rendered, and why. */
|
|
35
47
|
notes: string[];
|
|
36
48
|
/**
|
|
@@ -55,6 +67,28 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
|
55
67
|
/** The roles the compiler itself emits policies for; anything else is app-specific. */
|
|
56
68
|
const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
|
|
72
|
+
* always governed because a grant to PUBLIC is the broadest privilege the database can express.
|
|
73
|
+
*
|
|
74
|
+
* The beyond-CRUD privileges of anyone else are deliberately not rendered: the reconciler leaves
|
|
75
|
+
* an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
|
|
76
|
+
* would GOVERN it — turning a rendering decision into an access decision for every table.
|
|
77
|
+
*/
|
|
78
|
+
const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
79
|
+
|
|
80
|
+
/** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
|
|
81
|
+
function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
|
|
82
|
+
const out: Record<string, string[]> = {};
|
|
83
|
+
const extra = EXTRA_PRIVILEGES as readonly string[];
|
|
84
|
+
for (const grantee of Object.keys(contract.grants).sort()) {
|
|
85
|
+
if (!GOVERNED_VOCABULARY.has(grantee)) continue;
|
|
86
|
+
const held = (contract.grants[grantee] ?? []).filter((p) => extra.includes(p)).sort();
|
|
87
|
+
if (held.length) out[grantee] = held;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
58
92
|
/**
|
|
59
93
|
* The deparsed owner predicate the compiler produces, in both its casts:
|
|
60
94
|
* (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
|
|
@@ -181,12 +215,15 @@ export function parsePublicOrOwn(
|
|
|
181
215
|
return { owner, ownerSql: rest[0] ?? null };
|
|
182
216
|
}
|
|
183
217
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
218
|
+
/**
|
|
219
|
+
* May we EMIT an ability for this privilege? Table-level only.
|
|
220
|
+
*
|
|
221
|
+
* `can('update')` compiles to a policy plus a table-wide `GRANT UPDATE`, so a role holding
|
|
222
|
+
* UPDATE on three columns must not satisfy this — rendering the ability would widen a column
|
|
223
|
+
* grant into a whole-table one. Whether the privilege is held AT ALL is a different question,
|
|
224
|
+
* answered by `holdsPrivilege`; both live in authz-contract so they cannot drift apart.
|
|
225
|
+
*/
|
|
226
|
+
const granted = holdsTablePrivilege;
|
|
190
227
|
|
|
191
228
|
/** The policies that apply to a role for a command (an `ALL` policy covers every command). */
|
|
192
229
|
function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
|
|
@@ -219,21 +256,36 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
219
256
|
const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
|
|
220
257
|
if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
|
|
221
258
|
|
|
222
|
-
// ---
|
|
223
|
-
//
|
|
259
|
+
// --- policed but not granted at the table level ---------------------------------------
|
|
260
|
+
//
|
|
261
|
+
// Two different findings wear the same shape here, and calling both "dead" was a lie the
|
|
262
|
+
// adopter read beside the model they were about to trust:
|
|
263
|
+
//
|
|
264
|
+
// - NOTHING holds the privilege → the policy really is dead code, and rendering it as an
|
|
265
|
+
// ability would ADD a privilege the database does not give. That is the trap.
|
|
266
|
+
// - a COLUMN grant holds it → the policy is doing live work on those columns. Rendering
|
|
267
|
+
// the ability would still be wrong (it grants the whole table), but the privilege is
|
|
268
|
+
// real, and the plan that drops the policy is taking away access that exists.
|
|
224
269
|
for (const p of contract.policies) {
|
|
225
270
|
const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
|
|
226
271
|
for (const cmd of commands) {
|
|
227
272
|
const priv = cmd;
|
|
228
273
|
const live = p.roles.filter((r) => r !== 'public' && r !== 'admin' && !granted(contract, r, priv));
|
|
229
|
-
if (live.length
|
|
274
|
+
if (!live.length || !p.roles.some((r) => r !== 'admin')) continue;
|
|
275
|
+
const viaColumns = live.filter((r) => holdsPrivilege(contract, r, priv));
|
|
276
|
+
if (viaColumns.length) {
|
|
230
277
|
notes.push(
|
|
231
|
-
`policy "${p.name}" (${cmd})
|
|
232
|
-
);
|
|
233
|
-
notes.push(
|
|
234
|
-
` it is dead code today. Declaring it would ADD a privilege the database does not give.`,
|
|
278
|
+
`policy "${p.name}" (${cmd}) is live through the ${viaColumns.join(', ')} COLUMN grant below, not a table grant.`,
|
|
235
279
|
);
|
|
280
|
+
notes.push(` NOT dead. Not rendered either: an ability would grant ${priv} on the whole table.`);
|
|
281
|
+
continue;
|
|
236
282
|
}
|
|
283
|
+
notes.push(
|
|
284
|
+
`policy "${p.name}" (${cmd}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
|
|
285
|
+
);
|
|
286
|
+
notes.push(
|
|
287
|
+
` it is dead code today. Declaring it would ADD a privilege the database does not give.`,
|
|
288
|
+
);
|
|
237
289
|
}
|
|
238
290
|
}
|
|
239
291
|
|
|
@@ -328,6 +380,27 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
328
380
|
abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
|
|
329
381
|
}
|
|
330
382
|
|
|
383
|
+
// --- the column grants, which nothing above renders -----------------------------------
|
|
384
|
+
//
|
|
385
|
+
// The model expresses column scoping for READ only (`can('read', { owner, columns })`), and
|
|
386
|
+
// even that pairs with an owner policy this deriver does not attempt to reconstruct. So
|
|
387
|
+
// every live column grant is a privilege the rendered model does not carry, and the first
|
|
388
|
+
// plan REVOKES it — 11 statements on the reference schema, none of which had a word beside
|
|
389
|
+
// the model the adopter reviews.
|
|
390
|
+
//
|
|
391
|
+
// Named exactly: grantee, privilege, and the columns. A count is not auditable, and "some
|
|
392
|
+
// column grants were not captured" is the shape of silence this note exists to break.
|
|
393
|
+
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
394
|
+
if (!GOVERNED_VOCABULARY.has(grantee)) continue; // ungoverned: left alone, never revoked
|
|
395
|
+
const byPriv = contract.columnGrants![grantee];
|
|
396
|
+
for (const priv of Object.keys(byPriv).sort()) {
|
|
397
|
+
const cols = byPriv[priv] ?? [];
|
|
398
|
+
if (!cols.length) continue;
|
|
399
|
+
notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
|
|
400
|
+
notes.push(` NOT rendered — the model has no way to declare it. The next plan REVOKES it.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
331
404
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
332
405
|
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
333
406
|
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
|
|
@@ -337,7 +410,12 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
337
410
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
338
411
|
}
|
|
339
412
|
|
|
340
|
-
return { abilities, notes, unmappedRoles };
|
|
413
|
+
return { abilities, notes, unmappedRoles, privileges: deriveExtraPrivileges(contract) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
|
417
|
+
function identKey(name: string): string {
|
|
418
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
341
419
|
}
|
|
342
420
|
|
|
343
421
|
/** Render one table's derived stanza as the lines that sit inside a model literal. */
|
|
@@ -353,5 +431,12 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
|
353
431
|
} else {
|
|
354
432
|
out.push(` private: true, // no effective privilege found — not part of the data API`);
|
|
355
433
|
}
|
|
434
|
+
// The beyond-CRUD grants, transcribed. Omitted entirely when there are none, so a database
|
|
435
|
+
// without them renders exactly the model it rendered before this key existed.
|
|
436
|
+
const roles = Object.keys(d.privileges ?? {});
|
|
437
|
+
if (roles.length) {
|
|
438
|
+
const entries = roles.map((r) => `${identKey(r)}: [${d.privileges[r].map((p) => `'${p}'`).join(', ')}]`);
|
|
439
|
+
out.push(` privileges: { ${entries.join(', ')} }, // live grants can() has no verb for`);
|
|
440
|
+
}
|
|
356
441
|
return out.join('\n');
|
|
357
442
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-identity — policy identity by RULE, not by name. The shared matcher.
|
|
3
|
+
*
|
|
4
|
+
* ONE module, called by BOTH the emitter (`authz-reconcile`) and the differ
|
|
5
|
+
* (`authz-contract`). They must never hold separate implementations: two hand-mirrored
|
|
6
|
+
* bipartite matchers will disagree on tie-breaks, and then `db:check` reports drift the plan
|
|
7
|
+
* does not carry — the two surfaces contradicting each other about the same database.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS. A brownfield database names its policies whatever its previous migration
|
|
10
|
+
* tool named them. `reconcilePolicies` matched by NAME, so a live policy with the same
|
|
11
|
+
* command, roles, USING and CHECK but a different name was reported twice — "declared but
|
|
12
|
+
* missing" and "live but undeclared" — which the emitter turned into DROP + CREATE. On a real
|
|
13
|
+
* adopter's schema that was ~25 statements of pure spelling.
|
|
14
|
+
*
|
|
15
|
+
* THE SAFETY PROPERTY, which every rule below serves:
|
|
16
|
+
*
|
|
17
|
+
* **Identity by rule must be NARROWER than identity by name, never wider.**
|
|
18
|
+
*
|
|
19
|
+
* A false match leaves a live policy in place while the model believes it declared it. That is
|
|
20
|
+
* the one direction no review catches. So matching requires FULL field equality — the same
|
|
21
|
+
* predicate that already decides "unchanged" now also decides "same rule". The matcher is
|
|
22
|
+
* therefore MONOTONE: it can only turn a would-be DROP + CREATE pair into a no-op, never emit
|
|
23
|
+
* different DDL. Anything short of equality falls through to the previous behaviour.
|
|
24
|
+
*
|
|
25
|
+
* Two known limits, stated rather than hidden:
|
|
26
|
+
*
|
|
27
|
+
* - Predicates compare as TEXT. Postgres normalizes `pg_get_expr` output, so a semantically
|
|
28
|
+
* identical predicate written differently is a MISS (drop + create, the status quo), never
|
|
29
|
+
* a false match. Misses are the expected failure mode and they are safe.
|
|
30
|
+
* - Text equality is only false-match-proof if introspection renders references fully
|
|
31
|
+
* qualified. A live policy created under a different `search_path` could deparse `f(x)`
|
|
32
|
+
* meaning `legacy.f` identically to a declared `f(x)` meaning `app.f`. Adoption never
|
|
33
|
+
* re-executes DDL so it cannot emit wrong SQL, but it could falsely ADOPT. Pinning
|
|
34
|
+
* introspection's search_path is what closes that hole.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { effectivePolicyCheck, type PolicyContract } from './authz-contract.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Do two role lists name the same set?
|
|
41
|
+
*
|
|
42
|
+
* Compared as SETS. The previous `roles.join(',')` comparison had a false-match seam: a role
|
|
43
|
+
* name may legally contain a comma (`CREATE ROLE "a,b"`), which made `['a,b']` compare equal
|
|
44
|
+
* to `['a','b']` — two different authorizations reported as one.
|
|
45
|
+
*
|
|
46
|
+
* Nothing folds case here, and that is load-bearing. PUBLIC (`pg_policy.polroles = {0}`,
|
|
47
|
+
* rendered by `pg_policies` as the literal `public`) is an OPEN set: every role that exists or
|
|
48
|
+
* ever will, including ones created after the model was written. It can equal PUBLIC and
|
|
49
|
+
* nothing else — no enumerated set can account for roles that do not exist yet. Plain set
|
|
50
|
+
* equality gives that sentinel property for free, so long as no caller case-folds a grantee
|
|
51
|
+
* into it (`CREATE ROLE "Public"` is legal and distinct).
|
|
52
|
+
*/
|
|
53
|
+
export function roleSetEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
54
|
+
if (a.length !== b.length) return false;
|
|
55
|
+
const sa = new Set(a);
|
|
56
|
+
if (sa.size !== new Set(b).size) return false;
|
|
57
|
+
return b.every((r) => sa.has(r));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Are these the same authorization, ignoring only the NAME?
|
|
62
|
+
*
|
|
63
|
+
* The check compares through {@link effectivePolicyCheck} — the server's own defaulting rule
|
|
64
|
+
* — so a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
|
|
65
|
+
* are recognized as identical rather than reconciled into a DROP + CREATE that changes
|
|
66
|
+
* nothing. That defaulting applies to `ALL` and `UPDATE` only; on a `SELECT` an omitted check
|
|
67
|
+
* is genuinely no check.
|
|
68
|
+
*
|
|
69
|
+
* Command equality is exact and always. A live `FOR ALL` must never satisfy a declared
|
|
70
|
+
* SELECT/INSERT/UPDATE/DELETE quartet: USING and WITH CHECK applicability differ per command,
|
|
71
|
+
* and that is precisely where false-equivalence reasoning breeds.
|
|
72
|
+
*/
|
|
73
|
+
export function policyRuleEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
74
|
+
return a.command === b.command
|
|
75
|
+
&& a.permissive === b.permissive
|
|
76
|
+
&& roleSetEqual(a.roles, b.roles)
|
|
77
|
+
&& (a.using ?? '') === (b.using ?? '')
|
|
78
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Same authorization on every axis EXCEPT which roles it names. Used only by the role-union
|
|
83
|
+
* pass; the command is still compared exactly, because USING and WITH CHECK applicability
|
|
84
|
+
* differ per command and a live `FOR ALL` must never satisfy a declared per-command set.
|
|
85
|
+
*/
|
|
86
|
+
function sameRuleIgnoringRoles(a: PolicyContract, b: PolicyContract): boolean {
|
|
87
|
+
return a.command === b.command
|
|
88
|
+
&& a.permissive === b.permissive
|
|
89
|
+
&& (a.using ?? '') === (b.using ?? '')
|
|
90
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One declared policy satisfied by a live policy of the same rule under a different name. */
|
|
94
|
+
export interface AdoptedPolicy {
|
|
95
|
+
/** The name the model would have created. */
|
|
96
|
+
declared: string;
|
|
97
|
+
/** The name the database already uses, and keeps. */
|
|
98
|
+
live: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One live multi-role policy that satisfies a GROUP of declared per-role policies. */
|
|
102
|
+
export interface AdoptedGroup {
|
|
103
|
+
/** The per-role policies the model would have created, sorted. */
|
|
104
|
+
declared: string[];
|
|
105
|
+
/** The single live policy that already authorizes exactly the same thing. */
|
|
106
|
+
live: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PolicyMatch {
|
|
110
|
+
/**
|
|
111
|
+
* Rule-identical pairs whose NAMES differ. These emit nothing: the live policy already IS
|
|
112
|
+
* the declared authorization, and renaming it would be DDL that buys spelling.
|
|
113
|
+
*/
|
|
114
|
+
adopted: AdoptedPolicy[];
|
|
115
|
+
/**
|
|
116
|
+
* Live multi-role policies satisfying a declared per-role group. Postgres applies a policy
|
|
117
|
+
* per session role by membership, so one policy `TO a, b` and two identical-predicate
|
|
118
|
+
* policies `TO a` and `TO b` are applicable to exactly the same sessions.
|
|
119
|
+
*/
|
|
120
|
+
adoptedGroups: AdoptedGroup[];
|
|
121
|
+
/** Declared policies with no live counterpart — emit `CREATE POLICY`. */
|
|
122
|
+
toCreate: PolicyContract[];
|
|
123
|
+
/** Live policy NAMES with no declared counterpart — emit `DROP POLICY`. */
|
|
124
|
+
toDrop: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Match declared policies to live ones, deterministically and ONE-TO-ONE in both directions.
|
|
129
|
+
*
|
|
130
|
+
* Three passes, in order. The order is part of the contract, not an implementation detail —
|
|
131
|
+
* the emitter and the differ must reach the same answer or they contradict each other:
|
|
132
|
+
*
|
|
133
|
+
* 1. **Name AND rule agree** — already reconciled, nothing to do, no adoption recorded.
|
|
134
|
+
* 2. **Rule agrees among the leftovers** — adopt: keep the live name, emit nothing. Ties
|
|
135
|
+
* (two live policies of the same rule competing for one declared policy) break
|
|
136
|
+
* lexicographically by LIVE name, so both surfaces pick the same survivor.
|
|
137
|
+
* 3. **Everything still unmatched** — declared → CREATE, live → DROP. This includes a name
|
|
138
|
+
* match whose rule differs, which is a real change and must still be re-created.
|
|
139
|
+
*
|
|
140
|
+
* One-to-one runs both directions: a declared policy is consumed at most once, so it can never
|
|
141
|
+
* "satisfy" two overlapping live policies and leave the second silently in place.
|
|
142
|
+
*/
|
|
143
|
+
export function matchPolicies(
|
|
144
|
+
declared: readonly PolicyContract[],
|
|
145
|
+
live: readonly PolicyContract[],
|
|
146
|
+
): PolicyMatch {
|
|
147
|
+
const adopted: AdoptedPolicy[] = [];
|
|
148
|
+
const declaredLeft = new Map(declared.map((p) => [p.name, p]));
|
|
149
|
+
const liveLeft = new Map(live.map((p) => [p.name, p]));
|
|
150
|
+
|
|
151
|
+
// Pass 1 — name and rule both agree. Sorted so the walk order cannot depend on input order.
|
|
152
|
+
for (const name of [...declaredLeft.keys()].sort()) {
|
|
153
|
+
const d = declaredLeft.get(name)!;
|
|
154
|
+
const l = liveLeft.get(name);
|
|
155
|
+
if (l && policyRuleEqual(d, l)) {
|
|
156
|
+
declaredLeft.delete(name);
|
|
157
|
+
liveLeft.delete(name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Pass 2 — same rule, different name. The live name wins and the pair emits nothing.
|
|
162
|
+
// Declared side walked in sorted order, live candidates chosen by sorted name, so the
|
|
163
|
+
// result is a pure function of the two sets and never of their input order.
|
|
164
|
+
for (const dName of [...declaredLeft.keys()].sort()) {
|
|
165
|
+
const d = declaredLeft.get(dName)!;
|
|
166
|
+
const candidate = [...liveLeft.keys()].sort().find((lName) => policyRuleEqual(d, liveLeft.get(lName)!));
|
|
167
|
+
if (candidate !== undefined) {
|
|
168
|
+
adopted.push({ declared: dName, live: candidate });
|
|
169
|
+
declaredLeft.delete(dName);
|
|
170
|
+
liveLeft.delete(candidate);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pass 2b — the ROLE AXIS, and only the role axis.
|
|
175
|
+
//
|
|
176
|
+
// A live policy `TO a, b` and two declared policies `TO a` / `TO b` with the same predicate
|
|
177
|
+
// are applicable to precisely the same sessions: Postgres selects policies per session role
|
|
178
|
+
// by membership. So the live one already authorizes what the group declares.
|
|
179
|
+
//
|
|
180
|
+
// Three conditions, all required, and each closes a way to be wrong:
|
|
181
|
+
// - every field but the roles is identical (never the command axis — USING/CHECK
|
|
182
|
+
// applicability differs per command, and that is where false equivalence breeds);
|
|
183
|
+
// - each member's roles are a SUBSET of the live policy's, so a member can never smuggle
|
|
184
|
+
// in a role the live policy did not cover;
|
|
185
|
+
// - the members are pairwise DISJOINT and their union EQUALS the live role set exactly.
|
|
186
|
+
// Subset never matches: a live `TO anon, authenticated` must not be satisfied by a
|
|
187
|
+
// declared anon policy alone, which would leave `authenticated` reading rows the model
|
|
188
|
+
// believes it no longer grants.
|
|
189
|
+
//
|
|
190
|
+
// PUBLIC cannot be reached from here. It is an open set, and no union of enumerated roles
|
|
191
|
+
// can equal it — `roleSetEqual` decides that, and nothing folds case into it.
|
|
192
|
+
const adoptedGroups: AdoptedGroup[] = [];
|
|
193
|
+
for (const lName of [...liveLeft.keys()].sort()) {
|
|
194
|
+
const live = liveLeft.get(lName)!;
|
|
195
|
+
if (live.roles.length < 2) continue; // a single-role live policy is pass 2's job
|
|
196
|
+
|
|
197
|
+
const members: PolicyContract[] = [];
|
|
198
|
+
const seen = new Set<string>();
|
|
199
|
+
for (const dName of [...declaredLeft.keys()].sort()) {
|
|
200
|
+
const d = declaredLeft.get(dName)!;
|
|
201
|
+
if (!sameRuleIgnoringRoles(d, live)) continue;
|
|
202
|
+
if (!d.roles.every((r) => live.roles.includes(r))) continue; // subset only
|
|
203
|
+
if (d.roles.some((r) => seen.has(r))) continue; // pairwise disjoint
|
|
204
|
+
d.roles.forEach((r) => seen.add(r));
|
|
205
|
+
members.push(d);
|
|
206
|
+
}
|
|
207
|
+
if (!members.length) continue;
|
|
208
|
+
if (!roleSetEqual([...seen], live.roles)) continue; // exact union, never a subset
|
|
209
|
+
|
|
210
|
+
adoptedGroups.push({ declared: members.map((m) => m.name).sort(), live: lName });
|
|
211
|
+
liveLeft.delete(lName);
|
|
212
|
+
for (const m of members) declaredLeft.delete(m.name);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Pass 3 — no counterpart, or a name match whose rule changed.
|
|
216
|
+
return {
|
|
217
|
+
adopted,
|
|
218
|
+
adoptedGroups,
|
|
219
|
+
toCreate: [...declaredLeft.keys()].sort().map((n) => declaredLeft.get(n)!),
|
|
220
|
+
toDrop: [...liveLeft.keys()].sort(),
|
|
221
|
+
};
|
|
222
|
+
}
|