@everystack/cli 0.4.41 → 0.4.44
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-compile.ts +128 -16
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-derive.ts +156 -9
- package/src/cli/authz-reconcile.ts +10 -19
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-plan.ts +24 -0
- package/src/cli/commands/db-pull.ts +32 -3
- package/src/cli/edge-plan.ts +62 -4
- package/src/cli/model-render.ts +116 -32
- package/src/cli/schema-compile.ts +25 -2
- package/src/cli/schema-diff.ts +26 -1
- package/src/cli/state-apply.ts +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.44",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.9"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ModelDescriptor, Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
|
+
import { parenthesizeOnce } from './authz-contract.js';
|
|
25
26
|
|
|
26
27
|
export interface CompileOptions {
|
|
27
28
|
/** Schema the table lives in. Default: `public`. */
|
|
@@ -65,7 +66,11 @@ function claimExpr(claim: string): string {
|
|
|
65
66
|
* uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
|
|
66
67
|
* text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
|
|
67
68
|
*/
|
|
68
|
-
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})`;
|
|
69
74
|
const ce = claimExpr(claim);
|
|
70
75
|
if (type === 'text') return `(${sqlColumn} = ${ce})`;
|
|
71
76
|
return `(${sqlColumn} = (${ce})::${castForType(type)})`;
|
|
@@ -88,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
|
|
|
88
93
|
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
89
94
|
*/
|
|
90
95
|
function viaPredicate(v: ViaRef): string {
|
|
91
|
-
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);
|
|
92
97
|
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
93
98
|
}
|
|
94
99
|
|
|
@@ -102,6 +107,8 @@ interface OwnerRef {
|
|
|
102
107
|
type: string;
|
|
103
108
|
/** JWT claim holding the owner id. */
|
|
104
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;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
/**
|
|
@@ -134,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
|
134
141
|
sqlColumn: toSnakeCase(fieldKey),
|
|
135
142
|
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
136
143
|
claim: ownerAbility.condition.userField ?? 'sub',
|
|
144
|
+
expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
|
|
137
145
|
};
|
|
138
146
|
}
|
|
139
147
|
|
|
@@ -176,9 +184,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
|
|
179
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* The soft-delete column SQL name, when the model DECLARES one.
|
|
189
|
+
*
|
|
190
|
+
* Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
|
|
191
|
+
* statement of visibility intent, and inferring the guard from it made the compiler author a
|
|
192
|
+
* security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
|
|
193
|
+
* automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
|
|
194
|
+
* refuses to guess: a model with the field and a public read must say which it means.
|
|
195
|
+
*/
|
|
180
196
|
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
181
|
-
return
|
|
197
|
+
return model.softDelete ? 'deleted_at' : null;
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
/**
|
|
@@ -228,7 +244,28 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
228
244
|
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
|
|
229
245
|
);
|
|
230
246
|
}
|
|
231
|
-
|
|
247
|
+
// Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
|
|
248
|
+
// (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
|
|
249
|
+
// differ from the live one by a single layer of parens — enough for the reconciler to
|
|
250
|
+
// plan a DROP + CREATE that changed nothing.
|
|
251
|
+
return parenthesizeOnce(text.trim());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
|
|
256
|
+
* predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
|
|
257
|
+
* emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
|
|
258
|
+
* whole point of declaring it is to match the live text exactly.
|
|
259
|
+
*/
|
|
260
|
+
function rawExpr(value: unknown, where: string): string | null {
|
|
261
|
+
if (value == null) return null;
|
|
262
|
+
const text = (value as { sql?: unknown }).sql;
|
|
263
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`${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.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return text.trim();
|
|
232
269
|
}
|
|
233
270
|
|
|
234
271
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
@@ -257,7 +294,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
257
294
|
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
258
295
|
// update/delete_own); only the predicate text differs.
|
|
259
296
|
const rowPred = owner
|
|
260
|
-
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
297
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
|
|
261
298
|
: via
|
|
262
299
|
? viaPredicate(via)
|
|
263
300
|
: null;
|
|
@@ -266,16 +303,52 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
266
303
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
267
304
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
268
305
|
|
|
306
|
+
/**
|
|
307
|
+
* A read scoped to a named role — `can('read', { role })`. It compiles to its own
|
|
308
|
+
* `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
|
|
309
|
+
* public read's predicate: a rule written for one role would otherwise narrow what
|
|
310
|
+
* every other role can see.
|
|
311
|
+
*/
|
|
312
|
+
const isRoleRead = (a: Ability): boolean =>
|
|
313
|
+
a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
|
|
314
|
+
|
|
269
315
|
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
270
316
|
// it is the admin bypass — so only the specific verbs are consulted.
|
|
271
317
|
const predFor = (action: Ability['action']): string | null => {
|
|
272
318
|
for (const a of model.abilities) {
|
|
273
|
-
if (a.action !== action) continue;
|
|
319
|
+
if (a.action !== action || isRoleRead(a)) continue;
|
|
274
320
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
275
321
|
if (p) return p;
|
|
276
322
|
}
|
|
277
323
|
return null;
|
|
278
324
|
};
|
|
325
|
+
/**
|
|
326
|
+
* The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
|
|
327
|
+
*
|
|
328
|
+
* Read separately from {@link ownerReadPred} because the two are different BRANCHES of
|
|
329
|
+
* the same authenticated policy. Taking both from one ordered lookup made the anon
|
|
330
|
+
* policy depend on the order the abilities happened to be declared in: put the owner
|
|
331
|
+
* read first and `anon` inherited the owner branch's guard as its whole public rule.
|
|
332
|
+
*/
|
|
333
|
+
const publicReadPred = (): string | null => {
|
|
334
|
+
for (const a of model.abilities) {
|
|
335
|
+
if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
|
|
336
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
|
|
337
|
+
if (p) return p;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
343
|
+
const ownerReadPred = (): string | null => {
|
|
344
|
+
for (const a of model.abilities) {
|
|
345
|
+
if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
|
|
346
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
347
|
+
if (p) return p;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
};
|
|
351
|
+
|
|
279
352
|
/** The WRITE half, when the author declared one distinct from the read half. */
|
|
280
353
|
const checkFor = (action: Ability['action']): string | null => {
|
|
281
354
|
for (const a of model.abilities) {
|
|
@@ -312,18 +385,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
312
385
|
if (hasPublicRead) {
|
|
313
386
|
// public read on a soft-delete table — split per role; authenticated owners
|
|
314
387
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
315
|
-
const readPred =
|
|
388
|
+
const readPred = publicReadPred();
|
|
316
389
|
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
317
390
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
318
391
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
392
|
+
let authedUsing: string;
|
|
393
|
+
if (hasOwnerRead && rowPred && anonUsing !== 'true') {
|
|
394
|
+
// PUBLIC **OR** MINE. Two read abilities on one model — a public read and an
|
|
395
|
+
// owner read — are two branches of one authenticated policy, not a contest the
|
|
396
|
+
// public one wins. They used to be exactly that contest: `hasPublicRead` took
|
|
397
|
+
// the branch and the owner read was computed and discarded, so a signed-in
|
|
398
|
+
// author could not see their own non-public rows. On a real brownfield schema
|
|
399
|
+
// that hid ~13k legacy `pending` rows from the people who wrote them.
|
|
400
|
+
//
|
|
401
|
+
// The owner branch escapes the public predicate (that is the whole point) and
|
|
402
|
+
// carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
|
|
403
|
+
// author says what "mine" means without loosening what "public" means.
|
|
404
|
+
const ownBranch = andPredicates(rowPred, ownerReadPred())!;
|
|
405
|
+
authedUsing = `(${anonUsing} OR ${ownBranch})`;
|
|
406
|
+
} else {
|
|
407
|
+
// The owner predicate may only ever WIDEN a public read (the soft-delete OR:
|
|
408
|
+
// owners also see their own deleted rows). It must never REPLACE it — an owner
|
|
409
|
+
// condition on a WRITE ability used to narrow the authenticated SELECT to
|
|
410
|
+
// own-rows-only, so anon saw the whole table and a signed-in user lost it.
|
|
411
|
+
// Without a soft-delete guard, `true OR owner` is just `true` — and an
|
|
412
|
+
// unfiltered public read already contains every row an owner could add, which
|
|
413
|
+
// is why `anonUsing === 'true'` skips the disjunction rather than emitting a
|
|
414
|
+
// branch that can never change the answer.
|
|
415
|
+
authedUsing = sdGuard && rowPred
|
|
416
|
+
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
417
|
+
: anonUsing;
|
|
418
|
+
}
|
|
327
419
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
328
420
|
} else if (hasOwnerRead && rowPred) {
|
|
329
421
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
@@ -336,6 +428,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
336
428
|
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
337
429
|
}
|
|
338
430
|
|
|
431
|
+
// Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
|
|
432
|
+
// none of the branches above: the public read requires NO role, and the owner/column reads
|
|
433
|
+
// require a row-scoping condition. Before this they compiled to a SELECT grant and no
|
|
434
|
+
// policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
|
|
435
|
+
// the model read as though it could see its own. The declared predicate was computed and
|
|
436
|
+
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
437
|
+
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
438
|
+
for (const a of abilities.filter(isRoleRead)) {
|
|
439
|
+
const role = a.condition.role!;
|
|
440
|
+
const name = `${t}_select_${role}`;
|
|
441
|
+
const where = `${table}: can('read', { role: '${role}' })`;
|
|
442
|
+
if (policies.some((p) => p.name === name)) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`${where} collides with the ${name} policy already compiled from this model's public read. `
|
|
445
|
+
+ `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
|
|
449
|
+
}
|
|
450
|
+
|
|
339
451
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
340
452
|
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
341
453
|
//
|
|
@@ -82,6 +82,51 @@ export interface PolicyContract {
|
|
|
82
82
|
check: string | null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The WITH CHECK expression PostgreSQL will actually enforce.
|
|
87
|
+
*
|
|
88
|
+
* For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
|
|
89
|
+
* `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
|
|
90
|
+
* (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
|
|
91
|
+
* WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
|
|
92
|
+
* different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
|
|
93
|
+
* defeats a zero-statement bar even when the SQL is identical.
|
|
94
|
+
*
|
|
95
|
+
* `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
|
|
96
|
+
* and no defaulting applies.
|
|
97
|
+
*/
|
|
98
|
+
export function effectivePolicyCheck(p: PolicyContract): string | null {
|
|
99
|
+
if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
|
|
100
|
+
return p.check;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
104
|
+
export function isWrappedExpression(s: string): boolean {
|
|
105
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
106
|
+
let depth = 0;
|
|
107
|
+
for (let i = 0; i < s.length; i++) {
|
|
108
|
+
if (s[i] === '(') depth++;
|
|
109
|
+
else if (s[i] === ')') {
|
|
110
|
+
depth--;
|
|
111
|
+
if (depth === 0 && i < s.length - 1) return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return depth === 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A predicate parenthesized exactly once — the normal form both producers must agree on.
|
|
119
|
+
*
|
|
120
|
+
* `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
|
|
121
|
+
* one into a model verbatim. Wrapping it again produced a compiled predicate identical to
|
|
122
|
+
* live but for one layer of parens, which the equality check called a difference and turned
|
|
123
|
+
* into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
|
|
124
|
+
* comparison did not, so both go through this.
|
|
125
|
+
*/
|
|
126
|
+
export function parenthesizeOnce(expr: string): string {
|
|
127
|
+
return isWrappedExpression(expr) ? expr : `(${expr})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* One function, enumerated. The cross-table authorization that RLS does not hold lives
|
|
87
132
|
* in SECURITY DEFINER functions as imperative gates; the contract records that they
|
|
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
|
|
|
581
626
|
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
|
|
582
627
|
if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
|
|
583
628
|
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
|
|
584
|
-
|
|
629
|
+
// Compared through the server's own defaulting rule, so this agrees with
|
|
630
|
+
// emitReconcileSql — otherwise db:check reports drift the plan does not carry.
|
|
631
|
+
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
|
|
585
632
|
if (changes.length) {
|
|
586
633
|
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
587
634
|
}
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -65,11 +65,120 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
|
65
65
|
const OWNER_RE =
|
|
66
66
|
/^\(?([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
67
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
|
|
70
|
+
*
|
|
71
|
+
* A brownfield schema almost always has one — a STABLE SECURITY DEFINER function wrapping
|
|
72
|
+
* the same claim read — and every owner policy in it is written against that function, not
|
|
73
|
+
* against the claim inline. Rendering those as the inline claim would be a semantically
|
|
74
|
+
* similar but TEXTUALLY different predicate, and predicates are diffed as text: it would
|
|
75
|
+
* plan a DROP + CREATE on every owner policy in the schema.
|
|
76
|
+
*
|
|
77
|
+
* Deliberately narrow: the right side must be a zero-argument function call. That is what
|
|
78
|
+
* an "who am I" accessor looks like, and it is what a column-to-column comparison
|
|
79
|
+
* (`(user_id = reviewer_id)`) is not — so the matcher cannot mistake a join predicate for
|
|
80
|
+
* a statement of ownership.
|
|
81
|
+
*/
|
|
82
|
+
const OWNER_FN_RE = /^\(?([a-z_][a-z0-9_]*)\s*=\s*((?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*\(\))\)?$/i;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Does this predicate say "the row is mine"? Returns the column, the claim, and the
|
|
86
|
+
* accessor expression when the schema uses one (null = the claim read inline).
|
|
87
|
+
*/
|
|
88
|
+
export function parseOwnerPredicate(
|
|
89
|
+
pred: string | null,
|
|
90
|
+
): { column: string; claim: string; expr: string | null } | null {
|
|
70
91
|
if (!pred) return null;
|
|
71
|
-
const
|
|
72
|
-
|
|
92
|
+
const t = pred.trim();
|
|
93
|
+
const m = OWNER_RE.exec(t);
|
|
94
|
+
if (m) return { column: m[1], claim: m[2], expr: null };
|
|
95
|
+
const f = OWNER_FN_RE.exec(t);
|
|
96
|
+
return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split a predicate on a top-level operator, respecting parens and string literals.
|
|
101
|
+
*
|
|
102
|
+
* Needed because the shapes worth recognising are compositional — `(public) OR (mine AND
|
|
103
|
+
* guard)` — and a naive `.split(' OR ')` cuts inside a nested disjunction, which is how a
|
|
104
|
+
* template matcher silently mis-reads `((state <> 'deleted') AND ((user_id = …) OR (…)))`
|
|
105
|
+
* as a top-level OR it is not.
|
|
106
|
+
*/
|
|
107
|
+
function splitTopLevel(pred: string, op: 'OR' | 'AND'): string[] {
|
|
108
|
+
const needle = ` ${op} `;
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
let depth = 0;
|
|
111
|
+
let quoted = false;
|
|
112
|
+
let start = 0;
|
|
113
|
+
// Strip one enclosing paren pair so the operator we want is genuinely at depth 0.
|
|
114
|
+
const s = unwrapOnce(pred.trim());
|
|
115
|
+
for (let i = 0; i < s.length; i++) {
|
|
116
|
+
const c = s[i];
|
|
117
|
+
if (c === "'") { quoted = !quoted; continue; }
|
|
118
|
+
if (quoted) continue;
|
|
119
|
+
if (c === '(') depth++;
|
|
120
|
+
else if (c === ')') depth--;
|
|
121
|
+
else if (depth === 0 && s.startsWith(needle, i)) {
|
|
122
|
+
parts.push(s.slice(start, i));
|
|
123
|
+
i += needle.length - 1;
|
|
124
|
+
start = i + 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
parts.push(s.slice(start));
|
|
128
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Drop one enclosing paren pair, but only when it spans the WHOLE expression. */
|
|
132
|
+
function unwrapOnce(s: string): string {
|
|
133
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return s;
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let quoted = false;
|
|
136
|
+
for (let i = 0; i < s.length; i++) {
|
|
137
|
+
const c = s[i];
|
|
138
|
+
if (c === "'") { quoted = !quoted; continue; }
|
|
139
|
+
if (quoted) continue;
|
|
140
|
+
if (c === '(') depth++;
|
|
141
|
+
else if (c === ')') {
|
|
142
|
+
depth--;
|
|
143
|
+
if (depth === 0 && i < s.length - 1) return s; // the pair closed early — not enclosing
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return depth === 0 ? s.slice(1, -1).trim() : s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decompose an authenticated read of the form `(public) OR (mine [AND guard])`, given the
|
|
151
|
+
* public predicate the anon policy already states.
|
|
152
|
+
*
|
|
153
|
+
* Keyed on the ANON PREDICATE APPEARING VERBATIM as one of two top-level disjuncts, and an
|
|
154
|
+
* owner predicate appearing in the other — not on a shape template. The alternative,
|
|
155
|
+
* matching a `X OR (Y AND Z)` template, both over- and under-fires: it claims predicates it
|
|
156
|
+
* has not proven are a public branch, and it misses the schema whose OR is nested inside an
|
|
157
|
+
* AND. Anything that does not decompose EXACTLY returns null, and the caller says so.
|
|
158
|
+
*/
|
|
159
|
+
export function parsePublicOrOwn(
|
|
160
|
+
authedPred: string | null,
|
|
161
|
+
anonPred: string | null,
|
|
162
|
+
): { owner: { column: string; claim: string; expr: string | null }; ownerSql: string | null } | null {
|
|
163
|
+
if (!authedPred || !anonPred) return null;
|
|
164
|
+
const branches = splitTopLevel(authedPred, 'OR');
|
|
165
|
+
if (branches.length !== 2) return null;
|
|
166
|
+
|
|
167
|
+
const anonNorm = unwrapOnce(anonPred.trim());
|
|
168
|
+
const publicIdx = branches.findIndex((b) => unwrapOnce(b) === anonNorm);
|
|
169
|
+
if (publicIdx === -1) return null;
|
|
170
|
+
|
|
171
|
+
const ownBranch = branches[1 - publicIdx];
|
|
172
|
+
const conjuncts = splitTopLevel(ownBranch, 'AND');
|
|
173
|
+
const ownerIdx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
|
|
174
|
+
if (ownerIdx === -1) return null;
|
|
175
|
+
|
|
176
|
+
const owner = parseOwnerPredicate(conjuncts[ownerIdx])!;
|
|
177
|
+
const rest = conjuncts.filter((_, i) => i !== ownerIdx);
|
|
178
|
+
// More than one leftover conjunct would have to be re-ANDed, and the text the compiler
|
|
179
|
+
// then emits is not guaranteed to be the text that was read. Refuse rather than guess.
|
|
180
|
+
if (rest.length > 1) return null;
|
|
181
|
+
return { owner, ownerSql: rest[0] ?? null };
|
|
73
182
|
}
|
|
74
183
|
|
|
75
184
|
/** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
|
|
@@ -135,15 +244,48 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
135
244
|
if (anonRead) {
|
|
136
245
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
137
246
|
// carry that predicate — a public read with a filter is still a public read.
|
|
138
|
-
const
|
|
139
|
-
const
|
|
247
|
+
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
248
|
+
const anonPred = anonPol?.using ?? null;
|
|
249
|
+
const frag = sqlFragment(anonPred);
|
|
140
250
|
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
251
|
+
|
|
252
|
+
// The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
|
|
253
|
+
// one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
|
|
254
|
+
// only the anon policy dropped that branch and rendered a model that meant strictly
|
|
255
|
+
// less than the database did, with nothing said. On a real schema it hid ~13,184
|
|
256
|
+
// legacy rows from the people who wrote them.
|
|
257
|
+
const authedPol = authedRead
|
|
258
|
+
? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
|
|
259
|
+
: undefined;
|
|
260
|
+
const authedPred = authedPol?.using ?? null;
|
|
261
|
+
if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
|
|
262
|
+
const own = parsePublicOrOwn(authedPred, anonPred);
|
|
263
|
+
if (own) {
|
|
264
|
+
const parts = [`owner: '${own.owner.column}'`];
|
|
265
|
+
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
266
|
+
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
267
|
+
const guard = sqlFragment(own.ownerSql);
|
|
268
|
+
if (guard) parts.push(`sql: ${guard}`);
|
|
269
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
270
|
+
} else {
|
|
271
|
+
// P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
|
|
272
|
+
// policy and its text, never a model that quietly means less.
|
|
273
|
+
notes.push(
|
|
274
|
+
`policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
|
|
275
|
+
);
|
|
276
|
+
notes.push(` and its shape is not expressible as abilities. Live USING:`);
|
|
277
|
+
notes.push(` ${authedPred}`);
|
|
278
|
+
notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
141
281
|
} else if (authedRead) {
|
|
142
282
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
143
283
|
const owner = parseOwnerPredicate(pol?.using ?? null);
|
|
144
284
|
if (owner) {
|
|
145
|
-
const
|
|
146
|
-
|
|
285
|
+
const parts = [`owner: '${owner.column}'`];
|
|
286
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
287
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
288
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
147
289
|
} else {
|
|
148
290
|
const frag = sqlFragment(pol?.using ?? null);
|
|
149
291
|
abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
|
|
@@ -165,6 +307,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
165
307
|
if (owner) {
|
|
166
308
|
parts.push(`owner: '${owner.column}'`);
|
|
167
309
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
310
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
168
311
|
}
|
|
169
312
|
// A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
|
|
170
313
|
const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
|
|
@@ -200,7 +343,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
200
343
|
/** Render one table's derived stanza as the lines that sit inside a model literal. */
|
|
201
344
|
export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
202
345
|
const out: string[] = [];
|
|
203
|
-
|
|
346
|
+
// Split on newlines: a note may carry a live predicate verbatim, and `pg_get_expr`
|
|
347
|
+
// deparses a subquery across several lines. One `//` for the first line left the rest
|
|
348
|
+
// as bare SQL in the middle of a TypeScript object — a generated file that would not
|
|
349
|
+
// parse. Commenting per line here means no note, present or future, can do that.
|
|
350
|
+
for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
|
|
204
351
|
if (d.abilities.length) {
|
|
205
352
|
out.push(` abilities: [${d.abilities.join(', ')}],`);
|
|
206
353
|
} else {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
|
+
import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
|
|
19
20
|
import { quoteQualified } from './pg-ident.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -116,24 +117,8 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
|
|
|
116
117
|
return lines;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
120
|
-
function isWrapped(s: string): boolean {
|
|
121
|
-
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
122
|
-
let depth = 0;
|
|
123
|
-
for (let i = 0; i < s.length; i++) {
|
|
124
|
-
if (s[i] === '(') depth++;
|
|
125
|
-
else if (s[i] === ')') {
|
|
126
|
-
depth--;
|
|
127
|
-
if (depth === 0 && i < s.length - 1) return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return depth === 0;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
120
|
/** A USING/WITH CHECK clause expression, parenthesized exactly once. */
|
|
134
|
-
|
|
135
|
-
return isWrapped(expr) ? expr : `(${expr})`;
|
|
136
|
-
}
|
|
121
|
+
const clause = parenthesizeOnce;
|
|
137
122
|
|
|
138
123
|
/** A CREATE POLICY statement from a policy descriptor. */
|
|
139
124
|
function policyCreateSql(table: string, p: PolicyContract): string {
|
|
@@ -150,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
|
|
|
150
135
|
return `DROP POLICY IF EXISTS ${name} ON ${table};`;
|
|
151
136
|
}
|
|
152
137
|
|
|
153
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Two policies are the same authorization when every diffed field matches. The check is
|
|
140
|
+
* compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule —
|
|
141
|
+
* a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
|
|
142
|
+
* authorize identically, and reconciling them would emit a DROP + CREATE that changes
|
|
143
|
+
* nothing.
|
|
144
|
+
*/
|
|
154
145
|
function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
155
146
|
return a.command === b.command
|
|
156
147
|
&& a.permissive === b.permissive
|
|
157
148
|
&& a.roles.join(',') === b.roles.join(',')
|
|
158
149
|
&& (a.using ?? '') === (b.using ?? '')
|
|
159
|
-
&& (a
|
|
150
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
160
151
|
}
|
|
161
152
|
|
|
162
153
|
function tableMap(c: AuthzContract): Map<string, TableContract> {
|