@everystack/cli 0.4.100 → 0.4.103
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 +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +32 -13
- package/src/cli/commands/db-pull.ts +155 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +5 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +92 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,118 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- export compileDeclaredState from @everystack/cli/model
|
|
12
|
+
|
|
13
|
+
## [0.4.103] - 2026-10-23
|
|
14
|
+
|
|
15
|
+
### BREAKING CHANGES
|
|
16
|
+
|
|
17
|
+
- lifecycle delete and restore hand the batch id to the caller
|
|
18
|
+
- lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
|
|
19
|
+
- refuse dependent + where on a lifecycle parent's hasMany
|
|
20
|
+
- can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
|
|
21
|
+
- deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- index the FK column of every lifecycle dependent
|
|
26
|
+
- db:plan warns when a CREATE INDEX will block writes to a live table
|
|
27
|
+
|
|
28
|
+
## [0.4.102] - 2026-10-23
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- db:doctor reports lifecycle rows past the purge window
|
|
33
|
+
- generated lifecycle functions carry manifests
|
|
34
|
+
- db:pull verifies generated lifecycle objects
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- restore uses set-based updates, not a subtransaction per row
|
|
39
|
+
- drop a replaced primary key before altering its columns
|
|
40
|
+
- match quoted trigger functions by parsed identifiers
|
|
41
|
+
- db:pull renders the owner read pair as owner
|
|
42
|
+
- the verifier leaves an author's own restore overload alone
|
|
43
|
+
- allow author after-delete lifecycle triggers
|
|
44
|
+
- detect constraint lifecycle trigger replacements
|
|
45
|
+
- the verifier compares the declared delete rules with the live policies
|
|
46
|
+
- an invalid --purge-after fails cleanly
|
|
47
|
+
- check lifecycle restrict edges once, after the whole cascade
|
|
48
|
+
- reconcile drops a removed trigger before the function it executes
|
|
49
|
+
- a DELETE issued from inside a trigger on a lifecycle table is a soft delete
|
|
50
|
+
- pull-verify names the older CLI's restrict pair as stale and points to db:apply
|
|
51
|
+
- pull-verify drops only a whole pg_catalog qualifier from a trigger WHEN
|
|
52
|
+
|
|
53
|
+
### Documentation
|
|
54
|
+
|
|
55
|
+
- state the lifecycle restrict contract, multi-row order and the lifecycle-column rule
|
|
56
|
+
- consumer triggers under a stamp or purge: qualified names, soft deletes, and the db:apply remedy for an older CLI's restrict pair
|
|
57
|
+
- an older CLI's restrict function may report a missing manifest instead of stale; both say db:apply
|
|
58
|
+
|
|
59
|
+
## [0.4.101] - 2026-10-22
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- db:plan lists the functions, views and triggers db:apply will change
|
|
64
|
+
- update names the .sst files it read when it finds no HOST_URL
|
|
65
|
+
- external FK target, .references({ schema, table, column })
|
|
66
|
+
- F7a relation options; hasMany dependent sets the child FK ON DELETE
|
|
67
|
+
- relation display options and hasOne (F7b)
|
|
68
|
+
- db:generate writes the model map; db:check fails when it is stale (F8)
|
|
69
|
+
- the model map carries softDelete; the admin reads it instead of the preset's hand-written flag (A5)
|
|
70
|
+
- hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
|
|
71
|
+
- lifecycle: true declares four generated columns (L1.1)
|
|
72
|
+
- lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
|
|
73
|
+
- lifecycle triggers turn DELETE into a soft delete (L1.2b)
|
|
74
|
+
- lifecycle restore, for whoever may delete the row (L1.3)
|
|
75
|
+
- db:pull folds a lifecycle table back to lifecycle: true (L1.4)
|
|
76
|
+
- a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
|
|
77
|
+
- <t>_restore brings back the row's whole batch subtree on the root's authority (L3, P1.3)
|
|
78
|
+
- Restore on a lifecycle resource restores its batch and previews it first (P1.5)
|
|
79
|
+
- db:provision creates the reserved lifecycle role (P2.7)
|
|
80
|
+
- a NOT NULL column has no clear control (N6)
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- a disabled derived trigger is drift
|
|
85
|
+
- import a non-public FK target by its qualified name
|
|
86
|
+
- a plan that removes a function, view or trigger is not "Safe to apply"
|
|
87
|
+
- key the pulled set on qualified names
|
|
88
|
+
- db:pull --govern-roles round-trips custom-role policies and column grants (assoc F4)
|
|
89
|
+
- db:pull refuses a no-USING column policy and escapes live predicates (assoc F4 review)
|
|
90
|
+
- escape live text in db:pull's sql templates
|
|
91
|
+
- escape the defineSql fallback's DROP FUNCTION template
|
|
92
|
+
- db:pull refuses live names with no safe generated identifier or file name
|
|
93
|
+
- db:pull escapes live text in // comments and walls every write inside its directory
|
|
94
|
+
- a grant to a role named __proto__ is visible to db:plan, db:pull and the fingerprint
|
|
95
|
+
- db:pull never renders a policy that admits no row as an ability
|
|
96
|
+
- F3e review minors: catalog FK target, one target slot, derived refusal
|
|
97
|
+
- F3e re-review: fail closed on a changed FK target; unambiguous comment
|
|
98
|
+
- F7a review: derive dependent ON DELETE per set, never write a field spec
|
|
99
|
+
- F7b review: mark display relations in drizzle, withhold only where/limit
|
|
100
|
+
- F8 review: private SQL spellings, key collisions, T2 mock proof
|
|
101
|
+
- run state DDL under the canonical search_path; read non-public enums as qualified
|
|
102
|
+
- retype a column onto an enum through text, dropping the old default first
|
|
103
|
+
- the map publishes the soft-delete field key, not a boolean (A5 review)
|
|
104
|
+
- owned trigger functions deploy as a non-superuser; the lifecycle transition runs only on its own table (L1.2c)
|
|
105
|
+
- the lifecycle pull fold keeps an author's restore overload and governs the reserved role at adoption (L1.4 review)
|
|
106
|
+
- the lifecycle pull fold checks the reserved role's effective privileges and always names its unfolded grants (P1.0 review)
|
|
107
|
+
- db:plan changes a primary key, and refuses to mint what it cannot express (P2.8)
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- fake timers for the two polling suites; generate the default cert once
|
|
112
|
+
|
|
113
|
+
### Documentation
|
|
114
|
+
|
|
115
|
+
- name the one runtime-vs-generated Drizzle difference a parent dependent creates
|
|
116
|
+
- say which fingerprint inputs moved; mark the guard-only enum diff test
|
|
117
|
+
- state precisely which policies admit no row and that a column grant under one is revoked
|
|
118
|
+
|
|
7
119
|
## [0.4.100] - 2026-10-16
|
|
8
120
|
|
|
9
121
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.103",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"structured-headers": "1.0.1",
|
|
146
146
|
"tsx": "4.21.0",
|
|
147
147
|
"typescript": "5.9.3",
|
|
148
|
-
"@everystack/model": "0.4.
|
|
148
|
+
"@everystack/model": "0.4.24"
|
|
149
149
|
},
|
|
150
150
|
"peerDependencies": {
|
|
151
151
|
"@everystack/server": ">=0.4.0",
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
"react": "19.2.0",
|
|
193
193
|
"ts-jest": "29.4.9",
|
|
194
194
|
"zod": "3.25.67",
|
|
195
|
-
"@everystack/server": "0.4.
|
|
195
|
+
"@everystack/server": "0.4.39"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
|
@@ -83,7 +83,9 @@ export function buildStageBaseline(
|
|
|
83
83
|
exemptions: readonly GrantExemption[],
|
|
84
84
|
meta: { observedAt: string; fingerprint: string; fpVersion?: number },
|
|
85
85
|
): BaselineStage {
|
|
86
|
-
|
|
86
|
+
// Null-prototype: a grantee is a live role name. On a plain object `grantees['__proto__'] ??=`
|
|
87
|
+
// reads Object.prototype, and the writes below would land ON Object.prototype.
|
|
88
|
+
const grantees: Record<string, BaselineGrantee> = Object.create(null);
|
|
87
89
|
for (const e of exemptions) {
|
|
88
90
|
const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
|
|
89
91
|
g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
|
|
@@ -112,10 +114,10 @@ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, ent
|
|
|
112
114
|
* itself would make the review signal worthless.
|
|
113
115
|
*/
|
|
114
116
|
export function renderBaseline(baseline: AuthzBaseline): string {
|
|
115
|
-
const stages: Record<string, BaselineStage> =
|
|
117
|
+
const stages: Record<string, BaselineStage> = Object.create(null);
|
|
116
118
|
for (const stage of Object.keys(baseline.stages).sort()) {
|
|
117
119
|
const s = baseline.stages[stage];
|
|
118
|
-
const grantees: Record<string, BaselineGrantee> =
|
|
120
|
+
const grantees: Record<string, BaselineGrantee> = Object.create(null);
|
|
119
121
|
for (const g of Object.keys(s.grantees).sort()) {
|
|
120
122
|
const { privileges, tables, write } = s.grantees[g];
|
|
121
123
|
grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
|
|
@@ -182,7 +184,9 @@ export function checkAgainstBaseline(
|
|
|
182
184
|
|
|
183
185
|
for (const grantee of Object.keys(observed).sort()) {
|
|
184
186
|
const now = observed[grantee];
|
|
185
|
-
|
|
187
|
+
// Own keys only: the recorded baseline is JSON.parse'd, so a missing `__proto__` entry would
|
|
188
|
+
// read Object.prototype — truthy — and wave an unbaselined role through.
|
|
189
|
+
const then = Object.hasOwn(recorded, grantee) ? recorded[grantee] : undefined;
|
|
186
190
|
if (!then) {
|
|
187
191
|
violations.push({
|
|
188
192
|
grantee,
|
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 { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
23
|
+
import { isColumnAbility, LIFECYCLE_OWNER_ROLE, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract';
|
|
25
25
|
import { parenthesizeOnce, isBareCall } from './authz-contract';
|
|
26
26
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
@@ -278,23 +278,42 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
|
|
|
278
278
|
/** The policy-side spelling of a declared role — what `pg_policies` would report. */
|
|
279
279
|
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Who deleted a row, in which batch and which transaction: the lifecycle columns a deleted reader
|
|
283
|
+
* never selects. Only a `manage` role sees them; `deletedAt` stays visible to every reader.
|
|
284
|
+
*/
|
|
285
|
+
const DELETED_READ_HIDDEN = new Set(['deletedBy', 'deletedBatch', 'deletedXid']);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `can('read', { role, deleted })` with no `columns`: it reaches deleted rows, whose deleted_by,
|
|
289
|
+
* deleted_batch and deleted_xid are set, so it grants SELECT on every other column instead of the
|
|
290
|
+
* whole table. Grants are a union: defineModel refuses a `manage` for the same role, and the policy
|
|
291
|
+
* compile refuses a second read for it, so nothing re-grants the table.
|
|
292
|
+
*/
|
|
293
|
+
const isDeletedReadOfEveryColumn = (a: Ability): boolean =>
|
|
294
|
+
a.action === 'read' && a.condition.deleted !== undefined && !isColumnAbility(a);
|
|
295
|
+
|
|
281
296
|
/**
|
|
282
297
|
* Column grants from column abilities. The action determines the PostgreSQL verb; the named role
|
|
283
298
|
* (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
|
|
284
299
|
* deduplicated, matching the introspected contract.
|
|
285
300
|
*/
|
|
286
|
-
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
287
|
-
|
|
301
|
+
function compileColumnGrants(abilities: readonly Ability[], fields: readonly string[] = []): Record<string, Record<string, string[]>> | undefined {
|
|
302
|
+
// Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
|
|
303
|
+
// '__proto__' passes the role-name guard, so a plain object would lose it.
|
|
304
|
+
const out: Record<string, Record<string, Set<string>>> = Object.create(null);
|
|
305
|
+
const add = (role: string, verb: string, columns: readonly string[]): void => {
|
|
306
|
+
const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
|
|
307
|
+
for (const column of columns) cols.add(toSnakeCase(column));
|
|
308
|
+
};
|
|
288
309
|
for (const a of abilities) {
|
|
310
|
+
if (isDeletedReadOfEveryColumn(a)) add(granteeKey(a.condition.role!), 'SELECT', fields.filter((f) => !DELETED_READ_HIDDEN.has(f)));
|
|
289
311
|
if (!isColumnAbility(a)) continue;
|
|
290
|
-
|
|
291
|
-
const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
|
|
292
|
-
const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
|
|
293
|
-
for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
|
|
312
|
+
add(granteeKey(a.condition.role ?? 'authenticated'), a.action === 'read' ? 'SELECT' : 'UPDATE', a.condition.columns!);
|
|
294
313
|
}
|
|
295
|
-
const grants: Record<string, Record<string, string[]>> =
|
|
314
|
+
const grants: Record<string, Record<string, string[]>> = Object.create(null);
|
|
296
315
|
for (const [role, verbs] of Object.entries(out)) {
|
|
297
|
-
grants[role] =
|
|
316
|
+
grants[role] = Object.create(null);
|
|
298
317
|
for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
|
|
299
318
|
}
|
|
300
319
|
return Object.keys(grants).length ? grants : undefined;
|
|
@@ -438,6 +457,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
438
457
|
? viaPredicate(via)
|
|
439
458
|
: null;
|
|
440
459
|
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
460
|
+
// A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
|
|
461
|
+
// Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
|
|
462
|
+
// compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
|
|
463
|
+
// restore or purge them), exactly as it does under softDelete.
|
|
464
|
+
const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
|
|
465
|
+
const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
|
|
441
466
|
|
|
442
467
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
443
468
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
@@ -533,7 +558,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
533
558
|
const policy = (
|
|
534
559
|
name: string, command: PolicyCommand, roles: string[],
|
|
535
560
|
using: string | null, check: string | null,
|
|
561
|
+
deleted?: 'include' | 'only',
|
|
536
562
|
): void => {
|
|
563
|
+
// can('read', { deleted }): 'include' leaves the guard off this role's policy (its other
|
|
564
|
+
// restrictions stay); 'only' puts `(deleted_at IS NOT NULL)` in its place. Omitted: the guard.
|
|
565
|
+
if (lifecycleGuard && lifecycleCol && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE) && deleted !== 'include') {
|
|
566
|
+
const state = deleted === 'only' ? `(${lifecycleCol} IS NOT NULL)` : lifecycleGuard;
|
|
567
|
+
using = andPredicates(state, using) ?? state;
|
|
568
|
+
}
|
|
537
569
|
// Same normalization the live producer applies (authz-contract's `predicate`): a model
|
|
538
570
|
// pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
|
|
539
571
|
// and it must compare equal against a stage on another. One rule table, two producers.
|
|
@@ -650,6 +682,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
650
682
|
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
651
683
|
) ?? 'true',
|
|
652
684
|
null,
|
|
685
|
+
a.condition.deleted,
|
|
653
686
|
);
|
|
654
687
|
}
|
|
655
688
|
|
|
@@ -823,6 +856,24 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
823
856
|
|
|
824
857
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
825
858
|
|
|
859
|
+
// The lifecycle restore function inlines these DELETE rules and runs them as the lifecycle
|
|
860
|
+
// owner. Check the text as compiled, whatever produced it (via, ownerExpr, where, sql): a
|
|
861
|
+
// subquery would read with that role's privileges, and current_user/session_user would not
|
|
862
|
+
// name the caller. defineModel refuses the common spellings first with friendlier messages.
|
|
863
|
+
if (model.lifecycle) {
|
|
864
|
+
for (const p of policies) {
|
|
865
|
+
if (p.command !== 'DELETE' && p.command !== 'ALL') continue;
|
|
866
|
+
const offender = /\bselect\b|\bcurrent_user\b|\bsession_user\b/i.exec(p.using ?? '')?.[0];
|
|
867
|
+
if (!offender) continue;
|
|
868
|
+
throw new Error(
|
|
869
|
+
`${table}: policy ${p.name} (${p.command}) uses ${offender} on a lifecycle: true model. Restore runs this delete rule `
|
|
870
|
+
+ `inside a SECURITY DEFINER function as the lifecycle owner, where a subquery reads with that role's privileges and `
|
|
871
|
+
+ `current_user/session_user do not name the caller. Use a predicate on the row's own columns or the request claims `
|
|
872
|
+
+ `(owner without ownerExpr, or a plain where).`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
826
877
|
return {
|
|
827
878
|
table,
|
|
828
879
|
// The FORCE axiom: FORCE iff the write principal is subject to its own RLS
|
|
@@ -836,11 +887,35 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
836
887
|
// security downgrade via version skew.
|
|
837
888
|
rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
|
|
838
889
|
grants: compileGrants(model.abilities, model.privileges),
|
|
839
|
-
...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
|
|
890
|
+
...(compileColumnGrants(model.abilities, Object.keys(model.fields)) ? { columnGrants: compileColumnGrants(model.abilities, Object.keys(model.fields)) } : {}),
|
|
840
891
|
policies,
|
|
841
892
|
};
|
|
842
893
|
}
|
|
843
894
|
|
|
895
|
+
/**
|
|
896
|
+
* The table's DELETE rule per role: role -> the USING of every compiled DELETE or ALL policy
|
|
897
|
+
* naming that role, OR-ed (each parenthesized) when there are several. Read back from
|
|
898
|
+
* {@link compileTableContract}, so it is the policy rule by construction, never a second
|
|
899
|
+
* derivation. The lifecycle role is left out: it is the machinery, not an author's role.
|
|
900
|
+
* A policy with no USING admits nothing here, so it is skipped (fail closed).
|
|
901
|
+
*/
|
|
902
|
+
export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
|
|
903
|
+
const byRole = new Map<string, string[]>();
|
|
904
|
+
for (const p of compileTableContract(model, opts).policies) {
|
|
905
|
+
if ((p.command !== 'DELETE' && p.command !== 'ALL') || p.using == null) continue;
|
|
906
|
+
for (const role of p.roles) {
|
|
907
|
+
if (role === LIFECYCLE_OWNER_ROLE) continue;
|
|
908
|
+
byRole.set(role, [...(byRole.get(role) ?? []), p.using]);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const out: Record<string, string> = {};
|
|
912
|
+
for (const role of [...byRole.keys()].sort()) {
|
|
913
|
+
const preds = byRole.get(role)!;
|
|
914
|
+
out[role] = preds.length === 1 ? preds[0] : preds.map((p) => `(${p})`).join(' OR ');
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
|
|
844
919
|
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
845
920
|
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
846
921
|
|
|
@@ -885,14 +960,16 @@ function compileGrants(
|
|
|
885
960
|
abilities: readonly Ability[],
|
|
886
961
|
privileges: Record<string, readonly string[]> = {},
|
|
887
962
|
): Record<string, string[]> {
|
|
888
|
-
|
|
963
|
+
// Null-prototype, for the same reason as compileColumnGrants.
|
|
964
|
+
const grants: Record<string, Set<string>> = Object.create(null);
|
|
889
965
|
const add = (role: string, verbs: readonly string[]): void => {
|
|
890
966
|
const set = (grants[role] ??= new Set<string>());
|
|
891
967
|
for (const v of verbs) set.add(v);
|
|
892
968
|
};
|
|
893
969
|
for (const a of abilities) {
|
|
894
|
-
// A column ability grants only its listed columns, never a table-level privilege
|
|
895
|
-
|
|
970
|
+
// A column ability grants only its listed columns, never a table-level privilege; nor does
|
|
971
|
+
// a deleted read (compileColumnGrants grants it every column but the three it may not see).
|
|
972
|
+
if (isColumnAbility(a) || isDeletedReadOfEveryColumn(a)) continue;
|
|
896
973
|
const verbs = verbsFor(a.action);
|
|
897
974
|
if (a.condition.role) {
|
|
898
975
|
add(granteeKey(a.condition.role), verbs);
|
|
@@ -907,7 +984,7 @@ function compileGrants(
|
|
|
907
984
|
for (const [role, privs] of Object.entries(privileges)) {
|
|
908
985
|
if (privs.length) add(granteeKey(role), privs);
|
|
909
986
|
}
|
|
910
|
-
const out: Record<string, string[]> =
|
|
987
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
911
988
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
912
989
|
return out;
|
|
913
990
|
}
|
|
@@ -102,6 +102,12 @@ export interface PolicyContract {
|
|
|
102
102
|
using: string | null;
|
|
103
103
|
/** `WITH CHECK` predicate (deparsed), or null when absent. */
|
|
104
104
|
check: string | null;
|
|
105
|
+
/**
|
|
106
|
+
* Set only by db:pull's lifecycle fold, never by the catalog read or the compiler: this role's
|
|
107
|
+
* read policy on a lifecycle table carries no `(deleted_at IS NULL)` guard ('include') or carries
|
|
108
|
+
* `(deleted_at IS NOT NULL)` instead ('only'), so it renders as can('read', { role, deleted }).
|
|
109
|
+
*/
|
|
110
|
+
deleted?: 'include' | 'only';
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
/**
|
|
@@ -713,7 +719,7 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
|
|
|
713
719
|
for (const row of rows) {
|
|
714
720
|
const entry = bySequence.get(row.sequence)
|
|
715
721
|
?? {
|
|
716
|
-
sequence: row.sequence, table: row.table, grants:
|
|
722
|
+
sequence: row.sequence, table: row.table, grants: Object.create(null) as Record<string, string[]>,
|
|
717
723
|
// Omitted rather than `false` on a serial sequence, so a contract read before this
|
|
718
724
|
// field existed and one read after compare structurally equal.
|
|
719
725
|
...(truthy(row.is_identity) ? { identity: true } : {}),
|
|
@@ -766,15 +772,15 @@ export type DefaultAclScope = Record<string, Record<string, string[]>>;
|
|
|
766
772
|
|
|
767
773
|
/** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
|
|
768
774
|
export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
|
|
769
|
-
const tables: DefaultAclScope =
|
|
770
|
-
const sequences: DefaultAclScope =
|
|
775
|
+
const tables: DefaultAclScope = Object.create(null);
|
|
776
|
+
const sequences: DefaultAclScope = Object.create(null);
|
|
771
777
|
for (const row of rows) {
|
|
772
778
|
if (row.grantee == null || row.privilege == null) continue;
|
|
773
779
|
const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
|
|
774
780
|
if (!scope) continue;
|
|
775
781
|
const schema = String(row.schema ?? '');
|
|
776
782
|
const grantee = String(row.grantee);
|
|
777
|
-
const byGrantee = scope[schema] ?? (scope[schema] =
|
|
783
|
+
const byGrantee = scope[schema] ?? (scope[schema] = Object.create(null));
|
|
778
784
|
byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
|
|
779
785
|
}
|
|
780
786
|
return { tables, sequences };
|
|
@@ -796,8 +802,8 @@ export function parseSchemaAcl(text: string | null | undefined): Record<string,
|
|
|
796
802
|
const s = String(text).trim();
|
|
797
803
|
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
|
798
804
|
const body = s.slice(1, -1);
|
|
799
|
-
if (body.trim() === '') return
|
|
800
|
-
const out: Record<string, string[]> =
|
|
805
|
+
if (body.trim() === '') return Object.create(null);
|
|
806
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
801
807
|
// Split items at top-level commas — a quoted region may contain commas.
|
|
802
808
|
const items: string[] = [];
|
|
803
809
|
let start = 0;
|
|
@@ -986,13 +992,19 @@ export interface ContractRows {
|
|
|
986
992
|
* empty allowlist a diff would never flag. Only SECURITY DEFINER functions are
|
|
987
993
|
* recorded: an INVOKER function runs as the caller under RLS and is not an
|
|
988
994
|
* authorization decision point; a SECDEF function runs above RLS and is.
|
|
995
|
+
*
|
|
996
|
+
* Every map keyed by a live name (role, schema) is `Object.create(null)`. On a plain object
|
|
997
|
+
* `grants['__proto__'] = x` sets the prototype and adds no key, and PostgreSQL allows a quoted
|
|
998
|
+
* role named "__proto__": its grants vanished from this read, so the plan, the fingerprint and
|
|
999
|
+
* the pull could not see them. A null-prototype object serializes and iterates exactly like a
|
|
1000
|
+
* plain one, so an ordinary database reads byte-identically.
|
|
989
1001
|
*/
|
|
990
1002
|
export function assembleContract(rows: ContractRows): AuthzContract {
|
|
991
1003
|
const tables = new Map<string, TableContract>();
|
|
992
1004
|
for (const row of rows.rls) {
|
|
993
1005
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
994
1006
|
const { table, rls } = rlsRowToDescriptor(row);
|
|
995
|
-
tables.set(table, { table, rls, grants:
|
|
1007
|
+
tables.set(table, { table, rls, grants: Object.create(null), policies: [] });
|
|
996
1008
|
}
|
|
997
1009
|
|
|
998
1010
|
// A grant or policy on a table the RLS enumerator didn't list (e.g. a view, excluded
|
|
@@ -1011,8 +1023,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1011
1023
|
const { table, grantee, privilege, column } = columnGrantRowToDescriptor(row);
|
|
1012
1024
|
const t = tables.get(table);
|
|
1013
1025
|
if (!t) continue;
|
|
1014
|
-
const byGrantee = (t.columnGrants ??=
|
|
1015
|
-
const byPriv = (byGrantee[grantee] ??=
|
|
1026
|
+
const byGrantee = (t.columnGrants ??= Object.create(null) as Record<string, Record<string, string[]>>);
|
|
1027
|
+
const byPriv = (byGrantee[grantee] ??= Object.create(null) as Record<string, string[]>);
|
|
1016
1028
|
(byPriv[privilege] ??= []).push(column);
|
|
1017
1029
|
}
|
|
1018
1030
|
|
|
@@ -1025,7 +1037,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1025
1037
|
const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
|
|
1026
1038
|
const t = tables.get(table);
|
|
1027
1039
|
if (!t || privileges.length === 0) continue;
|
|
1028
|
-
(t.effectivePrivileges ??=
|
|
1040
|
+
(t.effectivePrivileges ??= Object.create(null) as Record<string, string[]>)[role] = [...privileges].sort();
|
|
1029
1041
|
}
|
|
1030
1042
|
}
|
|
1031
1043
|
|
|
@@ -1053,10 +1065,10 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1053
1065
|
// is distinguishable from "we never looked".
|
|
1054
1066
|
let schemaAcls: Record<string, Record<string, string[]>> | undefined;
|
|
1055
1067
|
if (rows.schemaAcls) {
|
|
1056
|
-
schemaAcls =
|
|
1068
|
+
schemaAcls = Object.create(null) as Record<string, Record<string, string[]>>;
|
|
1057
1069
|
for (const row of rows.schemaAcls) {
|
|
1058
1070
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
1059
|
-
schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ??
|
|
1071
|
+
schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? Object.create(null);
|
|
1060
1072
|
}
|
|
1061
1073
|
}
|
|
1062
1074
|
|
|
@@ -1212,9 +1224,12 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
|
|
|
1212
1224
|
|
|
1213
1225
|
function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
1214
1226
|
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l.grants)]);
|
|
1227
|
+
// Own keys only: a declared contract loaded with JSON.parse is a plain object, where a
|
|
1228
|
+
// grantee the file lacks (`__proto__`, `constructor`) would read an Object.prototype member.
|
|
1229
|
+
const held = (grants: Record<string, string[]>, g: string): string[] => (Object.hasOwn(grants, g) ? grants[g] : []);
|
|
1215
1230
|
for (const g of grantees) {
|
|
1216
|
-
const dp = (d.grants
|
|
1217
|
-
const lp = (l.grants
|
|
1231
|
+
const dp = held(d.grants, g).join(',');
|
|
1232
|
+
const lp = held(l.grants, g).join(',');
|
|
1218
1233
|
if (dp !== lp) {
|
|
1219
1234
|
out.push({ subject: table, kind: 'grant', detail: `grant to ${g}: declared [${dp}], live [${lp}]` });
|
|
1220
1235
|
}
|