@everystack/cli 0.4.79 → 0.4.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +40 -17
- package/src/cli/authz-compile.ts +184 -51
- package/src/cli/authz-contract.ts +68 -2
- package/src/cli/authz-derive.ts +87 -18
- package/src/cli/authz-reconcile.ts +68 -2
- package/src/cli/commands/db-check.ts +23 -2
- package/src/cli/commands/db-plan.ts +17 -11
- package/src/cli/edge-plan.ts +219 -28
- package/src/cli/migration-generate.ts +37 -3
- package/src/cli/schema-diff.ts +14 -4
- package/src/cli/schema-fingerprint.ts +12 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.81] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- every declared write compiles a policy; check narrows, never replaces
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- db:pull recovers owner: from the normalized predicate and round-trips
|
|
16
|
+
|
|
17
|
+
## [0.4.80] - 2026-09-07
|
|
18
|
+
|
|
19
|
+
### BREAKING CHANGES
|
|
20
|
+
|
|
21
|
+
- apply-contract v3 — the default-privilege lock is a server-run gate; redeploy the stage before the first staged db:apply after upgrading
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- empty-edge summary names the ungoverned grantees, not 35 tables
|
|
26
|
+
- carryUngovernedAuthz — the full-prediction merge helper (N3 step 1)
|
|
27
|
+
- predictLiveState carryUngoverned — the full endpoint flavor (N3 step 2)
|
|
28
|
+
- the plan `to` is the full endpoint, verified and disclosed (N3 steps 3-5)
|
|
29
|
+
- a plan-created serial sequence is granted in the same plan
|
|
30
|
+
- the plan locks the default-privilege posture it predicted from
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- the full endpoint carries default privileges onto plan-created objects
|
|
35
|
+
- bump apply-contract to v2 for the full endpoint; sharpen the empty-edge summary
|
|
36
|
+
- db:check fails on a named artifact path with nothing at it
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
- the plan `to` flavor; test: condition (a) drop end-to-end (N3 step 6)
|
|
41
|
+
- every db verb has a reference entry, and a test says so
|
|
42
|
+
- the disclosed gap names what actually compiles, and a test pins it
|
|
43
|
+
- the last two rollback-point sentences say what the code does; the retargeting edge is named
|
|
44
|
+
|
|
7
45
|
## [0.4.79] - 2026-09-07
|
|
8
46
|
|
|
9
47
|
### Removed
|
package/package.json
CHANGED
package/src/cli/apply-execute.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
COLUMN_GRANTS_SQL,
|
|
39
39
|
EFFECTIVE_PRIVILEGES_SQL,
|
|
40
40
|
SCHEMA_ACL_SQL,
|
|
41
|
-
SEQUENCE_ACLS_SQL,
|
|
41
|
+
SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL,
|
|
42
42
|
type QueryRunner,
|
|
43
43
|
type AuthzContract,
|
|
44
44
|
} from './authz-contract';
|
|
@@ -56,10 +56,9 @@ import {
|
|
|
56
56
|
} from './schema-introspect';
|
|
57
57
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
58
58
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
59
|
-
import { governedLiveFingerprint } from './edge-plan';
|
|
60
59
|
import { applyGeneratedStatements } from './state-apply';
|
|
61
60
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
62
|
-
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan';
|
|
61
|
+
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
63
62
|
import type { DescentLedgerRow } from './git-descent';
|
|
64
63
|
|
|
65
64
|
/**
|
|
@@ -255,6 +254,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
255
254
|
[
|
|
256
255
|
COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
|
|
257
256
|
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
257
|
+
// The default-privilege posture the plan's `defaultAclHash` is checked against — read in
|
|
258
|
+
// the SAME session as the lock's state, so the two gates describe one instant.
|
|
259
|
+
DEFAULT_ACL_SQL,
|
|
258
260
|
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
259
261
|
],
|
|
260
262
|
INTROSPECTION_SESSION,
|
|
@@ -270,8 +272,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
270
272
|
columnGrants: results[9] as any[], effectivePrivileges: results[10] as any[],
|
|
271
273
|
schemaAcls: results[11] as any[], sequenceAcls: results[12] as any[],
|
|
272
274
|
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
275
|
+
defaultAcls: results[14] as any[],
|
|
273
276
|
});
|
|
274
|
-
const ledger = mapLedgerRows(rowsOrEmpty(results[
|
|
277
|
+
const ledger = mapLedgerRows(rowsOrEmpty(results[15]));
|
|
275
278
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
276
279
|
}
|
|
277
280
|
|
|
@@ -284,15 +287,17 @@ export async function executeApplyPlan(
|
|
|
284
287
|
): Promise<ApplyPlanResult> {
|
|
285
288
|
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
286
289
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// the
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
291
|
+
// the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
|
|
292
|
+
// the full endpoint (N3, milestone plan-endpoint-full): its `to` is a governed hash, and
|
|
293
|
+
// verifying the full live against it would false-fail AFTER the DDL landed. Refuse and re-mint.
|
|
294
|
+
if (plan.toFlavor !== 'full') {
|
|
295
|
+
const reason = 'this plan predates the full endpoint — its `to` is a governed fingerprint. Re-mint with db:plan.';
|
|
296
|
+
await recordRefusal(runner, plan, live, 'plan flavor', reason, options);
|
|
297
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
298
|
+
}
|
|
294
299
|
|
|
295
|
-
if (
|
|
300
|
+
if (live === plan.toFingerprint) {
|
|
296
301
|
return { status: 'already-applied', liveFingerprint: live };
|
|
297
302
|
}
|
|
298
303
|
const pre = checkPlanPrecondition(plan, live);
|
|
@@ -301,6 +306,24 @@ export async function executeApplyPlan(
|
|
|
301
306
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// The lock covers object state. The default-privilege posture is not object state — `from`
|
|
310
|
+
// never hashed it — yet the FULL prediction consumed it for every object this plan creates.
|
|
311
|
+
// Compare it BEFORE any write: an ALTER DEFAULT PRIVILEGES since minting used to pass the
|
|
312
|
+
// lock, land the DDL, and fail verify-after post-DDL. A plan without the field predates the
|
|
313
|
+
// gate and cannot be checked; plans are minutes old, so refusing costs one re-mint.
|
|
314
|
+
if (plan.defaultAclHash === undefined) {
|
|
315
|
+
const reason = 'this plan predates the default privileges lock — it carries no defaultAclHash. Re-mint with db:plan.';
|
|
316
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
317
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
318
|
+
}
|
|
319
|
+
if (defaultAclHash(beforeAuthz) !== plan.defaultAclHash) {
|
|
320
|
+
const reason =
|
|
321
|
+
'the default-privilege posture (ALTER DEFAULT PRIVILEGES) moved since minting — the plan predicted its endpoint ' +
|
|
322
|
+
'from the old one, so the objects it creates would land with grants it did not carry. Re-mint with db:plan.';
|
|
323
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
324
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
if (options.checkDescent) {
|
|
305
328
|
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
306
329
|
if (!descent.ok) {
|
|
@@ -347,19 +370,19 @@ export async function executeApplyPlan(
|
|
|
347
370
|
...(options.descentNote ? { descentNote: options.descentNote } : {}),
|
|
348
371
|
});
|
|
349
372
|
|
|
350
|
-
const { hash: landed
|
|
373
|
+
const { hash: landed } = await liveBaseFingerprint(session);
|
|
351
374
|
if (result.logId !== undefined) {
|
|
352
|
-
// The log stamps the RAW landed hash — the same flavor as `from`, so the
|
|
375
|
+
// The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
|
|
353
376
|
// schema_log chain reads as one consistent series of live states.
|
|
354
377
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
355
378
|
}
|
|
356
379
|
|
|
357
|
-
|
|
358
|
-
if (
|
|
380
|
+
// The full `to` equals the post-apply raw live hash, so verify-after compares them directly.
|
|
381
|
+
if (landed !== plan.toFingerprint) {
|
|
359
382
|
return {
|
|
360
383
|
status: 'verify-failed',
|
|
361
384
|
liveFingerprint: landed,
|
|
362
|
-
reason: `applied, but the target landed on ${
|
|
385
|
+
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
363
386
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
364
387
|
};
|
|
365
388
|
}
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -322,6 +322,22 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
322
322
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
323
323
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
324
324
|
|
|
325
|
+
// The `authenticated` role — named `role: 'authenticated'` OR the role-less default. On a
|
|
326
|
+
// row-scoped model this role is ALWAYS row-scoped; every other role gets its own policy.
|
|
327
|
+
const isAuthClass = (a: Ability): boolean => !a.condition.role || a.condition.role === 'authenticated';
|
|
328
|
+
|
|
329
|
+
// The model's row scope as a bare condition — owner/via keys ONLY, with the READ's guards
|
|
330
|
+
// stripped. It is what an authenticated/role-less `manage` desugars to: the four verbs are
|
|
331
|
+
// scoped to the model's owner, never to whatever a `can('read', { owner, sql })` narrowed the
|
|
332
|
+
// read with. Non-null exactly when `rowPred` is.
|
|
333
|
+
const ownerConditionAbility = model.abilities.find((a) => a.condition.owner);
|
|
334
|
+
const viaConditionAbility = model.abilities.find((a) => a.condition.via);
|
|
335
|
+
const rowScopeCondition: Ability['condition'] | null = owner && ownerConditionAbility
|
|
336
|
+
? { owner: ownerConditionAbility.condition.owner, userField: ownerConditionAbility.condition.userField, ownerExpr: ownerConditionAbility.condition.ownerExpr }
|
|
337
|
+
: via && viaConditionAbility
|
|
338
|
+
? { via: viaConditionAbility.condition.via }
|
|
339
|
+
: null;
|
|
340
|
+
|
|
325
341
|
/**
|
|
326
342
|
* A read scoped to a named role — `can('read', { role })`. It compiles to its own
|
|
327
343
|
* `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
|
|
@@ -331,19 +347,29 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
331
347
|
const isRoleRead = (a: Ability): boolean =>
|
|
332
348
|
a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
|
|
333
349
|
|
|
334
|
-
// `manage`
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
350
|
+
// `manage` desugars into four verb abilities for policy emission. Two cases become
|
|
351
|
+
// manage-OWN (four row-scoped verbs):
|
|
352
|
+
// - `manage` carrying its OWN owner/via (`can('manage', { owner })`); or
|
|
353
|
+
// - authenticated/role-less `manage` on a row-scoped MODEL — the role is dropped and the
|
|
354
|
+
// model's owner/via added, because the authenticated role on a row-scoped model is
|
|
355
|
+
// always row-scoped (the W1 ruling). The literal reading (an ALL policy USING true for
|
|
356
|
+
// authenticated beside `_select_own`) would be the round-1 widening: permissive policies
|
|
357
|
+
// UNION, so an unconditional policy beside an owner-checked one opens the whole table.
|
|
358
|
+
// Everything else — another role's `manage` (the admin bypass, an editor's ALL), or an
|
|
359
|
+
// authenticated `manage` with no row scope — passes through to the ALL-policy path below.
|
|
360
|
+
// Grants handle `manage` natively via `verbsFor`, so this expansion is policy-compiler-local:
|
|
361
|
+
// `compileGrants` still reads `model.abilities`.
|
|
362
|
+
const desugarManage = (c: Ability['condition']): Ability[] => [
|
|
363
|
+
{ action: 'read', condition: c },
|
|
364
|
+
{ action: 'create', condition: c },
|
|
365
|
+
{ action: 'update', condition: c },
|
|
366
|
+
{ action: 'delete', condition: c },
|
|
367
|
+
];
|
|
338
368
|
const abilities: readonly Ability[] = model.abilities.flatMap((a) => {
|
|
339
|
-
if (a.action !== 'manage'
|
|
340
|
-
|
|
341
|
-
return
|
|
342
|
-
|
|
343
|
-
{ action: 'create' as const, condition: c },
|
|
344
|
-
{ action: 'update' as const, condition: c },
|
|
345
|
-
{ action: 'delete' as const, condition: c },
|
|
346
|
-
];
|
|
369
|
+
if (a.action !== 'manage') return [a];
|
|
370
|
+
if (rowScoped(a)) return desugarManage(a.condition);
|
|
371
|
+
if (isAuthClass(a) && rowScopeCondition) return desugarManage(rowScopeCondition);
|
|
372
|
+
return [a];
|
|
347
373
|
});
|
|
348
374
|
|
|
349
375
|
const predFor = (action: Ability['action']): string | null => {
|
|
@@ -370,15 +396,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
370
396
|
}
|
|
371
397
|
return null;
|
|
372
398
|
};
|
|
373
|
-
const checkFor = (action: Ability['action']): string | null => {
|
|
374
|
-
for (const a of abilities) {
|
|
375
|
-
if (a.action !== action) continue;
|
|
376
|
-
const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
|
|
377
|
-
if (p) return p;
|
|
378
|
-
}
|
|
379
|
-
return null;
|
|
380
|
-
};
|
|
381
|
-
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
382
399
|
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
383
400
|
// The audience is open when the public read says so. Read off the SAME abilities that make
|
|
384
401
|
// `hasPublicRead` true, so the two can never disagree about which read is being compiled.
|
|
@@ -389,10 +406,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
389
406
|
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
390
407
|
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
|
|
391
408
|
const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
392
|
-
const canCreate = abilities.some((a) => a.action === 'create');
|
|
393
|
-
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
|
|
394
409
|
const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
395
|
-
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
396
410
|
|
|
397
411
|
const t = model.table;
|
|
398
412
|
const policies: PolicyContract[] = [];
|
|
@@ -410,8 +424,15 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
410
424
|
});
|
|
411
425
|
};
|
|
412
426
|
|
|
413
|
-
//
|
|
414
|
-
|
|
427
|
+
// The ALL-policy `manage` roles — every `manage` ability that did NOT desugar to manage-own:
|
|
428
|
+
// another role's bypass (admin, editor, moderator) and an authenticated `manage` with no row
|
|
429
|
+
// scope. Each is one ALL policy `<t>_<role>` USING true WITH CHECK true. `admin` is simply the
|
|
430
|
+
// first instance of this rule, not a special case; the fold to `authenticated` covers the
|
|
431
|
+
// role-less no-scope `can('manage')`. Deduplicated by policy name.
|
|
432
|
+
const manageAllRoles = [...new Set(
|
|
433
|
+
abilities.filter((a) => a.action === 'manage').map((a) => policyRole(a.condition.role ?? 'authenticated')),
|
|
434
|
+
)];
|
|
435
|
+
for (const role of manageAllRoles) policy(`${t}_${role}`, 'ALL', [role], 'true', 'true');
|
|
415
436
|
|
|
416
437
|
if (hasPublicRead) {
|
|
417
438
|
// public read on a soft-delete table — split per role; authenticated owners
|
|
@@ -512,26 +533,136 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
512
533
|
);
|
|
513
534
|
}
|
|
514
535
|
|
|
515
|
-
//
|
|
516
|
-
//
|
|
536
|
+
// Writes. Every declared write compiles a policy — a grant with no policy behind it is dead
|
|
537
|
+
// under RLS, which is the whole bug this milestone closes. The rule per verb:
|
|
517
538
|
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
539
|
+
// - authenticated (named or role-less), on a ROW-SCOPED model → `_<verb>_own`.
|
|
540
|
+
// USING = rowScope AND where; WITH CHECK = rowScope AND (check ?? where). The row-scope
|
|
541
|
+
// term is NEVER removable: a `where`/`check` narrows WITHIN own rows. INSERT has no USING;
|
|
542
|
+
// DELETE has no WITH CHECK.
|
|
543
|
+
// - authenticated, on a model with NO row scope → `_<verb>_authenticated`.
|
|
544
|
+
// USING = where ?? true; WITH CHECK = check ?? where ?? true.
|
|
545
|
+
// - any OTHER named role → `_<verb>_<role>`, predicate ?? true. NEVER folded into `_own`,
|
|
546
|
+
// so one role's `where` can never narrow (or, unscoped, widen) another's rows.
|
|
547
|
+
//
|
|
548
|
+
// Permissive policies UNION, so at most ONE authenticated policy is emitted per verb — the
|
|
549
|
+
// where/check are taken from the first authenticated ability that declares one.
|
|
550
|
+
// A column-scoped write with no role cannot carry a WITH CHECK. Postgres' WITH CHECK is
|
|
551
|
+
// ROW-level, never column-level, so the predicate has nowhere honest to go: AND-ing it into
|
|
552
|
+
// the full-row `_own` policy over-applies it to every update (not just the scoped columns),
|
|
553
|
+
// and dropping it silently is an evaporating security rule — the exact hole this compiler
|
|
554
|
+
// throws over for a malformed `sql:`. So it throws here too, rather than swallow it. (A
|
|
555
|
+
// NAMED-role column write keeps its own policy and is unaffected; a row-level `check:` on a
|
|
556
|
+
// non-column ability is the supported spelling.)
|
|
557
|
+
for (const a of model.abilities) {
|
|
558
|
+
// A role name is emitted UNQUOTED into GRANT/CREATE POLICY, so Postgres folds or splits
|
|
559
|
+
// anything that is not a bare lowercase identifier: `AUTHENTICATED` folds onto
|
|
560
|
+
// `authenticated`, `anon, authenticated` becomes two grantees — and a policy meant for a
|
|
561
|
+
// distinct role silently lands on a real vocabulary role beside its owner-checked one, which
|
|
562
|
+
// permissive UNION turns into the whole table. Refuse it. (PUBLIC is folded deliberately by
|
|
563
|
+
// isPseudoPublic and is exempt.)
|
|
564
|
+
const role = a.condition.role;
|
|
565
|
+
if (role && !isPseudoPublic(role) && !/^[a-z_][a-z0-9_]*$/.test(role)) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`${table}: can('${a.action}', { role: '${role}' }) — a role name is emitted unquoted, so it must be a bare `
|
|
568
|
+
+ `lowercase identifier ([a-z_][a-z0-9_]*). '${role}' would be case-folded or split by Postgres and the policy `
|
|
569
|
+
+ `would land on a different role. Use the exact lowercase role name.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
// A role-less column ability cannot carry a ROW predicate (where/sql/check). Postgres USING
|
|
573
|
+
// and WITH CHECK are row-level, never column-level, so the predicate has nowhere honest to
|
|
574
|
+
// go: applied to the whole-row policy it over-applies to every column, dropped it evaporates
|
|
575
|
+
// — the exact hole this compiler throws over for a malformed `sql:`. (A NAMED-role column
|
|
576
|
+
// ability keeps its own policy and is unaffected; a row-level predicate on a non-column
|
|
577
|
+
// ability is the supported spelling.)
|
|
578
|
+
if (isColumnAbility(a) && !a.condition.role
|
|
579
|
+
&& (a.condition.check != null || a.condition.sql != null || a.condition.where != null)) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`${table}: can('${a.action}', { owner, columns, … }) — a column-scoped ability cannot carry a row predicate `
|
|
582
|
+
+ `(where/sql/check). Postgres USING/WITH CHECK are row-level, not column-level, so the predicate could only be `
|
|
583
|
+
+ `applied to the whole-row policy (over-applying it to every column) or dropped (an evaporating security rule). `
|
|
584
|
+
+ `Move the predicate to a row-level ability — can('${a.action}', { owner, where }) — or enforce it with a trigger.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
531
587
|
}
|
|
532
|
-
|
|
533
|
-
|
|
588
|
+
|
|
589
|
+
const WRITE_VERBS = [
|
|
590
|
+
{ action: 'create' as const, word: 'insert', command: 'INSERT' as PolicyCommand },
|
|
591
|
+
{ action: 'update' as const, word: 'update', command: 'UPDATE' as PolicyCommand },
|
|
592
|
+
{ action: 'delete' as const, word: 'delete', command: 'DELETE' as PolicyCommand },
|
|
593
|
+
];
|
|
594
|
+
const firstPred = (abs: readonly Ability[], half: 'where' | 'check'): string | null => {
|
|
595
|
+
for (const a of abs) {
|
|
596
|
+
const raw = half === 'check' ? a.condition.check : (a.condition.sql ?? a.condition.where);
|
|
597
|
+
const label = `${table}: can('${a.action}'${half === 'check' ? ', { check }' : ''})`;
|
|
598
|
+
const p = rawPredicate(raw, label);
|
|
599
|
+
if (p) return p;
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
};
|
|
603
|
+
for (const { action, word, command } of WRITE_VERBS) {
|
|
604
|
+
const verbAbilities = abilities.filter((a) => a.action === action && !isColumnAbility(a));
|
|
605
|
+
const authAbilities = verbAbilities.filter(isAuthClass);
|
|
606
|
+
|
|
607
|
+
if (authAbilities.length) {
|
|
608
|
+
const where = firstPred(authAbilities, 'where');
|
|
609
|
+
const check = firstPred(authAbilities, 'check');
|
|
610
|
+
if (rowPred) {
|
|
611
|
+
policy(
|
|
612
|
+
`${t}_${word}_own`, command, ['authenticated'],
|
|
613
|
+
command === 'INSERT' ? null : andPredicates(rowPred, where),
|
|
614
|
+
command === 'DELETE' ? null : andPredicates(rowPred, check ?? where),
|
|
615
|
+
);
|
|
616
|
+
} else {
|
|
617
|
+
// No row scope: USING = where ?? true, WITH CHECK = check ?? where ?? true. Route through
|
|
618
|
+
// andPredicates so a vacuous `where: sql`true`` collapses to plain `true` rather than
|
|
619
|
+
// leaking a `(true)` term that would churn the reconciler against a live `USING (true)`.
|
|
620
|
+
policy(
|
|
621
|
+
`${t}_${word}_authenticated`, command, ['authenticated'],
|
|
622
|
+
command === 'INSERT' ? null : (andPredicates(where) ?? 'true'),
|
|
623
|
+
command === 'DELETE' ? null : (andPredicates(check ?? where) ?? 'true'),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
} else if (action === 'update' && hasColumnUpdateOwner && rowPred) {
|
|
627
|
+
// A column-scoped owner update with no full-row authenticated update: the row gate is the
|
|
628
|
+
// owner (this policy), the GRANT scopes the columns. The full-row branch above would have
|
|
629
|
+
// covered it otherwise, so this only fires when there is no non-column owner update.
|
|
630
|
+
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Other named roles — each its own policy, never folded into `_own`.
|
|
634
|
+
const seenRoles = new Set<string>();
|
|
635
|
+
for (const a of verbAbilities) {
|
|
636
|
+
if (isAuthClass(a)) continue;
|
|
637
|
+
const role = policyRole(a.condition.role!);
|
|
638
|
+
if (seenRoles.has(role)) continue;
|
|
639
|
+
seenRoles.add(role);
|
|
640
|
+
const name = `${t}_${word}_${role}`;
|
|
641
|
+
const label = `${table}: can('${action}', { role: '${role}' })`;
|
|
642
|
+
if (policies.some((p) => p.name === name)) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
`${label} collides with the ${name} policy already compiled from this model. `
|
|
645
|
+
+ `Declare one ${action} ability for that role: permissive policies OR together, so separate rules could only widen.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const where = rawPredicate(a.condition.sql ?? a.condition.where, label);
|
|
649
|
+
const check = rawPredicate(a.condition.check, `${label}, { check }`);
|
|
650
|
+
// A role that ALSO declares owner:/via: (`can('update', { role: 'editor', owner })`) is
|
|
651
|
+
// scoped to its own rows exactly as an authenticated owner write is — the row scope AND-s
|
|
652
|
+
// in, and without it the role would get USING (true): the whole table, not its own rows.
|
|
653
|
+
// Same rule the role-read (`isRoleRead`) and column-role-update loops already apply.
|
|
654
|
+
const roleScope = rowScoped(a) ? rowPred : null;
|
|
655
|
+
policy(
|
|
656
|
+
name, command, [role],
|
|
657
|
+
command === 'INSERT' ? null : (andPredicates(roleScope, where) ?? 'true'),
|
|
658
|
+
command === 'DELETE' ? null : (andPredicates(roleScope, check ?? where) ?? 'true'),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
534
661
|
}
|
|
662
|
+
|
|
663
|
+
// Column-scoped role UPDATE — a `can('update', { role, columns })` grants those columns and
|
|
664
|
+
// needs its own policy TO that role. (The owner/column-owner cases are handled above; this is
|
|
665
|
+
// the named-role column path, kept distinct because `verbAbilities` excludes column abilities.)
|
|
535
666
|
for (const a of abilities.filter((ability) =>
|
|
536
667
|
isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
|
|
537
668
|
)) {
|
|
@@ -544,14 +675,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
544
675
|
+ `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
|
|
545
676
|
);
|
|
546
677
|
}
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
) ?? 'true';
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
678
|
+
const rowScope = rowScoped(a) ? rowPred : null;
|
|
679
|
+
const wherePred = rawPredicate(a.condition.sql ?? a.condition.where, where);
|
|
680
|
+
const checkPred = rawPredicate(a.condition.check, `${where}, { check }`);
|
|
681
|
+
const using = andPredicates(rowScope, wherePred) ?? 'true';
|
|
682
|
+
// WITH CHECK NARROWS, symmetric with USING: the row scope is ANDed in, never replaced, and
|
|
683
|
+
// the write guard defaults to the read guard. The old `rawPredicate(check) ?? using` emitted
|
|
684
|
+
// the check verbatim, so on a row-scoped column update a `check:` dropped the owner term —
|
|
685
|
+
// the one path the milestone's headline claim did not yet hold.
|
|
686
|
+
const check = andPredicates(rowScope, checkPred ?? wherePred) ?? using;
|
|
687
|
+
policy(name, 'UPDATE', [role], using, check);
|
|
555
688
|
}
|
|
556
689
|
|
|
557
690
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -357,6 +357,15 @@ export interface AuthzContract {
|
|
|
357
357
|
* it did not read.
|
|
358
358
|
*/
|
|
359
359
|
sequenceGrants?: SequenceGrantContract[];
|
|
360
|
+
/**
|
|
361
|
+
* Default privileges (`pg_default_acl`) that fire for objects the current role CREATES —
|
|
362
|
+
* `{ tables, sequences }`, each schema (`''` = all schemas) → grantee → sorted privileges. The
|
|
363
|
+
* FULL prediction folds the ungoverned slice onto plan-created objects: Postgres auto-grants
|
|
364
|
+
* them at creation, so a `to` endpoint without them false-verify-fails a successful CREATE
|
|
365
|
+
* apply on a brownfield BI stage. Absent means NOT MEASURED (older recorder, hand-built
|
|
366
|
+
* fixture) — the prediction then models no default ACL, as before.
|
|
367
|
+
*/
|
|
368
|
+
defaultAcls?: { tables: DefaultAclScope; sequences: DefaultAclScope };
|
|
360
369
|
}
|
|
361
370
|
|
|
362
371
|
/**
|
|
@@ -682,6 +691,59 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
|
|
|
682
691
|
return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
|
|
683
692
|
}
|
|
684
693
|
|
|
694
|
+
/**
|
|
695
|
+
* The default privileges (`ALTER DEFAULT PRIVILEGES`) that fire for TABLES and SEQUENCES the
|
|
696
|
+
* CURRENT role creates — so the prediction can model what a plan-CREATED object will carry.
|
|
697
|
+
*
|
|
698
|
+
* `pg_default_acl` is keyed by (defaclrole, defaclnamespace, defaclobjtype). An object created by
|
|
699
|
+
* role R in schema S receives the ACL for (R, S) AND for (R, all-schemas). We read the current
|
|
700
|
+
* role's rows only: everystack applies as one operator, so the creator IS the introspector. A
|
|
701
|
+
* NULL `defaclnamespace` means "every schema" — reported as an empty schema string so the
|
|
702
|
+
* resolver can union it with any specific schema. Objtype 'r' is a table, 'S' a sequence.
|
|
703
|
+
*/
|
|
704
|
+
export const DEFAULT_ACL_SQL = `
|
|
705
|
+
SELECT
|
|
706
|
+
COALESCE(n.nspname, '') AS schema,
|
|
707
|
+
d.defaclobjtype AS objtype,
|
|
708
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
709
|
+
a.privilege_type AS privilege
|
|
710
|
+
FROM pg_default_acl d
|
|
711
|
+
LEFT JOIN pg_namespace n ON n.oid = d.defaclnamespace
|
|
712
|
+
CROSS JOIN LATERAL aclexplode(d.defaclacl) a
|
|
713
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
714
|
+
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
|
715
|
+
AND d.defaclobjtype IN ('r', 'S')
|
|
716
|
+
AND (n.nspname IS NULL OR (n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'))
|
|
717
|
+
ORDER BY 1, 2, 3, 4;
|
|
718
|
+
`.trim();
|
|
719
|
+
|
|
720
|
+
export interface DefaultAclRow {
|
|
721
|
+
schema: unknown;
|
|
722
|
+
/** 'r' = table, 'S' = sequence. */
|
|
723
|
+
objtype: unknown;
|
|
724
|
+
grantee: unknown;
|
|
725
|
+
privilege: unknown;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** schema (`''` = all schemas) → grantee → sorted privileges. */
|
|
729
|
+
export type DefaultAclScope = Record<string, Record<string, string[]>>;
|
|
730
|
+
|
|
731
|
+
/** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
|
|
732
|
+
export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
|
|
733
|
+
const tables: DefaultAclScope = {};
|
|
734
|
+
const sequences: DefaultAclScope = {};
|
|
735
|
+
for (const row of rows) {
|
|
736
|
+
if (row.grantee == null || row.privilege == null) continue;
|
|
737
|
+
const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
|
|
738
|
+
if (!scope) continue;
|
|
739
|
+
const schema = String(row.schema ?? '');
|
|
740
|
+
const grantee = String(row.grantee);
|
|
741
|
+
const byGrantee = scope[schema] ?? (scope[schema] = {});
|
|
742
|
+
byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
|
|
743
|
+
}
|
|
744
|
+
return { tables, sequences };
|
|
745
|
+
}
|
|
746
|
+
|
|
685
747
|
/** The two privileges a schema ACL can carry, in aclitem letter form. */
|
|
686
748
|
const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
|
|
687
749
|
|
|
@@ -872,6 +934,8 @@ export interface ContractRows {
|
|
|
872
934
|
schemaAcls?: SchemaAclRow[];
|
|
873
935
|
/** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
|
|
874
936
|
sequenceAcls?: SequenceAclRow[];
|
|
937
|
+
/** DEFAULT_ACL_SQL. Optional — absent means not measured, never "no default privileges". */
|
|
938
|
+
defaultAcls?: DefaultAclRow[];
|
|
875
939
|
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
876
940
|
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
|
|
877
941
|
}
|
|
@@ -966,6 +1030,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
966
1030
|
...(schemaAcls ? { schemaAcls } : {}),
|
|
967
1031
|
// Same absent-means-unmeasured contract as schemaAcls above.
|
|
968
1032
|
...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
|
|
1033
|
+
...(rows.defaultAcls ? { defaultAcls: assembleDefaultAcls(rows.defaultAcls) } : {}),
|
|
969
1034
|
};
|
|
970
1035
|
}
|
|
971
1036
|
|
|
@@ -978,8 +1043,8 @@ export async function introspectContract(
|
|
|
978
1043
|
// ONE session: the queries describe one moment under one pinned search_path.
|
|
979
1044
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
980
1045
|
// across connections can report authz drift that does not exist.
|
|
981
|
-
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
982
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
1046
|
+
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, defaultAcls, fnRows] = await session(
|
|
1047
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL, functionsSql],
|
|
983
1048
|
INTROSPECTION_SESSION,
|
|
984
1049
|
);
|
|
985
1050
|
return assembleContract({
|
|
@@ -990,6 +1055,7 @@ export async function introspectContract(
|
|
|
990
1055
|
effectivePrivileges: effectivePrivileges as EffectivePrivilegeRow[],
|
|
991
1056
|
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
992
1057
|
sequenceAcls: sequenceAcls as SequenceAclRow[],
|
|
1058
|
+
defaultAcls: defaultAcls as DefaultAclRow[],
|
|
993
1059
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
994
1060
|
});
|
|
995
1061
|
}
|