@everystack/cli 0.4.90 → 0.4.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/db-reconcile.ts +85 -6
- package/src/cli/derived-apply.ts +28 -11
- package/src/cli/derived-grants.ts +12 -1
- package/src/cli/derived-plan.ts +90 -29
package/package.json
CHANGED
|
@@ -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`)
|
|
@@ -476,6 +554,7 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
|
476
554
|
for (const a of plan.actions) {
|
|
477
555
|
const cost = a.bytes !== undefined ? `; ${formatBytes(a.bytes)}${a.rows ? `, ~${a.rows} rows` : ''}` : '';
|
|
478
556
|
lines.push(`${VERB_GLYPH[a.action] ?? '?'} ${a.action} ${a.identity} (${a.reason}${cost})`);
|
|
557
|
+
for (const r of a.revokes ?? []) lines.push(` - ${r} (no longer declared)`);
|
|
479
558
|
}
|
|
480
559
|
// Pending identity migrations. Bookkeeping only — no DDL, no object changed — but they ARE a
|
|
481
560
|
// write, and they were invisible here while also being excluded from `checkFails`. Silence in
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -140,9 +140,11 @@ export type OwnerApplyMode = 'set-role' | 'alter-owner';
|
|
|
140
140
|
* carry pg_dump's WITH NO DATA ordering.)
|
|
141
141
|
*
|
|
142
142
|
* A declared owner is enacted one of two ways; see {@link OwnerApplyMode} for which and why.
|
|
143
|
+
* `revokes` (a replace's REVOKEs of dropped grantees) trail the attachments, inside the owner
|
|
144
|
+
* block for the same reason the attachments are.
|
|
143
145
|
*/
|
|
144
|
-
function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role'): string[] {
|
|
145
|
-
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
|
|
146
|
+
function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', revokes: readonly string[] = []): string[] {
|
|
147
|
+
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql), ...revokes];
|
|
146
148
|
if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
|
|
147
149
|
statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
|
|
148
150
|
}
|
|
@@ -271,7 +273,9 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
271
273
|
// Declared-owner preflight — the refusal that beats a raw Postgres error.
|
|
272
274
|
// ---------------------------------------------------------------------------
|
|
273
275
|
|
|
274
|
-
/** One declared owner and every schema the batch will create an object of theirs in.
|
|
276
|
+
/** One declared owner and every schema the batch will create an object of theirs in. Empty
|
|
277
|
+
* `schemas` means the batch only GRANTs/REVOKEs as that owner: it must exist and be assumable,
|
|
278
|
+
* but it creates nothing, so it needs CREATE nowhere. */
|
|
275
279
|
export interface OwnerRequirement {
|
|
276
280
|
owner: string;
|
|
277
281
|
schemas: string[];
|
|
@@ -280,7 +284,7 @@ export interface OwnerRequirement {
|
|
|
280
284
|
/**
|
|
281
285
|
* The declared owners this batch will actually enact, and where.
|
|
282
286
|
*
|
|
283
|
-
* Scoped to CREATE and
|
|
287
|
+
* Scoped to CREATE, REPLACE and owned REGRANTS, because those are what run under `SET ROLE`. A
|
|
284
288
|
* baseline, a prune or an unchanged object never assumes anyone's identity, so demanding its
|
|
285
289
|
* owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
|
|
286
290
|
* build every object is created, so this set is every declared owner anyway.
|
|
@@ -294,6 +298,9 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
294
298
|
if (!obj?.owner) continue;
|
|
295
299
|
(byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
|
|
296
300
|
}
|
|
301
|
+
for (const r of plan.regrants) {
|
|
302
|
+
if (r.owner && !byOwner.has(r.owner)) byOwner.set(r.owner, new Set());
|
|
303
|
+
}
|
|
297
304
|
return [...byOwner.entries()]
|
|
298
305
|
.map(([owner, schemas]) => ({ owner, schemas: [...schemas].sort() }))
|
|
299
306
|
.sort((a, b) => a.owner.localeCompare(b.owner));
|
|
@@ -308,7 +315,9 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
308
315
|
* and the privilege only evaluated when it is really there.
|
|
309
316
|
*/
|
|
310
317
|
export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
|
|
311
|
-
|
|
318
|
+
// A grant-only owner still needs its row (does it exist, can we assume it): one with no schema.
|
|
319
|
+
const pairs = requirements.flatMap((r) => (r.schemas.length ? r.schemas : [null])
|
|
320
|
+
.map((s) => `(${escapeLiteral(r.owner)}, ${s === null ? 'NULL::text' : escapeLiteral(s)})`));
|
|
312
321
|
return `
|
|
313
322
|
WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
|
|
314
323
|
SELECT
|
|
@@ -343,7 +352,8 @@ export interface OwnerPreflightRow {
|
|
|
343
352
|
builder?: unknown;
|
|
344
353
|
builder_is_superuser?: unknown;
|
|
345
354
|
owner_name: string;
|
|
346
|
-
|
|
355
|
+
/** Null for a grant-only owner — nothing is created, so there is no schema to check. */
|
|
356
|
+
schema_name: string | null;
|
|
347
357
|
role_exists?: unknown;
|
|
348
358
|
schema_exists?: unknown;
|
|
349
359
|
owner_is_superuser?: unknown;
|
|
@@ -401,6 +411,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
401
411
|
// A superuser owner holds CREATE everywhere, including in a schema this build is about to
|
|
402
412
|
// make — there is nothing to refuse.
|
|
403
413
|
if (truthy(row.owner_is_superuser)) continue;
|
|
414
|
+
if (row.schema_name == null) continue;
|
|
404
415
|
if (!truthy(row.schema_exists)) noCreate.push({ owner, schema: String(row.schema_name), exists: false });
|
|
405
416
|
else if (!truthy(row.owner_can_create)) noCreate.push({ owner, schema: String(row.schema_name), exists: true });
|
|
406
417
|
}
|
|
@@ -408,7 +419,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
408
419
|
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
409
420
|
|
|
410
421
|
const lines: string[] = [
|
|
411
|
-
`declared
|
|
422
|
+
`declared owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
|
|
412
423
|
`The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
|
|
413
424
|
`pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
|
|
414
425
|
`The exact statements, if you must run them by hand:`,
|
|
@@ -422,7 +433,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
422
433
|
}
|
|
423
434
|
for (const owner of [...unassumable].sort()) {
|
|
424
435
|
lines.push(
|
|
425
|
-
` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE
|
|
436
|
+
` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
|
|
426
437
|
` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
|
|
427
438
|
);
|
|
428
439
|
}
|
|
@@ -457,7 +468,7 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
457
468
|
case 'replace': {
|
|
458
469
|
const obj = srcById.get(action.identity);
|
|
459
470
|
if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
|
|
460
|
-
statements.push(...objectSql(obj, ownerMode).map(ensureOrReplace));
|
|
471
|
+
statements.push(...objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace));
|
|
461
472
|
record.push(action.identity);
|
|
462
473
|
break;
|
|
463
474
|
}
|
|
@@ -496,8 +507,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
496
507
|
}
|
|
497
508
|
|
|
498
509
|
// Grant convergence rides the same batch, after every object exists. Idempotent
|
|
499
|
-
// REVOKE/GRANT — no provenance to record (ACLs are not in any hash).
|
|
500
|
-
for
|
|
510
|
+
// REVOKE/GRANT — no provenance to record (ACLs are not in any hash). An owned object's grants
|
|
511
|
+
// run AS its owner, for the reason objectSql keeps attachments inside the block: GRANT/REVOKE
|
|
512
|
+
// needs ownership, and a non-superuser operator does not have it. A superuser builder
|
|
513
|
+
// (alter-owner) acts as any owner already.
|
|
514
|
+
for (const r of plan.regrants) {
|
|
515
|
+
if (r.owner && ownerMode === 'set-role') statements.push(`SET ROLE ${r.owner}`, ...r.statements, 'RESET ROLE');
|
|
516
|
+
else statements.push(...r.statements);
|
|
517
|
+
}
|
|
501
518
|
|
|
502
519
|
// NOTE — a `remove` entry that is also a migration's TARGET is CORRECT, not a contradiction.
|
|
503
520
|
// A legacy-keyed function the source has removed migrates onto its signature identity and is
|
|
@@ -62,6 +62,12 @@ export function isPlainGrantAttachment(a: Attachment): boolean {
|
|
|
62
62
|
return g !== null && !g[2]; // g[2] is the (columns) group — present => column-scoped => not plain
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Whether the attachments run a `REVOKE ALL … FROM PUBLIC` — PUBLIC holds nothing after them
|
|
66
|
+
* beyond what a later attachment grants it back. */
|
|
67
|
+
export function revokesPublic(attachments: readonly Attachment[]): boolean {
|
|
68
|
+
return attachments.some((a) => a.kind === 'grant' && REVOKE_PUBLIC_RE.test(a.sql));
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
/** The declared grant contract, read back from an object's own grant attachments. */
|
|
66
72
|
export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
|
|
67
73
|
const grants: Record<string, Set<string>> = {};
|
|
@@ -86,7 +92,12 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
86
92
|
target ??= onTarget.trim();
|
|
87
93
|
const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
88
94
|
for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
|
|
89
|
-
|
|
95
|
+
// PUBLIC is a keyword, not a role: `TO public` and `TO PUBLIC` are one grantee, and the live
|
|
96
|
+
// read spells it `PUBLIC` (the table path's granteeKey, same rule). Keyed as written, a
|
|
97
|
+
// lowercase declaration read as a REVOKE from PUBLIC plus a GRANT to `public` — a replace,
|
|
98
|
+
// which keeps only the REVOKE, removed every caller's access.
|
|
99
|
+
const grantee = role.toLowerCase() === 'public' ? 'PUBLIC' : role;
|
|
100
|
+
for (const privilege of privileges) (grants[grantee] ??= new Set()).add(privilege);
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
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';
|
|
@@ -43,6 +45,10 @@ export interface ReconcileAction {
|
|
|
43
45
|
/** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
|
|
44
46
|
* object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
|
|
45
47
|
dropSql?: string;
|
|
48
|
+
/** A replace's REVOKEs of grantees the source no longer declares. CREATE OR REPLACE keeps the
|
|
49
|
+
* live ACL and the attachments only add, so without these a dropped role keeps its privilege.
|
|
50
|
+
* They run with the replace, inside its owner block — REVOKE on a function needs its owner. */
|
|
51
|
+
revokes?: string[];
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface DriftFinding {
|
|
@@ -54,6 +60,11 @@ export interface DriftFinding {
|
|
|
54
60
|
export interface RegrantFinding {
|
|
55
61
|
identity: string;
|
|
56
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;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
/** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
|
|
@@ -144,6 +155,40 @@ export interface ReconcileOptions {
|
|
|
144
155
|
|
|
145
156
|
const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
|
|
146
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
|
+
|
|
147
192
|
export function planReconcile(
|
|
148
193
|
source: ParsedSources,
|
|
149
194
|
live: DerivedCatalog,
|
|
@@ -474,34 +519,21 @@ export function planReconcile(
|
|
|
474
519
|
// Every object is descriptor-compiled (db/sql retired, B7), and the compiler
|
|
475
520
|
// ALWAYS declares authz — a private relation is declared-empty, not undeclared —
|
|
476
521
|
// so every up-to-date object is grant-checked.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// that a relation's grant set "does not shift with its owner" holds only while relacl is
|
|
490
|
-
// NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
|
|
491
|
-
// exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
|
|
492
|
-
// grants, looped forever — 2026-09-04.)
|
|
493
|
-
// KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
|
|
494
|
-
// is a failure to ADD access (availability), never to remove it, and unnormalized code did
|
|
495
|
-
// not converge on it either.
|
|
496
|
-
const owners = {
|
|
497
|
-
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
498
|
-
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
499
|
-
};
|
|
500
|
-
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
|
|
501
|
-
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
502
|
-
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
503
|
-
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 } : {}) });
|
|
504
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);
|
|
505
537
|
}
|
|
506
538
|
|
|
507
539
|
// Dependency drift — declared, then verified. Only descriptor-home relations carry a
|
|
@@ -631,6 +663,32 @@ export function planReconcile(
|
|
|
631
663
|
return { action, identity: id, kind, reason, ...cost, ...structural };
|
|
632
664
|
};
|
|
633
665
|
|
|
666
|
+
/**
|
|
667
|
+
* The REVOKEs a function replace needs to converge its ACL in the same apply.
|
|
668
|
+
*
|
|
669
|
+
* CREATE OR REPLACE keeps the live ACL, and the compiled attachments only ADD: a REVOKE ALL
|
|
670
|
+
* FROM PUBLIC (unless PUBLIC is declared), then one GRANT per declared role. So a role dropped
|
|
671
|
+
* from the abilities in the same change as a body edit kept its EXECUTE until a second apply
|
|
672
|
+
* found it as grant drift. The ACL after the replace is the live one minus PUBLIC (when the
|
|
673
|
+
* attachments revoke it) plus the declared grants, so only the REVOKEs of the diff are left
|
|
674
|
+
* to do. They ride the replace itself rather than the regrant pass, because the replace runs
|
|
675
|
+
* inside its owner's SET ROLE block and a REVOKE on a function needs its owner.
|
|
676
|
+
*
|
|
677
|
+
* Read after the dependency cascade: a function it promoted to drop+create is recreated with a
|
|
678
|
+
* fresh ACL from its attachments alone, and is no longer in fnReplace.
|
|
679
|
+
*/
|
|
680
|
+
const replaceRevokes = (id: string): string[] => {
|
|
681
|
+
const src = srcById.get(id);
|
|
682
|
+
const liveObj = liveById.get(id);
|
|
683
|
+
if (!src || !liveObj || liveObj.grants === undefined) return [];
|
|
684
|
+
const afterReplace = { ...liveObj.grants };
|
|
685
|
+
if (revokesPublic(src.attachments)) delete afterReplace.PUBLIC;
|
|
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));
|
|
690
|
+
};
|
|
691
|
+
|
|
634
692
|
const actions: ReconcileAction[] = [
|
|
635
693
|
...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
|
|
636
694
|
// 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
|
|
@@ -638,7 +696,10 @@ export function planReconcile(
|
|
|
638
696
|
...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
639
697
|
.map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
|
|
640
698
|
...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|
|
641
|
-
.map(([id, reason]) =>
|
|
699
|
+
.map(([id, reason]) => {
|
|
700
|
+
const revokes = replaceRevokes(id);
|
|
701
|
+
return { ...actionFor(id, 'replace', reason), ...(revokes.length ? { revokes } : {}) };
|
|
702
|
+
}),
|
|
642
703
|
...createSet.sort((a, b) => seqOf(a) - seqOf(b))
|
|
643
704
|
.map((id) => actionFor(id, 'create', rebuild.get(id) ?? create.get(id) ?? '')),
|
|
644
705
|
...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|