@everystack/cli 0.4.81 → 0.4.83
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 +41 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +126 -6
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-derive.ts +31 -6
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ 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.83] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- db:pull --derived-out takes a directory, split by kind and schema
|
|
12
|
+
- a real SELECT-only role for db:query's read connection
|
|
13
|
+
- version-guard the Model-derived wire payloads (provision, converge-ownership, reconcile)
|
|
14
|
+
- two symbols and plain words — the default output for db:plan/db:apply/db:check
|
|
15
|
+
- db:apply does the whole job — the plan, then the functions and views
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- derive USAGE only on serial sequences (least privilege) (#11)
|
|
20
|
+
- close the closing-gate findings on --derived-out <dir>
|
|
21
|
+
- owner-adoption fast-follows — identity guard, self-grant revoke, ENACT transaction
|
|
22
|
+
- mirror the reader fields in ServerProvision, and pin that no credential prints
|
|
23
|
+
- an E-string prefix only counts when it stands alone
|
|
24
|
+
- throw a private sentinel instead of calling tx.rollback() — closes a console-sandbox swallow/neutralize hole
|
|
25
|
+
- the shape-pin also covers enum value domains, not just field names
|
|
26
|
+
- symmetric v-prefix in the mismatch message, restore db:provision's --direct detail
|
|
27
|
+
- ADD CONSTRAINT/PRIMARY KEY are not additive — they can fail against existing data
|
|
28
|
+
- the lease covers the catch-up, the receipt marks a strip, the preflight never exits mid-chain
|
|
29
|
+
- the catch-up's refusal mode belongs to the caller, and the failure line says why
|
|
30
|
+
- the catch-up failure names two subjects, quotes its cause once, and nobody inherits 'exit'
|
|
31
|
+
- db:apply refuses a malformed plan `statements` instead of throwing
|
|
32
|
+
- what a statement IS gets read once, and a comment can neither hide a revoke nor invent a drop
|
|
33
|
+
- the declared side rides on the plan, so a strip reads the same before and after
|
|
34
|
+
- one statement, one bucket — the summary's two halves read the same declared side
|
|
35
|
+
- the plan's optional grantee set is refused at load, not dereferenced after the write
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
- the workflow is three commands — db:plan, db:apply, db:check
|
|
40
|
+
|
|
41
|
+
## [0.4.82] - 2026-09-08
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- four compiler-hardening throws close the A22/F-A10/F-A12 tail
|
|
46
|
+
- column-path check disclosure parity closes the F1 gap
|
|
47
|
+
|
|
7
48
|
## [0.4.81] - 2026-09-08
|
|
8
49
|
|
|
9
50
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.83",
|
|
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>",
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"types": "./src/cli/db-source.ts",
|
|
49
49
|
"default": "./src/cli/db-source.ts"
|
|
50
50
|
},
|
|
51
|
+
"./wire": {
|
|
52
|
+
"source": "./src/wire.ts",
|
|
53
|
+
"types": "./src/wire.ts",
|
|
54
|
+
"default": "./src/wire.ts"
|
|
55
|
+
},
|
|
51
56
|
"./reconcile": {
|
|
52
57
|
"source": "./src/reconcile.ts",
|
|
53
58
|
"types": "./src/reconcile.ts",
|
|
@@ -120,10 +125,12 @@
|
|
|
120
125
|
"@aws-sdk/client-rds": "3.1053.0",
|
|
121
126
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
122
127
|
"@aws-sdk/client-ssm": "3.1053.0",
|
|
128
|
+
"@aws-sdk/credential-provider-node": "3.972.44",
|
|
123
129
|
"@aws-sdk/lib-storage": "3.1053.0",
|
|
124
130
|
"@aws-sdk/client-sts": "3.1053.0",
|
|
125
131
|
"@aws-sdk/signature-v4a": "3.1048.0",
|
|
126
132
|
"glob": "13.0.6",
|
|
133
|
+
"minimatch": "10.2.5",
|
|
127
134
|
"node-forge": "1.4.0",
|
|
128
135
|
"structured-headers": "1.0.1",
|
|
129
136
|
"tsx": "4.21.0",
|
|
@@ -174,6 +181,7 @@
|
|
|
174
181
|
"jest": "29.7.0",
|
|
175
182
|
"react": "19.2.0",
|
|
176
183
|
"ts-jest": "29.4.9",
|
|
184
|
+
"zod": "3.25.67",
|
|
177
185
|
"@everystack/server": "0.4.30"
|
|
178
186
|
},
|
|
179
187
|
"scripts": {
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -133,16 +133,48 @@ function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
|
|
|
133
133
|
return model.schema ?? opts.schema ?? 'public';
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* The identity of a row scope — owner column, claim, and accessor — as one string, so two
|
|
138
|
+
* owner abilities can be compared for agreement. A model may declare `owner` on several
|
|
139
|
+
* abilities (a read and each write); they MUST all name the same scope, because the compiler
|
|
140
|
+
* folds them into ONE `rowPred`. It used to take the first silently, so a second ability with
|
|
141
|
+
* a different `owner:` compiled a policy scoped to a column nobody meant (A22).
|
|
142
|
+
*/
|
|
143
|
+
function ownerScopeKey(a: Ability): string {
|
|
144
|
+
const expr = (a.condition.ownerExpr as { sql?: unknown } | undefined)?.sql;
|
|
145
|
+
return `${a.condition.owner}\u0000${a.condition.userField ?? 'sub'}\u0000${typeof expr === 'string' ? expr : ''}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the owner column (SQL name + type + claim) from the owner abilities.
|
|
150
|
+
*
|
|
151
|
+
* Every ability that declares `owner:` must agree on the SAME column, userField and ownerExpr —
|
|
152
|
+
* the compiler has one `rowPred` per model, so disagreeing owners cannot both be honored. Taking
|
|
153
|
+
* the first silently meant a `can('read', { owner: 'authorId' })` beside a
|
|
154
|
+
* `can('update', { owner: 'editorId' })` scoped every write to `author_id` with no error (A22).
|
|
155
|
+
* A repeat of the SAME owner is fine — that is the normal shape (one owner, many verbs).
|
|
156
|
+
*/
|
|
137
157
|
function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
140
|
-
const
|
|
158
|
+
const ownerAbilities = model.abilities.filter((a) => a.condition.owner);
|
|
159
|
+
if (!ownerAbilities.length) return null;
|
|
160
|
+
const first = ownerAbilities[0];
|
|
161
|
+
const firstKey = ownerScopeKey(first);
|
|
162
|
+
const clash = ownerAbilities.find((a) => ownerScopeKey(a) !== firstKey);
|
|
163
|
+
if (clash) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${model.table}: two owner abilities declare DIFFERENT row scopes — `
|
|
166
|
+
+ `can('${first.action}', { owner: '${first.condition.owner}'${first.condition.userField ? `, userField: '${first.condition.userField}'` : ''} }) `
|
|
167
|
+
+ `vs can('${clash.action}', { owner: '${clash.condition.owner}'${clash.condition.userField ? `, userField: '${clash.condition.userField}'` : ''} }). `
|
|
168
|
+
+ `A model has ONE owner row scope; every owner ability must name the same owner, userField and ownerExpr. `
|
|
169
|
+
+ `Declare the owner once and reuse it across the verbs.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const fieldKey = first.condition.owner!;
|
|
141
173
|
return {
|
|
142
174
|
sqlColumn: toSnakeCase(fieldKey),
|
|
143
175
|
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
144
|
-
claim:
|
|
145
|
-
expr: rawExpr(
|
|
176
|
+
claim: first.condition.userField ?? 'sub',
|
|
177
|
+
expr: rawExpr(first.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
|
|
146
178
|
};
|
|
147
179
|
}
|
|
148
180
|
|
|
@@ -167,6 +199,19 @@ interface ViaRef {
|
|
|
167
199
|
function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
168
200
|
const viaAbility = model.abilities.find((a) => a.condition.via);
|
|
169
201
|
if (!viaAbility) return null;
|
|
202
|
+
// A model scopes its rows EITHER directly (`owner:`) OR transitively (`via:`) — never both.
|
|
203
|
+
// The compiler keeps one `rowPred`, and with both declared `owner` won and `via` vanished
|
|
204
|
+
// silently (A22). Two definitions of "mine" on one model is a declaration bug, not a merge.
|
|
205
|
+
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
206
|
+
if (ownerAbility) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${model.table}: a model declares BOTH an owner row scope and a via: row scope — `
|
|
209
|
+
+ `can('${ownerAbility.action}', { owner: '${ownerAbility.condition.owner}' }) `
|
|
210
|
+
+ `and can('${viaAbility.action}', { via: '${viaAbility.condition.via}' }). `
|
|
211
|
+
+ `They are two definitions of which rows are the caller's, and the compiler can honor only one. `
|
|
212
|
+
+ `Use a single row scope — a direct owner OR a transitive via — for the whole model.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
170
215
|
const fieldKey = viaAbility.condition.via!;
|
|
171
216
|
const ref = model.fields[fieldKey]?.spec.references;
|
|
172
217
|
if (!ref) {
|
|
@@ -263,6 +308,7 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
263
308
|
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
|
|
264
309
|
);
|
|
265
310
|
}
|
|
311
|
+
assertBalancedParens(text.trim(), where);
|
|
266
312
|
// Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
|
|
267
313
|
// (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
|
|
268
314
|
// differ from the live one by a single layer of parens — enough for the reconciler to
|
|
@@ -284,9 +330,69 @@ function rawExpr(value: unknown, where: string): string | null {
|
|
|
284
330
|
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
|
|
285
331
|
);
|
|
286
332
|
}
|
|
333
|
+
assertBalancedParens(text.trim(), where);
|
|
287
334
|
return text.trim();
|
|
288
335
|
}
|
|
289
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Refuse a fragment whose parentheses do not balance WITHIN it (quote-aware).
|
|
339
|
+
*
|
|
340
|
+
* A compiled predicate is assembled by templates — `andPredicates` joins terms with ` AND ` and
|
|
341
|
+
* `ownerPredicate` writes `(col = …)`. A fragment that closes a paren it never opened, or opens
|
|
342
|
+
* one it never closes, RE-ASSOCIATES against the terms around it: `sql\`true) OR (true\`` ANDed
|
|
343
|
+
* beside an owner gate becomes `(owner AND (true) OR (true))`, and AND binds tighter than OR, so
|
|
344
|
+
* the whole policy collapses to `USING (true)` — the owner term defeated (F-A10). pg_get_expr
|
|
345
|
+
* always deparses balanced predicates, so a pulled fragment is never rejected here; only a
|
|
346
|
+
* hand-authored fragment with a stray paren is, and that is the one this catches.
|
|
347
|
+
*
|
|
348
|
+
* Quote-aware because a paren INSIDE a string literal (`status = ')'`) or a quoted identifier
|
|
349
|
+
* is data, not structure — counting it would false-reject a fragment that composes cleanly.
|
|
350
|
+
* Dollar-quoted strings ($tag$…$tag$) are skipped whole for the same reason.
|
|
351
|
+
*/
|
|
352
|
+
function assertBalancedParens(text: string, where: string): void {
|
|
353
|
+
const fail = (): never => {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`${where}: the predicate has unbalanced parentheses. A fragment whose parens do not close `
|
|
356
|
+
+ `within it re-associates against the terms it is composed beside — `
|
|
357
|
+
+ `sql\`true) OR (true\` ANDed next to an owner gate becomes USING (true), defeating the gate. `
|
|
358
|
+
+ `Parenthesize the fragment so every '(' it opens closes inside it.`,
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
let depth = 0;
|
|
362
|
+
let i = 0;
|
|
363
|
+
const n = text.length;
|
|
364
|
+
while (i < n) {
|
|
365
|
+
const c = text[i];
|
|
366
|
+
// A single-quoted string or double-quoted identifier — parens inside are literal text.
|
|
367
|
+
if (c === "'" || c === '"') {
|
|
368
|
+
const q = c;
|
|
369
|
+
i++;
|
|
370
|
+
while (i < n) {
|
|
371
|
+
if (text[i] === q) {
|
|
372
|
+
if (text[i + 1] === q) { i += 2; continue; } // '' / "" — an escaped quote, stay in
|
|
373
|
+
i++;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
i++;
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// A dollar-quoted string ($tag$ … $tag$) — skip it whole, any paren inside is data.
|
|
381
|
+
if (c === '$') {
|
|
382
|
+
const m = /^\$[A-Za-z0-9_]*\$/.exec(text.slice(i));
|
|
383
|
+
if (m) {
|
|
384
|
+
const close = text.indexOf(m[0], i + m[0].length);
|
|
385
|
+
i = close === -1 ? n : close + m[0].length;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (c === '(') depth++;
|
|
390
|
+
else if (c === ')') { if (--depth < 0) fail(); }
|
|
391
|
+
i++;
|
|
392
|
+
}
|
|
393
|
+
if (depth !== 0) fail();
|
|
394
|
+
}
|
|
395
|
+
|
|
290
396
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
291
397
|
function andPredicates(...parts: (string | null)[]): string | null {
|
|
292
398
|
const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
|
|
@@ -586,6 +692,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
586
692
|
}
|
|
587
693
|
}
|
|
588
694
|
|
|
695
|
+
// The `privileges` map's KEYS are grantees too, emitted unquoted into GRANT just like a
|
|
696
|
+
// `role:` (see compileGrants). The role-name guard above only saw ability roles, so a
|
|
697
|
+
// fold/split grantee here — `privileges: { AUTHENTICATED: [...] }` — slid straight through and
|
|
698
|
+
// GRANTed a beyond-CRUD privilege to the wrong role. Same refusal, same exemption for PUBLIC.
|
|
699
|
+
for (const role of Object.keys(model.privileges ?? {})) {
|
|
700
|
+
if (!isPseudoPublic(role) && !/^[a-z_][a-z0-9_]*$/.test(role)) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`${table}: privileges['${role}'] — a grantee is emitted unquoted, so it must be a bare `
|
|
703
|
+
+ `lowercase identifier ([a-z_][a-z0-9_]*). '${role}' would be case-folded or split by Postgres and the grant `
|
|
704
|
+
+ `would land on a different role. Use the exact lowercase role name (or 'public').`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
589
709
|
const WRITE_VERBS = [
|
|
590
710
|
{ action: 'create' as const, word: 'insert', command: 'INSERT' as PolicyCommand },
|
|
591
711
|
{ action: 'update' as const, word: 'update', command: 'UPDATE' as PolicyCommand },
|
|
@@ -385,9 +385,11 @@ export interface SequenceGrantContract {
|
|
|
385
385
|
/**
|
|
386
386
|
* The owning column is `GENERATED … AS IDENTITY`, not `serial` — and that changes who needs
|
|
387
387
|
* what. A serial column's value comes from a DEFAULT `nextval(...)` evaluated with the
|
|
388
|
-
* CALLER's privileges, so an INSERTing role must hold USAGE
|
|
389
|
-
* every insert fails `permission denied for sequence`.
|
|
390
|
-
*
|
|
388
|
+
* CALLER's privileges, so an INSERTing role must hold USAGE on the sequence (all `nextval`
|
|
389
|
+
* needs) or every insert fails `permission denied for sequence`. `SELECT`/`UPDATE` are NOT
|
|
390
|
+
* required for an INSERT — they are administrative (see SEQUENCE_PRIVILEGES_FOR_INSERT). An
|
|
391
|
+
* identity column's value is supplied by the system as part of the column definition; no
|
|
392
|
+
* caller privilege is consulted at all.
|
|
391
393
|
*
|
|
392
394
|
* Measured, not assumed: a role with INSERT on the table and NOTHING on the sequence inserts
|
|
393
395
|
* successfully into an identity table and fails on a serial one. So the A6 inheritance rule
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -364,15 +364,24 @@ function renderColumnAbility(
|
|
|
364
364
|
grantee: string,
|
|
365
365
|
columns: readonly string[],
|
|
366
366
|
policy: PolicyContract,
|
|
367
|
+
notes: string[],
|
|
367
368
|
): string | null {
|
|
368
369
|
const using = policy.using ?? null;
|
|
369
370
|
const owner = parseOwnerPredicate(using);
|
|
370
371
|
const parts: string[] = [];
|
|
371
372
|
const role = policyRoleForGrantee(grantee);
|
|
372
373
|
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
374
|
+
// A distinct WITH CHECK on a column UPDATE must ride as `check:`, and the compiler REFUSES a
|
|
375
|
+
// role-less column ability that carries any row predicate (F-A12: a column ability has nowhere
|
|
376
|
+
// row-level to put one). So a differing check forces the NAMED-role spelling — `role:
|
|
377
|
+
// 'authenticated'` — which routes to the compiler's named-role column path: that path ANDs the
|
|
378
|
+
// owner back into BOTH halves and honors the check, where the role-less shape would throw.
|
|
379
|
+
const distinctCheck = action === 'update' && policy.check != null && policy.check !== using;
|
|
380
|
+
|
|
381
|
+
// Owner-only uses the model's authenticated default. Every other grantee must be named so the
|
|
382
|
+
// compiler emits the policy TO the live role, not a role-less public ability — and a differing
|
|
383
|
+
// check names it too, to escape the F-A12 refusal above.
|
|
384
|
+
if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: '${role}'`);
|
|
376
385
|
if (owner) {
|
|
377
386
|
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
378
387
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
@@ -382,10 +391,26 @@ function renderColumnAbility(
|
|
|
382
391
|
if (fragment) parts.push(`sql: ${fragment}`);
|
|
383
392
|
}
|
|
384
393
|
|
|
385
|
-
if (
|
|
386
|
-
|
|
394
|
+
if (distinctCheck) {
|
|
395
|
+
// Emit only the guard BEYOND the owner gate. The live WITH CHECK restates the owner term
|
|
396
|
+
// (the compiler's named-role column path ANDs it back in), so carrying the whole predicate
|
|
397
|
+
// would double the owner on recompile and churn the plan. Strip it exactly as the row-level
|
|
398
|
+
// write path does; a check with no owner conjunct rides whole (guard === the check).
|
|
399
|
+
const split = owner ? splitOwnerPredicate(policy.check) : null;
|
|
400
|
+
const guard = split ? split.guard : policy.check;
|
|
401
|
+
const fragment = sqlFragment(guard);
|
|
387
402
|
if (!fragment) return null;
|
|
388
403
|
parts.push(`check: ${fragment}`);
|
|
404
|
+
// Disclosure parity with the row-level write path. When USING is a PURE owner predicate but
|
|
405
|
+
// the live WITH CHECK does NOT restate the owner, the compiler ANDs the owner back into the
|
|
406
|
+
// rendered check — TIGHTENING it (the F-A11 "wider write half" the model cannot express). Say
|
|
407
|
+
// so, rather than let an unexplained conjunct surface in the next db:plan, which an operator
|
|
408
|
+
// might "fix" back toward the wider live shape by hand — the exact spelling the compiler refuses.
|
|
409
|
+
if (split && split.owner == null) {
|
|
410
|
+
notes.push(`policy "${policy.name}" (UPDATE) has a WITH CHECK that does not restate the owner gate in its USING —`);
|
|
411
|
+
notes.push(` it is wider than a column owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
|
|
412
|
+
notes.push(` ${policy.check}`);
|
|
413
|
+
}
|
|
389
414
|
}
|
|
390
415
|
parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
|
|
391
416
|
return `can('${action}', { ${parts.join(', ')} })`;
|
|
@@ -739,7 +764,7 @@ export function deriveAbilities(
|
|
|
739
764
|
if (!cols.length) continue;
|
|
740
765
|
const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
|
|
741
766
|
const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
|
|
742
|
-
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
|
|
767
|
+
const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
|
|
743
768
|
if (ability) {
|
|
744
769
|
abilities.push(ability);
|
|
745
770
|
renderedColumnRoles.add(grantee);
|
|
@@ -277,20 +277,23 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
277
277
|
* Measured on a consumer's database: every INSERT into a serial-PK table failed
|
|
278
278
|
* `permission denied for sequence`, and two of their 563 tests were exactly that.
|
|
279
279
|
*
|
|
280
|
-
* The rule is INSERT on the table → USAGE
|
|
281
|
-
*
|
|
282
|
-
*
|
|
280
|
+
* The rule is INSERT on the table → USAGE on its sequence, and ONLY USAGE. A serial column's
|
|
281
|
+
* value comes from a DEFAULT `nextval(...)`; USAGE is exactly the privilege `nextval` (and
|
|
282
|
+
* `currval`) requires. `setval` (reseed the counter) needs UPDATE and reading `last_value`
|
|
283
|
+
* directly needs SELECT — but neither is part of an INSERT. They are administrative operations,
|
|
284
|
+
* so an INSERT-holder must not inherit them.
|
|
283
285
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
286
|
+
* Least privilege, corrected after a regression: an earlier form derived SELECT + UPDATE + USAGE
|
|
287
|
+
* and handed every INSERT-holder `setval` control of a surrogate key. It widened a consumer's
|
|
288
|
+
* governed prod migrator by 28 UPDATE grants nobody asked for — governance ADDED privileges the
|
|
289
|
+
* role had worked fine without. The fix returns to the minimum INSERT needs: USAGE. A role that
|
|
290
|
+
* genuinely reseeds a counter is administrative and is either ungoverned (the reconciler leaves
|
|
291
|
+
* it alone) or must declare the grant deliberately; it is never inferred from INSERT.
|
|
290
292
|
*
|
|
291
|
-
* Derived, never declared: there is no `sequence:` key in the model
|
|
293
|
+
* Derived, never declared: there is no `sequence:` key in the model. Declaring sequence UPDATE
|
|
294
|
+
* for a governed role is a deliberately deferred feature — added when a real need names it.
|
|
292
295
|
*/
|
|
293
|
-
export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['
|
|
296
|
+
export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['USAGE'] as const;
|
|
294
297
|
|
|
295
298
|
export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
|
|
296
299
|
const out: Record<string, string[]> = {};
|
|
@@ -332,10 +335,10 @@ function reconcileSequenceGrants(
|
|
|
332
335
|
// NOTHING on the sequence inserts fine into an identity table and fails on a serial one.
|
|
333
336
|
//
|
|
334
337
|
// Applying the rule here was a real WIDENING, caught by the round-trip oracle the day
|
|
335
|
-
// identity columns became declarable: the rebuild handed an INSERTing role
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
338
|
+
// identity columns became declarable: the rebuild handed an INSERTing role USAGE on the
|
|
339
|
+
// identity sequence that the original database never granted and never needed. Nothing is
|
|
340
|
+
// revoked either: a grant somebody made deliberately is not ours to remove, and no
|
|
341
|
+
// declaration says it should not be there.
|
|
339
342
|
if (seq.identity) continue;
|
|
340
343
|
const target = quoteQualified(seq.sequence);
|
|
341
344
|
const grantees = new Set([...Object.keys(declared), ...Object.keys(seq.grants)]);
|
package/src/cli/aws.ts
CHANGED
|
@@ -290,7 +290,23 @@ export async function resolveFunctionName(
|
|
|
290
290
|
* credential-free introspection path. Every deployed-stage read verb
|
|
291
291
|
* (db:plan, db:apply --stage's descent probe, …) shares it: catalog SQL runs
|
|
292
292
|
* inside AWS on the operator connection, and the row set comes back over IAM.
|
|
293
|
-
*
|
|
293
|
+
*
|
|
294
|
+
* WHAT MAKES THIS READ-ONLY: not this file, and no longer a keyword blocklist in the
|
|
295
|
+
* action. `db:query` runs its single statement inside an explicit PostgreSQL `READ ONLY`
|
|
296
|
+
* transaction that always rolls back, so the ENGINE refuses a write — after parsing,
|
|
297
|
+
* where no leading comment, quoted identifier, writable CTE, `SELECT ... INTO`,
|
|
298
|
+
* `EXPLAIN ANALYZE <write>`, `COPY`, `MERGE`, `CALL` or `DO` block reaches. (A red team
|
|
299
|
+
* against a real PostgreSQL 16 defeated the blocklist that stood here before; see the
|
|
300
|
+
* comment on the `db:query` action in `@everystack/server`'s plugin.ts.)
|
|
301
|
+
*
|
|
302
|
+
* WHAT IT DOES NOT COVER: a read-only transaction stops DATA writes, not every effect.
|
|
303
|
+
* Still reachable by anyone who may invoke this action — `CLUSTER`, `REINDEX`,
|
|
304
|
+
* `LOCK TABLE ... ACCESS EXCLUSIVE`, `pg_sleep`, `NOTIFY`, and session advisory locks:
|
|
305
|
+
* availability and side-channel effects, not data change. An out-of-transaction escape
|
|
306
|
+
* exists via `dblink_exec` / `pg_background` IF those extensions are installed AND
|
|
307
|
+
* executable by the operator role. Closing those needs a real SELECT-only role wired in
|
|
308
|
+
* as the query connection (`readDatabaseUrl`) — a separate, already-filed follow-up, and
|
|
309
|
+
* an infrastructure decision rather than a code change here.
|
|
294
310
|
*/
|
|
295
311
|
export function lambdaQueryRunner(
|
|
296
312
|
region: string,
|