@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-compile.ts
CHANGED
|
@@ -66,7 +66,11 @@ function claimExpr(claim: string): string {
|
|
|
66
66
|
* uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
|
|
67
67
|
* text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
|
|
68
68
|
*/
|
|
69
|
-
function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
|
|
69
|
+
function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: string | null = null): string {
|
|
70
|
+
// A declared `ownerExpr` is the accessor the database already has (`auth.user_id()`),
|
|
71
|
+
// emitted verbatim and uncast: the function returns the owner column's type, and a cast
|
|
72
|
+
// the deparser would not print is a DROP + CREATE on every owner policy in the schema.
|
|
73
|
+
if (expr) return `(${sqlColumn} = ${expr})`;
|
|
70
74
|
const ce = claimExpr(claim);
|
|
71
75
|
if (type === 'text') return `(${sqlColumn} = ${ce})`;
|
|
72
76
|
return `(${sqlColumn} = (${ce})::${castForType(type)})`;
|
|
@@ -89,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
|
|
|
89
93
|
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
90
94
|
*/
|
|
91
95
|
function viaPredicate(v: ViaRef): string {
|
|
92
|
-
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
|
|
96
|
+
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim, v.parentOwner.expr);
|
|
93
97
|
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -103,6 +107,8 @@ interface OwnerRef {
|
|
|
103
107
|
type: string;
|
|
104
108
|
/** JWT claim holding the owner id. */
|
|
105
109
|
claim: string;
|
|
110
|
+
/** A declared accessor for the caller's id (`auth.user_id()`), or null for the inline claim. */
|
|
111
|
+
expr: string | null;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/**
|
|
@@ -135,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
|
135
141
|
sqlColumn: toSnakeCase(fieldKey),
|
|
136
142
|
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
137
143
|
claim: ownerAbility.condition.userField ?? 'sub',
|
|
144
|
+
expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
|
|
138
145
|
};
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -201,6 +208,31 @@ function isColumnRead(a: Ability): boolean {
|
|
|
201
208
|
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
202
209
|
}
|
|
203
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
|
|
213
|
+
* that describe it spell it differently — both correctly:
|
|
214
|
+
*
|
|
215
|
+
* - `pg_policies.roles` renders it as the literal `{public}` (lowercase)
|
|
216
|
+
* - `aclexplode()` yields grantee OID 0, which GRANTS_SQL renders as `'PUBLIC'`
|
|
217
|
+
*
|
|
218
|
+
* So the compiler speaks each catalog's spelling on its own axis. Saying `public` on both
|
|
219
|
+
* made the grant set-difference see `public` and `PUBLIC` as two grantees and emit a REVOKE
|
|
220
|
+
* and a GRANT that cancel out — on every run, converging never.
|
|
221
|
+
*
|
|
222
|
+
* The fold is COMPILE-TIME ONLY, applied to the author's declared role. Nothing folds at
|
|
223
|
+
* comparison time: `CREATE ROLE "Public"` is a legal, distinct role, and a case-insensitive
|
|
224
|
+
* compare against a live grantee would silently conflate it with the pseudo-role. (The cost
|
|
225
|
+
* of that choice: a real role named `Public` cannot be named by `role:`, because a model
|
|
226
|
+
* string carries no way to say "quoted identifier".)
|
|
227
|
+
*/
|
|
228
|
+
const isPseudoPublic = (role: string): boolean => role.toLowerCase() === 'public';
|
|
229
|
+
|
|
230
|
+
/** The grant-side spelling of a declared role — what `aclexplode` would report. */
|
|
231
|
+
const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' : role);
|
|
232
|
+
|
|
233
|
+
/** The policy-side spelling of a declared role — what `pg_policies` would report. */
|
|
234
|
+
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
235
|
+
|
|
204
236
|
/**
|
|
205
237
|
* Column grants from the column-scoped read abilities: the read role (`authenticated`, since
|
|
206
238
|
* the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
|
|
@@ -212,7 +244,7 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
|
|
|
212
244
|
const out: Record<string, Record<string, string[]>> = {};
|
|
213
245
|
for (const a of abilities) {
|
|
214
246
|
if (!isColumnRead(a)) continue;
|
|
215
|
-
const role = a.condition.role ?? 'authenticated';
|
|
247
|
+
const role = granteeKey(a.condition.role ?? 'authenticated');
|
|
216
248
|
const cols = [...a.condition.columns!].map(toSnakeCase).sort();
|
|
217
249
|
(out[role] ??= {}).SELECT = cols;
|
|
218
250
|
}
|
|
@@ -244,6 +276,23 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
244
276
|
return parenthesizeOnce(text.trim());
|
|
245
277
|
}
|
|
246
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
|
|
281
|
+
* predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
|
|
282
|
+
* emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
|
|
283
|
+
* whole point of declaring it is to match the live text exactly.
|
|
284
|
+
*/
|
|
285
|
+
function rawExpr(value: unknown, where: string): string | null {
|
|
286
|
+
if (value == null) return null;
|
|
287
|
+
const text = (value as { sql?: unknown }).sql;
|
|
288
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return text.trim();
|
|
294
|
+
}
|
|
295
|
+
|
|
247
296
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
248
297
|
function andPredicates(...parts: (string | null)[]): string | null {
|
|
249
298
|
const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
|
|
@@ -270,7 +319,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
270
319
|
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
271
320
|
// update/delete_own); only the predicate text differs.
|
|
272
321
|
const rowPred = owner
|
|
273
|
-
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
322
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
|
|
274
323
|
: via
|
|
275
324
|
? viaPredicate(via)
|
|
276
325
|
: null;
|
|
@@ -298,6 +347,33 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
298
347
|
}
|
|
299
348
|
return null;
|
|
300
349
|
};
|
|
350
|
+
/**
|
|
351
|
+
* The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
|
|
352
|
+
*
|
|
353
|
+
* Read separately from {@link ownerReadPred} because the two are different BRANCHES of
|
|
354
|
+
* the same authenticated policy. Taking both from one ordered lookup made the anon
|
|
355
|
+
* policy depend on the order the abilities happened to be declared in: put the owner
|
|
356
|
+
* read first and `anon` inherited the owner branch's guard as its whole public rule.
|
|
357
|
+
*/
|
|
358
|
+
const publicReadPred = (): string | null => {
|
|
359
|
+
for (const a of model.abilities) {
|
|
360
|
+
if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
|
|
361
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
|
|
362
|
+
if (p) return p;
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
368
|
+
const ownerReadPred = (): string | null => {
|
|
369
|
+
for (const a of model.abilities) {
|
|
370
|
+
if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
|
|
371
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
372
|
+
if (p) return p;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
};
|
|
376
|
+
|
|
301
377
|
/** The WRITE half, when the author declared one distinct from the read half. */
|
|
302
378
|
const checkFor = (action: Ability['action']): string | null => {
|
|
303
379
|
for (const a of model.abilities) {
|
|
@@ -334,18 +410,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
334
410
|
if (hasPublicRead) {
|
|
335
411
|
// public read on a soft-delete table — split per role; authenticated owners
|
|
336
412
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
337
|
-
const readPred =
|
|
413
|
+
const readPred = publicReadPred();
|
|
338
414
|
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
339
415
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
340
416
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
417
|
+
let authedUsing: string;
|
|
418
|
+
if (hasOwnerRead && rowPred && anonUsing !== 'true') {
|
|
419
|
+
// PUBLIC **OR** MINE. Two read abilities on one model — a public read and an
|
|
420
|
+
// owner read — are two branches of one authenticated policy, not a contest the
|
|
421
|
+
// public one wins. They used to be exactly that contest: `hasPublicRead` took
|
|
422
|
+
// the branch and the owner read was computed and discarded, so a signed-in
|
|
423
|
+
// author could not see their own non-public rows. On a real brownfield schema
|
|
424
|
+
// that hid ~13k legacy `pending` rows from the people who wrote them.
|
|
425
|
+
//
|
|
426
|
+
// The owner branch escapes the public predicate (that is the whole point) and
|
|
427
|
+
// carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
|
|
428
|
+
// author says what "mine" means without loosening what "public" means.
|
|
429
|
+
const ownBranch = andPredicates(rowPred, ownerReadPred())!;
|
|
430
|
+
authedUsing = `(${anonUsing} OR ${ownBranch})`;
|
|
431
|
+
} else {
|
|
432
|
+
// The owner predicate may only ever WIDEN a public read (the soft-delete OR:
|
|
433
|
+
// owners also see their own deleted rows). It must never REPLACE it — an owner
|
|
434
|
+
// condition on a WRITE ability used to narrow the authenticated SELECT to
|
|
435
|
+
// own-rows-only, so anon saw the whole table and a signed-in user lost it.
|
|
436
|
+
// Without a soft-delete guard, `true OR owner` is just `true` — and an
|
|
437
|
+
// unfiltered public read already contains every row an owner could add, which
|
|
438
|
+
// is why `anonUsing === 'true'` skips the disjunction rather than emitting a
|
|
439
|
+
// branch that can never change the answer.
|
|
440
|
+
authedUsing = sdGuard && rowPred
|
|
441
|
+
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
442
|
+
: anonUsing;
|
|
443
|
+
}
|
|
349
444
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
350
445
|
} else if (hasOwnerRead && rowPred) {
|
|
351
446
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
@@ -366,7 +461,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
366
461
|
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
367
462
|
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
368
463
|
for (const a of abilities.filter(isRoleRead)) {
|
|
369
|
-
|
|
464
|
+
// The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
|
|
465
|
+
const role = policyRole(a.condition.role!);
|
|
370
466
|
const name = `${t}_select_${role}`;
|
|
371
467
|
const where = `${table}: can('read', { role: '${role}' })`;
|
|
372
468
|
if (policies.some((p) => p.name === name)) {
|
|
@@ -408,7 +504,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
408
504
|
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
409
505
|
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
410
506
|
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
411
|
-
grants: compileGrants(abilities),
|
|
507
|
+
grants: compileGrants(abilities, model.privileges),
|
|
412
508
|
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
413
509
|
policies,
|
|
414
510
|
};
|
|
@@ -447,10 +543,19 @@ function verbsFor(action: Ability['action']): string[] {
|
|
|
447
543
|
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
448
544
|
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
449
545
|
* so the output is byte-comparable with an introspected contract.
|
|
546
|
+
*
|
|
547
|
+
* `privileges` is the model's beyond-CRUD key — REFERENCES/TRIGGER/TRUNCATE, the table
|
|
548
|
+
* privileges `can()` has no verb for. They are UNIONED in, never subtracted: an admin that
|
|
549
|
+
* `can('manage')` and holds TRUNCATE keeps both. Without it, `manage` meant exactly CRUD and
|
|
550
|
+
* every live REFERENCES/TRIGGER/TRUNCATE read as drift, so the first plan against an existing
|
|
551
|
+
* schema revoked all three on every table — our spelling, not their schema.
|
|
450
552
|
*/
|
|
451
|
-
function compileGrants(
|
|
553
|
+
function compileGrants(
|
|
554
|
+
abilities: readonly Ability[],
|
|
555
|
+
privileges: Record<string, readonly string[]> = {},
|
|
556
|
+
): Record<string, string[]> {
|
|
452
557
|
const grants: Record<string, Set<string>> = {};
|
|
453
|
-
const add = (role: string, verbs: string[]): void => {
|
|
558
|
+
const add = (role: string, verbs: readonly string[]): void => {
|
|
454
559
|
const set = (grants[role] ??= new Set<string>());
|
|
455
560
|
for (const v of verbs) set.add(v);
|
|
456
561
|
};
|
|
@@ -461,13 +566,18 @@ function compileGrants(abilities: readonly Ability[]): Record<string, string[]>
|
|
|
461
566
|
if (isColumnRead(a)) continue;
|
|
462
567
|
const verbs = verbsFor(a.action);
|
|
463
568
|
if (a.condition.role) {
|
|
464
|
-
add(a.condition.role, verbs);
|
|
569
|
+
add(granteeKey(a.condition.role), verbs);
|
|
465
570
|
} else {
|
|
466
571
|
add('authenticated', verbs);
|
|
467
572
|
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
468
573
|
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
469
574
|
}
|
|
470
575
|
}
|
|
576
|
+
// The role is spelled on the GRANT axis, so `privileges: { public: [...] }` reaches the
|
|
577
|
+
// same grantee `aclexplode` reports as `PUBLIC` — the fold the ability path already does.
|
|
578
|
+
for (const [role, privs] of Object.entries(privileges)) {
|
|
579
|
+
if (privs.length) add(granteeKey(role), privs);
|
|
580
|
+
}
|
|
471
581
|
const out: Record<string, string[]> = {};
|
|
472
582
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
473
583
|
return out;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { parsePgArray } from './security-catalog.js';
|
|
25
25
|
import { withCanonicalSearchPath } from './search-path.js';
|
|
26
|
+
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
@@ -100,6 +101,42 @@ export function effectivePolicyCheck(p: PolicyContract): string | null {
|
|
|
100
101
|
return p.check;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Does a role hold a privilege at the TABLE level? — the question "may we emit an ability?"
|
|
106
|
+
*
|
|
107
|
+
* `can('update')` compiles to a policy PLUS a table-wide `GRANT UPDATE`. So a role that holds
|
|
108
|
+
* UPDATE only on three columns does NOT satisfy this: rendering the ability would widen a
|
|
109
|
+
* column grant into a whole-table one. PUBLIC counts, in both catalog spellings, because a
|
|
110
|
+
* grant to PUBLIC really is held by every role.
|
|
111
|
+
*/
|
|
112
|
+
export function holdsTablePrivilege(t: TableContract, role: string, privilege: string): boolean {
|
|
113
|
+
const direct = t.grants[role] ?? [];
|
|
114
|
+
const pub = t.grants.PUBLIC ?? t.grants.public ?? [];
|
|
115
|
+
return direct.includes(privilege) || pub.includes(privilege);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does a role hold a privilege AT ALL? — the question "does this policy authorize anything?"
|
|
120
|
+
*
|
|
121
|
+
* The same question one layer wider: a COLUMN-scoped grant counts. `GRANT UPDATE (body) ON t
|
|
122
|
+
* TO authenticated` really does let that role update, so a policy governing UPDATE is doing
|
|
123
|
+
* live work even though the role holds no table-level UPDATE.
|
|
124
|
+
*
|
|
125
|
+
* TWO PREDICATES ON PURPOSE, and they answer different questions — that is why they are here
|
|
126
|
+
* together rather than one being written twice. `db:pull` used the narrow one to answer the
|
|
127
|
+
* wide question and told adopters a live, column-granted UPDATE policy was "dead code today,
|
|
128
|
+
* declaring it would ADD a privilege the database does not give". Both halves were false, and
|
|
129
|
+
* it was printed beside the model they were about to trust. Use {@link holdsTablePrivilege} to
|
|
130
|
+
* decide what to EMIT; use this to decide what is DEAD.
|
|
131
|
+
*/
|
|
132
|
+
export function holdsPrivilege(t: TableContract, role: string, privilege: string): boolean {
|
|
133
|
+
if (holdsTablePrivilege(t, role, privilege)) return true;
|
|
134
|
+
const cols = t.columnGrants ?? {};
|
|
135
|
+
return Boolean(cols[role]?.[privilege]?.length)
|
|
136
|
+
|| Boolean(cols.PUBLIC?.[privilege]?.length)
|
|
137
|
+
|| Boolean(cols.public?.[privilege]?.length);
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
104
141
|
export function isWrappedExpression(s: string): boolean {
|
|
105
142
|
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
@@ -509,6 +546,24 @@ export async function introspectContract(
|
|
|
509
546
|
|
|
510
547
|
export type DriftSeverity = 'drift';
|
|
511
548
|
|
|
549
|
+
/** A live policy kept under its own name because it already carries the declared rule. */
|
|
550
|
+
export interface PolicyAdoption {
|
|
551
|
+
/** Schema-qualified table. */
|
|
552
|
+
subject: string;
|
|
553
|
+
/** The policy name(s) the model would have created. */
|
|
554
|
+
declared: string[];
|
|
555
|
+
/** The name the database uses, and keeps. */
|
|
556
|
+
live: string;
|
|
557
|
+
/** Why it was accepted, in plain words. */
|
|
558
|
+
reason: string;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Drift, plus the adoptions that are deliberately NOT drift. */
|
|
562
|
+
export interface ContractComparison {
|
|
563
|
+
findings: DriftFinding[];
|
|
564
|
+
adoptions: PolicyAdoption[];
|
|
565
|
+
}
|
|
566
|
+
|
|
512
567
|
export interface DriftFinding {
|
|
513
568
|
/** Schema-qualified table, or `fn:<name>` for a function finding. */
|
|
514
569
|
subject: string;
|
|
@@ -528,7 +583,20 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
528
583
|
* Returns every discrepancy; an empty list means the live DB matches the declaration.
|
|
529
584
|
*/
|
|
530
585
|
export function diffContracts(declared: AuthzContract, live: AuthzContract): DriftFinding[] {
|
|
586
|
+
return compareContracts(declared, live).findings;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The full comparison: drift AND the name divergences accepted by rule.
|
|
591
|
+
*
|
|
592
|
+
* `diffContracts` returns only the findings, because every caller treats a finding as drift
|
|
593
|
+
* and exits non-zero. An adoption is the opposite of drift, so it cannot travel in that list —
|
|
594
|
+
* but it must still be SHOWN, or the mapping between the model's name and the database's name
|
|
595
|
+
* becomes tribal knowledge.
|
|
596
|
+
*/
|
|
597
|
+
export function compareContracts(declared: AuthzContract, live: AuthzContract): ContractComparison {
|
|
531
598
|
const findings: DriftFinding[] = [];
|
|
599
|
+
const adoptions: PolicyAdoption[] = [];
|
|
532
600
|
const dTables = tableMap(declared);
|
|
533
601
|
const lTables = tableMap(live);
|
|
534
602
|
|
|
@@ -546,7 +614,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
|
|
|
546
614
|
}
|
|
547
615
|
diffGrants(name, d, l, findings);
|
|
548
616
|
diffColumnGrants(name, d, l, findings);
|
|
549
|
-
diffPolicies(name, d, l, findings);
|
|
617
|
+
diffPolicies(name, d, l, findings, adoptions);
|
|
550
618
|
}
|
|
551
619
|
for (const name of lTables.keys()) {
|
|
552
620
|
if (!dTables.has(name)) {
|
|
@@ -575,7 +643,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
|
|
|
575
643
|
}
|
|
576
644
|
}
|
|
577
645
|
|
|
578
|
-
return findings;
|
|
646
|
+
return { findings, adoptions };
|
|
579
647
|
}
|
|
580
648
|
|
|
581
649
|
function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
@@ -612,31 +680,58 @@ function diffColumnGrants(table: string, d: TableContract, l: TableContract, out
|
|
|
612
680
|
}
|
|
613
681
|
}
|
|
614
682
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
683
|
+
/**
|
|
684
|
+
* Diff policies through the SHARED matcher — the same equivalence `emitReconcileSql` and the
|
|
685
|
+
* fingerprint use. A rule-identical live policy under a different name is not drift: the
|
|
686
|
+
* database already authorizes exactly what the model declares.
|
|
687
|
+
*
|
|
688
|
+
* Adoptions travel on their own channel, not as findings. A finding means drift and drift
|
|
689
|
+
* means exit 1, while an adoption is agreement. It is never SILENT though — once a live policy
|
|
690
|
+
* keeps its own name, the name the model shows and the name a human greps for have diverged,
|
|
691
|
+
* and that mapping has to stay machine-derived and visible.
|
|
692
|
+
*/
|
|
693
|
+
function diffPolicies(
|
|
694
|
+
table: string,
|
|
695
|
+
d: TableContract,
|
|
696
|
+
l: TableContract,
|
|
697
|
+
out: DriftFinding[],
|
|
698
|
+
adoptions: PolicyAdoption[],
|
|
699
|
+
): void {
|
|
700
|
+
const m = matchPolicies(d.policies, l.policies);
|
|
701
|
+
|
|
702
|
+
for (const a of m.adopted) {
|
|
703
|
+
adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
|
|
704
|
+
}
|
|
705
|
+
for (const g of m.adoptedGroups) {
|
|
706
|
+
adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
|
|
710
|
+
// field moved is the difference between a finding an operator can act on and one they have
|
|
711
|
+
// to go read the catalog to understand.
|
|
712
|
+
const createdByName = new Map(m.toCreate.map((p) => [p.name, p]));
|
|
713
|
+
const changed = new Set<string>();
|
|
714
|
+
for (const name of m.toDrop) {
|
|
715
|
+
const dp = createdByName.get(name);
|
|
716
|
+
if (!dp) continue;
|
|
717
|
+
const lp = l.policies.find((p) => p.name === name)!;
|
|
718
|
+
changed.add(name);
|
|
624
719
|
const changes: string[] = [];
|
|
625
|
-
if (dp.command !== lp.command) changes.push(`command ${dp.command}
|
|
626
|
-
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}
|
|
627
|
-
if (dp.roles
|
|
628
|
-
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
|
|
632
|
-
if (changes.length) {
|
|
633
|
-
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
634
|
-
}
|
|
720
|
+
if (dp.command !== lp.command) changes.push(`command ${dp.command}\u2192${lp.command}`);
|
|
721
|
+
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}\u2192${lp.permissive}`);
|
|
722
|
+
if (!roleSetEqual(dp.roles, lp.roles)) changes.push(`roles [${dp.roles}]\u2192[${lp.roles}]`);
|
|
723
|
+
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push('USING changed');
|
|
724
|
+
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push('WITH CHECK changed');
|
|
725
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
635
726
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
727
|
+
|
|
728
|
+
for (const p of m.toCreate) {
|
|
729
|
+
if (changed.has(p.name)) continue;
|
|
730
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${p.name}" declared but missing from the live database` });
|
|
731
|
+
}
|
|
732
|
+
for (const name of m.toDrop) {
|
|
733
|
+
if (changed.has(name)) continue;
|
|
734
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
|
|
640
735
|
}
|
|
641
736
|
}
|
|
642
737
|
|