@everystack/cli 0.4.55 → 0.4.57
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 +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
|
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
|
|
|
65
65
|
models: ModelDescriptor[] | null,
|
|
66
66
|
sequences?: SequenceDescriptor[],
|
|
67
67
|
governedExtras?: readonly string[],
|
|
68
|
+
/** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
|
|
69
|
+
declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
|
|
68
70
|
): Promise<FingerprintStatus> {
|
|
69
71
|
const snapshot = await introspectSchema(session);
|
|
70
72
|
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
|
|
|
77
79
|
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
78
80
|
const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
|
|
79
81
|
const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
|
|
80
|
-
|
|
82
|
+
// B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
|
|
83
|
+
// unfingerprinted would be a false claim in the one report whose whole job is honesty about
|
|
84
|
+
// coverage. SQL cannot know what the models declare; the filter belongs here.
|
|
85
|
+
const ownerDeclared = new Set(
|
|
86
|
+
(declaredDerived ?? [])
|
|
87
|
+
.filter((o) => o.kind === 'function' && o.owner !== undefined)
|
|
88
|
+
.map((o) => o.identity.replace(/\(.*\)$/, '')),
|
|
89
|
+
);
|
|
90
|
+
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
|
|
91
|
+
.filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
|
|
81
92
|
return {
|
|
82
93
|
live,
|
|
83
94
|
...(governedLive !== undefined ? { governedLive } : {}),
|
|
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
101
112
|
let models: ModelDescriptor[] | null = null;
|
|
102
113
|
let sequences: SequenceDescriptor[] | undefined;
|
|
103
114
|
let governedExtras: string[] | undefined;
|
|
115
|
+
let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
|
|
104
116
|
try {
|
|
105
117
|
models = await loadModels(modelsPath);
|
|
106
118
|
const declared = await loadDeclaredDerived(flags.models);
|
|
107
119
|
sequences = declared?.sequences;
|
|
108
120
|
governedExtras = declared?.governedRoles;
|
|
121
|
+
// B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
|
|
122
|
+
declaredDerived = declared?.objects;
|
|
109
123
|
} catch (err: any) {
|
|
110
124
|
// Two very different situations used to land here identically.
|
|
111
125
|
//
|
|
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
try {
|
|
144
|
-
const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
|
|
158
|
+
const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
|
|
145
159
|
|
|
146
160
|
if (flags.json === 'true') {
|
|
147
161
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -279,9 +279,26 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
279
279
|
// work — say so on the review surface, where a human still reads it.
|
|
280
280
|
if (plan.statements.length > 0) {
|
|
281
281
|
const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
// A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
|
|
283
|
+
// it did not name, so `indeterminate` would have minted a plan with no warning at all
|
|
284
|
+
// and then been refused at apply time with no hint of why. Exhaustive here means a new
|
|
285
|
+
// verdict cannot be forgotten.
|
|
286
|
+
switch (verdict.status) {
|
|
287
|
+
case 'ok':
|
|
288
|
+
case 'fresh-target':
|
|
289
|
+
case 'no-git':
|
|
290
|
+
break;
|
|
291
|
+
case 'diverged':
|
|
292
|
+
case 'drift':
|
|
293
|
+
warn(`descent: ${verdict.reason}`);
|
|
294
|
+
warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
|
|
295
|
+
break;
|
|
296
|
+
case 'indeterminate':
|
|
297
|
+
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
298
|
+
// search failed, so the rule is unproven either way.
|
|
299
|
+
warn(`descent: ${verdict.reason}`);
|
|
300
|
+
warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
|
|
301
|
+
break;
|
|
285
302
|
}
|
|
286
303
|
}
|
|
287
304
|
|
|
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
37
37
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
38
|
-
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
38
|
+
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
41
41
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
|
|
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
235
|
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
236
236
|
// as exemptions too would double-book them — declared and exempted at once.
|
|
237
237
|
pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
238
|
+
// A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
|
|
239
|
+
// common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
|
|
240
|
+
// what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
|
|
241
|
+
// it would be the tool deciding a security property from an inference.
|
|
242
|
+
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
|
|
238
243
|
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
239
244
|
}
|
|
240
245
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
derivedSearchPath,
|
|
44
44
|
renderSetSearchPath,
|
|
45
45
|
renderEnsureObjectSchemas,
|
|
46
|
+
ownerRequirements,
|
|
47
|
+
ownerPreflightSql,
|
|
48
|
+
ownerPreflightRefusal,
|
|
49
|
+
builderOwnerMode,
|
|
50
|
+
type OwnerApplyMode,
|
|
46
51
|
ENSURE_RECONCILER_SQL,
|
|
47
52
|
} from '../derived-apply.js';
|
|
48
53
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
@@ -78,6 +83,13 @@ export interface ReconcileRun {
|
|
|
78
83
|
statements: string[];
|
|
79
84
|
/** Why apply was refused, when it was. */
|
|
80
85
|
refusal?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Which mechanism enacted the declared owners, when any were enacted. Reported because the two
|
|
88
|
+
* make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
|
|
89
|
+
* the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
|
|
90
|
+
* operator debugging a permission error should not have to guess which one ran.
|
|
91
|
+
*/
|
|
92
|
+
ownerMode?: OwnerApplyMode;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
@@ -118,7 +130,10 @@ export async function executeReconcile(
|
|
|
118
130
|
const live = await introspectDerived(session);
|
|
119
131
|
const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
|
|
120
132
|
const plan = planReconcile(parsed, live, options);
|
|
121
|
-
|
|
133
|
+
// Rendered with the mechanism that works for EVERY builder. The preflight below reads the
|
|
134
|
+
// catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
|
|
135
|
+
// (or is refused) shows the conservative form, which is the honest default.
|
|
136
|
+
let rendered = renderReconcileSql(plan, parsed.objects);
|
|
122
137
|
|
|
123
138
|
if (!options.apply) return { plan, applied: false, statements: rendered.statements };
|
|
124
139
|
|
|
@@ -138,6 +153,23 @@ export async function executeReconcile(
|
|
|
138
153
|
return { plan, applied: false, statements: [] };
|
|
139
154
|
}
|
|
140
155
|
|
|
156
|
+
// DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
|
|
157
|
+
//
|
|
158
|
+
// Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
|
|
159
|
+
// rather than one per re-run, so the operator's first news is not a raw Postgres error from the
|
|
160
|
+
// middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
|
|
161
|
+
// with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
|
|
162
|
+
// owner, which needs membership and CREATE on the schema.
|
|
163
|
+
const requirements = ownerRequirements(plan, parsed.objects);
|
|
164
|
+
let ownerMode: OwnerApplyMode | undefined;
|
|
165
|
+
if (requirements.length > 0) {
|
|
166
|
+
const rows = (await runner(ownerPreflightSql(requirements))) as any[];
|
|
167
|
+
const refusal = ownerPreflightRefusal(rows);
|
|
168
|
+
if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
|
|
169
|
+
ownerMode = builderOwnerMode(rows);
|
|
170
|
+
if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
const now = options.now ?? Date.now;
|
|
142
174
|
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
143
175
|
|
|
@@ -263,7 +295,7 @@ export async function executeReconcile(
|
|
|
263
295
|
throw explainReconcileError(err);
|
|
264
296
|
}
|
|
265
297
|
|
|
266
|
-
return { plan, applied: true, statements: rendered.statements };
|
|
298
|
+
return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
|
|
267
299
|
}
|
|
268
300
|
|
|
269
301
|
/**
|
|
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
520
552
|
fail(`not applied: ${run.refusal}`);
|
|
521
553
|
} else if (run.applied) {
|
|
522
554
|
success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
|
|
555
|
+
// Which ownership mechanism ran. The two make different demands, so an operator
|
|
556
|
+
// debugging a permission error should not have to guess which one they hit.
|
|
557
|
+
if (run.ownerMode === 'alter-owner') {
|
|
558
|
+
info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
|
|
559
|
+
} else if (run.ownerMode === 'set-role') {
|
|
560
|
+
info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
|
|
561
|
+
}
|
|
523
562
|
if (run.plan.actions.some((a) => a.action === 'baseline')) {
|
|
524
563
|
warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
|
|
525
564
|
}
|
|
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import {
|
|
17
17
|
audit,
|
|
18
|
+
schemaDescriptorsFromContract,
|
|
18
19
|
parseFunctionsFromSql,
|
|
19
20
|
parseViewsAndGrantsFromSql,
|
|
20
21
|
type Waivers,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
catalogFunctionToDescriptor,
|
|
29
30
|
catalogRelationToDescriptor,
|
|
30
31
|
} from '../security-catalog.js';
|
|
32
|
+
import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract.js';
|
|
31
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
32
34
|
import { invokeAction } from '../aws.js';
|
|
33
35
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -116,11 +118,28 @@ export async function auditDeployedSql(
|
|
|
116
118
|
step('Introspecting database catalog (functions + relations)...');
|
|
117
119
|
const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
|
|
118
120
|
const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
|
|
121
|
+
// A10(ii) — the PRECONDITION leg. Unpinned SECDEF and owner-bypasses-RLS both describe the
|
|
122
|
+
// privileged CODE; neither says whether an attacker can plant something for it to resolve to.
|
|
123
|
+
// Only the catalog path can answer that, so the static path leaves `schemas` empty rather
|
|
124
|
+
// than report every schema as clean.
|
|
125
|
+
const aclRows = await catalogQuery(region, opsFn, SCHEMA_ACL_SQL);
|
|
119
126
|
|
|
120
127
|
const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
|
|
121
128
|
const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
const schemaAcls: Record<string, Record<string, string[]>> = {};
|
|
130
|
+
for (const row of aclRows) {
|
|
131
|
+
schemaAcls[String(row.schema)] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
|
|
132
|
+
}
|
|
133
|
+
const schemas = schemaDescriptorsFromContract(
|
|
134
|
+
schemaAcls,
|
|
135
|
+
fnRows.map((r: any) => ({
|
|
136
|
+
name: `${r.schema}.${r.name}`,
|
|
137
|
+
securityDefiner: r.security_definer === true || r.security_definer === 't',
|
|
138
|
+
hasSearchPath: r.has_search_path === true || r.has_search_path === 't',
|
|
139
|
+
})),
|
|
140
|
+
);
|
|
141
|
+
info(`Catalog: ${functions.length} function(s), ${views.length} relation(s), ${schemas.length} schema(s).`);
|
|
142
|
+
return audit(functions, views, waivers, schemas);
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
|
|
@@ -153,12 +172,14 @@ export function printReport(report: AuditReport, instrument: 'static' | 'catalog
|
|
|
153
172
|
const reds = [
|
|
154
173
|
...report.functions.filter((f) => f.severity === 'red'),
|
|
155
174
|
...report.views.filter((v) => v.severity === 'red'),
|
|
175
|
+
...report.schemas.filter((s) => s.severity === 'red'),
|
|
156
176
|
];
|
|
157
177
|
const warns = [
|
|
158
178
|
...report.functions.filter((f) => f.severity === 'warn'),
|
|
159
179
|
...report.views.filter((v) => v.severity === 'warn'),
|
|
180
|
+
...report.schemas.filter((s) => s.severity === 'warn'),
|
|
160
181
|
];
|
|
161
|
-
const waived = report.functions.filter((f) => f.waived);
|
|
182
|
+
const waived = [...report.functions.filter((f) => f.waived), ...report.schemas.filter((s) => s.waived)];
|
|
162
183
|
|
|
163
184
|
if (reds.length > 0) {
|
|
164
185
|
console.log('');
|
package/src/cli/db-build.ts
CHANGED
|
@@ -129,6 +129,19 @@ export async function buildIntoDatabase(
|
|
|
129
129
|
for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
|
|
130
130
|
await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
|
131
131
|
}
|
|
132
|
+
// A10(i) — ASSERT the schema posture, do not inherit it.
|
|
133
|
+
//
|
|
134
|
+
// PG15 removed PUBLIC's CREATE on schema `public`; before that it was the default, and it
|
|
135
|
+
// still rides in with any pre-PG15 dump. So a database everystack BUILDS was hardened only
|
|
136
|
+
// by accident of the server version, while one it ADOPTS was not — and db:fingerprint
|
|
137
|
+
// reported MATCH across both. A security posture that depends on which major created the
|
|
138
|
+
// database is exactly the environmental dependence this class of defect is about.
|
|
139
|
+
//
|
|
140
|
+
// Safe here and ONLY here: db:build refuses a database that already holds objects, so there
|
|
141
|
+
// is nothing in `public` to strand. Bringing an ADOPTED database to this posture is a
|
|
142
|
+
// reviewable REVOKE the operator sees before it runs, never a silent side effect — some
|
|
143
|
+
// legacy apps do create objects in `public` at runtime.
|
|
144
|
+
await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
|
|
132
145
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
133
146
|
|
|
134
147
|
// FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
|
|
@@ -170,12 +183,34 @@ export async function buildIntoDatabase(
|
|
|
170
183
|
await runner('RESET check_function_bodies');
|
|
171
184
|
}
|
|
172
185
|
}
|
|
173
|
-
|
|
186
|
+
let run = await executeSync(runner, session, models, {
|
|
174
187
|
declared: options.declared,
|
|
175
188
|
sequences: options.sequences,
|
|
176
189
|
actor: options.actor ?? 'db-build',
|
|
177
190
|
gitRef: options.gitRef ?? null,
|
|
178
191
|
});
|
|
192
|
+
// A12 — THE SECOND PASS, and it is inherent to building from nothing rather than a patch
|
|
193
|
+
// over one bug. `executeSync` is a single diff-and-verify: it reads the live authz contract,
|
|
194
|
+
// computes the delta, applies it. On a FROM-SCRATCH build some objects the authz layer must
|
|
195
|
+
// grant on do not exist at read time — most sharply the sequence behind a serial column,
|
|
196
|
+
// which our own CREATE TABLE makes moments later. So the first pass cannot see it, the
|
|
197
|
+
// sequence inheritance rule (A6) has nothing to grant on, and the build lands with a role
|
|
198
|
+
// that may INSERT into a table but cannot draw its sequence value.
|
|
199
|
+
//
|
|
200
|
+
// Caught by the round-trip oracle (B7), not by A6's own tests: the rule converges perfectly
|
|
201
|
+
// against an EXISTING database, which is what those tests exercise.
|
|
202
|
+
//
|
|
203
|
+
// Bounded at exactly one extra pass. The second read sees everything the first one created,
|
|
204
|
+
// so a third could only differ if the apply were non-convergent — and that is a real failure
|
|
205
|
+
// the `converged` bar must report, never something to loop away.
|
|
206
|
+
if (!run.converged) {
|
|
207
|
+
run = await executeSync(runner, session, models, {
|
|
208
|
+
declared: options.declared,
|
|
209
|
+
sequences: options.sequences,
|
|
210
|
+
actor: options.actor ?? 'db-build',
|
|
211
|
+
gitRef: options.gitRef ?? null,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
179
214
|
return {
|
|
180
215
|
converged: run.converged,
|
|
181
216
|
fingerprintMatch: run.fingerprintMatch,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -106,19 +106,72 @@ export function ensureOrReplace(sql: string): string {
|
|
|
106
106
|
const WITH_NO_DATA_RE = /WITH\s+NO\s+DATA\s*$/i;
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
109
|
+
* How the applier enacts a declared owner. Chosen from the BUILDER, not from taste.
|
|
110
|
+
*
|
|
111
|
+
* `set-role` — run the CREATE as the owner. Works for any builder that can assume the role, and
|
|
112
|
+
* it is the only option for a non-superuser builder: PostgreSQL will not let it hand an object
|
|
113
|
+
* to somebody else. The cost is real, and it is borne by the role the feature exists to protect:
|
|
114
|
+
* the CREATE happens AS the owner, so the owner needs `CREATE` on the schema — and a
|
|
115
|
+
* deliberately-powerless operator role commonly has CREATE nowhere.
|
|
116
|
+
*
|
|
117
|
+
* `alter-owner` — create as the builder, then `ALTER … OWNER TO`. Available only to a SUPERUSER
|
|
118
|
+
* (or rds_superuser member), which needs neither membership in the owner nor CREATE for it, and
|
|
119
|
+
* can replace a function it does not own on the re-run. Measured on PG16.
|
|
120
|
+
*
|
|
121
|
+
* Getting this backwards is not cosmetic: forcing `set-role` on a superuser builder would demand
|
|
122
|
+
* a privilege expansion onto the very role that is supposed to hold none.
|
|
123
|
+
*/
|
|
124
|
+
export type OwnerApplyMode = 'set-role' | 'alter-owner';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An object's full creation SQL: the CREATE statement plus its attachments, in source order. A
|
|
128
|
+
* matview whose source says `WITH NO DATA` gets a trailing REFRESH: the reconciler dropped a
|
|
129
|
+
* populated matview to rebuild it, and executing the source verbatim would leave it unpopulated
|
|
130
|
+
* — matching the text but regressing the database. (Found by a consumer whose extracted sources
|
|
131
|
+
* carry pg_dump's WITH NO DATA ordering.)
|
|
132
|
+
*
|
|
133
|
+
* A declared owner is enacted one of two ways; see {@link OwnerApplyMode} for which and why.
|
|
115
134
|
*/
|
|
116
|
-
function objectSql(obj: SourceObject): string[] {
|
|
135
|
+
function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role'): string[] {
|
|
117
136
|
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
|
|
118
137
|
if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
|
|
119
138
|
statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
|
|
120
139
|
}
|
|
121
|
-
return statements;
|
|
140
|
+
if (!obj.owner) return statements;
|
|
141
|
+
|
|
142
|
+
if (ownerMode === 'alter-owner') {
|
|
143
|
+
// A superuser builder creates as itself and hands the object over. It needs no membership in
|
|
144
|
+
// the owner and no CREATE on the owner's behalf, so the owner role stays as powerless as it
|
|
145
|
+
// was designed to be — which is the whole point of naming it. The re-run is safe for the
|
|
146
|
+
// same reason: a superuser may CREATE OR REPLACE a function it does not own.
|
|
147
|
+
//
|
|
148
|
+
// The ALTER trails the attachments because a superuser can still make them either way, and
|
|
149
|
+
// "build it, then hand it over" is the order a reader expects.
|
|
150
|
+
return [...statements, `${ALTER_OWNER[obj.kind] ?? 'ALTER FUNCTION'} ${ownerTarget(obj)} OWNER TO ${obj.owner}`];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Every other builder creates AS the owner. Measured on PG16: a non-superuser cannot give an
|
|
154
|
+
// object away, and once ownership has moved off the applying role the next `CREATE OR REPLACE`
|
|
155
|
+
// fails with "must be owner of function" — so the alter-after form would work exactly once and
|
|
156
|
+
// break every later reconcile. Creating under SET ROLE is correct by construction and stays
|
|
157
|
+
// correct on re-run.
|
|
158
|
+
//
|
|
159
|
+
// The attachments stay INSIDE the block: GRANT and COMMENT on a function require ownership, so
|
|
160
|
+
// once the function belongs to `owner` the applying role can no longer make them. RESET ROLE is
|
|
161
|
+
// last and unconditional — a leaked SET ROLE would silently re-own every object created after
|
|
162
|
+
// it. The role name is validated at declaration (defineFunction), never here.
|
|
163
|
+
return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ALTER_OWNER: Record<string, string> = {
|
|
167
|
+
function: 'ALTER FUNCTION',
|
|
168
|
+
view: 'ALTER VIEW',
|
|
169
|
+
'materialized view': 'ALTER MATERIALIZED VIEW',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** The `ALTER … OWNER TO` target. A function needs its argument types — the same rule as DROP. */
|
|
173
|
+
function ownerTarget(obj: SourceObject): string {
|
|
174
|
+
return obj.kind === 'function' ? dropTarget('function', obj.identity) : quoteQualified(obj.identity);
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
/**
|
|
@@ -166,10 +219,22 @@ export function renderSetSearchPath(schemas: string[], local: boolean): string |
|
|
|
166
219
|
*
|
|
167
220
|
* `GRANT USAGE` rides along per schema (0a-bis mirrored), derived from the objects'
|
|
168
221
|
* own grant attachments: a role granted SELECT on a matview in a non-public schema
|
|
169
|
-
* cannot reach it without USAGE, so the object grant is dead without this.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
222
|
+
* cannot reach it without USAGE, so the object grant is dead without this. Empty for
|
|
223
|
+
* all-public apps — their apply stays byte-identical. Idempotent when the state layer
|
|
224
|
+
* already created the schema (a model schema also carrying derived objects).
|
|
225
|
+
*
|
|
226
|
+
* A13 — PUBLIC IS NEVER GRANTED SCHEMA USAGE HERE, and the reason is the distinction this
|
|
227
|
+
* codebase draws everywhere else: an ability is an intended audience, a privilege is a recorded
|
|
228
|
+
* fact. PostgreSQL grants every new function EXECUTE to PUBLIC, `db:pull` faithfully records
|
|
229
|
+
* that as `privileges: { PUBLIC: ['EXECUTE'] }` — and this pass used to read it as an audience
|
|
230
|
+
* and widen the SCHEMA to match. Caught by the round-trip oracle (B7): a schema whose original
|
|
231
|
+
* ACL gave PUBLIC nothing came back from a rebuild with `GRANT USAGE ON SCHEMA … TO PUBLIC`,
|
|
232
|
+
* turning an unreachable default grant into a reachable one. Recording a fact about one object
|
|
233
|
+
* must never widen a DIFFERENT object.
|
|
234
|
+
*
|
|
235
|
+
* The consequence is deliberate: a genuinely PUBLIC-callable function in a non-public schema
|
|
236
|
+
* needs schema USAGE, and that is now a decision someone DECLARES on the schema, not one this
|
|
237
|
+
* pass infers from a default nobody chose.
|
|
173
238
|
*/
|
|
174
239
|
export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
175
240
|
const statements: string[] = [];
|
|
@@ -181,18 +246,184 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
181
246
|
if (o.schema !== schema) continue;
|
|
182
247
|
const { grants } = parseGrantAttachments(o.attachments);
|
|
183
248
|
for (const [role, privileges] of Object.entries(grants)) {
|
|
184
|
-
|
|
249
|
+
// See A13 above: PUBLIC's presence here is a recorded default, never a declared
|
|
250
|
+
// audience, and widening the schema to match it is a real privilege escalation.
|
|
251
|
+
if (privileges.length > 0 && role.toUpperCase() !== 'PUBLIC') roles.add(role);
|
|
185
252
|
}
|
|
186
253
|
}
|
|
187
254
|
if (roles.size > 0) {
|
|
188
|
-
|
|
189
|
-
statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${targets}`);
|
|
255
|
+
statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${[...roles].sort().join(', ')}`);
|
|
190
256
|
}
|
|
191
257
|
}
|
|
192
258
|
return statements;
|
|
193
259
|
}
|
|
194
260
|
|
|
195
|
-
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Declared-owner preflight — the refusal that beats a raw Postgres error.
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/** One declared owner and every schema the batch will create an object of theirs in. */
|
|
266
|
+
export interface OwnerRequirement {
|
|
267
|
+
owner: string;
|
|
268
|
+
schemas: string[];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The declared owners this batch will actually enact, and where.
|
|
273
|
+
*
|
|
274
|
+
* Scoped to CREATE and REPLACE, because those are the actions that run under `SET ROLE`. A
|
|
275
|
+
* baseline, a prune or an unchanged object never assumes anyone's identity, so demanding its
|
|
276
|
+
* owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
|
|
277
|
+
* build every object is created, so this set is every declared owner anyway.
|
|
278
|
+
*/
|
|
279
|
+
export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]): OwnerRequirement[] {
|
|
280
|
+
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
281
|
+
const byOwner = new Map<string, Set<string>>();
|
|
282
|
+
for (const action of plan.actions) {
|
|
283
|
+
if (action.action !== 'create' && action.action !== 'replace') continue;
|
|
284
|
+
const obj = srcById.get(action.identity);
|
|
285
|
+
if (!obj?.owner) continue;
|
|
286
|
+
(byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
|
|
287
|
+
}
|
|
288
|
+
return [...byOwner.entries()]
|
|
289
|
+
.map(([owner, schemas]) => ({ owner, schemas: [...schemas].sort() }))
|
|
290
|
+
.sort((a, b) => a.owner.localeCompare(b.owner));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* ONE catalog read answering every question the preflight has, for every declared owner at once
|
|
295
|
+
* — so the refusal can name the COMPLETE list. A first-failure refusal makes the operator
|
|
296
|
+
* provision one role, re-run, and discover the next; the whole point is that they see the work.
|
|
297
|
+
*
|
|
298
|
+
* `has_schema_privilege` throws on a schema that does not exist, so the schema is LEFT JOINed
|
|
299
|
+
* and the privilege only evaluated when it is really there.
|
|
300
|
+
*/
|
|
301
|
+
export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
|
|
302
|
+
const pairs = requirements.flatMap((r) => r.schemas.map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)})`));
|
|
303
|
+
return `
|
|
304
|
+
WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
|
|
305
|
+
SELECT
|
|
306
|
+
CURRENT_USER AS builder,
|
|
307
|
+
-- Which mechanism the applier may use. A SUPERUSER (RDS: an rds_superuser member) can hand an
|
|
308
|
+
-- object to a role it is not a member of, so it creates as itself and ALTERs the owner —
|
|
309
|
+
-- demanding SET ROLE of it would force CREATE-on-schema onto the very role that is supposed to
|
|
310
|
+
-- hold none. Everyone else creates AS the owner. Same shape as security-catalog's rule.
|
|
311
|
+
(
|
|
312
|
+
SELECT b.rolsuper OR EXISTS (
|
|
313
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
314
|
+
WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
|
|
315
|
+
)
|
|
316
|
+
FROM pg_roles b WHERE b.rolname = CURRENT_USER
|
|
317
|
+
) AS builder_is_superuser,
|
|
318
|
+
d.owner_name,
|
|
319
|
+
d.schema_name,
|
|
320
|
+
(r.oid IS NOT NULL) AS role_exists,
|
|
321
|
+
(n.oid IS NOT NULL) AS schema_exists,
|
|
322
|
+
r.rolsuper AS owner_is_superuser,
|
|
323
|
+
CASE WHEN r.oid IS NULL THEN NULL ELSE pg_has_role(CURRENT_USER, r.oid, 'SET') END AS can_set_role,
|
|
324
|
+
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
|
|
325
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
|
|
326
|
+
FROM declared d
|
|
327
|
+
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
328
|
+
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
329
|
+
ORDER BY d.owner_name, d.schema_name
|
|
330
|
+
`.trim();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface OwnerPreflightRow {
|
|
334
|
+
builder?: unknown;
|
|
335
|
+
builder_is_superuser?: unknown;
|
|
336
|
+
owner_name: string;
|
|
337
|
+
schema_name: string;
|
|
338
|
+
role_exists?: unknown;
|
|
339
|
+
schema_exists?: unknown;
|
|
340
|
+
owner_is_superuser?: unknown;
|
|
341
|
+
can_set_role?: unknown;
|
|
342
|
+
owner_can_create?: unknown;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The mechanism this builder may use, read off the same preflight rows.
|
|
349
|
+
*
|
|
350
|
+
* Defaults to `set-role` when the column is absent — an older caller or a fixture — because that
|
|
351
|
+
* is the mechanism that works for everyone. Guessing `alter-owner` for an unknown builder would
|
|
352
|
+
* emit an ALTER a non-superuser cannot run, and fail mid-batch.
|
|
353
|
+
*/
|
|
354
|
+
export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
355
|
+
return rows.some((r) => truthy(r.builder_is_superuser)) ? 'alter-owner' : 'set-role';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The refusal text, or null when every declared owner is usable.
|
|
360
|
+
*
|
|
361
|
+
* Three ways a declared owner fails, all named at once with the exact statement that fixes each:
|
|
362
|
+
*
|
|
363
|
+
* 1. THE ROLE DOES NOT EXIST. The build refuses and does NOT create it. A role is
|
|
364
|
+
* cluster-scoped and a security principal, and a principal invented by a tool cannot know
|
|
365
|
+
* the design it stands in for — it would arrive with no memberships, no grants and no
|
|
366
|
+
* review, wearing the name of something that was supposed to be deliberate.
|
|
367
|
+
* 2. THE BUILDER CANNOT ASSUME IT. The CREATE runs under `SET ROLE <owner>`, so membership
|
|
368
|
+
* WITH SET TRUE is required. `WITH INHERIT FALSE` is part of the fix on purpose: it lets
|
|
369
|
+
* the builder become the owner without silently inheriting the owner's privileges.
|
|
370
|
+
* 3. THE OWNER CANNOT CREATE IN THE SCHEMA. Same mechanism — the CREATE happens AS the owner,
|
|
371
|
+
* so the owner needs CREATE on the schema the object lives in. A schema that does not exist
|
|
372
|
+
* yet counts: this build creates it as the BUILDER, and the owner will not hold CREATE on it.
|
|
373
|
+
*/
|
|
374
|
+
export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null {
|
|
375
|
+
const builder = rows.find((r) => r.builder != null)?.builder;
|
|
376
|
+
const builderName = builder == null ? 'the connecting role' : String(builder);
|
|
377
|
+
const missing = new Set<string>();
|
|
378
|
+
const unassumable = new Set<string>();
|
|
379
|
+
const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
|
|
380
|
+
|
|
381
|
+
// A superuser builder creates as itself and ALTERs the owner, so it needs neither membership in
|
|
382
|
+
// the owner nor CREATE on the owner's behalf. Refusing on either would be a false refusal that
|
|
383
|
+
// demands a privilege expansion onto a role designed to hold none. Only "the role must exist"
|
|
384
|
+
// survives, because no mechanism can hand an object to a principal that is not there.
|
|
385
|
+
const alterOwner = builderOwnerMode(rows) === 'alter-owner';
|
|
386
|
+
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
const owner = String(row.owner_name);
|
|
389
|
+
if (!truthy(row.role_exists)) { missing.add(owner); continue; }
|
|
390
|
+
if (alterOwner) continue;
|
|
391
|
+
if (!truthy(row.can_set_role)) unassumable.add(owner);
|
|
392
|
+
// A superuser owner holds CREATE everywhere, including in a schema this build is about to
|
|
393
|
+
// make — there is nothing to refuse.
|
|
394
|
+
if (truthy(row.owner_is_superuser)) continue;
|
|
395
|
+
if (!truthy(row.schema_exists)) noCreate.push({ owner, schema: String(row.schema_name), exists: false });
|
|
396
|
+
else if (!truthy(row.owner_can_create)) noCreate.push({ owner, schema: String(row.schema_name), exists: true });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
400
|
+
|
|
401
|
+
const lines: string[] = [
|
|
402
|
+
`declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied:`,
|
|
403
|
+
];
|
|
404
|
+
for (const owner of [...missing].sort()) {
|
|
405
|
+
lines.push(
|
|
406
|
+
` NO SUCH ROLE '${owner}' does not exist in this cluster. The build will not create it —`,
|
|
407
|
+
` a role is a security principal, and one invented by a tool arrives with no memberships,`,
|
|
408
|
+
` no grants and no review. Provision it, then re-run: CREATE ROLE ${owner} NOLOGIN;`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
for (const owner of [...unassumable].sort()) {
|
|
412
|
+
lines.push(
|
|
413
|
+
` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE runs as the owner:`,
|
|
414
|
+
` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
for (const { owner, schema, exists } of noCreate) {
|
|
418
|
+
lines.push(
|
|
419
|
+
` NO CREATE ON SCHEMA '${owner}' cannot create in schema "${schema}"${exists ? '' : ' (which this build creates as the builder)'}:`,
|
|
420
|
+
` GRANT CREATE ON SCHEMA ${schema} TO ${owner};`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return lines.join('\n');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[], ownerMode: OwnerApplyMode = 'set-role'): RenderedReconcile {
|
|
196
427
|
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
197
428
|
const statements: string[] = [];
|
|
198
429
|
const record: string[] = [];
|
|
@@ -214,14 +445,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
214
445
|
case 'replace': {
|
|
215
446
|
const obj = srcById.get(action.identity);
|
|
216
447
|
if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
|
|
217
|
-
statements.push(...objectSql(obj).map(ensureOrReplace));
|
|
448
|
+
statements.push(...objectSql(obj, ownerMode).map(ensureOrReplace));
|
|
218
449
|
record.push(action.identity);
|
|
219
450
|
break;
|
|
220
451
|
}
|
|
221
452
|
case 'create': {
|
|
222
453
|
const obj = srcById.get(action.identity);
|
|
223
454
|
if (!obj) throw new Error(`create action for ${action.identity} has no source object`);
|
|
224
|
-
statements.push(...objectSql(obj));
|
|
455
|
+
statements.push(...objectSql(obj, ownerMode));
|
|
225
456
|
record.push(action.identity);
|
|
226
457
|
break;
|
|
227
458
|
}
|