@everystack/cli 0.4.44 → 0.4.46
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/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
package/src/cli/authz-derive.ts
CHANGED
|
@@ -25,12 +25,24 @@
|
|
|
25
25
|
* comment: the comment costs a human five minutes, the ability costs them a privilege.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { EXTRA_PRIVILEGES } from '@everystack/model';
|
|
29
|
+
import { holdsPrivilege, holdsTablePrivilege } from './authz-contract.js';
|
|
28
30
|
import type { TableContract, PolicyContract } from './authz-contract.js';
|
|
29
31
|
|
|
30
32
|
/** One rendered decision: either an ability line, or a comment explaining the omission. */
|
|
31
33
|
export interface DerivedAbilities {
|
|
32
34
|
/** `can(...)` lines, ready to sit inside `abilities: [ ... ]`. */
|
|
33
35
|
abilities: string[];
|
|
36
|
+
/**
|
|
37
|
+
* The beyond-CRUD privileges (REFERENCES/TRIGGER/TRUNCATE) a GOVERNED role holds live —
|
|
38
|
+
* rendered as the model's `privileges` key.
|
|
39
|
+
*
|
|
40
|
+
* `can()` has no verb for these, so before they were rendered the model could not say a
|
|
41
|
+
* live admin held them, and the first plan revoked all three on every table. Present only
|
|
42
|
+
* when the live database actually has some; empty means the key is omitted entirely, so a
|
|
43
|
+
* greenfield model is byte-identical to what it was.
|
|
44
|
+
*/
|
|
45
|
+
privileges: Record<string, string[]>;
|
|
34
46
|
/** `//` comment lines naming what could not be rendered, and why. */
|
|
35
47
|
notes: string[];
|
|
36
48
|
/**
|
|
@@ -55,6 +67,28 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
|
55
67
|
/** The roles the compiler itself emits policies for; anything else is app-specific. */
|
|
56
68
|
const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
|
|
72
|
+
* always governed because a grant to PUBLIC is the broadest privilege the database can express.
|
|
73
|
+
*
|
|
74
|
+
* The beyond-CRUD privileges of anyone else are deliberately not rendered: the reconciler leaves
|
|
75
|
+
* an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
|
|
76
|
+
* would GOVERN it — turning a rendering decision into an access decision for every table.
|
|
77
|
+
*/
|
|
78
|
+
const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
79
|
+
|
|
80
|
+
/** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
|
|
81
|
+
function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
|
|
82
|
+
const out: Record<string, string[]> = {};
|
|
83
|
+
const extra = EXTRA_PRIVILEGES as readonly string[];
|
|
84
|
+
for (const grantee of Object.keys(contract.grants).sort()) {
|
|
85
|
+
if (!GOVERNED_VOCABULARY.has(grantee)) continue;
|
|
86
|
+
const held = (contract.grants[grantee] ?? []).filter((p) => extra.includes(p)).sort();
|
|
87
|
+
if (held.length) out[grantee] = held;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
58
92
|
/**
|
|
59
93
|
* The deparsed owner predicate the compiler produces, in both its casts:
|
|
60
94
|
* (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
|
|
@@ -181,12 +215,15 @@ export function parsePublicOrOwn(
|
|
|
181
215
|
return { owner, ownerSql: rest[0] ?? null };
|
|
182
216
|
}
|
|
183
217
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
218
|
+
/**
|
|
219
|
+
* May we EMIT an ability for this privilege? Table-level only.
|
|
220
|
+
*
|
|
221
|
+
* `can('update')` compiles to a policy plus a table-wide `GRANT UPDATE`, so a role holding
|
|
222
|
+
* UPDATE on three columns must not satisfy this — rendering the ability would widen a column
|
|
223
|
+
* grant into a whole-table one. Whether the privilege is held AT ALL is a different question,
|
|
224
|
+
* answered by `holdsPrivilege`; both live in authz-contract so they cannot drift apart.
|
|
225
|
+
*/
|
|
226
|
+
const granted = holdsTablePrivilege;
|
|
190
227
|
|
|
191
228
|
/** The policies that apply to a role for a command (an `ALL` policy covers every command). */
|
|
192
229
|
function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
|
|
@@ -203,6 +240,58 @@ function sqlFragment(pred: string | null): string | null {
|
|
|
203
240
|
return `sql\`${t.replace(/`/g, '\\`')}\``;
|
|
204
241
|
}
|
|
205
242
|
|
|
243
|
+
/** The policy catalog spells the PUBLIC pseudo-role lower-case; grants spell it upper-case. */
|
|
244
|
+
function policyRoleForGrantee(grantee: string): string {
|
|
245
|
+
return grantee === 'PUBLIC' ? 'public' : grantee;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** `db:pull` renders fields camel-cased; abilities must name those same model keys. */
|
|
249
|
+
function modelFieldKey(column: string): string {
|
|
250
|
+
return column.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One column grant is renderable only when exactly one declared-style policy gates it. */
|
|
254
|
+
function columnPolicyFor(contract: TableContract, grantee: string, command: string): PolicyContract | null {
|
|
255
|
+
const role = policyRoleForGrantee(grantee);
|
|
256
|
+
const matches = contract.policies.filter((p) =>
|
|
257
|
+
p.command === command && p.permissive && p.roles.length === 1 && p.roles[0] === role,
|
|
258
|
+
);
|
|
259
|
+
return matches.length === 1 ? matches[0] : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Render one precise column grant plus its exactly-paired policy as one ability. */
|
|
263
|
+
function renderColumnAbility(
|
|
264
|
+
action: 'read' | 'update',
|
|
265
|
+
grantee: string,
|
|
266
|
+
columns: readonly string[],
|
|
267
|
+
policy: PolicyContract,
|
|
268
|
+
): string | null {
|
|
269
|
+
const using = policy.using ?? null;
|
|
270
|
+
const owner = parseOwnerPredicate(using);
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
const role = policyRoleForGrantee(grantee);
|
|
273
|
+
|
|
274
|
+
// Owner-only uses the model's authenticated default. Every other grantee must be named so
|
|
275
|
+
// the compiler emits the policy TO the live role, rather than a role-less public ability.
|
|
276
|
+
if (!owner || role !== 'authenticated') parts.push(`role: '${role}'`);
|
|
277
|
+
if (owner) {
|
|
278
|
+
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
279
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
280
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
281
|
+
} else {
|
|
282
|
+
const fragment = sqlFragment(using);
|
|
283
|
+
if (fragment) parts.push(`sql: ${fragment}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (action === 'update' && policy.check && policy.check !== using) {
|
|
287
|
+
const fragment = sqlFragment(policy.check);
|
|
288
|
+
if (!fragment) return null;
|
|
289
|
+
parts.push(`check: ${fragment}`);
|
|
290
|
+
}
|
|
291
|
+
parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
|
|
292
|
+
return `can('${action}', { ${parts.join(', ')} })`;
|
|
293
|
+
}
|
|
294
|
+
|
|
206
295
|
/**
|
|
207
296
|
* Derive the ability stanza for ONE table from its live contract.
|
|
208
297
|
*
|
|
@@ -219,21 +308,34 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
219
308
|
const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
|
|
220
309
|
if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
|
|
221
310
|
|
|
222
|
-
// ---
|
|
223
|
-
//
|
|
311
|
+
// --- policed but not granted at the table level ---------------------------------------
|
|
312
|
+
//
|
|
313
|
+
// Two different findings wear the same shape here, and calling both "dead" was a lie the
|
|
314
|
+
// adopter read beside the model they were about to trust:
|
|
315
|
+
//
|
|
316
|
+
// - NOTHING holds the privilege → the policy really is dead code, and rendering it as an
|
|
317
|
+
// ability would ADD a privilege the database does not give. That is the trap.
|
|
318
|
+
// - a COLUMN grant holds it → the policy is doing live work on those columns. Rendering
|
|
319
|
+
// the ability would still be wrong (it grants the whole table), but the privilege is
|
|
320
|
+
// real, and the plan that drops the policy is taking away access that exists.
|
|
224
321
|
for (const p of contract.policies) {
|
|
225
322
|
const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
|
|
226
323
|
for (const cmd of commands) {
|
|
227
324
|
const priv = cmd;
|
|
228
325
|
const live = p.roles.filter((r) => r !== 'public' && r !== 'admin' && !granted(contract, r, priv));
|
|
229
|
-
if (live.length
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
326
|
+
if (!live.length || !p.roles.some((r) => r !== 'admin')) continue;
|
|
327
|
+
const viaColumns = live.filter((r) => holdsPrivilege(contract, r, priv));
|
|
328
|
+
if (viaColumns.length) {
|
|
329
|
+
// The column-grant pass below carries this pair into a column ability, or names the
|
|
330
|
+
// exact missing pairing. Do not pre-judge it here as dead or unrenderable.
|
|
331
|
+
continue;
|
|
236
332
|
}
|
|
333
|
+
notes.push(
|
|
334
|
+
`policy "${p.name}" (${cmd}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
|
|
335
|
+
);
|
|
336
|
+
notes.push(
|
|
337
|
+
` it is dead code today. Declaring it would ADD a privilege the database does not give.`,
|
|
338
|
+
);
|
|
237
339
|
}
|
|
238
340
|
}
|
|
239
341
|
|
|
@@ -247,7 +349,6 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
247
349
|
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
248
350
|
const anonPred = anonPol?.using ?? null;
|
|
249
351
|
const frag = sqlFragment(anonPred);
|
|
250
|
-
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
251
352
|
|
|
252
353
|
// The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
|
|
253
354
|
// one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
|
|
@@ -258,25 +359,94 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
258
359
|
? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
|
|
259
360
|
: undefined;
|
|
260
361
|
const authedPred = authedPol?.using ?? null;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
362
|
+
const diverges = !!authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true');
|
|
363
|
+
const own = diverges ? parsePublicOrOwn(authedPred, anonPred) : null;
|
|
364
|
+
|
|
365
|
+
// A role-less `can('read')` fans out to anon AND authenticated — one ability, two policies
|
|
366
|
+
// and two GRANTS. That is only honest when both roles genuinely hold a table-level SELECT
|
|
367
|
+
// and the same rule. Two separate ways it was not:
|
|
368
|
+
//
|
|
369
|
+
// - WIDENING: `authedRead` is a TABLE-level check, so a live authenticated SELECT held
|
|
370
|
+
// only through COLUMN grants reads as false. The fan-out then granted authenticated
|
|
371
|
+
// SELECT on the WHOLE table — the model meaning MORE than the database, which is the
|
|
372
|
+
// one thing this project never does. Now the read is scoped to anon alone.
|
|
373
|
+
// - NARROWING: when the authenticated rule diverges and cannot be decomposed, the
|
|
374
|
+
// fan-out pushed the ANON predicate onto authenticated, silently replacing a live
|
|
375
|
+
// owner-admitting read with a public-only one. Now each role carries its own rule.
|
|
376
|
+
// The fan-out is honest in exactly two cases, and both need authenticated to hold a real
|
|
377
|
+
// table-level SELECT:
|
|
378
|
+
// - the two roles carry the SAME rule, so one ability states it once; or
|
|
379
|
+
// - the rule DECOMPOSES, and the `owner:` ability below ADDS the owner branch on top of
|
|
380
|
+
// this public one. Scoping this read to anon there would strip authenticated of the
|
|
381
|
+
// public half — a narrowing introduced by the fix itself.
|
|
382
|
+
const fanOutIsHonest = authedRead && (!diverges || !!own);
|
|
383
|
+
|
|
384
|
+
// The live read is ONE policy TO PUBLIC — the ordinary Postgres idiom, and an OPEN
|
|
385
|
+
// audience. Rendering it as the role-scoped pair would mean strictly less than the
|
|
386
|
+
// database does: every role outside {anon, authenticated} loses the read. `policyRoles`
|
|
387
|
+
// says the audience without touching the grants, so this transcribes rather than narrows
|
|
388
|
+
// (B1). Only when PUBLIC is the whole audience — a policy naming public ALONGSIDE a
|
|
389
|
+
// named role is a different shape and is left to the branches below.
|
|
390
|
+
const publicAudience = !!anonPol && anonPol.roles.length === 1 && anonPol.roles[0] === 'public';
|
|
391
|
+
|
|
392
|
+
if (publicAudience) {
|
|
393
|
+
abilities.push(frag ? `can('read', { policyRoles: 'public', sql: ${frag} })` : `can('read', { policyRoles: 'public' })`);
|
|
394
|
+
|
|
395
|
+
// An OPEN audience must say who it actually opens to, ON THIS TABLE.
|
|
396
|
+
//
|
|
397
|
+
// `policyRoles: 'public'` reads like "anyone", and the model vocabulary only names
|
|
398
|
+
// anon/authenticated/admin — so a reviewer has no way to know that a migrator or an ETL
|
|
399
|
+
// role also holds SELECT here and therefore reads rows through this very policy. The
|
|
400
|
+
// pull already names ungoverned grantees once, globally, on stdout; that is the wrong
|
|
401
|
+
// place and the wrong granularity for a decision recorded per table in a committed file.
|
|
402
|
+
// Adopting an open audience without seeing its real membership is exactly "inert must
|
|
403
|
+
// never mean blind".
|
|
404
|
+
const openTo = Object.keys(contract.grants)
|
|
405
|
+
.filter((r) => !GOVERNED_VOCABULARY.has(r) && holdsPrivilege(contract, r, 'SELECT'))
|
|
406
|
+
.sort();
|
|
407
|
+
if (openTo.length) {
|
|
273
408
|
notes.push(
|
|
274
|
-
`
|
|
409
|
+
`policyRoles: 'public' is an OPEN audience — it also admits ${openTo.join(', ')}, which hold`,
|
|
275
410
|
);
|
|
276
|
-
notes.push(`
|
|
277
|
-
notes.push(` ${authedPred}`);
|
|
278
|
-
notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
|
|
411
|
+
notes.push(` SELECT on this table. The grants are unchanged; this names who the policy reaches.`);
|
|
279
412
|
}
|
|
413
|
+
} else if (fanOutIsHonest) {
|
|
414
|
+
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
415
|
+
} else {
|
|
416
|
+
abilities.push(frag ? `can('read', { role: 'anon', sql: ${frag} })` : `can('read', { role: 'anon' })`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (diverges && own) {
|
|
420
|
+
const parts = [`owner: '${own.owner.column}'`];
|
|
421
|
+
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
422
|
+
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
423
|
+
const guard = sqlFragment(own.ownerSql);
|
|
424
|
+
if (guard) parts.push(`sql: ${guard}`);
|
|
425
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
426
|
+
} else if (diverges && authedRead) {
|
|
427
|
+
// TRANSCRIBE, never synthesize. The structured parse failed, so the live predicate is
|
|
428
|
+
// carried VERBATIM and scoped to exactly the role that holds it — meaning preserved
|
|
429
|
+
// bit for bit, at the cost of a model that expresses little. The note below is
|
|
430
|
+
// DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
|
|
431
|
+
// reached zero through escape hatches can be counted apart from one that reached zero
|
|
432
|
+
// through vocabulary. Fidelity achieved is not expressiveness achieved.
|
|
433
|
+
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred!.replace(/`/g, '\\`')}\` })`);
|
|
434
|
+
notes.push(
|
|
435
|
+
`verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
|
|
436
|
+
);
|
|
437
|
+
notes.push(` Its shape does not decompose, so it is transcribed VERBATIM above, scoped to`);
|
|
438
|
+
notes.push(` authenticated. The model does not interpret it. Restructure by hand into`);
|
|
439
|
+
notes.push(` owner:/via: when you can — until then this table is adopted, not expressed.`);
|
|
440
|
+
} else if (diverges) {
|
|
441
|
+
// Diverges, and authenticated does NOT hold a table-level SELECT — a column-scoped grant,
|
|
442
|
+
// or none. Rendering the read either way would widen it to the whole table, so we refuse
|
|
443
|
+
// and say so. The residual statements in db:plan ARE the visibility here.
|
|
444
|
+
notes.push(
|
|
445
|
+
`policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read,`,
|
|
446
|
+
);
|
|
447
|
+
notes.push(` but authenticated holds no TABLE-level SELECT — rendering it would widen a`);
|
|
448
|
+
notes.push(` column-scoped grant to the whole table. NOT rendered. Live USING:`);
|
|
449
|
+
notes.push(` ${authedPred}`);
|
|
280
450
|
}
|
|
281
451
|
} else if (authedRead) {
|
|
282
452
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
@@ -328,16 +498,49 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
328
498
|
abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
|
|
329
499
|
}
|
|
330
500
|
|
|
501
|
+
// --- column grants ---------------------------------------------------------------------
|
|
502
|
+
//
|
|
503
|
+
// A column grant is live access, not a lesser table grant. Render SELECT and UPDATE when
|
|
504
|
+
// exactly one permissive policy TO the same role proves the row gate. Anything else stays a
|
|
505
|
+
// note: guessing the pairing could grant the right columns under the wrong row policy.
|
|
506
|
+
const renderedColumnRoles = new Set<string>();
|
|
507
|
+
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
508
|
+
const byPriv = contract.columnGrants![grantee];
|
|
509
|
+
for (const priv of Object.keys(byPriv).sort()) {
|
|
510
|
+
const cols = byPriv[priv] ?? [];
|
|
511
|
+
if (!cols.length) continue;
|
|
512
|
+
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
513
|
+
const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
514
|
+
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
|
|
515
|
+
if (ability) {
|
|
516
|
+
abilities.push(ability);
|
|
517
|
+
renderedColumnRoles.add(grantee);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
|
|
521
|
+
notes.push(
|
|
522
|
+
action
|
|
523
|
+
? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
|
|
524
|
+
: ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
331
529
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
332
530
|
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
333
|
-
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
|
|
531
|
+
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
|
|
334
532
|
);
|
|
335
533
|
|
|
336
534
|
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
337
535
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
338
536
|
}
|
|
339
537
|
|
|
340
|
-
return { abilities, notes, unmappedRoles };
|
|
538
|
+
return { abilities, notes, unmappedRoles, privileges: deriveExtraPrivileges(contract) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
|
542
|
+
function identKey(name: string): string {
|
|
543
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
341
544
|
}
|
|
342
545
|
|
|
343
546
|
/** Render one table's derived stanza as the lines that sit inside a model literal. */
|
|
@@ -353,5 +556,12 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
|
353
556
|
} else {
|
|
354
557
|
out.push(` private: true, // no effective privilege found — not part of the data API`);
|
|
355
558
|
}
|
|
559
|
+
// The beyond-CRUD grants, transcribed. Omitted entirely when there are none, so a database
|
|
560
|
+
// without them renders exactly the model it rendered before this key existed.
|
|
561
|
+
const roles = Object.keys(d.privileges ?? {});
|
|
562
|
+
if (roles.length) {
|
|
563
|
+
const entries = roles.map((r) => `${identKey(r)}: [${d.privileges[r].map((p) => `'${p}'`).join(', ')}]`);
|
|
564
|
+
out.push(` privileges: { ${entries.join(', ')} }, // live grants can() has no verb for`);
|
|
565
|
+
}
|
|
356
566
|
return out.join('\n');
|
|
357
567
|
}
|
|
@@ -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
|
+
}
|