@everystack/cli 0.4.45 → 0.4.47
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 +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +82 -20
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-backfill.ts +1 -1
- package/src/cli/commands/db-exec.ts +20 -1
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +11 -17
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +16 -18
- package/src/cli/commands/db-reconcile.ts +19 -21
- package/src/cli/commands/db-refresh.ts +33 -5
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/commands/db.ts +2 -1
- 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 +112 -16
- package/src/cli/exec-digest.ts +55 -13
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +3 -3
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- 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 +145 -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
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parsePgArray } from './security-catalog.js';
|
|
25
|
-
import {
|
|
25
|
+
import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
|
|
26
26
|
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -137,6 +137,83 @@ export function holdsPrivilege(t: TableContract, role: string, privilege: string
|
|
|
137
137
|
|| Boolean(cols.public?.[privilege]?.length);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** The four row-level commands a policy can govern. `FOR ALL` fans out across them. */
|
|
141
|
+
export const POLICY_COMMANDS = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
142
|
+
|
|
143
|
+
/** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
|
|
144
|
+
export function policyCommands(p: PolicyContract): string[] {
|
|
145
|
+
return p.command === 'ALL' ? [...POLICY_COMMANDS] : [p.command];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is this policy DEAD — does it authorize nothing at all?
|
|
150
|
+
*
|
|
151
|
+
* PostgreSQL checks the GRANT before the policy, so a policy governing a privilege none of
|
|
152
|
+
* its roles holds is inert: it cannot admit a single row today, and removing it takes away
|
|
153
|
+
* access that was never there. A PUBLIC policy applies to every grantee, so it is dead only
|
|
154
|
+
* when NO grantee holds the privilege.
|
|
155
|
+
*
|
|
156
|
+
* ONE definition, four callers — the classifier (what class is this statement?), the
|
|
157
|
+
* reconciler (should I drop it?), the canonical form (is it part of the state?), and db:pull
|
|
158
|
+
* (what do I tell the adopter?). They were drifting apart, which is this milestone's recurring
|
|
159
|
+
* bug: two surfaces answering the same question differently. Note the deadness is a property
|
|
160
|
+
* of the CONTRACT, not the policy — it is recomputed against live grants every time, so a
|
|
161
|
+
* policy RESURRECTS the moment a grant makes it effective.
|
|
162
|
+
*/
|
|
163
|
+
export function isPolicyDead(t: TableContract, p: PolicyContract): boolean {
|
|
164
|
+
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
|
|
165
|
+
? Object.keys(t.grants)
|
|
166
|
+
: p.roles;
|
|
167
|
+
if (!roles.length) return true;
|
|
168
|
+
// `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, so a
|
|
169
|
+
// policy governing it is doing live work. See the two-predicate note above.
|
|
170
|
+
return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Does this policy name PostgreSQL's PUBLIC pseudo-role — the open set of every role? */
|
|
174
|
+
export function isPublicPolicy(p: PolicyContract): boolean {
|
|
175
|
+
return p.roles.some((r) => r.toUpperCase() === 'PUBLIC');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A policy's RULE, with the roles and the NAME deliberately absent — what it authorizes, not
|
|
180
|
+
* whom it names or what it is called. The same normalization the canonical form hashes by, so
|
|
181
|
+
* "these two policies say the same thing" has one answer.
|
|
182
|
+
*/
|
|
183
|
+
export function policyRuleKey(p: PolicyContract): string {
|
|
184
|
+
return JSON.stringify([p.command, p.permissive, p.using ?? '', effectivePolicyCheck(p) ?? '']);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Is this policy SUBSUMED — does an identical rule already reach every role it names?
|
|
189
|
+
*
|
|
190
|
+
* PERMISSIVE policies OR together, and a policy TO PUBLIC applies to every role there is. So a
|
|
191
|
+
* role-scoped policy whose rule is character-for-character a PUBLIC policy's rule contributes
|
|
192
|
+
* nothing: every session it would admit is already admitted. Dropping it changes no access,
|
|
193
|
+
* which is why it is churn rather than a change — a brownfield database often carries both,
|
|
194
|
+
* one from the framework and one from a migration written years earlier.
|
|
195
|
+
*
|
|
196
|
+
* Two guards, and they are the whole safety of this:
|
|
197
|
+
*
|
|
198
|
+
* - **RESTRICTIVE policies are never subsumed.** They AND rather than OR, so a restrictive
|
|
199
|
+
* policy is the thing NARROWING access; removing it would widen. Only permissive policies
|
|
200
|
+
* can be redundant, and only a permissive PUBLIC policy can make them so.
|
|
201
|
+
* - **The rule must match exactly**, including the effective WITH CHECK. A PUBLIC policy with
|
|
202
|
+
* a laxer predicate does not subsume a stricter role-scoped one — under OR the lax rule
|
|
203
|
+
* already wins, but the two are not the same state, and hashing them equal would report
|
|
204
|
+
* MATCH while the reconciler still had work.
|
|
205
|
+
*/
|
|
206
|
+
export function isPolicySubsumed(t: TableContract, p: PolicyContract): boolean {
|
|
207
|
+
if (!p.permissive || isPublicPolicy(p)) return false;
|
|
208
|
+
const key = policyRuleKey(p);
|
|
209
|
+
// No self-exclusion by NAME: `p` is asked about against BOTH the live and the declared
|
|
210
|
+
// contract (subsumed before and after), and the compiler routinely picks the same policy
|
|
211
|
+
// name the brownfield database already uses — so a name guard here would silently exclude
|
|
212
|
+
// the very PUBLIC policy doing the covering. It is not needed: `p` is non-PUBLIC by the
|
|
213
|
+
// line above and every candidate is PUBLIC, so `p` can never match itself.
|
|
214
|
+
return t.policies.some((o) => o.permissive && isPublicPolicy(o) && policyRuleKey(o) === key);
|
|
215
|
+
}
|
|
216
|
+
|
|
140
217
|
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
141
218
|
export function isWrappedExpression(s: string): boolean {
|
|
142
219
|
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
@@ -516,27 +593,23 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
516
593
|
|
|
517
594
|
/** Run every introspection query through one runner and assemble the contract. */
|
|
518
595
|
export async function introspectContract(
|
|
519
|
-
|
|
596
|
+
session: SessionRunner,
|
|
520
597
|
mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
|
|
521
598
|
functionsSql: string,
|
|
522
599
|
): Promise<AuthzContract> {
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
]
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
grants: grants as GrantRow[],
|
|
537
|
-
columnGrants: columnGrants as ColumnGrantRow[],
|
|
538
|
-
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
539
|
-
});
|
|
600
|
+
// ONE session: the five queries describe one moment under one pinned search_path.
|
|
601
|
+
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
602
|
+
// across connections can report authz drift that does not exist.
|
|
603
|
+
const [rls, policies, grants, columnGrants, fnRows] = await session(
|
|
604
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, functionsSql],
|
|
605
|
+
INTROSPECTION_SESSION,
|
|
606
|
+
);
|
|
607
|
+
return assembleContract({
|
|
608
|
+
rls: rls as RlsRow[],
|
|
609
|
+
policies: policies as PolicyRow[],
|
|
610
|
+
grants: grants as GrantRow[],
|
|
611
|
+
columnGrants: columnGrants as ColumnGrantRow[],
|
|
612
|
+
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
540
613
|
});
|
|
541
614
|
}
|
|
542
615
|
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -240,6 +240,58 @@ function sqlFragment(pred: string | null): string | null {
|
|
|
240
240
|
return `sql\`${t.replace(/`/g, '\\`')}\``;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/** The policy catalog spells the PUBLIC pseudo-role lower-case; grants spell it upper-case. */
|
|
244
|
+
function policyRoleForGrantee(grantee: string): string {
|
|
245
|
+
return grantee === 'PUBLIC' ? 'public' : grantee;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** `db:pull` renders fields camel-cased; abilities must name those same model keys. */
|
|
249
|
+
function modelFieldKey(column: string): string {
|
|
250
|
+
return column.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One column grant is renderable only when exactly one declared-style policy gates it. */
|
|
254
|
+
function columnPolicyFor(contract: TableContract, grantee: string, command: string): PolicyContract | null {
|
|
255
|
+
const role = policyRoleForGrantee(grantee);
|
|
256
|
+
const matches = contract.policies.filter((p) =>
|
|
257
|
+
p.command === command && p.permissive && p.roles.length === 1 && p.roles[0] === role,
|
|
258
|
+
);
|
|
259
|
+
return matches.length === 1 ? matches[0] : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Render one precise column grant plus its exactly-paired policy as one ability. */
|
|
263
|
+
function renderColumnAbility(
|
|
264
|
+
action: 'read' | 'update',
|
|
265
|
+
grantee: string,
|
|
266
|
+
columns: readonly string[],
|
|
267
|
+
policy: PolicyContract,
|
|
268
|
+
): string | null {
|
|
269
|
+
const using = policy.using ?? null;
|
|
270
|
+
const owner = parseOwnerPredicate(using);
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
const role = policyRoleForGrantee(grantee);
|
|
273
|
+
|
|
274
|
+
// Owner-only uses the model's authenticated default. Every other grantee must be named so
|
|
275
|
+
// the compiler emits the policy TO the live role, rather than a role-less public ability.
|
|
276
|
+
if (!owner || role !== 'authenticated') parts.push(`role: '${role}'`);
|
|
277
|
+
if (owner) {
|
|
278
|
+
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
279
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
280
|
+
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
281
|
+
} else {
|
|
282
|
+
const fragment = sqlFragment(using);
|
|
283
|
+
if (fragment) parts.push(`sql: ${fragment}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (action === 'update' && policy.check && policy.check !== using) {
|
|
287
|
+
const fragment = sqlFragment(policy.check);
|
|
288
|
+
if (!fragment) return null;
|
|
289
|
+
parts.push(`check: ${fragment}`);
|
|
290
|
+
}
|
|
291
|
+
parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
|
|
292
|
+
return `can('${action}', { ${parts.join(', ')} })`;
|
|
293
|
+
}
|
|
294
|
+
|
|
243
295
|
/**
|
|
244
296
|
* Derive the ability stanza for ONE table from its live contract.
|
|
245
297
|
*
|
|
@@ -274,10 +326,8 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
274
326
|
if (!live.length || !p.roles.some((r) => r !== 'admin')) continue;
|
|
275
327
|
const viaColumns = live.filter((r) => holdsPrivilege(contract, r, priv));
|
|
276
328
|
if (viaColumns.length) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
);
|
|
280
|
-
notes.push(` NOT dead. Not rendered either: an ability would grant ${priv} on the whole table.`);
|
|
329
|
+
// The column-grant pass below carries this pair into a column ability, or names the
|
|
330
|
+
// exact missing pairing. Do not pre-judge it here as dead or unrenderable.
|
|
281
331
|
continue;
|
|
282
332
|
}
|
|
283
333
|
notes.push(
|
|
@@ -299,7 +349,6 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
299
349
|
const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
300
350
|
const anonPred = anonPol?.using ?? null;
|
|
301
351
|
const frag = sqlFragment(anonPred);
|
|
302
|
-
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
303
352
|
|
|
304
353
|
// The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
|
|
305
354
|
// one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
|
|
@@ -310,25 +359,94 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
310
359
|
? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
|
|
311
360
|
: undefined;
|
|
312
361
|
const authedPred = authedPol?.using ?? null;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
362
|
+
const diverges = !!authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true');
|
|
363
|
+
const own = diverges ? parsePublicOrOwn(authedPred, anonPred) : null;
|
|
364
|
+
|
|
365
|
+
// A role-less `can('read')` fans out to anon AND authenticated — one ability, two policies
|
|
366
|
+
// and two GRANTS. That is only honest when both roles genuinely hold a table-level SELECT
|
|
367
|
+
// and the same rule. Two separate ways it was not:
|
|
368
|
+
//
|
|
369
|
+
// - WIDENING: `authedRead` is a TABLE-level check, so a live authenticated SELECT held
|
|
370
|
+
// only through COLUMN grants reads as false. The fan-out then granted authenticated
|
|
371
|
+
// SELECT on the WHOLE table — the model meaning MORE than the database, which is the
|
|
372
|
+
// one thing this project never does. Now the read is scoped to anon alone.
|
|
373
|
+
// - NARROWING: when the authenticated rule diverges and cannot be decomposed, the
|
|
374
|
+
// fan-out pushed the ANON predicate onto authenticated, silently replacing a live
|
|
375
|
+
// owner-admitting read with a public-only one. Now each role carries its own rule.
|
|
376
|
+
// The fan-out is honest in exactly two cases, and both need authenticated to hold a real
|
|
377
|
+
// table-level SELECT:
|
|
378
|
+
// - the two roles carry the SAME rule, so one ability states it once; or
|
|
379
|
+
// - the rule DECOMPOSES, and the `owner:` ability below ADDS the owner branch on top of
|
|
380
|
+
// this public one. Scoping this read to anon there would strip authenticated of the
|
|
381
|
+
// public half — a narrowing introduced by the fix itself.
|
|
382
|
+
const fanOutIsHonest = authedRead && (!diverges || !!own);
|
|
383
|
+
|
|
384
|
+
// The live read is ONE policy TO PUBLIC — the ordinary Postgres idiom, and an OPEN
|
|
385
|
+
// audience. Rendering it as the role-scoped pair would mean strictly less than the
|
|
386
|
+
// database does: every role outside {anon, authenticated} loses the read. `policyRoles`
|
|
387
|
+
// says the audience without touching the grants, so this transcribes rather than narrows
|
|
388
|
+
// (B1). Only when PUBLIC is the whole audience — a policy naming public ALONGSIDE a
|
|
389
|
+
// named role is a different shape and is left to the branches below.
|
|
390
|
+
const publicAudience = !!anonPol && anonPol.roles.length === 1 && anonPol.roles[0] === 'public';
|
|
391
|
+
|
|
392
|
+
if (publicAudience) {
|
|
393
|
+
abilities.push(frag ? `can('read', { policyRoles: 'public', sql: ${frag} })` : `can('read', { policyRoles: 'public' })`);
|
|
394
|
+
|
|
395
|
+
// An OPEN audience must say who it actually opens to, ON THIS TABLE.
|
|
396
|
+
//
|
|
397
|
+
// `policyRoles: 'public'` reads like "anyone", and the model vocabulary only names
|
|
398
|
+
// anon/authenticated/admin — so a reviewer has no way to know that a migrator or an ETL
|
|
399
|
+
// role also holds SELECT here and therefore reads rows through this very policy. The
|
|
400
|
+
// pull already names ungoverned grantees once, globally, on stdout; that is the wrong
|
|
401
|
+
// place and the wrong granularity for a decision recorded per table in a committed file.
|
|
402
|
+
// Adopting an open audience without seeing its real membership is exactly "inert must
|
|
403
|
+
// never mean blind".
|
|
404
|
+
const openTo = Object.keys(contract.grants)
|
|
405
|
+
.filter((r) => !GOVERNED_VOCABULARY.has(r) && holdsPrivilege(contract, r, 'SELECT'))
|
|
406
|
+
.sort();
|
|
407
|
+
if (openTo.length) {
|
|
325
408
|
notes.push(
|
|
326
|
-
`
|
|
409
|
+
`policyRoles: 'public' is an OPEN audience — it also admits ${openTo.join(', ')}, which hold`,
|
|
327
410
|
);
|
|
328
|
-
notes.push(`
|
|
329
|
-
notes.push(` ${authedPred}`);
|
|
330
|
-
notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
|
|
411
|
+
notes.push(` SELECT on this table. The grants are unchanged; this names who the policy reaches.`);
|
|
331
412
|
}
|
|
413
|
+
} else if (fanOutIsHonest) {
|
|
414
|
+
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
415
|
+
} else {
|
|
416
|
+
abilities.push(frag ? `can('read', { role: 'anon', sql: ${frag} })` : `can('read', { role: 'anon' })`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (diverges && own) {
|
|
420
|
+
const parts = [`owner: '${own.owner.column}'`];
|
|
421
|
+
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
422
|
+
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
423
|
+
const guard = sqlFragment(own.ownerSql);
|
|
424
|
+
if (guard) parts.push(`sql: ${guard}`);
|
|
425
|
+
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
426
|
+
} else if (diverges && authedRead) {
|
|
427
|
+
// TRANSCRIBE, never synthesize. The structured parse failed, so the live predicate is
|
|
428
|
+
// carried VERBATIM and scoped to exactly the role that holds it — meaning preserved
|
|
429
|
+
// bit for bit, at the cost of a model that expresses little. The note below is
|
|
430
|
+
// DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
|
|
431
|
+
// reached zero through escape hatches can be counted apart from one that reached zero
|
|
432
|
+
// through vocabulary. Fidelity achieved is not expressiveness achieved.
|
|
433
|
+
abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred!.replace(/`/g, '\\`')}\` })`);
|
|
434
|
+
notes.push(
|
|
435
|
+
`verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
|
|
436
|
+
);
|
|
437
|
+
notes.push(` Its shape does not decompose, so it is transcribed VERBATIM above, scoped to`);
|
|
438
|
+
notes.push(` authenticated. The model does not interpret it. Restructure by hand into`);
|
|
439
|
+
notes.push(` owner:/via: when you can — until then this table is adopted, not expressed.`);
|
|
440
|
+
} else if (diverges) {
|
|
441
|
+
// Diverges, and authenticated does NOT hold a table-level SELECT — a column-scoped grant,
|
|
442
|
+
// or none. Rendering the read either way would widen it to the whole table, so we refuse
|
|
443
|
+
// and say so. The residual statements in db:plan ARE the visibility here.
|
|
444
|
+
notes.push(
|
|
445
|
+
`policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read,`,
|
|
446
|
+
);
|
|
447
|
+
notes.push(` but authenticated holds no TABLE-level SELECT — rendering it would widen a`);
|
|
448
|
+
notes.push(` column-scoped grant to the whole table. NOT rendered. Live USING:`);
|
|
449
|
+
notes.push(` ${authedPred}`);
|
|
332
450
|
}
|
|
333
451
|
} else if (authedRead) {
|
|
334
452
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
@@ -380,30 +498,37 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
380
498
|
abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
|
|
381
499
|
}
|
|
382
500
|
|
|
383
|
-
// ---
|
|
501
|
+
// --- column grants ---------------------------------------------------------------------
|
|
384
502
|
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
// the model the adopter reviews.
|
|
390
|
-
//
|
|
391
|
-
// Named exactly: grantee, privilege, and the columns. A count is not auditable, and "some
|
|
392
|
-
// column grants were not captured" is the shape of silence this note exists to break.
|
|
503
|
+
// A column grant is live access, not a lesser table grant. Render SELECT and UPDATE when
|
|
504
|
+
// exactly one permissive policy TO the same role proves the row gate. Anything else stays a
|
|
505
|
+
// note: guessing the pairing could grant the right columns under the wrong row policy.
|
|
506
|
+
const renderedColumnRoles = new Set<string>();
|
|
393
507
|
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
394
|
-
if (!GOVERNED_VOCABULARY.has(grantee)) continue; // ungoverned: left alone, never revoked
|
|
395
508
|
const byPriv = contract.columnGrants![grantee];
|
|
396
509
|
for (const priv of Object.keys(byPriv).sort()) {
|
|
397
510
|
const cols = byPriv[priv] ?? [];
|
|
398
511
|
if (!cols.length) continue;
|
|
512
|
+
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
513
|
+
const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
514
|
+
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
|
|
515
|
+
if (ability) {
|
|
516
|
+
abilities.push(ability);
|
|
517
|
+
renderedColumnRoles.add(grantee);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
399
520
|
notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
|
|
400
|
-
notes.push(
|
|
521
|
+
notes.push(
|
|
522
|
+
action
|
|
523
|
+
? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
|
|
524
|
+
: ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
|
|
525
|
+
);
|
|
401
526
|
}
|
|
402
527
|
}
|
|
403
528
|
|
|
404
529
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
405
530
|
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
406
|
-
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
|
|
531
|
+
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
|
|
407
532
|
);
|
|
408
533
|
|
|
409
534
|
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
@@ -16,7 +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
|
+
import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
|
|
20
20
|
import { matchPolicies } from './authz-identity.js';
|
|
21
21
|
import { quoteQualified } from './pg-ident.js';
|
|
22
22
|
|
|
@@ -169,7 +169,7 @@ export function emitReconcileSql(
|
|
|
169
169
|
// the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
|
|
170
170
|
const table = quoteQualified(d.table);
|
|
171
171
|
reconcileRls(table, d, l, sql);
|
|
172
|
-
reconcilePolicies(table, d, l, sql);
|
|
172
|
+
reconcilePolicies(table, d, l, sql, governed);
|
|
173
173
|
reconcileGrants(table, d, l, sql, governed);
|
|
174
174
|
reconcileColumnGrants(table, d, l, sql, governed);
|
|
175
175
|
}
|
|
@@ -230,11 +230,53 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
|
|
|
230
230
|
* The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
|
|
231
231
|
* no-op — never emit DDL the name-keyed version would not have emitted.
|
|
232
232
|
*/
|
|
233
|
-
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
234
|
-
const
|
|
233
|
+
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
|
234
|
+
const live = l?.policies ?? [];
|
|
235
|
+
const m = matchPolicies(d.policies, live);
|
|
236
|
+
const byName = new Map(live.map((p) => [p.name, p]));
|
|
237
|
+
|
|
235
238
|
// Drops first, so a replaced policy never briefly co-exists with its old form.
|
|
236
|
-
for (const name of m.toDrop)
|
|
237
|
-
|
|
239
|
+
for (const name of m.toDrop) {
|
|
240
|
+
// A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
|
|
241
|
+
// as reconcileGrants leaves that role's privileges alone. Without this the two lanes
|
|
242
|
+
// disagree about the same role: it keeps its GRANT and loses its POLICY, and on an
|
|
243
|
+
// RLS-enabled table that combination reads zero rows. A bounded read-only role added for
|
|
244
|
+
// one indexable query is the shape this bites — the model has no word for it, so "no
|
|
245
|
+
// declaration" and "no access" collapsed into the same thing. Same failure as the
|
|
246
|
+
// ungoverned-grantee bug, one lane over.
|
|
247
|
+
//
|
|
248
|
+
// Only when EVERY role is ungoverned. A policy naming anon alongside such a role still
|
|
249
|
+
// governs anon, and leaving it would be the reconciler declining to do its job.
|
|
250
|
+
const p = byName.get(name);
|
|
251
|
+
if (p && p.roles.length > 0 && p.roles.every((r) => !isGoverned(governed, r))) continue;
|
|
252
|
+
// A DEAD policy authorizes nothing — no role it names holds the privilege it polices, so
|
|
253
|
+
// Postgres refuses at the GRANT before ever consulting it. Dropping it changes no access,
|
|
254
|
+
// which makes it churn: a statement an adopter must read, approve and apply to reach zero,
|
|
255
|
+
// whose only effect is to tidy a catalog. Leave it, exactly as the canonical form leaves
|
|
256
|
+
// it out of the state.
|
|
257
|
+
//
|
|
258
|
+
// DEAD BEFORE **AND AFTER**, and the second half is the whole safety of this. Deadness is
|
|
259
|
+
// a property of the contract, not the policy: if THIS plan grants the privilege the policy
|
|
260
|
+
// polices, the policy wakes up the moment the plan lands — undeclared, ungoverned, and
|
|
261
|
+
// granting access the model never asked for. Leaving it would be a widening the plan
|
|
262
|
+
// performs on itself. So it may only be left alone when the DECLARED state keeps it just
|
|
263
|
+
// as inert as the live one does.
|
|
264
|
+
if (p && l && isPolicyDead(l, p) && isPolicyDead(d, p)) continue;
|
|
265
|
+
// Subsumed, before AND after, for exactly the reason deadness needs both: if this plan
|
|
266
|
+
// drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
|
|
267
|
+
// the moment the plan lands — and leaving it would keep access the model never declared.
|
|
268
|
+
if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
|
|
269
|
+
out.push(policyDropSql(table, name));
|
|
270
|
+
}
|
|
271
|
+
// Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
|
|
272
|
+
// the declared grants do not back is inert — it would authorize nothing the moment it
|
|
273
|
+
// landed. The canonical form already leaves it out of the declared state, so creating it
|
|
274
|
+
// would emit a statement for a difference the hash says does not exist. It appears the
|
|
275
|
+
// moment the model grants the privilege it polices.
|
|
276
|
+
for (const p of m.toCreate) {
|
|
277
|
+
if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
|
|
278
|
+
out.push(policyCreateSql(table, p));
|
|
279
|
+
}
|
|
238
280
|
}
|
|
239
281
|
|
|
240
282
|
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
package/src/cli/aws.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
|
|
9
9
|
import { tagInvokeTransport } from './ops-advice.js';
|
|
10
|
+
import type { SessionRunner } from './session.js';
|
|
10
11
|
|
|
11
12
|
let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
|
|
12
13
|
let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
|
|
@@ -302,6 +303,37 @@ export function lambdaQueryRunner(
|
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
/**
|
|
307
|
+
* A `SessionRunner` backed by the ops Lambda's `db:session` action — the stage lane's
|
|
308
|
+
* only consistent read.
|
|
309
|
+
*
|
|
310
|
+
* `lambdaQueryRunner` above sends one invoke per statement, so a multi-statement read is
|
|
311
|
+
* answered by however many containers happen to be warm, each on its own connection.
|
|
312
|
+
* This sends the statements TOGETHER: one invoke, one container, one connection, one
|
|
313
|
+
* transaction. The response arrives gzipped (catalog JSON compresses 10-20x, which is
|
|
314
|
+
* what keeps a real schema inside the 6 MB invoke-response limit).
|
|
315
|
+
*/
|
|
316
|
+
export function lambdaSessionRunner(
|
|
317
|
+
region: string,
|
|
318
|
+
functionName: string,
|
|
319
|
+
invoke: typeof invokeAction = invokeAction,
|
|
320
|
+
): SessionRunner {
|
|
321
|
+
return async (statements, opts) => {
|
|
322
|
+
const result: any = await invoke(region, functionName, 'db:session', {
|
|
323
|
+
statements,
|
|
324
|
+
...(opts ?? {}),
|
|
325
|
+
});
|
|
326
|
+
if (result?.error) throw new Error(`Session failed: ${result.error}`);
|
|
327
|
+
if (typeof result?.gzip !== 'string') {
|
|
328
|
+
throw new Error(
|
|
329
|
+
'the ops Lambda did not answer db:session. Deploy a server build that ships the db:session action — the stage lane cannot read consistently without it.',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const { gunzipSync } = await import('node:zlib');
|
|
333
|
+
return JSON.parse(gunzipSync(Buffer.from(result.gzip, 'base64')).toString('utf8'));
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
305
337
|
export async function invokeAction(
|
|
306
338
|
region: string,
|
|
307
339
|
functionName: string,
|