@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.
@@ -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
- function diffPolicies(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
616
- const dPol = new Map(d.policies.map((p) => [p.name, p]));
617
- const lPol = new Map(l.policies.map((p) => [p.name, p]));
618
- for (const [name, dp] of dPol) {
619
- const lp = lPol.get(name);
620
- if (!lp) {
621
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" declared but missing from the live database` });
622
- continue;
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}→${lp.command}`);
626
- if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
627
- if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}][${lp.roles}]`);
628
- if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
629
- // Compared through the server's own defaulting rule, so this agrees with
630
- // emitReconcileSql otherwise db:check reports drift the plan does not carry.
631
- if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
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
- for (const name of lPol.keys()) {
637
- if (!dPol.has(name)) {
638
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
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
 
@@ -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
- /** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
185
- function granted(contract: TableContract, role: string, privilege: string): boolean {
186
- const direct = contract.grants[role] ?? [];
187
- const publicGrant = contract.grants.PUBLIC ?? contract.grants.public ?? [];
188
- return direct.includes(privilege) || publicGrant.includes(privilege);
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
- // --- dead policies: policed but never granted ----------------------------------------
223
- // The trap. Rendering one of these would ADD a privilege that does not exist today.
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 && p.roles.some((r) => r !== 'admin')) {
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}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
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
+ }