@everystack/cli 0.4.80 → 0.4.82
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/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/cli/authz-compile.ts +0 -0
- package/src/cli/authz-derive.ts +118 -24
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.82] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- four compiler-hardening throws close the A22/F-A10/F-A12 tail
|
|
12
|
+
- column-path check disclosure parity closes the F1 gap
|
|
13
|
+
|
|
14
|
+
## [0.4.81] - 2026-09-08
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- every declared write compiles a policy; check narrows, never replaces
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- db:pull recovers owner: from the normalized predicate and round-trips
|
|
23
|
+
|
|
7
24
|
## [0.4.80] - 2026-09-07
|
|
8
25
|
|
|
9
26
|
### BREAKING CHANGES
|
package/package.json
CHANGED
package/src/cli/authz-compile.ts
CHANGED
|
Binary file
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -92,14 +92,20 @@ function deriveExtraPrivileges(contract: TableContract): Record<string, string[]
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* The deparsed owner predicate
|
|
96
|
-
* (
|
|
95
|
+
* The deparsed owner predicate, in BOTH wire spellings — the `::text` casts are optional:
|
|
96
|
+
* normalized (what both producers emit since a616a1f3, 2026-08-24, and what a live stage
|
|
97
|
+
* returns through `predicate()`'s cross-version normalizer):
|
|
98
|
+
* (col = (((current_setting('request.jwt.claims', true))::jsonb ->> 'sub'))::uuid)
|
|
99
|
+
* raw old-major deparse (a dump captured before the normalizer, or an older stage):
|
|
100
|
+
* (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
|
|
97
101
|
* Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
|
|
98
|
-
* instead of an opaque `sql:` fragment.
|
|
99
|
-
*
|
|
102
|
+
* instead of an opaque `sql:` fragment. Before the casts were made optional the matcher
|
|
103
|
+
* expected only the raw spelling, so a normalized live predicate — every one, today — fell
|
|
104
|
+
* through to `sql:` and lost the `owner:` on round-trip. Anything unrecognised still stays
|
|
105
|
+
* verbatim rather than being guessed at.
|
|
100
106
|
*/
|
|
101
107
|
const OWNER_RE =
|
|
102
|
-
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'
|
|
108
|
+
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)*(?:::[a-z]+)?\)?$/i;
|
|
103
109
|
|
|
104
110
|
/**
|
|
105
111
|
* The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
|
|
@@ -132,6 +138,33 @@ export function parseOwnerPredicate(
|
|
|
132
138
|
return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Decompose a WRITE predicate into its owner conjunct and the remaining guard.
|
|
143
|
+
*
|
|
144
|
+
* The compiler builds an owner write as `rowScope AND where` (USING) and `rowScope AND (check ??
|
|
145
|
+
* where)` (WITH CHECK). To round-trip, the deriver reverses that: the owner term may be the whole
|
|
146
|
+
* predicate (`owner = claim`) or one top-level AND conjunct (`owner = claim AND state = 'draft'`),
|
|
147
|
+
* and everything else is the guard the compiler will AND back in beside the owner. Returns the
|
|
148
|
+
* owner parse (null when there is none) and the guard text (null when the owner is the whole
|
|
149
|
+
* predicate). Nothing is guessed: a predicate with no recognisable owner conjunct returns
|
|
150
|
+
* `{ owner: null, guard: <the whole predicate> }`, which the caller carries verbatim as `sql:`.
|
|
151
|
+
*/
|
|
152
|
+
function splitOwnerPredicate(
|
|
153
|
+
pred: string | null,
|
|
154
|
+
): { owner: ReturnType<typeof parseOwnerPredicate>; guard: string | null } {
|
|
155
|
+
if (!pred) return { owner: null, guard: null };
|
|
156
|
+
const direct = parseOwnerPredicate(pred);
|
|
157
|
+
if (direct) return { owner: direct, guard: null };
|
|
158
|
+
const conjuncts = splitTopLevel(pred, 'AND');
|
|
159
|
+
if (conjuncts.length < 2) return { owner: null, guard: pred };
|
|
160
|
+
const idx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
|
|
161
|
+
if (idx === -1) return { owner: null, guard: pred };
|
|
162
|
+
const owner = parseOwnerPredicate(conjuncts[idx]);
|
|
163
|
+
const rest = conjuncts.filter((_, i) => i !== idx);
|
|
164
|
+
const guard = rest.length === 0 ? null : rest.length === 1 ? rest[0] : `(${rest.join(' AND ')})`;
|
|
165
|
+
return { owner, guard };
|
|
166
|
+
}
|
|
167
|
+
|
|
135
168
|
/**
|
|
136
169
|
* Split a predicate on a top-level operator, respecting parens and string literals.
|
|
137
170
|
*
|
|
@@ -331,15 +364,24 @@ function renderColumnAbility(
|
|
|
331
364
|
grantee: string,
|
|
332
365
|
columns: readonly string[],
|
|
333
366
|
policy: PolicyContract,
|
|
367
|
+
notes: string[],
|
|
334
368
|
): string | null {
|
|
335
369
|
const using = policy.using ?? null;
|
|
336
370
|
const owner = parseOwnerPredicate(using);
|
|
337
371
|
const parts: string[] = [];
|
|
338
372
|
const role = policyRoleForGrantee(grantee);
|
|
339
373
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
374
|
+
// A distinct WITH CHECK on a column UPDATE must ride as `check:`, and the compiler REFUSES a
|
|
375
|
+
// role-less column ability that carries any row predicate (F-A12: a column ability has nowhere
|
|
376
|
+
// row-level to put one). So a differing check forces the NAMED-role spelling — `role:
|
|
377
|
+
// 'authenticated'` — which routes to the compiler's named-role column path: that path ANDs the
|
|
378
|
+
// owner back into BOTH halves and honors the check, where the role-less shape would throw.
|
|
379
|
+
const distinctCheck = action === 'update' && policy.check != null && policy.check !== using;
|
|
380
|
+
|
|
381
|
+
// Owner-only uses the model's authenticated default. Every other grantee must be named so the
|
|
382
|
+
// compiler emits the policy TO the live role, not a role-less public ability — and a differing
|
|
383
|
+
// check names it too, to escape the F-A12 refusal above.
|
|
384
|
+
if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: '${role}'`);
|
|
343
385
|
if (owner) {
|
|
344
386
|
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
345
387
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
@@ -349,10 +391,26 @@ function renderColumnAbility(
|
|
|
349
391
|
if (fragment) parts.push(`sql: ${fragment}`);
|
|
350
392
|
}
|
|
351
393
|
|
|
352
|
-
if (
|
|
353
|
-
|
|
394
|
+
if (distinctCheck) {
|
|
395
|
+
// Emit only the guard BEYOND the owner gate. The live WITH CHECK restates the owner term
|
|
396
|
+
// (the compiler's named-role column path ANDs it back in), so carrying the whole predicate
|
|
397
|
+
// would double the owner on recompile and churn the plan. Strip it exactly as the row-level
|
|
398
|
+
// write path does; a check with no owner conjunct rides whole (guard === the check).
|
|
399
|
+
const split = owner ? splitOwnerPredicate(policy.check) : null;
|
|
400
|
+
const guard = split ? split.guard : policy.check;
|
|
401
|
+
const fragment = sqlFragment(guard);
|
|
354
402
|
if (!fragment) return null;
|
|
355
403
|
parts.push(`check: ${fragment}`);
|
|
404
|
+
// Disclosure parity with the row-level write path. When USING is a PURE owner predicate but
|
|
405
|
+
// the live WITH CHECK does NOT restate the owner, the compiler ANDs the owner back into the
|
|
406
|
+
// rendered check — TIGHTENING it (the F-A11 "wider write half" the model cannot express). Say
|
|
407
|
+
// so, rather than let an unexplained conjunct surface in the next db:plan, which an operator
|
|
408
|
+
// might "fix" back toward the wider live shape by hand — the exact spelling the compiler refuses.
|
|
409
|
+
if (split && split.owner == null) {
|
|
410
|
+
notes.push(`policy "${policy.name}" (UPDATE) has a WITH CHECK that does not restate the owner gate in its USING —`);
|
|
411
|
+
notes.push(` it is wider than a column owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
|
|
412
|
+
notes.push(` ${policy.check}`);
|
|
413
|
+
}
|
|
356
414
|
}
|
|
357
415
|
parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
|
|
358
416
|
return `can('${action}', { ${parts.join(', ')} })`;
|
|
@@ -562,7 +620,7 @@ export function deriveAbilities(
|
|
|
562
620
|
}
|
|
563
621
|
|
|
564
622
|
if (diverges && own) {
|
|
565
|
-
const parts = [`owner: '${own.owner.column}'`];
|
|
623
|
+
const parts = [`owner: '${modelFieldKey(own.owner.column)}'`];
|
|
566
624
|
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
567
625
|
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
568
626
|
const guard = sqlFragment(own.ownerSql);
|
|
@@ -597,7 +655,7 @@ export function deriveAbilities(
|
|
|
597
655
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
598
656
|
const owner = parseOwnerPredicate(pol?.using ?? null);
|
|
599
657
|
if (owner) {
|
|
600
|
-
const parts = [`owner: '${owner.column}'`];
|
|
658
|
+
const parts = [`owner: '${modelFieldKey(owner.column)}'`];
|
|
601
659
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
602
660
|
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
603
661
|
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
@@ -616,22 +674,58 @@ export function deriveAbilities(
|
|
|
616
674
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
617
675
|
const using = pol?.using ?? null;
|
|
618
676
|
const check = pol?.check ?? null;
|
|
619
|
-
|
|
677
|
+
// Decompose both halves into (owner, guard). INSERT carries the owner in WITH CHECK (its
|
|
678
|
+
// USING is null); UPDATE/DELETE carry it in USING. The owner is recovered from whichever
|
|
679
|
+
// half has it.
|
|
680
|
+
const u = splitOwnerPredicate(using);
|
|
681
|
+
const c = splitOwnerPredicate(check);
|
|
682
|
+
const owner = u.owner ?? c.owner;
|
|
620
683
|
|
|
621
684
|
const parts: string[] = [];
|
|
622
685
|
if (owner) {
|
|
623
|
-
parts.push(`owner: '${owner.column}'`);
|
|
686
|
+
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
624
687
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
625
688
|
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
689
|
+
// The USING guard beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
|
|
690
|
+
const usingGuard = sqlFragment(u.guard);
|
|
691
|
+
if (usingGuard) parts.push(`sql: ${usingGuard}`);
|
|
692
|
+
// The WRITE half rides as `check` ONLY when its guard differs from the USING guard. The
|
|
693
|
+
// compiler defaults WITH CHECK to (rowScope AND where), so emitting an equal guard would
|
|
694
|
+
// double the owner term on recompile — the round-trip regression this closes.
|
|
695
|
+
// WITH CHECK handling. `check == null` means the live policy has NO explicit WITH CHECK
|
|
696
|
+
// (Postgres defaults it to USING at runtime) — emit no `check:` and let the compiler
|
|
697
|
+
// default it the same way. Only an EXPLICIT check that differs from the USING guard needs a
|
|
698
|
+
// `check:`, and only in the shapes below.
|
|
699
|
+
if (command !== 'DELETE' && check != null && c.owner == null) {
|
|
700
|
+
// The live WITH CHECK does not even restate the owner gate that USING carries — it is
|
|
701
|
+
// WIDER on the owner axis (the extreme is `WITH CHECK (true)`: write a row you do not
|
|
702
|
+
// own). The model has no spelling for that, and every `check:` the compiler emits ANDs
|
|
703
|
+
// the owner back in, so the derived ability necessarily TIGHTENS live. Disclose it rather
|
|
704
|
+
// than round-trip the loss silently; the residual db:plan statement is the real diff.
|
|
705
|
+
notes.push(`policy "${pol?.name}" (${command}) has a WITH CHECK that does not restate the owner gate in its USING —`);
|
|
706
|
+
notes.push(` it is wider than any owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
|
|
707
|
+
notes.push(` ${check}`);
|
|
708
|
+
} else if (command !== 'DELETE' && check != null && c.owner != null && c.guard == null && u.guard != null) {
|
|
709
|
+
// Explicit owner-only WITH CHECK while USING carries a guard — the write half is
|
|
710
|
+
// deliberately WIDER than the read half (the moderation shape this file's header names:
|
|
711
|
+
// "your own draft rows" to target, "your own row" to write into). Left implicit, the
|
|
712
|
+
// compiler would default WITH CHECK to (owner AND where) and TIGHTEN it, so the pull would
|
|
713
|
+
// misreport live. `check: sql`true`` forces WITH CHECK back to the owner gate alone.
|
|
714
|
+
parts.push('check: sql`true`');
|
|
715
|
+
} else if (c.guard != null && (c.guard ?? '') !== (u.guard ?? '')) {
|
|
716
|
+
// An explicit WITH CHECK carrying a DIFFERENT guard than USING (owner ∧ a distinct write
|
|
717
|
+
// guard). Rides as `check:`; the compiler ANDs the owner back in.
|
|
718
|
+
const checkFrag = sqlFragment(c.guard);
|
|
719
|
+
if (checkFrag) parts.push(`check: ${checkFrag}`);
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
// No owner recovered — carry the predicates verbatim, USING and (differing) WITH CHECK.
|
|
723
|
+
const usingFrag = sqlFragment(using);
|
|
724
|
+
if (usingFrag) parts.push(`sql: ${usingFrag}`);
|
|
725
|
+
if (check && check !== using) {
|
|
726
|
+
const checkFrag = sqlFragment(check);
|
|
727
|
+
if (checkFrag) parts.push(`check: ${checkFrag}`);
|
|
728
|
+
}
|
|
635
729
|
}
|
|
636
730
|
|
|
637
731
|
if (!parts.length && !pol) {
|
|
@@ -670,7 +764,7 @@ export function deriveAbilities(
|
|
|
670
764
|
if (!cols.length) continue;
|
|
671
765
|
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
672
766
|
const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
673
|
-
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
|
|
767
|
+
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
|
|
674
768
|
if (ability) {
|
|
675
769
|
abilities.push(ability);
|
|
676
770
|
renderedColumnRoles.add(grantee);
|