@everystack/cli 0.4.43 → 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.
@@ -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)))
@@ -65,20 +99,132 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
65
99
  const OWNER_RE =
66
100
  /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
67
101
 
68
- /** Does this predicate say "the row is mine"? Returns the column and the claim if so. */
69
- export function parseOwnerPredicate(pred: string | null): { column: string; claim: string } | null {
102
+ /**
103
+ * The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
104
+ *
105
+ * A brownfield schema almost always has one — a STABLE SECURITY DEFINER function wrapping
106
+ * the same claim read — and every owner policy in it is written against that function, not
107
+ * against the claim inline. Rendering those as the inline claim would be a semantically
108
+ * similar but TEXTUALLY different predicate, and predicates are diffed as text: it would
109
+ * plan a DROP + CREATE on every owner policy in the schema.
110
+ *
111
+ * Deliberately narrow: the right side must be a zero-argument function call. That is what
112
+ * an "who am I" accessor looks like, and it is what a column-to-column comparison
113
+ * (`(user_id = reviewer_id)`) is not — so the matcher cannot mistake a join predicate for
114
+ * a statement of ownership.
115
+ */
116
+ const OWNER_FN_RE = /^\(?([a-z_][a-z0-9_]*)\s*=\s*((?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*\(\))\)?$/i;
117
+
118
+ /**
119
+ * Does this predicate say "the row is mine"? Returns the column, the claim, and the
120
+ * accessor expression when the schema uses one (null = the claim read inline).
121
+ */
122
+ export function parseOwnerPredicate(
123
+ pred: string | null,
124
+ ): { column: string; claim: string; expr: string | null } | null {
70
125
  if (!pred) return null;
71
- const m = OWNER_RE.exec(pred.trim());
72
- return m ? { column: m[1], claim: m[2] } : null;
126
+ const t = pred.trim();
127
+ const m = OWNER_RE.exec(t);
128
+ if (m) return { column: m[1], claim: m[2], expr: null };
129
+ const f = OWNER_FN_RE.exec(t);
130
+ return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
73
131
  }
74
132
 
75
- /** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
76
- function granted(contract: TableContract, role: string, privilege: string): boolean {
77
- const direct = contract.grants[role] ?? [];
78
- const publicGrant = contract.grants.PUBLIC ?? contract.grants.public ?? [];
79
- return direct.includes(privilege) || publicGrant.includes(privilege);
133
+ /**
134
+ * Split a predicate on a top-level operator, respecting parens and string literals.
135
+ *
136
+ * Needed because the shapes worth recognising are compositional — `(public) OR (mine AND
137
+ * guard)` and a naive `.split(' OR ')` cuts inside a nested disjunction, which is how a
138
+ * template matcher silently mis-reads `((state <> 'deleted') AND ((user_id = …) OR (…)))`
139
+ * as a top-level OR it is not.
140
+ */
141
+ function splitTopLevel(pred: string, op: 'OR' | 'AND'): string[] {
142
+ const needle = ` ${op} `;
143
+ const parts: string[] = [];
144
+ let depth = 0;
145
+ let quoted = false;
146
+ let start = 0;
147
+ // Strip one enclosing paren pair so the operator we want is genuinely at depth 0.
148
+ const s = unwrapOnce(pred.trim());
149
+ for (let i = 0; i < s.length; i++) {
150
+ const c = s[i];
151
+ if (c === "'") { quoted = !quoted; continue; }
152
+ if (quoted) continue;
153
+ if (c === '(') depth++;
154
+ else if (c === ')') depth--;
155
+ else if (depth === 0 && s.startsWith(needle, i)) {
156
+ parts.push(s.slice(start, i));
157
+ i += needle.length - 1;
158
+ start = i + 1;
159
+ }
160
+ }
161
+ parts.push(s.slice(start));
162
+ return parts.map((p) => p.trim()).filter(Boolean);
163
+ }
164
+
165
+ /** Drop one enclosing paren pair, but only when it spans the WHOLE expression. */
166
+ function unwrapOnce(s: string): string {
167
+ if (!s.startsWith('(') || !s.endsWith(')')) return s;
168
+ let depth = 0;
169
+ let quoted = false;
170
+ for (let i = 0; i < s.length; i++) {
171
+ const c = s[i];
172
+ if (c === "'") { quoted = !quoted; continue; }
173
+ if (quoted) continue;
174
+ if (c === '(') depth++;
175
+ else if (c === ')') {
176
+ depth--;
177
+ if (depth === 0 && i < s.length - 1) return s; // the pair closed early — not enclosing
178
+ }
179
+ }
180
+ return depth === 0 ? s.slice(1, -1).trim() : s;
80
181
  }
81
182
 
183
+ /**
184
+ * Decompose an authenticated read of the form `(public) OR (mine [AND guard])`, given the
185
+ * public predicate the anon policy already states.
186
+ *
187
+ * Keyed on the ANON PREDICATE APPEARING VERBATIM as one of two top-level disjuncts, and an
188
+ * owner predicate appearing in the other — not on a shape template. The alternative,
189
+ * matching a `X OR (Y AND Z)` template, both over- and under-fires: it claims predicates it
190
+ * has not proven are a public branch, and it misses the schema whose OR is nested inside an
191
+ * AND. Anything that does not decompose EXACTLY returns null, and the caller says so.
192
+ */
193
+ export function parsePublicOrOwn(
194
+ authedPred: string | null,
195
+ anonPred: string | null,
196
+ ): { owner: { column: string; claim: string; expr: string | null }; ownerSql: string | null } | null {
197
+ if (!authedPred || !anonPred) return null;
198
+ const branches = splitTopLevel(authedPred, 'OR');
199
+ if (branches.length !== 2) return null;
200
+
201
+ const anonNorm = unwrapOnce(anonPred.trim());
202
+ const publicIdx = branches.findIndex((b) => unwrapOnce(b) === anonNorm);
203
+ if (publicIdx === -1) return null;
204
+
205
+ const ownBranch = branches[1 - publicIdx];
206
+ const conjuncts = splitTopLevel(ownBranch, 'AND');
207
+ const ownerIdx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
208
+ if (ownerIdx === -1) return null;
209
+
210
+ const owner = parseOwnerPredicate(conjuncts[ownerIdx])!;
211
+ const rest = conjuncts.filter((_, i) => i !== ownerIdx);
212
+ // More than one leftover conjunct would have to be re-ANDed, and the text the compiler
213
+ // then emits is not guaranteed to be the text that was read. Refuse rather than guess.
214
+ if (rest.length > 1) return null;
215
+ return { owner, ownerSql: rest[0] ?? null };
216
+ }
217
+
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;
227
+
82
228
  /** The policies that apply to a role for a command (an `ALL` policy covers every command). */
83
229
  function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
84
230
  return contract.policies.filter(
@@ -110,21 +256,36 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
110
256
  const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
111
257
  if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
112
258
 
113
- // --- dead policies: policed but never granted ----------------------------------------
114
- // 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.
115
269
  for (const p of contract.policies) {
116
270
  const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
117
271
  for (const cmd of commands) {
118
272
  const priv = cmd;
119
273
  const live = p.roles.filter((r) => r !== 'public' && r !== 'admin' && !granted(contract, r, priv));
120
- if (live.length && p.roles.some((r) => r !== 'admin')) {
121
- notes.push(
122
- `policy "${p.name}" (${cmd}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
123
- );
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) {
124
277
  notes.push(
125
- ` 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.`,
126
279
  );
280
+ notes.push(` NOT dead. Not rendered either: an ability would grant ${priv} on the whole table.`);
281
+ continue;
127
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
+ );
128
289
  }
129
290
  }
130
291
 
@@ -135,15 +296,48 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
135
296
  if (anonRead) {
136
297
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
137
298
  // carry that predicate — a public read with a filter is still a public read.
138
- const pol = policiesFor(contract, 'anon', 'SELECT')[0];
139
- const frag = sqlFragment(pol?.using ?? null);
299
+ const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
300
+ const anonPred = anonPol?.using ?? null;
301
+ const frag = sqlFragment(anonPred);
140
302
  abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
303
+
304
+ // The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
305
+ // one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
306
+ // only the anon policy dropped that branch and rendered a model that meant strictly
307
+ // less than the database did, with nothing said. On a real schema it hid ~13,184
308
+ // legacy rows from the people who wrote them.
309
+ const authedPol = authedRead
310
+ ? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
311
+ : undefined;
312
+ const authedPred = authedPol?.using ?? null;
313
+ if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
314
+ const own = parsePublicOrOwn(authedPred, anonPred);
315
+ if (own) {
316
+ const parts = [`owner: '${own.owner.column}'`];
317
+ if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
318
+ if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
319
+ const guard = sqlFragment(own.ownerSql);
320
+ if (guard) parts.push(`sql: ${guard}`);
321
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
322
+ } else {
323
+ // P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
324
+ // policy and its text, never a model that quietly means less.
325
+ notes.push(
326
+ `policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
327
+ );
328
+ notes.push(` and its shape is not expressible as abilities. Live USING:`);
329
+ notes.push(` ${authedPred}`);
330
+ notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
331
+ }
332
+ }
141
333
  } else if (authedRead) {
142
334
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
143
335
  const owner = parseOwnerPredicate(pol?.using ?? null);
144
336
  if (owner) {
145
- const claim = owner.claim === 'sub' ? '' : `, userField: '${owner.claim}'`;
146
- abilities.push(`can('read', { owner: '${owner.column}'${claim} })`);
337
+ const parts = [`owner: '${owner.column}'`];
338
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
339
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
340
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
147
341
  } else {
148
342
  const frag = sqlFragment(pol?.using ?? null);
149
343
  abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
@@ -165,6 +359,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
165
359
  if (owner) {
166
360
  parts.push(`owner: '${owner.column}'`);
167
361
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
362
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
168
363
  }
169
364
  // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
170
365
  const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
@@ -185,6 +380,27 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
185
380
  abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
186
381
  }
187
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
+
188
404
  // --- roles the compiler has no vocabulary for ----------------------------------------
189
405
  const unmappedRoles = Object.keys(contract.grants).filter(
190
406
  (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
@@ -194,17 +410,33 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
194
410
  notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
195
411
  }
196
412
 
197
- 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);
198
419
  }
199
420
 
200
421
  /** Render one table's derived stanza as the lines that sit inside a model literal. */
201
422
  export function renderDerivedAbilities(d: DerivedAbilities): string {
202
423
  const out: string[] = [];
203
- for (const n of d.notes) out.push(` // ${n}`);
424
+ // Split on newlines: a note may carry a live predicate verbatim, and `pg_get_expr`
425
+ // deparses a subquery across several lines. One `//` for the first line left the rest
426
+ // as bare SQL in the middle of a TypeScript object — a generated file that would not
427
+ // parse. Commenting per line here means no note, present or future, can do that.
428
+ for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
204
429
  if (d.abilities.length) {
205
430
  out.push(` abilities: [${d.abilities.join(', ')}],`);
206
431
  } else {
207
432
  out.push(` private: true, // no effective privilege found — not part of the data API`);
208
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
+ }
209
441
  return out.join('\n');
210
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
+ }