@everystack/cli 0.4.91 → 0.4.93
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 +51 -0
- package/package.json +2 -2
- package/src/cli/commands/db-reconcile.ts +84 -6
- package/src/cli/derived-apply.ts +68 -17
- package/src/cli/derived-plan.ts +60 -34
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ 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.93] - 2026-09-24
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- a declared owner needs USAGE on its schema; db:apply refuses without it, db:provision (server 0.4.33) grants it
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- name USAGE with CREATE for a missing schema; review follow-ups
|
|
16
|
+
|
|
17
|
+
## [0.4.92] - 2026-09-24
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- grant drift on an owned object runs as its owner
|
|
22
|
+
- an apply fails unless the grants it set really converged
|
|
23
|
+
- settle a built object's grants before checking convergence
|
|
24
|
+
- settle statements are recorded and named; pin settle-as-owner
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
- a declared owner needs USAGE on its schema to grant there
|
|
29
|
+
|
|
30
|
+
## [0.4.91] - 2026-09-24
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- one apply revokes a grantee dropped from a replaced function
|
|
35
|
+
- a lowercase public grant is PUBLIC in the function grant diff
|
|
36
|
+
|
|
37
|
+
## [0.4.90] - 2026-09-23
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- db:plan warns when the derived layer is out of sync
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- db:apply reports skipped brownfield derived objects by default
|
|
46
|
+
|
|
47
|
+
## [0.4.89] - 2026-09-22
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- outputsDescribeStage matches SST's truncated app-name prefix
|
|
52
|
+
- discoverFunction/discoverCfFunction/discoverBuckets match SST's truncated app-name prefix
|
|
53
|
+
- warn once when cloudfront:ListTagsForResource is denied during distribution discovery
|
|
54
|
+
- db:provision --rotate probes siteUrl on a no-CDN_URL stage
|
|
55
|
+
- update refuses a missing HOST_URL with or without --stage
|
|
56
|
+
- db:reconcile/db:refresh --only respects parens in function identities
|
|
57
|
+
|
|
7
58
|
## [0.4.88] - 2026-09-21
|
|
8
59
|
|
|
9
60
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.93",
|
|
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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"react": "19.2.0",
|
|
183
183
|
"ts-jest": "29.4.9",
|
|
184
184
|
"zod": "3.25.67",
|
|
185
|
-
"@everystack/server": "0.4.
|
|
185
|
+
"@everystack/server": "0.4.33"
|
|
186
186
|
},
|
|
187
187
|
"scripts": {
|
|
188
188
|
"test": "jest",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import type { QueryRunner } from '../authz-contract';
|
|
33
33
|
import type { SourceObject } from '../derived-source';
|
|
34
34
|
import { introspectDerived } from '../derived-introspect';
|
|
35
|
-
import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
35
|
+
import { planReconcile, objectGrantDelta, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
|
|
36
36
|
import {
|
|
37
37
|
renderReconcileSql,
|
|
38
38
|
renderProvenanceUpsert,
|
|
@@ -269,8 +269,9 @@ export async function executeReconcile(
|
|
|
269
269
|
// stable regardless of the create-time wide path above — and that wide path survives the read.
|
|
270
270
|
// The unwrapped path has no transaction to borrow, so it takes a session of its own.
|
|
271
271
|
phase = 'verify';
|
|
272
|
-
const
|
|
273
|
-
|
|
272
|
+
const reread = () => introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
|
|
273
|
+
let after = await reread();
|
|
274
|
+
let liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
274
275
|
const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
|
|
275
276
|
|
|
276
277
|
// THE ORPHAN CHECK — an outcome assertion, not a plan-shape one. Every identity whose
|
|
@@ -309,6 +310,82 @@ export async function executeReconcile(
|
|
|
309
310
|
);
|
|
310
311
|
}
|
|
311
312
|
|
|
313
|
+
// THE GRANT CONVERGENCE CHECK — the same outcome assertion, for authz. Every object whose
|
|
314
|
+
// grants this batch set (creates, replaces, regrants) must now match its declaration, read
|
|
315
|
+
// from the real post-DDL catalog with the same diff the plan used.
|
|
316
|
+
//
|
|
317
|
+
// A statement that ran is not a statement that worked. PostgreSQL answers a REVOKE that
|
|
318
|
+
// matches nothing with `WARNING: no privileges could be revoked` and a success tag: issued by
|
|
319
|
+
// a role that is not the grantor (an operator while ownership is still converging, or a grant
|
|
320
|
+
// someone with grant option made by hand), it removes nothing. Without this check the apply
|
|
321
|
+
// reported success while the role kept its privilege — the exact failure a declared-authz
|
|
322
|
+
// tool exists to prevent. Inside the atomic path the throw rolls the whole batch back.
|
|
323
|
+
//
|
|
324
|
+
// SETTLE FIRST. A create's ACL is its attachments PLUS whatever the database adds on its own:
|
|
325
|
+
// `ALTER DEFAULT PRIVILEGES` grants (db:provision --with-reader sets exactly these), and — for a
|
|
326
|
+
// superuser builder that creates then hands over — a builder grant folded into the pre-transfer
|
|
327
|
+
// owner's entry and lost by ALTER … OWNER. The plan cannot see either; only the post-DDL catalog
|
|
328
|
+
// can. So each created/replaced object's remaining delta runs NOW, in the same batch, and only
|
|
329
|
+
// then is convergence checked. Without this, those shapes fail every apply, forever: the
|
|
330
|
+
// rollback restores the plan's `create`, which rebuilds the same ACL. (Main converged them one
|
|
331
|
+
// apply late, via the next plan's regrant.) Regrants are not re-run: they were computed from
|
|
332
|
+
// this catalog already, and a repeat of a statement that did not land lands no better.
|
|
333
|
+
const built = plan.actions.filter((a) => a.action === 'create' || a.action === 'replace').map((a) => a.identity);
|
|
334
|
+
const settle: string[] = [];
|
|
335
|
+
for (const identity of built) {
|
|
336
|
+
const src = srcById.get(identity);
|
|
337
|
+
const liveObj = liveById.get(identity);
|
|
338
|
+
if (!src || !liveObj) continue;
|
|
339
|
+
const delta = objectGrantDelta(src, liveObj);
|
|
340
|
+
if (!delta || delta.columnScoped || delta.statements.length === 0) continue;
|
|
341
|
+
// As the owner under set-role, for the reason regrants are: GRANT/REVOKE needs ownership.
|
|
342
|
+
// The preflight already proved every created object's owner assumable.
|
|
343
|
+
if (src.owner && liveObj.owner === src.owner && (ownerMode ?? 'set-role') === 'set-role') {
|
|
344
|
+
settle.push(`SET ROLE ${src.owner}`, ...delta.statements, 'RESET ROLE');
|
|
345
|
+
} else {
|
|
346
|
+
settle.push(...delta.statements);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (settle.length > 0) {
|
|
350
|
+
// Recorded BEFORE it runs, so a failed attempt's schema_log row carries the statement that failed.
|
|
351
|
+
rendered = { ...rendered, statements: [...rendered.statements, ...settle] };
|
|
352
|
+
phase = 'ddl'; // a GRANT/REVOKE that fails is a DDL failure, not a verify one
|
|
353
|
+
try {
|
|
354
|
+
if (atomic) await runner(settle.join(';\n'));
|
|
355
|
+
else for (const statement of settle) await runner(statement);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`${String((err as any)?.message ?? err)}\n\nThis came from the grant settle pass, which runs after the DDL: `
|
|
359
|
+
+ `the grants the built objects still needed once the catalog was read back (${settle.filter((st) => !/ROLE\b/.test(st)).join('; ')}).`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
phase = 'verify';
|
|
363
|
+
after = await reread();
|
|
364
|
+
liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const grantTouched = new Set([...built, ...plan.regrants.map((r) => r.identity)]);
|
|
368
|
+
const unconverged: string[] = [];
|
|
369
|
+
for (const identity of [...grantTouched].sort()) {
|
|
370
|
+
const src = srcById.get(identity);
|
|
371
|
+
const liveObj = liveById.get(identity);
|
|
372
|
+
if (!src || !liveObj) continue;
|
|
373
|
+
const delta = objectGrantDelta(src, liveObj);
|
|
374
|
+
if (delta && !delta.columnScoped && delta.statements.length > 0) {
|
|
375
|
+
unconverged.push(`${identity}: still needs ${delta.statements.join('; ')}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (unconverged.length > 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`grants did not converge — the statements ran, but the catalog afterwards still differs from the `
|
|
381
|
+
+ `declaration:\n ${unconverged.join('\n ')}\n\nA REVOKE that matches nothing only WARNs in PostgreSQL. `
|
|
382
|
+
+ `The usual cause of a REVOKE that did not land is a grant whose grantor is not the role that ran it: `
|
|
383
|
+
+ `the object's live owner differs from its declared owner (converge ownership with \`everystack `
|
|
384
|
+
+ `db:provision\`), or a role holding grant option granted it by hand (revoke it as that role). A GRANT `
|
|
385
|
+
+ `that did not land means the role that ran it holds no grant option on the object. No provenance was written.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
312
389
|
const bookkeeping: string[] = [];
|
|
313
390
|
// ORDER MATTERS: migrate, then record, then remove. This ordering is LOAD-BEARING — it is the
|
|
314
391
|
// fix, not the belt. `renderProvenanceMigrate`'s own guard is the belt (see its docstring).
|
|
@@ -426,9 +503,10 @@ export function explainReconcileError(err: unknown, phase: 'ddl' | 'verify' | 'b
|
|
|
426
503
|
`${msg}\n\nThis failed AFTER the DDL, not in it. `
|
|
427
504
|
+ (phase === 'verify'
|
|
428
505
|
? `The failure came from the verify half: the post-apply catalog re-read, or one of the checks `
|
|
429
|
-
+ `over it —
|
|
430
|
-
+ `
|
|
431
|
-
+ `was written; the bookkeeping
|
|
506
|
+
+ `over it — an object whose DROP left it live, grants that did not converge, a recorded `
|
|
507
|
+
+ `identity with no source object, an object the catalog cannot see afterwards, or a backend `
|
|
508
|
+
+ `pid that moved. No provenance and no 'applied' schema_log row was written; the bookkeeping `
|
|
509
|
+
+ `write is never reached`
|
|
432
510
|
: `The failure came from the bookkeeping write that records what was built — the provenance and `
|
|
433
511
|
+ `schema_log statements, or the COMMIT that seals them. The post-apply catalog re-read `
|
|
434
512
|
+ `succeeded, so every recorded object was introspectable when it ran`)
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -273,16 +273,19 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
273
273
|
// Declared-owner preflight — the refusal that beats a raw Postgres error.
|
|
274
274
|
// ---------------------------------------------------------------------------
|
|
275
275
|
|
|
276
|
-
/** One declared owner
|
|
276
|
+
/** One declared owner, every schema the batch will create an object of theirs in (`schemas`: the
|
|
277
|
+
* owner needs CREATE and USAGE there), and every other schema it only GRANTs/REVOKEs in
|
|
278
|
+
* (`grantSchemas`: USAGE alone — a GRANT names `schema.object`, and that lookup needs USAGE). */
|
|
277
279
|
export interface OwnerRequirement {
|
|
278
280
|
owner: string;
|
|
279
281
|
schemas: string[];
|
|
282
|
+
grantSchemas?: string[];
|
|
280
283
|
}
|
|
281
284
|
|
|
282
285
|
/**
|
|
283
286
|
* The declared owners this batch will actually enact, and where.
|
|
284
287
|
*
|
|
285
|
-
* Scoped to CREATE and
|
|
288
|
+
* Scoped to CREATE, REPLACE and owned REGRANTS, because those are what run under `SET ROLE`. A
|
|
286
289
|
* baseline, a prune or an unchanged object never assumes anyone's identity, so demanding its
|
|
287
290
|
* owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
|
|
288
291
|
* build every object is created, so this set is every declared owner anyway.
|
|
@@ -296,9 +299,18 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
296
299
|
if (!obj?.owner) continue;
|
|
297
300
|
(byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
|
|
298
301
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
+
const grantOnly = new Map<string, Set<string>>();
|
|
303
|
+
for (const r of plan.regrants) {
|
|
304
|
+
const schema = srcById.get(r.identity)?.schema;
|
|
305
|
+
if (!r.owner || !schema) continue;
|
|
306
|
+
(grantOnly.get(r.owner) ?? grantOnly.set(r.owner, new Set()).get(r.owner)!).add(schema);
|
|
307
|
+
}
|
|
308
|
+
const owners = new Set([...byOwner.keys(), ...grantOnly.keys()]);
|
|
309
|
+
return [...owners].sort().map((owner) => {
|
|
310
|
+
const schemas = [...(byOwner.get(owner) ?? [])].sort();
|
|
311
|
+
const grantSchemas = [...(grantOnly.get(owner) ?? [])].filter((s) => !schemas.includes(s)).sort();
|
|
312
|
+
return { owner, schemas, ...(grantSchemas.length ? { grantSchemas } : {}) };
|
|
313
|
+
});
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
/**
|
|
@@ -310,9 +322,13 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
310
322
|
* and the privilege only evaluated when it is really there.
|
|
311
323
|
*/
|
|
312
324
|
export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
|
|
313
|
-
const
|
|
325
|
+
const rowsFor = (r: OwnerRequirement) => [
|
|
326
|
+
...r.schemas.map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)}, true)`),
|
|
327
|
+
...(r.grantSchemas ?? []).map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)}, false)`),
|
|
328
|
+
];
|
|
329
|
+
const triples = requirements.flatMap(rowsFor);
|
|
314
330
|
return `
|
|
315
|
-
WITH declared(owner_name, schema_name) AS (VALUES ${
|
|
331
|
+
WITH declared(owner_name, schema_name, needs_create) AS (VALUES ${triples.join(', ')})
|
|
316
332
|
SELECT
|
|
317
333
|
CURRENT_USER AS builder,
|
|
318
334
|
-- Which mechanism the applier may use. A SUPERUSER (RDS: an rds_superuser member) can hand an
|
|
@@ -328,12 +344,15 @@ SELECT
|
|
|
328
344
|
) AS builder_is_superuser,
|
|
329
345
|
d.owner_name,
|
|
330
346
|
d.schema_name,
|
|
347
|
+
d.needs_create,
|
|
331
348
|
(r.oid IS NOT NULL) AS role_exists,
|
|
332
349
|
(n.oid IS NOT NULL) AS schema_exists,
|
|
333
350
|
r.rolsuper AS owner_is_superuser,
|
|
334
351
|
CASE WHEN r.oid IS NULL THEN NULL ELSE pg_has_role(CURRENT_USER, r.oid, 'SET') END AS can_set_role,
|
|
335
352
|
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
|
|
336
|
-
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
|
|
353
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create,
|
|
354
|
+
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
|
|
355
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'USAGE') END AS owner_can_use
|
|
337
356
|
FROM declared d
|
|
338
357
|
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
339
358
|
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
@@ -346,11 +365,14 @@ export interface OwnerPreflightRow {
|
|
|
346
365
|
builder_is_superuser?: unknown;
|
|
347
366
|
owner_name: string;
|
|
348
367
|
schema_name: string;
|
|
368
|
+
/** False for a schema the owner only GRANTs/REVOKEs in — USAGE is checked, CREATE is not. */
|
|
369
|
+
needs_create?: unknown;
|
|
349
370
|
role_exists?: unknown;
|
|
350
371
|
schema_exists?: unknown;
|
|
351
372
|
owner_is_superuser?: unknown;
|
|
352
373
|
can_set_role?: unknown;
|
|
353
374
|
owner_can_create?: unknown;
|
|
375
|
+
owner_can_use?: unknown;
|
|
354
376
|
}
|
|
355
377
|
|
|
356
378
|
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
@@ -369,7 +391,7 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
|
369
391
|
/**
|
|
370
392
|
* The refusal text, or null when every declared owner is usable.
|
|
371
393
|
*
|
|
372
|
-
*
|
|
394
|
+
* Four ways a declared owner fails, all named at once with the exact statement that fixes each:
|
|
373
395
|
*
|
|
374
396
|
* 1. THE ROLE DOES NOT EXIST. The build refuses and does NOT create it. A role is
|
|
375
397
|
* cluster-scoped and a security principal, and a principal invented by a tool cannot know
|
|
@@ -381,6 +403,9 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
|
381
403
|
* 3. THE OWNER CANNOT CREATE IN THE SCHEMA. Same mechanism — the CREATE happens AS the owner,
|
|
382
404
|
* so the owner needs CREATE on the schema the object lives in. A schema that does not exist
|
|
383
405
|
* yet counts: this build creates it as the BUILDER, and the owner will not hold CREATE on it.
|
|
406
|
+
* 4. THE OWNER CANNOT USE THE SCHEMA. Every GRANT/REVOKE it runs on its objects (attachments,
|
|
407
|
+
* a replace's revokes, regrants, the settle pass) names `schema.object`, and resolving that
|
|
408
|
+
* needs USAGE — checked for schemas it creates in AND schemas it only grants in.
|
|
384
409
|
*/
|
|
385
410
|
export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null {
|
|
386
411
|
const builder = rows.find((r) => r.builder != null)?.builder;
|
|
@@ -388,6 +413,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
388
413
|
const missing = new Set<string>();
|
|
389
414
|
const unassumable = new Set<string>();
|
|
390
415
|
const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
|
|
416
|
+
const noUsage: Array<{ owner: string; schema: string }> = [];
|
|
391
417
|
|
|
392
418
|
// A superuser builder creates as itself and ALTERs the owner, so it needs neither membership in
|
|
393
419
|
// the owner nor CREATE on the owner's behalf. Refusing on either would be a false refusal that
|
|
@@ -403,15 +429,28 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
403
429
|
// A superuser owner holds CREATE everywhere, including in a schema this build is about to
|
|
404
430
|
// make — there is nothing to refuse.
|
|
405
431
|
if (truthy(row.owner_is_superuser)) continue;
|
|
406
|
-
|
|
407
|
-
|
|
432
|
+
const schema = String(row.schema_name);
|
|
433
|
+
const needsCreate = truthy(row.needs_create);
|
|
434
|
+
if (!truthy(row.schema_exists)) {
|
|
435
|
+
// Named whole, in one round: the owner will need both once the builder creates the schema.
|
|
436
|
+
if (needsCreate) {
|
|
437
|
+
noCreate.push({ owner, schema, exists: false });
|
|
438
|
+
noUsage.push({ owner, schema });
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (needsCreate && !truthy(row.owner_can_create)) noCreate.push({ owner, schema, exists: true });
|
|
443
|
+
// Every GRANT/REVOKE the owner runs names `schema.object`, and resolving it needs USAGE. An
|
|
444
|
+
// owned function always has one (its REVOKE ALL … FROM PUBLIC), so this was a mid-batch
|
|
445
|
+
// `permission denied for schema` before it was a refusal.
|
|
446
|
+
if (!truthy(row.owner_can_use)) noUsage.push({ owner, schema });
|
|
408
447
|
}
|
|
409
448
|
|
|
410
|
-
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
449
|
+
if (!missing.size && !unassumable.size && !noCreate.length && !noUsage.length) return null;
|
|
411
450
|
|
|
412
451
|
const lines: string[] = [
|
|
413
|
-
`declared
|
|
414
|
-
`The one-command fix is \`everystack db:provision
|
|
452
|
+
`declared owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
|
|
453
|
+
`The one-command fix is \`everystack db:provision\` (USAGE needs @everystack/server >= 0.4.33 on the stage): it ENACTS these grants (and ADOPTS any`,
|
|
415
454
|
`pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
|
|
416
455
|
`The exact statements, if you must run them by hand:`,
|
|
417
456
|
];
|
|
@@ -424,7 +463,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
424
463
|
}
|
|
425
464
|
for (const owner of [...unassumable].sort()) {
|
|
426
465
|
lines.push(
|
|
427
|
-
` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE
|
|
466
|
+
` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
|
|
428
467
|
` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
|
|
429
468
|
);
|
|
430
469
|
}
|
|
@@ -434,6 +473,12 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
434
473
|
` GRANT CREATE ON SCHEMA ${schema} TO ${owner};`,
|
|
435
474
|
);
|
|
436
475
|
}
|
|
476
|
+
for (const { owner, schema } of noUsage) {
|
|
477
|
+
lines.push(
|
|
478
|
+
` NO USAGE ON SCHEMA '${owner}' cannot use schema "${schema}" — its GRANT/REVOKE on the objects there name the schema:`,
|
|
479
|
+
` GRANT USAGE ON SCHEMA ${schema} TO ${owner};`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
437
482
|
return lines.join('\n');
|
|
438
483
|
}
|
|
439
484
|
|
|
@@ -498,8 +543,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
498
543
|
}
|
|
499
544
|
|
|
500
545
|
// Grant convergence rides the same batch, after every object exists. Idempotent
|
|
501
|
-
// REVOKE/GRANT — no provenance to record (ACLs are not in any hash).
|
|
502
|
-
for
|
|
546
|
+
// REVOKE/GRANT — no provenance to record (ACLs are not in any hash). An owned object's grants
|
|
547
|
+
// run AS its owner, for the reason objectSql keeps attachments inside the block: GRANT/REVOKE
|
|
548
|
+
// needs ownership, and a non-superuser operator does not have it. A superuser builder
|
|
549
|
+
// (alter-owner) acts as any owner already.
|
|
550
|
+
for (const r of plan.regrants) {
|
|
551
|
+
if (r.owner && ownerMode === 'set-role') statements.push(`SET ROLE ${r.owner}`, ...r.statements, 'RESET ROLE');
|
|
552
|
+
else statements.push(...r.statements);
|
|
553
|
+
}
|
|
503
554
|
|
|
504
555
|
// NOTE — a `remove` entry that is also a migration's TARGET is CORRECT, not a contradiction.
|
|
505
556
|
// A legacy-keyed function the source has removed migrates onto its signature identity and is
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source';
|
|
28
28
|
import type { DerivedCatalog, LiveObject } from './derived-introspect';
|
|
29
29
|
import { triggerDropSql } from './derived-apply';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
parseGrantAttachments, diffObjectGrants, revokesPublic, type GrantOwnerContext, type ObjectGrantDiff,
|
|
32
|
+
} from './derived-grants';
|
|
31
33
|
import { legacyFunctionIdentity, splitFunctionIdentity } from './pg-argtypes';
|
|
32
34
|
|
|
33
35
|
export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
|
|
@@ -58,6 +60,11 @@ export interface DriftFinding {
|
|
|
58
60
|
export interface RegrantFinding {
|
|
59
61
|
identity: string;
|
|
60
62
|
statements: string[];
|
|
63
|
+
/** The role the statements must run as: GRANT/REVOKE on an object needs its owner, and a
|
|
64
|
+
* non-superuser operator is not it. Set only when the declared owner IS the live owner — under
|
|
65
|
+
* any other role a REVOKE can match nothing and only WARN. That case runs as the operator, and
|
|
66
|
+
* the post-apply convergence check (db-reconcile) fails it rather than letting it read as success. */
|
|
67
|
+
owner?: string;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
|
|
@@ -148,6 +155,40 @@ export interface ReconcileOptions {
|
|
|
148
155
|
|
|
149
156
|
const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
|
|
150
157
|
|
|
158
|
+
const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext => ({
|
|
159
|
+
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
160
|
+
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The REVOKE/GRANT delta between an object's declared grants and a live ACL — the ONE diff the
|
|
165
|
+
* drift pass, a replace's revokes and the post-apply convergence check all use, so the check can
|
|
166
|
+
* never disagree with the plan about what "converged" means. Null when there is nothing to
|
|
167
|
+
* compare (no ACL read, or a kind with no grants). Column-scoped grants are not compared
|
|
168
|
+
* (attacl is not introspected) and say so.
|
|
169
|
+
*
|
|
170
|
+
* Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded from
|
|
171
|
+
* the live read for relations too (`a.grantee <> c.relowner`), so a model that both owns an object
|
|
172
|
+
* and lists that owner in its grants declares a self-grant the read can never confirm —
|
|
173
|
+
* diffObjectGrants normalizes the owner grantee away. The earlier belief that a relation's grant
|
|
174
|
+
* set "does not shift with its owner" holds only while relacl is NULL; the first non-owner grant
|
|
175
|
+
* materializes the ACL and the owner's own rows drop out, exactly as for a function. (A consumer's
|
|
176
|
+
* ADOPT'd view, owned by a role its model also grants, looped forever — 2026-09-04.)
|
|
177
|
+
* KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that is a
|
|
178
|
+
* failure to ADD access (availability), never to remove it.
|
|
179
|
+
*/
|
|
180
|
+
export function objectGrantDelta(
|
|
181
|
+
src: SourceObject,
|
|
182
|
+
liveObj: LiveObject,
|
|
183
|
+
liveGrants: Record<string, string[]> | undefined = liveObj.grants,
|
|
184
|
+
): (ObjectGrantDiff & { columnScoped: boolean }) | null {
|
|
185
|
+
if (liveGrants === undefined || src.kind === 'trigger' || src.kind === 'sql') return null;
|
|
186
|
+
const parsed = parseGrantAttachments(src.attachments);
|
|
187
|
+
if (parsed.hasColumnGrants) return { statements: [], suppressed: [], columnScoped: true };
|
|
188
|
+
const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
|
|
189
|
+
return { ...diffObjectGrants(target, parsed.grants, liveGrants, ownerContext(src, liveObj)), columnScoped: false };
|
|
190
|
+
}
|
|
191
|
+
|
|
151
192
|
export function planReconcile(
|
|
152
193
|
source: ParsedSources,
|
|
153
194
|
live: DerivedCatalog,
|
|
@@ -465,10 +506,6 @@ export function planReconcile(
|
|
|
465
506
|
|
|
466
507
|
const regrants: RegrantFinding[] = [];
|
|
467
508
|
const dependencyDrift: DependencyDriftFinding[] = [];
|
|
468
|
-
const ownerContext = (src: SourceObject, liveObj: LiveObject): { declared?: string; live?: string } => ({
|
|
469
|
-
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
470
|
-
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
471
|
-
});
|
|
472
509
|
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
473
510
|
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
474
511
|
// instead of surfacing on the next plan.
|
|
@@ -482,30 +519,21 @@ export function planReconcile(
|
|
|
482
519
|
// Every object is descriptor-compiled (db/sql retired, B7), and the compiler
|
|
483
520
|
// ALWAYS declares authz — a private relation is declared-empty, not undeclared —
|
|
484
521
|
// so every up-to-date object is grant-checked.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
// that a relation's grant set "does not shift with its owner" holds only while relacl is
|
|
498
|
-
// NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
|
|
499
|
-
// exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
|
|
500
|
-
// grants, looped forever — 2026-09-04.)
|
|
501
|
-
// KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
|
|
502
|
-
// is a failure to ADD access (availability), never to remove it, and unnormalized code did
|
|
503
|
-
// not converge on it either.
|
|
504
|
-
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, ownerContext(src, liveObj));
|
|
505
|
-
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
506
|
-
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
507
|
-
for (const note of suppressed) extraWarnings.push(note);
|
|
522
|
+
const delta = liveObj ? objectGrantDelta(src, liveObj) : null;
|
|
523
|
+
if (delta?.columnScoped) {
|
|
524
|
+
extraWarnings.push(
|
|
525
|
+
`${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
|
|
526
|
+
);
|
|
527
|
+
} else if (delta && liveObj) {
|
|
528
|
+
if (delta.statements.length > 0) {
|
|
529
|
+
// Run as the owner only when the declared owner IS the live owner: under any other role a
|
|
530
|
+
// REVOKE can match nothing and only WARN. The post-apply convergence check catches that
|
|
531
|
+
// case whichever role runs it.
|
|
532
|
+
const owner = src.owner !== undefined && liveObj.owner === src.owner ? src.owner : undefined;
|
|
533
|
+
regrants.push({ identity: src.identity, statements: delta.statements, ...(owner ? { owner } : {}) });
|
|
508
534
|
}
|
|
535
|
+
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
536
|
+
for (const note of delta.suppressed) extraWarnings.push(note);
|
|
509
537
|
}
|
|
510
538
|
|
|
511
539
|
// Dependency drift — declared, then verified. Only descriptor-home relations carry a
|
|
@@ -653,14 +681,12 @@ export function planReconcile(
|
|
|
653
681
|
const src = srcById.get(id);
|
|
654
682
|
const liveObj = liveById.get(id);
|
|
655
683
|
if (!src || !liveObj || liveObj.grants === undefined) return [];
|
|
656
|
-
const parsed = parseGrantAttachments(src.attachments);
|
|
657
|
-
if (parsed.hasColumnGrants) return [];
|
|
658
|
-
const target = parsed.target ?? `FUNCTION ${src.identity}`;
|
|
659
684
|
const afterReplace = { ...liveObj.grants };
|
|
660
685
|
if (revokesPublic(src.attachments)) delete afterReplace.PUBLIC;
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
686
|
+
const delta = objectGrantDelta(src, liveObj, afterReplace);
|
|
687
|
+
if (!delta || delta.columnScoped) return [];
|
|
688
|
+
for (const note of delta.suppressed) extraWarnings.push(note);
|
|
689
|
+
return delta.statements.filter((st) => /^REVOKE\b/i.test(st));
|
|
664
690
|
};
|
|
665
691
|
|
|
666
692
|
const actions: ReconcileAction[] = [
|