@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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-canonical — ONE definition of "these two authorization states are the same".
|
|
3
|
+
*
|
|
4
|
+
* Three surfaces answer that question and they must never answer it differently:
|
|
5
|
+
*
|
|
6
|
+
* - `authz-reconcile` — emits the SQL that closes the gap (statements == 0 means same)
|
|
7
|
+
* - `authz-contract` — reports drift (no findings means same)
|
|
8
|
+
* - `schema-fingerprint` — content-addresses the state (equal hashes means same)
|
|
9
|
+
*
|
|
10
|
+
* They were two-and-a-half hand-mirrored copies, and that is exactly how the identity broke:
|
|
11
|
+
* the reconciler learned to exempt an ungoverned grantee, the fingerprint did not, and every
|
|
12
|
+
* brownfield adopter landed on "nothing to do" and "you have drifted" at the same time. The
|
|
13
|
+
* fingerprint is not a third opinion — it is this equivalence relation, cached. When the
|
|
14
|
+
* relation changes, the cache format changes with it.
|
|
15
|
+
*
|
|
16
|
+
* Two normalizations live here, and both are load-bearing:
|
|
17
|
+
*
|
|
18
|
+
* ROLE EXPANSION. A policy `TO a, b` and two identical-predicate policies `TO a` and `TO b`
|
|
19
|
+
* are applicable to precisely the same sessions, because Postgres selects policies per session
|
|
20
|
+
* role by membership. Expanding every policy to one entry per role makes those two states hash
|
|
21
|
+
* equal, which is what makes the reconciler's adoption of a role split legible to the gate.
|
|
22
|
+
*
|
|
23
|
+
* PUBLIC NEVER EXPANDS. It is an open set — every role that exists or ever will. Enumerating
|
|
24
|
+
* it at hash time against today's roles would rebuild, inside the fingerprint, exactly the
|
|
25
|
+
* contingent equivalence the matcher refuses: equal today, silently wrong the moment a role is
|
|
26
|
+
* created. It stays a single sentinel entry.
|
|
27
|
+
*
|
|
28
|
+
* MULTISET, NEVER A SET. Entries carry counts. If duplicates collapsed, two live policies with
|
|
29
|
+
* the same rule and different names would hash as one, the fingerprint would report MATCH, and
|
|
30
|
+
* the matcher — which adopts one and drops the other — would still emit statements. That is
|
|
31
|
+
* the same identity break in the opposite direction, and it is the harder one to notice.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { PolicyContract, TableContract } from './authz-contract.js';
|
|
35
|
+
import { effectivePolicyCheck, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
|
|
36
|
+
|
|
37
|
+
/** PostgreSQL's open role set, kept whole. */
|
|
38
|
+
const PUBLIC_ROLE = 'public';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One policy as (role, rule) entries — the form in which two equivalent states look equal.
|
|
42
|
+
* A PUBLIC policy yields exactly one entry; every other policy yields one per named role.
|
|
43
|
+
*/
|
|
44
|
+
function expandPolicy(p: PolicyContract): string[] {
|
|
45
|
+
const rule = stableRule(p);
|
|
46
|
+
if (p.roles.includes(PUBLIC_ROLE)) return [`${PUBLIC_ROLE}|${rule}`];
|
|
47
|
+
return [...p.roles].sort().map((r) => `${r}|${rule}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The rule, with the NAME deliberately absent.
|
|
52
|
+
*
|
|
53
|
+
* Names were the policy's identity in the v3 format, on the reasoning that the compiler emits
|
|
54
|
+
* them deterministically. That is true of a greenfield database and false of every brownfield
|
|
55
|
+
* one, where the previous migration tool chose the names. Hashing them made a live policy that
|
|
56
|
+
* carries the declared authorization under its own name read as a different state — so an
|
|
57
|
+
* adopter could reach zero statements and never reach MATCH.
|
|
58
|
+
*
|
|
59
|
+
* The check goes through the server's own defaulting rule, so a live `FOR ALL USING (true)`
|
|
60
|
+
* and a compiled `FOR ALL USING (true) WITH CHECK (true)` — the same authorization, written
|
|
61
|
+
* two ways — hash the same.
|
|
62
|
+
*/
|
|
63
|
+
function stableRule(p: PolicyContract): string {
|
|
64
|
+
return JSON.stringify([
|
|
65
|
+
p.command,
|
|
66
|
+
p.permissive,
|
|
67
|
+
p.using ?? '',
|
|
68
|
+
effectivePolicyCheck(p) ?? '',
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sorted multiset: every entry kept, duplicates counted, order irrelevant. */
|
|
73
|
+
function multiset(entries: string[]): Array<[string, number]> {
|
|
74
|
+
const counts = new Map<string, number>();
|
|
75
|
+
for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
76
|
+
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The canonical policy form: every policy expanded across its roles, as a sorted multiset.
|
|
81
|
+
*
|
|
82
|
+
* When a governed set is supplied (hashing a LIVE contract), per-role entries for roles the
|
|
83
|
+
* models do not govern are dropped — the SAME choice the reconciler makes when it leaves an
|
|
84
|
+
* ungoverned role's policy alone (the `reconcilePolicies` ungoverned escape). Hashing them
|
|
85
|
+
* would keep MATCH unreachable on any brownfield database whose previous stack left a policy
|
|
86
|
+
* TO a migrator/resolver role: zero statements, mismatched hash, forever. PUBLIC is a
|
|
87
|
+
* governed sentinel and always kept.
|
|
88
|
+
*/
|
|
89
|
+
export function canonicalPolicies(
|
|
90
|
+
policies: readonly PolicyContract[],
|
|
91
|
+
governed?: ReadonlySet<string>,
|
|
92
|
+
): Array<[string, number]> {
|
|
93
|
+
const entries = policies.flatMap(expandPolicy);
|
|
94
|
+
const kept = governed
|
|
95
|
+
? entries.filter((e) => {
|
|
96
|
+
const role = e.slice(0, e.indexOf('|'));
|
|
97
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
98
|
+
})
|
|
99
|
+
: entries;
|
|
100
|
+
return multiset(kept);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Keep only the grantees the models actually govern.
|
|
105
|
+
*
|
|
106
|
+
* The reconciler leaves an ungoverned grantee alone — a migrator, ETL or BI role the model
|
|
107
|
+
* vocabulary cannot name must not have its access destroyed by a plan nobody read. The hash
|
|
108
|
+
* has to make the same choice or it contradicts the plan: a live grant nothing will ever
|
|
109
|
+
* reconcile is not part of the state the models describe.
|
|
110
|
+
*
|
|
111
|
+
* `PUBLIC` compares case-insensitively (a pseudo-role with two catalog spellings); every other
|
|
112
|
+
* name compares exactly, because PostgreSQL role names are case-sensitive and `CREATE ROLE
|
|
113
|
+
* "Public"` is a real, distinct role.
|
|
114
|
+
*/
|
|
115
|
+
function governedOnly<T>(
|
|
116
|
+
map: Record<string, T> | undefined,
|
|
117
|
+
governed: ReadonlySet<string> | undefined,
|
|
118
|
+
): Record<string, T> {
|
|
119
|
+
if (!map) return {};
|
|
120
|
+
if (!governed) return map;
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(map).filter(([grantee]) =>
|
|
123
|
+
governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The canonical authorization form for one table.
|
|
129
|
+
*
|
|
130
|
+
* `governed` is the set the models declare. Pass it whenever hashing a LIVE contract, or the
|
|
131
|
+
* hash counts grants the reconciler will never touch and the state can never converge.
|
|
132
|
+
*/
|
|
133
|
+
export function canonicalAuthz(
|
|
134
|
+
contract: TableContract,
|
|
135
|
+
governed?: ReadonlySet<string>,
|
|
136
|
+
): Record<string, unknown> {
|
|
137
|
+
const grants = governedOnly(contract.grants, governed);
|
|
138
|
+
const columnGrants = governedOnly(contract.columnGrants, governed);
|
|
139
|
+
return {
|
|
140
|
+
table: contract.table,
|
|
141
|
+
rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
|
|
142
|
+
grants: Object.fromEntries(
|
|
143
|
+
Object.entries(grants)
|
|
144
|
+
.map(([role, privs]) => [role, [...(privs as string[])].sort()] as const)
|
|
145
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
146
|
+
),
|
|
147
|
+
...(Object.keys(columnGrants).length > 0
|
|
148
|
+
? {
|
|
149
|
+
columnGrants: Object.fromEntries(
|
|
150
|
+
Object.entries(columnGrants)
|
|
151
|
+
.map(([role, byPriv]) => [
|
|
152
|
+
role,
|
|
153
|
+
Object.fromEntries(
|
|
154
|
+
Object.entries(byPriv as Record<string, string[]>)
|
|
155
|
+
.map(([priv, cols]) => [priv, [...cols].sort()] as const)
|
|
156
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
157
|
+
),
|
|
158
|
+
] as const)
|
|
159
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
// A DEAD policy is not part of the state. It authorizes nothing (Postgres refuses at the
|
|
164
|
+
// GRANT before consulting it), and the reconciler now leaves it alone — so counting it
|
|
165
|
+
// here would keep MATCH unreachable on exactly the databases that have them: brownfield
|
|
166
|
+
// ones, where a previous stack left policies behind after its grants were revoked. Both
|
|
167
|
+
// surfaces make the same choice, from the same function. Deadness is recomputed against
|
|
168
|
+
// these grants every time, so the policy re-enters the state the moment a grant revives it.
|
|
169
|
+
// …and neither is a SUBSUMED one: a permissive policy whose rule an identical PUBLIC
|
|
170
|
+
// policy already applies to every role admits no session the other does not. Both
|
|
171
|
+
// exclusions are the same idea — a policy that changes no access is not part of the
|
|
172
|
+
// state — and both are made in lockstep with the reconciler, from the same functions.
|
|
173
|
+
policies: canonicalPolicies(
|
|
174
|
+
contract.policies.filter((p) => !isPolicyDead(contract, p) && !isPolicySubsumed(contract, p)),
|
|
175
|
+
governed,
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import type
|
|
23
|
+
import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
25
|
import { parenthesizeOnce } from './authz-contract.js';
|
|
26
26
|
|
|
@@ -198,32 +198,50 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
201
|
+
* `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
|
|
202
|
+
* that describe it spell it differently — both correctly:
|
|
203
|
+
*
|
|
204
|
+
* - `pg_policies.roles` renders it as the literal `{public}` (lowercase)
|
|
205
|
+
* - `aclexplode()` yields grantee OID 0, which GRANTS_SQL renders as `'PUBLIC'`
|
|
206
|
+
*
|
|
207
|
+
* So the compiler speaks each catalog's spelling on its own axis. Saying `public` on both
|
|
208
|
+
* made the grant set-difference see `public` and `PUBLIC` as two grantees and emit a REVOKE
|
|
209
|
+
* and a GRANT that cancel out — on every run, converging never.
|
|
210
|
+
*
|
|
211
|
+
* The fold is COMPILE-TIME ONLY, applied to the author's declared role. Nothing folds at
|
|
212
|
+
* comparison time: `CREATE ROLE "Public"` is a legal, distinct role, and a case-insensitive
|
|
213
|
+
* compare against a live grantee would silently conflate it with the pseudo-role. (The cost
|
|
214
|
+
* of that choice: a real role named `Public` cannot be named by `role:`, because a model
|
|
215
|
+
* string carries no way to say "quoted identifier".)
|
|
206
216
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
const isPseudoPublic = (role: string): boolean => role.toLowerCase() === 'public';
|
|
218
|
+
|
|
219
|
+
/** The grant-side spelling of a declared role — what `aclexplode` would report. */
|
|
220
|
+
const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' : role);
|
|
221
|
+
|
|
222
|
+
/** The policy-side spelling of a declared role — what `pg_policies` would report. */
|
|
223
|
+
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
210
224
|
|
|
211
225
|
/**
|
|
212
|
-
* Column grants from
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* ability declares columns — an introspected contract omits the key rather than carry an empty
|
|
216
|
-
* object, so the compiled one must too (or the round-trip diff would false-fire).
|
|
226
|
+
* Column grants from column abilities. The action determines the PostgreSQL verb; the named role
|
|
227
|
+
* (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
|
|
228
|
+
* deduplicated, matching the introspected contract.
|
|
217
229
|
*/
|
|
218
230
|
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
219
|
-
const out: Record<string, Record<string, string
|
|
231
|
+
const out: Record<string, Record<string, Set<string>>> = {};
|
|
220
232
|
for (const a of abilities) {
|
|
221
|
-
if (!
|
|
222
|
-
const role = a.condition.role ?? 'authenticated';
|
|
223
|
-
const
|
|
224
|
-
(out[role] ??= {})
|
|
233
|
+
if (!isColumnAbility(a)) continue;
|
|
234
|
+
const role = granteeKey(a.condition.role ?? 'authenticated');
|
|
235
|
+
const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
|
|
236
|
+
const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
|
|
237
|
+
for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
|
|
238
|
+
}
|
|
239
|
+
const grants: Record<string, Record<string, string[]>> = {};
|
|
240
|
+
for (const [role, verbs] of Object.entries(out)) {
|
|
241
|
+
grants[role] = {};
|
|
242
|
+
for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
|
|
225
243
|
}
|
|
226
|
-
return Object.keys(
|
|
244
|
+
return Object.keys(grants).length ? grants : undefined;
|
|
227
245
|
}
|
|
228
246
|
|
|
229
247
|
/**
|
|
@@ -310,13 +328,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
310
328
|
* every other role can see.
|
|
311
329
|
*/
|
|
312
330
|
const isRoleRead = (a: Ability): boolean =>
|
|
313
|
-
a.action === 'read' && Boolean(a.condition.role) && !
|
|
331
|
+
a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
|
|
314
332
|
|
|
315
333
|
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
316
334
|
// it is the admin bypass — so only the specific verbs are consulted.
|
|
317
335
|
const predFor = (action: Ability['action']): string | null => {
|
|
318
336
|
for (const a of model.abilities) {
|
|
319
|
-
if (a.action !== action || isRoleRead(a)) continue;
|
|
337
|
+
if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
|
|
320
338
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
321
339
|
if (p) return p;
|
|
322
340
|
}
|
|
@@ -342,7 +360,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
342
360
|
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
343
361
|
const ownerReadPred = (): string | null => {
|
|
344
362
|
for (const a of model.abilities) {
|
|
345
|
-
if (a.action !== 'read' || !rowScoped(a) ||
|
|
363
|
+
if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
|
|
346
364
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
347
365
|
if (p) return p;
|
|
348
366
|
}
|
|
@@ -362,12 +380,18 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
362
380
|
const abilities = model.abilities;
|
|
363
381
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
364
382
|
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
383
|
+
// The audience is open when the public read says so. Read off the SAME abilities that make
|
|
384
|
+
// `hasPublicRead` true, so the two can never disagree about which read is being compiled.
|
|
385
|
+
const publicAudience = abilities.some(
|
|
386
|
+
(a) => a.action === 'read' && !a.condition.role && !rowScoped(a) && a.condition.policyRoles === 'public',
|
|
387
|
+
);
|
|
365
388
|
// A column-scoped read is row-scoped but emits a self policy + column grant, not the
|
|
366
389
|
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
367
|
-
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !
|
|
368
|
-
const
|
|
390
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
|
|
391
|
+
const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
369
392
|
const canCreate = abilities.some((a) => a.action === 'create');
|
|
370
|
-
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
|
|
393
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
|
|
394
|
+
const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
371
395
|
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
372
396
|
|
|
373
397
|
const t = model.table;
|
|
@@ -387,7 +411,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
387
411
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
388
412
|
const readPred = publicReadPred();
|
|
389
413
|
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
390
|
-
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
391
414
|
|
|
392
415
|
let authedUsing: string;
|
|
393
416
|
if (hasOwnerRead && rowPred && anonUsing !== 'true') {
|
|
@@ -416,12 +439,34 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
416
439
|
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
417
440
|
: anonUsing;
|
|
418
441
|
}
|
|
419
|
-
policy
|
|
442
|
+
// ONE policy TO PUBLIC, when the model says the audience is open (B1).
|
|
443
|
+
//
|
|
444
|
+
// The two spellings are NOT equivalent and the difference is the whole point: PUBLIC is
|
|
445
|
+
// an open set, `{anon, authenticated}` is closed. A brownfield schema that wrote the
|
|
446
|
+
// ordinary Postgres idiom — one policy TO PUBLIC — had no way to say so, so every plan
|
|
447
|
+
// proposed dropping it for the role-scoped pair. That is a real narrowing: any role
|
|
448
|
+
// outside the pair loses the read.
|
|
449
|
+
//
|
|
450
|
+
// Emitted under the model's own name; identity-by-meaning adopts the live policy whose
|
|
451
|
+
// rule matches, whatever it is called, so a schema already carrying `<t>_read_public`
|
|
452
|
+
// reaches ZERO statements rather than a rename's DROP + CREATE.
|
|
453
|
+
//
|
|
454
|
+
// GRANTS ARE UNTOUCHED — they stay exactly as derived for anon/authenticated. An open
|
|
455
|
+
// audience over a closed grant set widens nothing: Postgres checks the GRANT first, so a
|
|
456
|
+
// role holding no SELECT still reads no rows no matter who the policy names.
|
|
457
|
+
if (publicAudience) {
|
|
458
|
+
policy(`${t}_select_public`, 'SELECT', ['public'], anonUsing, null);
|
|
459
|
+
} else {
|
|
460
|
+
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
461
|
+
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
462
|
+
}
|
|
420
463
|
} else if (hasOwnerRead && rowPred) {
|
|
421
464
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
422
465
|
// (directly, or transitively through a `via:` parent).
|
|
423
466
|
policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
|
|
424
|
-
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (hasColumnOwnerRead && rowPred) {
|
|
425
470
|
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
426
471
|
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
427
472
|
// `_select_self`, distinct from the full-row `_select_own`.
|
|
@@ -435,8 +480,11 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
435
480
|
// the model read as though it could see its own. The declared predicate was computed and
|
|
436
481
|
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
437
482
|
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
438
|
-
for (const a of abilities.filter(isRoleRead)
|
|
439
|
-
|
|
483
|
+
for (const a of abilities.filter((ability) => isRoleRead(ability) || (
|
|
484
|
+
isColumnAbility(ability) && ability.action === 'read' && Boolean(ability.condition.role)
|
|
485
|
+
))) {
|
|
486
|
+
// The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
|
|
487
|
+
const role = policyRole(a.condition.role!);
|
|
440
488
|
const name = `${t}_select_${role}`;
|
|
441
489
|
const where = `${table}: can('read', { role: '${role}' })`;
|
|
442
490
|
if (policies.some((p) => p.name === name)) {
|
|
@@ -445,7 +493,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
445
493
|
+ `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
494
|
);
|
|
447
495
|
}
|
|
448
|
-
policy(
|
|
496
|
+
policy(
|
|
497
|
+
name,
|
|
498
|
+
'SELECT',
|
|
499
|
+
[role],
|
|
500
|
+
andPredicates(
|
|
501
|
+
rowScoped(a) ? rowPred : null,
|
|
502
|
+
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
503
|
+
) ?? 'true',
|
|
504
|
+
null,
|
|
505
|
+
);
|
|
449
506
|
}
|
|
450
507
|
|
|
451
508
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
@@ -465,6 +522,27 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
465
522
|
if ((hasUpdateOwner || predFor('update')) && updatePred) {
|
|
466
523
|
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
|
|
467
524
|
}
|
|
525
|
+
else if (hasColumnUpdateOwner && updatePred) {
|
|
526
|
+
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, updatePred);
|
|
527
|
+
}
|
|
528
|
+
for (const a of abilities.filter((ability) =>
|
|
529
|
+
isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
|
|
530
|
+
)) {
|
|
531
|
+
const role = policyRole(a.condition.role!);
|
|
532
|
+
const name = `${t}_update_${role}`;
|
|
533
|
+
const where = `${table}: can('update', { role: '${role}' })`;
|
|
534
|
+
if (policies.some((p) => p.name === name)) {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`${where} collides with the ${name} policy already compiled from this model. `
|
|
537
|
+
+ `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const using = andPredicates(
|
|
541
|
+
rowScoped(a) ? rowPred : null,
|
|
542
|
+
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
543
|
+
) ?? 'true';
|
|
544
|
+
policy(name, 'UPDATE', [role], using, rawPredicate(a.condition.check, `${where}, { check }`) ?? using);
|
|
545
|
+
}
|
|
468
546
|
if ((hasDeleteOwner || predFor('delete')) && deletePred) {
|
|
469
547
|
policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
|
|
470
548
|
}
|
|
@@ -478,7 +556,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
478
556
|
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
479
557
|
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
480
558
|
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
481
|
-
grants: compileGrants(abilities),
|
|
559
|
+
grants: compileGrants(abilities, model.privileges),
|
|
482
560
|
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
483
561
|
policies,
|
|
484
562
|
};
|
|
@@ -517,27 +595,39 @@ function verbsFor(action: Ability['action']): string[] {
|
|
|
517
595
|
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
518
596
|
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
519
597
|
* so the output is byte-comparable with an introspected contract.
|
|
598
|
+
*
|
|
599
|
+
* `privileges` is the model's beyond-CRUD key — REFERENCES/TRIGGER/TRUNCATE, the table
|
|
600
|
+
* privileges `can()` has no verb for. They are UNIONED in, never subtracted: an admin that
|
|
601
|
+
* `can('manage')` and holds TRUNCATE keeps both. Without it, `manage` meant exactly CRUD and
|
|
602
|
+
* every live REFERENCES/TRIGGER/TRUNCATE read as drift, so the first plan against an existing
|
|
603
|
+
* schema revoked all three on every table — our spelling, not their schema.
|
|
520
604
|
*/
|
|
521
|
-
function compileGrants(
|
|
605
|
+
function compileGrants(
|
|
606
|
+
abilities: readonly Ability[],
|
|
607
|
+
privileges: Record<string, readonly string[]> = {},
|
|
608
|
+
): Record<string, string[]> {
|
|
522
609
|
const grants: Record<string, Set<string>> = {};
|
|
523
|
-
const add = (role: string, verbs: string[]): void => {
|
|
610
|
+
const add = (role: string, verbs: readonly string[]): void => {
|
|
524
611
|
const set = (grants[role] ??= new Set<string>());
|
|
525
612
|
for (const v of verbs) set.add(v);
|
|
526
613
|
};
|
|
527
614
|
for (const a of abilities) {
|
|
528
|
-
// A column
|
|
529
|
-
|
|
530
|
-
// whole-table grant that would defeat the column scoping.
|
|
531
|
-
if (isColumnRead(a)) continue;
|
|
615
|
+
// A column ability grants only its listed columns, never a table-level privilege.
|
|
616
|
+
if (isColumnAbility(a)) continue;
|
|
532
617
|
const verbs = verbsFor(a.action);
|
|
533
618
|
if (a.condition.role) {
|
|
534
|
-
add(a.condition.role, verbs);
|
|
619
|
+
add(granteeKey(a.condition.role), verbs);
|
|
535
620
|
} else {
|
|
536
621
|
add('authenticated', verbs);
|
|
537
622
|
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
538
623
|
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
539
624
|
}
|
|
540
625
|
}
|
|
626
|
+
// The role is spelled on the GRANT axis, so `privileges: { public: [...] }` reaches the
|
|
627
|
+
// same grantee `aclexplode` reports as `PUBLIC` — the fold the ability path already does.
|
|
628
|
+
for (const [role, privs] of Object.entries(privileges)) {
|
|
629
|
+
if (privs.length) add(granteeKey(role), privs);
|
|
630
|
+
}
|
|
541
631
|
const out: Record<string, string[]> = {};
|
|
542
632
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
543
633
|
return out;
|