@everystack/cli 0.4.55 → 0.4.56
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 +2 -2
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- 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/migration-generate.ts +3 -0
- package/src/cli/model-render.ts +28 -3
- 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
|
@@ -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
|
}
|
|
@@ -194,9 +194,20 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
|
|
|
194
194
|
.join(', ');
|
|
195
195
|
const returns = typeof fn.returns === 'string' ? fn.returns : `SETOF ${refName(fn.returns.setof)}`;
|
|
196
196
|
const volatility = fn.volatility === 'volatile' ? '' : ` ${fn.volatility.toUpperCase()}`;
|
|
197
|
-
const
|
|
198
|
-
|
|
197
|
+
const definer = fn.security === 'definer' ? ' SECURITY DEFINER' : '';
|
|
198
|
+
// The pin is INDEPENDENT of the security mode, in both directions.
|
|
199
|
+
//
|
|
200
|
+
// `'unpinned'` emits no `SET search_path` at all — that is the whole point of the sentinel:
|
|
201
|
+
// a brownfield definer function that genuinely has none is now declarable, instead of the
|
|
202
|
+
// pull inventing `pg_catalog` and the build applying the invention for real.
|
|
203
|
+
//
|
|
204
|
+
// And a pin on an INVOKER function is emitted too. It used to be silently dropped, so a model
|
|
205
|
+
// could declare one, the build would not apply it, and the differ (which never read proconfig)
|
|
206
|
+
// would not notice — a declared property with no effect and no complaint.
|
|
207
|
+
const pin = Array.isArray(fn.searchPath) && fn.searchPath.length > 0
|
|
208
|
+
? ` SET search_path = ${fn.searchPath.join(', ')}`
|
|
199
209
|
: '';
|
|
210
|
+
const security = `${definer}${pin}`;
|
|
200
211
|
const tag = dollarTag(fn.body);
|
|
201
212
|
const signature = functionSignature(target, fn);
|
|
202
213
|
return {
|
|
@@ -321,10 +332,12 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
|
|
|
321
332
|
kind, schema, name,
|
|
322
333
|
identity: extra.identity ?? `${schema}.${name}`,
|
|
323
334
|
sql, attachments,
|
|
324
|
-
hash: hashSourceContent(sql, attachments),
|
|
335
|
+
hash: hashSourceContent(sql, attachments, extra.owner),
|
|
325
336
|
// bodyHash excludes PLAIN grants (column-scoped grants stay in — attacl isn't drift-checked, so
|
|
326
337
|
// a column-grant change must still rebuild). Equal across an authz-only plain-grant change.
|
|
327
|
-
|
|
338
|
+
// The OWNER stays in: changing who a SECURITY DEFINER function (or a non-invoker view)
|
|
339
|
+
// executes as is a rebuild, not an authz delta, so it must not be routed to a bare GRANT diff.
|
|
340
|
+
bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a)), extra.owner),
|
|
328
341
|
file: DECLARED, seq,
|
|
329
342
|
...extra,
|
|
330
343
|
});
|
|
@@ -369,18 +382,23 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
369
382
|
switch (d.kind) {
|
|
370
383
|
case 'view': {
|
|
371
384
|
const { sql, attachments } = renderView(d);
|
|
372
|
-
|
|
385
|
+
// A15: the owner rides on the object, so it enters the content hash (a view that
|
|
386
|
+
// changes owner IS a different object) and the applier can create it under SET ROLE.
|
|
387
|
+
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
|
|
373
388
|
break;
|
|
374
389
|
}
|
|
375
390
|
case 'materialized view': {
|
|
376
391
|
const { sql, attachments } = renderMaterializedView(d);
|
|
377
|
-
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
|
|
392
|
+
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
|
|
378
393
|
break;
|
|
379
394
|
}
|
|
380
395
|
case 'function': {
|
|
381
396
|
const { sql, attachments } = renderFunction(d);
|
|
382
397
|
const setofDep = typeof d.returns === 'string' ? [] : depIdentities([d.returns.setof]);
|
|
383
|
-
|
|
398
|
+
// The pin rides STRUCTURALLY as well as inside `sql`, so the differ can compare it
|
|
399
|
+
// against the live catalog directly instead of only against a recorded hash. It is not
|
|
400
|
+
// in the hash (it is already inside `sql`), so carrying it moves nobody's fingerprint.
|
|
401
|
+
nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments, { identity, ...(d.owner ? { owner: d.owner } : {}), ...(d.searchPath !== undefined ? { searchPath: d.searchPath } : {}) }) });
|
|
384
402
|
break;
|
|
385
403
|
}
|
|
386
404
|
case 'sql': {
|
|
@@ -99,20 +99,113 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
99
99
|
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
100
100
|
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
101
101
|
*/
|
|
102
|
+
export interface ObjectGrantDiff {
|
|
103
|
+
statements: string[];
|
|
104
|
+
/** REVOKEs withheld because ownership made the two sides incomparable, with the reason. */
|
|
105
|
+
suppressed: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ownership context for a FUNCTION's grant diff. Absent for relations.
|
|
110
|
+
*
|
|
111
|
+
* Owner-implicit EXECUTE is excluded from both grant reads (`grantee <> proowner`), which is
|
|
112
|
+
* correct in isolation — an owner's privileges are not grants. But it means the SET of
|
|
113
|
+
* non-owner grants is computed RELATIVE TO THE OWNER, so two sides with different owners
|
|
114
|
+
* produce two different sets for the same function, and the differ reconciles a difference
|
|
115
|
+
* that is purely ownership.
|
|
116
|
+
*
|
|
117
|
+
* Measured by a consumer, 2026-08-10: their local `resolve_user_slug` is owned by
|
|
118
|
+
* `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
|
|
119
|
+
* same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
|
|
120
|
+
* The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
|
|
121
|
+
* access removal minted from an ownership difference. They did not apply it.
|
|
122
|
+
*/
|
|
123
|
+
export interface GrantOwnerContext {
|
|
124
|
+
/** The owner the DECLARED grant set was computed against, when the models declare one. */
|
|
125
|
+
declared?: string;
|
|
126
|
+
/** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
|
|
127
|
+
live?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
132
|
+
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
133
|
+
*
|
|
134
|
+
* When `owners` is supplied (functions) and the two sides cannot be shown to have been
|
|
135
|
+
* computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs still flow: a
|
|
136
|
+
* redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
|
|
137
|
+
* revoke is the recoverable error and it is never silent.
|
|
138
|
+
*
|
|
139
|
+
* This withholds some legitimate revokes on a project that has not declared `owner` yet —
|
|
140
|
+
* deliberately. Declaring the owner restores full capability, and until then the tooling says
|
|
141
|
+
* plainly what it is not doing rather than guessing.
|
|
142
|
+
*/
|
|
102
143
|
export function diffObjectGrants(
|
|
103
144
|
target: string,
|
|
104
145
|
declared: Record<string, string[]>,
|
|
105
146
|
live: Record<string, string[]>,
|
|
106
|
-
|
|
147
|
+
owners?: GrantOwnerContext,
|
|
148
|
+
): ObjectGrantDiff {
|
|
107
149
|
const out: string[] = [];
|
|
150
|
+
const suppressed: string[] = [];
|
|
151
|
+
// WHICH GRANTEE, not which object. An owner-implicit privilege can only make a grantee
|
|
152
|
+
// appear or disappear if THAT GRANTEE is an owner on one of the two sides — the owner's own
|
|
153
|
+
// rows are what each read excludes. So suppression is per-grantee, and every other revoke
|
|
154
|
+
// still flows.
|
|
155
|
+
//
|
|
156
|
+
// This matters concretely: PUBLIC is a pseudo-role that can never own an object, so a
|
|
157
|
+
// `REVOKE ... FROM PUBLIC` is NEVER explained by ownership. An earlier, object-scoped
|
|
158
|
+
// version of this guard swallowed exactly that revoke — a hand-run `GRANT EXECUTE TO PUBLIC`
|
|
159
|
+
// on a managed function stopped being cleaned up, turning a privilege-escalation cleanup
|
|
160
|
+
// into a silent no-op. Caught by __tests__/integration/derived-drift.test.ts against a real
|
|
161
|
+
// database.
|
|
162
|
+
//
|
|
163
|
+
// The declared owner is only known once the models declare one (`defineFunction owner`).
|
|
164
|
+
// Until then the pull-time owner is unrecorded, so a grantee that WAS the pull-time owner
|
|
165
|
+
// cannot be identified — that case is closed by db:pull emitting `owner`, not here. What we
|
|
166
|
+
// can always identify is the LIVE owner, and that is the half that produces the asymmetry
|
|
167
|
+
// in the direction that removes access.
|
|
168
|
+
const ownerish = new Set([owners?.declared, owners?.live].filter((o): o is string => !!o));
|
|
169
|
+
const suppressibleGrantee = (grantee: string): boolean =>
|
|
170
|
+
owners?.live !== undefined && grantee !== 'PUBLIC' && ownerish.has(grantee);
|
|
171
|
+
// NORMALIZATION, and it is a different move from the suppression above.
|
|
172
|
+
//
|
|
173
|
+
// The LIVE owner holds every privilege implicitly, and the live read excludes exactly those
|
|
174
|
+
// rows. So when the two sides share a basis — the models declare the live owner, or declare no
|
|
175
|
+
// owner at all — a declared grant TO that owner is already true and unreadable. Minting it
|
|
176
|
+
// produces a GRANT the next read still cannot see: a statement that runs on every reconcile,
|
|
177
|
+
// forever, and never converges. Measured as 31 of them on one consumer's function set.
|
|
178
|
+
//
|
|
179
|
+
// Both directions drop, because both are vacuous: an owner's implicit privileges cannot be
|
|
180
|
+
// granted (they are already held) and cannot be revoked (they follow ownership, not the ACL).
|
|
181
|
+
// This is not a withheld statement, so it is not reported as one.
|
|
182
|
+
//
|
|
183
|
+
// It applies ONLY when the bases agree. A declared owner that differs from the live one means
|
|
184
|
+
// ownership itself is converging, the two grant sets were computed against different owners,
|
|
185
|
+
// and that case stays with the suppression rule above.
|
|
186
|
+
const sameBasis = owners?.declared === undefined || owners.declared === owners.live;
|
|
187
|
+
const ownerImplicit = (grantee: string): boolean =>
|
|
188
|
+
owners?.live !== undefined && grantee !== 'PUBLIC' && grantee === owners.live && sameBasis;
|
|
108
189
|
const grantees = new Set([...Object.keys(declared), ...Object.keys(live)]);
|
|
109
190
|
for (const grantee of [...grantees].sort()) {
|
|
191
|
+
if (ownerImplicit(grantee)) continue;
|
|
110
192
|
const want = new Set(declared[grantee] ?? []);
|
|
111
193
|
const have = new Set(live[grantee] ?? []);
|
|
112
194
|
const toRevoke = [...have].filter((p) => !want.has(p)).sort();
|
|
113
195
|
const toGrant = [...want].filter((p) => !have.has(p)).sort();
|
|
114
|
-
if (toRevoke.length)
|
|
196
|
+
if (toRevoke.length) {
|
|
197
|
+
if (!suppressibleGrantee(grantee)) {
|
|
198
|
+
out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
|
|
199
|
+
} else {
|
|
200
|
+
const why = grantee === owners!.live
|
|
201
|
+
? `'${grantee}' is the function's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
|
|
202
|
+
: `'${grantee}' is the function's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
|
|
203
|
+
suppressed.push(
|
|
204
|
+
`${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the function's owner to restore revokes.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
115
208
|
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${target} TO ${grantee}`);
|
|
116
209
|
}
|
|
117
|
-
return out;
|
|
210
|
+
return { statements: out, suppressed };
|
|
118
211
|
}
|