@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.
- 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 +128 -18
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +254 -22
- 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 +52 -4
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +80 -14
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
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)))
|
|
@@ -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
|
-
/**
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
// ---
|
|
114
|
-
//
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
`
|
|
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
|
|
139
|
-
const
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
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
|
+
}
|