@everystack/server 0.4.30 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/package.json +3 -3
- package/src/db.ts +23 -2
- package/src/model-wire.ts +94 -0
- package/src/owner-adoption.ts +75 -4
- package/src/owner-enactment.ts +355 -20
- package/src/plugin.ts +273 -55
- package/src/provision.ts +406 -11
- package/src/role-chain.ts +114 -0
- package/src/sql-lex.ts +217 -0
package/src/provision.ts
CHANGED
|
@@ -20,15 +20,22 @@
|
|
|
20
20
|
* without a database; plugin.ts wires the real deps.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL } from './role-chain';
|
|
23
|
+
import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL } from './role-chain';
|
|
24
24
|
import {
|
|
25
25
|
pgVersionRefusal,
|
|
26
26
|
ownerFactsSql,
|
|
27
27
|
granterCapabilitySql,
|
|
28
28
|
planOwnerEnactment,
|
|
29
|
+
readerGrantFactsSql,
|
|
30
|
+
readerGrantCapabilitySql,
|
|
31
|
+
planReaderGrants,
|
|
32
|
+
unitRefusal,
|
|
33
|
+
type EnactmentPlan,
|
|
29
34
|
type DeclaredOwner,
|
|
30
35
|
type OwnerFactRow,
|
|
31
36
|
type GranterCapabilityRow,
|
|
37
|
+
type ReaderGrantFactRow,
|
|
38
|
+
type ReaderGranterCapabilityRow,
|
|
32
39
|
} from './owner-enactment';
|
|
33
40
|
import {
|
|
34
41
|
adopteeProbeSql,
|
|
@@ -41,13 +48,34 @@ import {
|
|
|
41
48
|
type AdoptionFinding,
|
|
42
49
|
} from './owner-adoption';
|
|
43
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The db:provision payload.
|
|
53
|
+
*
|
|
54
|
+
* Its Model-derived fields — `searchPath`, `declaredOwners`, `declaredObjects` — are computed by
|
|
55
|
+
* the CLI from the Models barrel, so the payload is a VERSIONED wire contract when it arrives over
|
|
56
|
+
* the `_action` wire. That version (`modelContract`, checked against `MODEL_WIRE_CONTRACT`) is
|
|
57
|
+
* enforced at the action boundary in plugin.ts, BEFORE any connection is resolved — see
|
|
58
|
+
* `model-wire.ts`. It is deliberately not modelled here: by the time this core runs, the payload
|
|
59
|
+
* has already been proven to speak this server's shape, and this file's job is the credential
|
|
60
|
+
* ordering, not the handshake. `@everystack/model/wire` holds the canonical declarations of
|
|
61
|
+
* `DeclaredOwner` and `Adoptee`; the local ones below are pinned equal to them by
|
|
62
|
+
* `__tests__/model-wire-shape.test.ts`.
|
|
63
|
+
*/
|
|
44
64
|
export interface ProvisionPayload {
|
|
45
65
|
/** Password for the least-privilege API login. Alphanumeric only. */
|
|
46
66
|
authPassword?: string;
|
|
47
67
|
/** Password for the operator login. Alphanumeric only. Absent = today's half-split. */
|
|
48
68
|
adminPassword?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Password for the SELECT-only reader login — the third credential, the one `db:query` connects
|
|
71
|
+
* as. Alphanumeric only. ABSENT = no reader role is created at all, byte-identical to today (a
|
|
72
|
+
* db:query with no reader keeps running on the operator connection; see plugin.ts).
|
|
73
|
+
*/
|
|
74
|
+
readerPassword?: string;
|
|
49
75
|
loginRole?: string;
|
|
50
76
|
adminRole?: string;
|
|
77
|
+
/** The SELECT-only login. Default 'reader'. */
|
|
78
|
+
readerRole?: string;
|
|
51
79
|
appRoles?: string[];
|
|
52
80
|
/**
|
|
53
81
|
* Declared owners to ENACT (brownfield ownership convergence, Gap 1). Each (owner, schema) the
|
|
@@ -129,6 +157,10 @@ export interface ProvisionResult {
|
|
|
129
157
|
appRoles?: string[];
|
|
130
158
|
passwordSet?: boolean;
|
|
131
159
|
adminPasswordSet?: boolean;
|
|
160
|
+
/** The SELECT-only login that was created/adopted. Absent = no reader was requested. */
|
|
161
|
+
readerRole?: string;
|
|
162
|
+
/** false = the reader already existed as a live login and was NOT re-passworded (additive-only). */
|
|
163
|
+
readerPasswordSet?: boolean;
|
|
132
164
|
/** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
|
|
133
165
|
adminVerified?: boolean | null;
|
|
134
166
|
rotationPairsCreated?: boolean;
|
|
@@ -146,23 +178,50 @@ export interface ProvisionResult {
|
|
|
146
178
|
* change) for the CLI to print; `findings` are the pre-adopt semantic audit; `refusals` block the
|
|
147
179
|
* whole phase (a pre-flight obstacle, a missing master, or a required --confirm that was absent).
|
|
148
180
|
* Like ENACT, refusals never abort credential provisioning and never throw.
|
|
181
|
+
*
|
|
182
|
+
* `warnings` is the SUCCEEDED-BUT half: the reassignment is committed, and something after it did
|
|
183
|
+
* not go cleanly (today: a self-granted membership that would not revoke). Never a refusal — a
|
|
184
|
+
* refusal says nothing was applied, and here everything was.
|
|
149
185
|
*/
|
|
150
186
|
ownerAdoption?: {
|
|
151
187
|
reassigned: number;
|
|
152
188
|
manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>;
|
|
153
189
|
findings: AdoptionFinding[];
|
|
154
190
|
refusals: string[];
|
|
191
|
+
warnings?: string[];
|
|
155
192
|
};
|
|
193
|
+
/**
|
|
194
|
+
* The result of the reader's read grants (SELECT/USAGE/default privileges over the declared
|
|
195
|
+
* schemas). Absent when no reader was declared. Same contract as ENACT: refusals never abort
|
|
196
|
+
* anything and never throw — a reader that cannot read is loud, recoverable, and reported.
|
|
197
|
+
*/
|
|
198
|
+
readerGrants?: { granted: number; refusals: string[] };
|
|
199
|
+
/**
|
|
200
|
+
* The PUBLIC-on-schema-`public` posture, and what (if anything) was done about it. Always present
|
|
201
|
+
* once convergence has run — `publicCanCreate: false` is the already-clean answer, and the CLI
|
|
202
|
+
* says nothing about it. Same non-fatal contract as ENACT: never throws, never aborts.
|
|
203
|
+
*/
|
|
204
|
+
publicSchemaPosture?: PublicSchemaPosture;
|
|
156
205
|
error?: string;
|
|
157
206
|
}
|
|
158
207
|
|
|
208
|
+
/** The result of the PUBLIC-on-`public` posture step. See `convergePublicSchemaPosture`. */
|
|
209
|
+
export interface PublicSchemaPosture {
|
|
210
|
+
/** Does the PUBLIC pseudo-role hold CREATE on schema `public`? (The pre-PG15 default.) */
|
|
211
|
+
publicCanCreate: boolean;
|
|
212
|
+
/** Was the REVOKE applied AND verified? Only ever true when `publicCanCreate` was true. */
|
|
213
|
+
revoked: boolean;
|
|
214
|
+
/** Why it was not applied — the missing `--confirm`, a connection that cannot, a server refusal. */
|
|
215
|
+
refusals: string[];
|
|
216
|
+
}
|
|
217
|
+
|
|
159
218
|
const ALNUM = /^[A-Za-z0-9]+$/;
|
|
160
219
|
|
|
161
220
|
export async function runProvision(
|
|
162
221
|
payload: ProvisionPayload,
|
|
163
222
|
deps: ProvisionDeps,
|
|
164
223
|
): Promise<ProvisionResult> {
|
|
165
|
-
const { authPassword, adminPassword, loginRole, adminRole, appRoles, searchPath } = payload;
|
|
224
|
+
const { authPassword, adminPassword, readerPassword, loginRole, adminRole, readerRole, appRoles, searchPath } = payload;
|
|
166
225
|
|
|
167
226
|
// Passwords are generated (RandomPassword special:false / hex) — reject anything else
|
|
168
227
|
// BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
|
|
@@ -172,12 +231,17 @@ export async function runProvision(
|
|
|
172
231
|
if (adminPassword && !ALNUM.test(adminPassword)) {
|
|
173
232
|
return { error: 'adminPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
174
233
|
}
|
|
234
|
+
if (readerPassword && !ALNUM.test(readerPassword)) {
|
|
235
|
+
return { error: 'readerPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
236
|
+
}
|
|
175
237
|
|
|
176
238
|
let roleSql: string;
|
|
177
239
|
let adminSql: string | null = null;
|
|
240
|
+
let readerSql: string | null = null;
|
|
178
241
|
try {
|
|
179
242
|
roleSql = generateRoleChainSQL({ loginRole, appRoles });
|
|
180
243
|
if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
|
|
244
|
+
if (readerPassword) readerSql = generateReaderRoleSQL({ readerRole });
|
|
181
245
|
} catch (err: any) {
|
|
182
246
|
return { error: err?.message || String(err) };
|
|
183
247
|
}
|
|
@@ -187,8 +251,10 @@ export async function runProvision(
|
|
|
187
251
|
// we must not reset (additive-only); one created now gets its password set.
|
|
188
252
|
const login = loginRole ?? 'authenticator';
|
|
189
253
|
const admin = adminRole ?? 'migrator';
|
|
254
|
+
const reader = readerRole ?? 'reader';
|
|
190
255
|
const authExisted = !!authPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(login));
|
|
191
256
|
const adminExisted = !!adminPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(admin));
|
|
257
|
+
const readerExisted = !!readerPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(reader));
|
|
192
258
|
|
|
193
259
|
await deps.execute(roleSql);
|
|
194
260
|
|
|
@@ -290,6 +356,26 @@ export async function runProvision(
|
|
|
290
356
|
result.passwordSet = true;
|
|
291
357
|
}
|
|
292
358
|
|
|
359
|
+
// The SELECT-only reader LAST — it is the one login nothing else depends on, so it can never
|
|
360
|
+
// disturb the ordering contract above. Role + password only; its GRANTs are made by
|
|
361
|
+
// convergeOwnership, AFTER the CLI has written the secret: `GRANT ... ON ALL TABLES` takes an
|
|
362
|
+
// ACCESS EXCLUSIVE lock on every table it touches, and a step that can stall behind a long reader
|
|
363
|
+
// must never sit between the autocommitted `ALTER ROLE … PASSWORD` and the secret-store write
|
|
364
|
+
// (the lockout this file's ordering exists to prevent — see the closing note below).
|
|
365
|
+
if (readerPassword && readerSql) {
|
|
366
|
+
await deps.execute(readerSql);
|
|
367
|
+
result.readerRole = reader;
|
|
368
|
+
const readerSearchPathSql = searchPath ? generateSearchPathSQL(reader, searchPath) : null;
|
|
369
|
+
if (readerSearchPathSql) await deps.execute(readerSearchPathSql);
|
|
370
|
+
if (readerExisted) {
|
|
371
|
+
// Additive-only, same rule as the other two logins: never rewrite a live password.
|
|
372
|
+
result.readerPasswordSet = false;
|
|
373
|
+
} else {
|
|
374
|
+
await deps.execute(`ALTER ROLE ${reader} WITH LOGIN PASSWORD '${readerPassword}';`);
|
|
375
|
+
result.readerPasswordSet = true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
// Ownership convergence (ENACT + ADOPT) is DELIBERATELY NOT here. It is the one part of the
|
|
294
380
|
// provisioning flow that can wait on a lock (ADOPT's transaction) or outlive the Lambda clock,
|
|
295
381
|
// and it sits AFTER the credential swap — so running it inside runProvision would put a
|
|
@@ -312,10 +398,10 @@ export async function runProvision(
|
|
|
312
398
|
* (empty result) when nothing is declared or the caller cannot read (no `query` dep — an older CLI).
|
|
313
399
|
*/
|
|
314
400
|
export async function convergeOwnership(
|
|
315
|
-
payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole'>,
|
|
401
|
+
payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole' | 'readerRole' | 'searchPath'>,
|
|
316
402
|
deps: ProvisionDeps,
|
|
317
|
-
): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'>> {
|
|
318
|
-
const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'> = {};
|
|
403
|
+
): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption' | 'readerGrants' | 'publicSchemaPosture'>> {
|
|
404
|
+
const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption' | 'readerGrants' | 'publicSchemaPosture'> = {};
|
|
319
405
|
if (!deps.query) return out;
|
|
320
406
|
const operator = payload.adminRole ?? 'migrator';
|
|
321
407
|
|
|
@@ -334,11 +420,184 @@ export async function convergeOwnership(
|
|
|
334
420
|
out.ownerAdoption = { reassigned: 0, manifest: [], findings: [], refusals: [`owner adoption failed (the transaction rolled back; credentials are untouched): ${err?.message ?? String(err)}`] };
|
|
335
421
|
}
|
|
336
422
|
}
|
|
423
|
+
|
|
424
|
+
// The reader's read grants — LAST, and here rather than in runProvision for the same reason ADOPT
|
|
425
|
+
// is here: `GRANT ... ON ALL TABLES` locks every table it touches, so it must never sit between
|
|
426
|
+
// the password swap and the secret write. A refusal (or a stall killed by the Lambda clock) leaves
|
|
427
|
+
// a reader that cannot read — loud, recoverable by a re-run, and stranding no credential.
|
|
428
|
+
if (payload.readerRole) {
|
|
429
|
+
try {
|
|
430
|
+
out.readerGrants = await grantReaderAccess(payload.readerRole, payload, operator, deps);
|
|
431
|
+
} catch (err: any) {
|
|
432
|
+
out.readerGrants = { granted: 0, refusals: [`reader grants failed (credentials are already saved; nothing is stranded): ${err?.message ?? String(err)}`] };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The PUBLIC-on-`public` posture — LAST, and UNCONDITIONAL. It is a property of the DATABASE, not
|
|
437
|
+
// of what the Models declare, so it must not hide behind the declared-ownership guards above: the
|
|
438
|
+
// brownfield app with nothing declared is precisely the one most likely to carry the pre-PG15
|
|
439
|
+
// grant. One catalog read on a converged database, one statement when the operator has confirmed.
|
|
440
|
+
try {
|
|
441
|
+
out.publicSchemaPosture = await convergePublicSchemaPosture(deps, !!payload.confirm);
|
|
442
|
+
} catch (err: any) {
|
|
443
|
+
out.publicSchemaPosture = { publicCanCreate: false, revoked: false, refusals: [
|
|
444
|
+
`the schema "public" posture check failed (credentials are already saved; nothing is stranded): ${err?.message ?? String(err)}`,
|
|
445
|
+
] };
|
|
446
|
+
}
|
|
337
447
|
return out;
|
|
338
448
|
}
|
|
339
449
|
|
|
340
450
|
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
341
451
|
|
|
452
|
+
/** The one statement this step ever sends. Also the fix SQL every refusal below hands back. */
|
|
453
|
+
export const REVOKE_PUBLIC_CREATE_SQL = 'REVOKE CREATE ON SCHEMA public FROM PUBLIC';
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The posture probe: does the PUBLIC pseudo-role hold CREATE on schema `public`, and can THIS
|
|
457
|
+
* connection take it away?
|
|
458
|
+
*
|
|
459
|
+
* `public_can_create` reads the ACL directly rather than asking `has_schema_privilege('public', …)`.
|
|
460
|
+
* Both answer correctly on a live server (measured, PG16), but the ACL read is the idiom the rest of
|
|
461
|
+
* this file already uses (`toFacts`/`toCaps` over `aclexplode`) and it is unambiguous: grantee 0 IS
|
|
462
|
+
* the PUBLIC pseudo-role, whereas the literal `'public'` in `has_schema_privilege` is a special-cased
|
|
463
|
+
* NAME that resolves to no row in `pg_roles`. A NULL `nspacl` (owner-only defaults, and what a
|
|
464
|
+
* hand-recreated `public` schema carries on every version) explodes to zero rows — correctly, no grant.
|
|
465
|
+
*
|
|
466
|
+
* This is deliberately NOT `server_version_num`. PG15 removed the grant as a DEFAULT; it says nothing
|
|
467
|
+
* about the database in front of us. A PG16 database restored from a pre-15 dump still carries it, and
|
|
468
|
+
* an operator can re-add it on any version with one legal statement. The catalog is the only truth.
|
|
469
|
+
*
|
|
470
|
+
* `can_revoke` is the capability half, and it is load-bearing for a reason PostgreSQL makes easy to
|
|
471
|
+
* miss: a REVOKE run by a role that does not administer the schema DOES NOT FAIL. It emits
|
|
472
|
+
* `WARNING: no privileges could be revoked for "public"` and returns success with the grant still in
|
|
473
|
+
* place. `pg_has_role(current_user, nspowner, 'USAGE')` is true for the schema's owner, for a member
|
|
474
|
+
* of it, and for a superuser — exactly the set that can actually revoke.
|
|
475
|
+
*/
|
|
476
|
+
export const PUBLIC_SCHEMA_POSTURE_SQL = `SELECT
|
|
477
|
+
EXISTS (
|
|
478
|
+
SELECT 1 FROM pg_namespace n, aclexplode(n.nspacl) a
|
|
479
|
+
WHERE n.nspname = 'public' AND a.grantee = 0 AND a.privilege_type = 'CREATE'
|
|
480
|
+
) AS public_can_create,
|
|
481
|
+
EXISTS (
|
|
482
|
+
SELECT 1 FROM pg_namespace n
|
|
483
|
+
WHERE n.nspname = 'public' AND pg_has_role(current_user, n.nspowner, 'USAGE')
|
|
484
|
+
) AS can_revoke`;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Converge the PUBLIC-on-schema-`public` posture — detect always, act only under `confirm`.
|
|
488
|
+
*
|
|
489
|
+
* THE GAP. Before PG15, `CREATE ON SCHEMA public` was granted to the PUBLIC pseudo-role by default.
|
|
490
|
+
* PG15 removed that default. Where it is still in place, every login this file provisions —
|
|
491
|
+
* authenticator, migrator, reader — can create objects in `public` through an ambient grant none of
|
|
492
|
+
* them was given, whatever its own privileges say. A schema anyone can write is also a search_path
|
|
493
|
+
* hijack surface: plant a shadow object there and a privileged session resolving through `public`
|
|
494
|
+
* runs it (the same chain `security-audit` reds on).
|
|
495
|
+
*
|
|
496
|
+
* WHY THIS IS GATED, when db:build revokes the same grant unconditionally. db:build refuses a
|
|
497
|
+
* non-empty target, so there is nothing in `public` to strand and no app to surprise. db:provision
|
|
498
|
+
* has no such guarantee: it runs against a LIVE, often long-running database, and some legacy apps
|
|
499
|
+
* genuinely create objects in `public` at runtime. Taking that away silently, mid-deploy, is a
|
|
500
|
+
* behaviour change no one asked for. So this reports by default and acts only when the operator has
|
|
501
|
+
* seen the report and re-run with `--confirm` — the same shape ADOPT uses for its runtime-changing
|
|
502
|
+
* reassignments.
|
|
503
|
+
*
|
|
504
|
+
* ROUTED like ENACT's grants: the operator's own connection when it administers `public`, the master
|
|
505
|
+
* when it does not, and a refusal carrying the runnable SQL when neither can. VERIFIED after the
|
|
506
|
+
* fact, because — see PUBLIC_SCHEMA_POSTURE_SQL — an under-privileged REVOKE reports success while
|
|
507
|
+
* changing nothing, so "the statement ran" would have printed `revoked` over a wide-open database.
|
|
508
|
+
*
|
|
509
|
+
* Non-fatal by construction: it never throws. It runs strictly after the credential is in the secret
|
|
510
|
+
* store, so anything it fails to do is recoverable by a re-run and strands nothing.
|
|
511
|
+
*/
|
|
512
|
+
export async function convergePublicSchemaPosture(
|
|
513
|
+
deps: ProvisionDeps,
|
|
514
|
+
confirm: boolean,
|
|
515
|
+
): Promise<PublicSchemaPosture> {
|
|
516
|
+
const query = deps.query;
|
|
517
|
+
if (!query) return { publicCanCreate: false, revoked: false, refusals: [] };
|
|
518
|
+
|
|
519
|
+
const [row] = await query(PUBLIC_SCHEMA_POSTURE_SQL);
|
|
520
|
+
if (!truthy(row?.public_can_create)) return { publicCanCreate: false, revoked: false, refusals: [] };
|
|
521
|
+
|
|
522
|
+
if (!confirm) {
|
|
523
|
+
return { publicCanCreate: true, revoked: false, refusals: [
|
|
524
|
+
'the PUBLIC pseudo-role holds CREATE on schema "public" — the pre-PG15 default, which PG15 ' +
|
|
525
|
+
'removed. Every login this provision created can place objects there through that ambient ' +
|
|
526
|
+
'grant, and a writable schema on a search path is a hijack surface for every privileged ' +
|
|
527
|
+
'session resolving through it. Taking it away can break an app that creates objects in ' +
|
|
528
|
+
'"public" at runtime, so it is NOT done silently: review, then re-run with --confirm. ' +
|
|
529
|
+
`The statement it would send: ${REVOKE_PUBLIC_CREATE_SQL};`,
|
|
530
|
+
] };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Route to a connection that can actually revoke. The operator usually does not own `public`
|
|
534
|
+
// (postgres or pg_database_owner does), so the master is the normal venue.
|
|
535
|
+
let execute: ((sql: string) => Promise<unknown>) | null = null;
|
|
536
|
+
if (truthy(row?.can_revoke)) {
|
|
537
|
+
execute = deps.execute;
|
|
538
|
+
} else if (deps.master) {
|
|
539
|
+
const [masterRow] = await deps.master.query(PUBLIC_SCHEMA_POSTURE_SQL);
|
|
540
|
+
if (truthy(masterRow?.can_revoke)) execute = (s: string) => deps.master!.execute(s);
|
|
541
|
+
}
|
|
542
|
+
if (!execute) {
|
|
543
|
+
return { publicCanCreate: true, revoked: false, refusals: [
|
|
544
|
+
'cannot revoke PUBLIC\'s CREATE on schema "public": neither the operator connection nor the ' +
|
|
545
|
+
'master is the owner of schema "public" (nor a member of its owner). PostgreSQL would accept ' +
|
|
546
|
+
'the statement from them and change NOTHING — it warns "no privileges could be revoked" and ' +
|
|
547
|
+
`returns success. Run it as the schema's owner: ${REVOKE_PUBLIC_CREATE_SQL};`,
|
|
548
|
+
] };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
await execute(`${REVOKE_PUBLIC_CREATE_SQL};`);
|
|
553
|
+
} catch (err: any) {
|
|
554
|
+
return { publicCanCreate: true, revoked: false, refusals: [
|
|
555
|
+
`revoking PUBLIC's CREATE on schema "public" failed: ${err?.message ?? String(err)}. ` +
|
|
556
|
+
`Nothing else is affected — credentials are already saved. Fix: ${REVOKE_PUBLIC_CREATE_SQL};`,
|
|
557
|
+
] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// VERIFY. The statement completing is not evidence (see above), so re-read the catalog.
|
|
561
|
+
const [after] = await query(PUBLIC_SCHEMA_POSTURE_SQL);
|
|
562
|
+
if (truthy(after?.public_can_create)) {
|
|
563
|
+
return { publicCanCreate: true, revoked: false, refusals: [
|
|
564
|
+
'the REVOKE was accepted but PUBLIC still holds CREATE on schema "public". PostgreSQL does ' +
|
|
565
|
+
'not error when a role that does not administer the schema revokes — it warns and changes ' +
|
|
566
|
+
`nothing. Run it as the schema's owner: ${REVOKE_PUBLIC_CREATE_SQL};`,
|
|
567
|
+
] };
|
|
568
|
+
}
|
|
569
|
+
return { publicCanCreate: true, revoked: true, refusals: [] };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Run `statements` as ONE transaction on `execute`, and leave the SESSION usable whatever happens.
|
|
574
|
+
*
|
|
575
|
+
* A statement that fails inside a multi-statement `BEGIN; … COMMIT;` string does NOT end the
|
|
576
|
+
* transaction: PostgreSQL leaves the session in an ABORTED transaction, where every later statement
|
|
577
|
+
* on that same connection fails with "current transaction is aborted, commands ignored until end of
|
|
578
|
+
* transaction block" — until something sends ROLLBACK. Both callers run on connections the caller
|
|
579
|
+
* opens once and keeps: the ops action holds ONE master connection across ENACT and then ADOPT, so
|
|
580
|
+
* an un-rolled-back ENACT failure would take ADOPT — a separate phase, on unrelated objects — down
|
|
581
|
+
* with it, reporting a stale abort instead of running its own probe.
|
|
582
|
+
*
|
|
583
|
+
* So a throw is followed by a best-effort ROLLBACK on the SAME connection, and the ORIGINAL error is
|
|
584
|
+
* rethrown unchanged. The rollback is a repair attempt, not a result: it is wrapped in its own
|
|
585
|
+
* try/catch so a failed rollback (a dead connection) can never mask or replace the failure that
|
|
586
|
+
* caused it — the caller must still see WHY the transaction failed.
|
|
587
|
+
*/
|
|
588
|
+
async function runTransaction(execute: (sql: string) => Promise<unknown>, statements: string[]): Promise<void> {
|
|
589
|
+
try {
|
|
590
|
+
await execute(`BEGIN;\n${statements.map((s) => `${s};`).join('\n')}\nCOMMIT;`);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
try {
|
|
593
|
+
await execute('ROLLBACK;');
|
|
594
|
+
} catch {
|
|
595
|
+
// Best-effort only. The transaction's own error is the one that matters.
|
|
596
|
+
}
|
|
597
|
+
throw err;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
342
601
|
function toFacts(rows: any[]): OwnerFactRow[] {
|
|
343
602
|
return rows.map((r) => ({
|
|
344
603
|
owner: String(r.owner),
|
|
@@ -380,9 +639,117 @@ async function enactOwners(
|
|
|
380
639
|
const masterCaps = deps.master ? toCaps(await deps.master.query(granterCapabilitySql(owners))) : null;
|
|
381
640
|
|
|
382
641
|
const plan = planOwnerEnactment(operator, facts, operatorCaps, masterCaps);
|
|
383
|
-
|
|
384
|
-
for (
|
|
385
|
-
|
|
642
|
+
// One transaction per OWNER (per connection) — not one per connection for the whole run. See
|
|
643
|
+
// applyUnits for why the grain moved; each set is still idempotent (a GRANT already in place is a
|
|
644
|
+
// no-op), so a rolled-back unit is simply re-run, and `runTransaction` is what keeps a failed one
|
|
645
|
+
// from aborting the session for whatever the caller runs on it next.
|
|
646
|
+
return applyUnits(plan, deps, runTransaction, (n) => ({ enacted: n.applied, refusals: n.refusals }));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Autocommit, statement by statement — the reader grants' runner. See applyUnits. */
|
|
650
|
+
async function runSequentially(execute: (sql: string) => Promise<unknown>, statements: string[]): Promise<void> {
|
|
651
|
+
for (const s of statements) await execute(`${s};`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Apply a routed plan ONE UNIT AT A TIME — a unit being one declared owner, or one of the reader's
|
|
656
|
+
* grant kinds for one schema, on one connection. Never the whole run as a single batch.
|
|
657
|
+
*
|
|
658
|
+
* THE GRAIN IS THE FIX. The subjects are independent of each other and every statement is
|
|
659
|
+
* idempotent, so batching them across subjects bought nothing and cost this: one owner whose GRANT
|
|
660
|
+
* the server rejected rolled back a co-batched, perfectly grantable owner, and the refusal named
|
|
661
|
+
* only the failure — not the casualty. Because the plan is rebuilt from the catalog on every run,
|
|
662
|
+
* the same batch then failed the same way on every later db:provision, and that owner never
|
|
663
|
+
* converged, silently. ADOPT stays all-or-nothing for the opposite reason: a half-adopted schema is
|
|
664
|
+
* residue db:reconcile chokes on. These grants have no such coupling.
|
|
665
|
+
*
|
|
666
|
+
* A unit that throws becomes a precise refusal naming its subject and carrying the runnable fix
|
|
667
|
+
* (`unitRefusal`); the run continues with the next unit. `run` is the unit's runner: ENACT wraps a
|
|
668
|
+
* unit in ONE transaction (`runTransaction`, which also leaves the shared session usable after a
|
|
669
|
+
* failure); the reader's grants stay autocommit, one statement at a time, because `GRANT ... ON ALL
|
|
670
|
+
* TABLES` takes an ACCESS EXCLUSIVE lock on every relation it touches and a transaction would hold
|
|
671
|
+
* those locks across the whole unit for no atomicity anyone needs.
|
|
672
|
+
*/
|
|
673
|
+
async function applyUnits<T>(
|
|
674
|
+
plan: EnactmentPlan,
|
|
675
|
+
deps: ProvisionDeps,
|
|
676
|
+
run: (execute: (sql: string) => Promise<unknown>, statements: string[]) => Promise<void>,
|
|
677
|
+
shape: (n: { applied: number; refusals: string[] }) => T,
|
|
678
|
+
): Promise<T> {
|
|
679
|
+
const refusals = [...plan.refusals];
|
|
680
|
+
let applied = 0;
|
|
681
|
+
for (const unit of plan.units) {
|
|
682
|
+
const execute = unit.connection === 'master'
|
|
683
|
+
? (s: string) => deps.master!.execute(s)
|
|
684
|
+
: (s: string) => deps.execute(s);
|
|
685
|
+
try {
|
|
686
|
+
await run(execute, unit.statements);
|
|
687
|
+
applied += unit.statements.length;
|
|
688
|
+
} catch (err: any) {
|
|
689
|
+
refusals.push(unitRefusal(unit, err?.message ?? String(err)));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return shape({ applied, refusals });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function toReaderFacts(rows: any[]): ReaderGrantFactRow[] {
|
|
696
|
+
return rows.map((r) => ({
|
|
697
|
+
schema: String(r.schema),
|
|
698
|
+
creator: String(r.creator ?? ''),
|
|
699
|
+
schemaExists: truthy(r.schema_exists),
|
|
700
|
+
creatorExists: truthy(r.creator_exists),
|
|
701
|
+
usageMissing: truthy(r.usage_missing),
|
|
702
|
+
selectMissing: truthy(r.select_missing),
|
|
703
|
+
// Two facts, never one — see ReaderGrantFactRow: either default-privilege kind can already be
|
|
704
|
+
// in place without the other, and folding them together starves the missing one forever.
|
|
705
|
+
tablesDefaultsMissing: truthy(r.tables_defaults_missing),
|
|
706
|
+
sequencesDefaultsMissing: truthy(r.sequences_defaults_missing),
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function toReaderCaps(rows: any[]): ReaderGranterCapabilityRow[] {
|
|
711
|
+
return rows.map((r) => ({
|
|
712
|
+
schema: String(r.schema),
|
|
713
|
+
creator: String(r.creator ?? ''),
|
|
714
|
+
canGrantUsage: truthy(r.can_grant_usage),
|
|
715
|
+
canGrantSelect: truthy(r.can_grant_select),
|
|
716
|
+
canSetDefaults: truthy(r.can_set_defaults),
|
|
717
|
+
}));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Probe (operator + master), route, and apply the SELECT-only reader's read grants over the app's
|
|
722
|
+
* declared schemas.
|
|
723
|
+
*
|
|
724
|
+
* The schemas are the app's declared set (the same ordered list the role-default search_path uses),
|
|
725
|
+
* falling back to `public` for an all-public app — the reader is scoped to what the app declares,
|
|
726
|
+
* never granted database-wide. The CREATING roles are the operator plus every declared object owner,
|
|
727
|
+
* because `ALTER DEFAULT PRIVILEGES` is recorded per creating role: covering only the operator would
|
|
728
|
+
* leave a later table created by a declared owner invisible to the reader.
|
|
729
|
+
*
|
|
730
|
+
* Idempotent by construction (the facts probe reports only what is missing), so a converged database
|
|
731
|
+
* runs zero statements and takes zero locks.
|
|
732
|
+
*/
|
|
733
|
+
async function grantReaderAccess(
|
|
734
|
+
reader: string,
|
|
735
|
+
payload: Pick<ProvisionPayload, 'declaredOwners' | 'searchPath'>,
|
|
736
|
+
operator: string,
|
|
737
|
+
deps: ProvisionDeps,
|
|
738
|
+
): Promise<{ granted: number; refusals: string[] }> {
|
|
739
|
+
const query = deps.query!;
|
|
740
|
+
const schemas = payload.searchPath?.length ? payload.searchPath : ['public'];
|
|
741
|
+
const creators = [...new Set([operator, ...(payload.declaredOwners ?? []).map((o) => o.owner)])];
|
|
742
|
+
|
|
743
|
+
const facts = toReaderFacts(await query(readerGrantFactsSql(reader, schemas, creators)));
|
|
744
|
+
const operatorCaps = toReaderCaps(await query(readerGrantCapabilitySql(schemas, creators)));
|
|
745
|
+
const masterCaps = deps.master ? toReaderCaps(await deps.master.query(readerGrantCapabilitySql(schemas, creators))) : null;
|
|
746
|
+
|
|
747
|
+
const plan = planReaderGrants(reader, facts, operatorCaps, masterCaps);
|
|
748
|
+
// Unit by unit, exactly like ENACT: a schema whose relations a DBA owns — the grant the honest
|
|
749
|
+
// capability probe now refuses up front, and which can still fail on the server for a reason no
|
|
750
|
+
// probe sees — must not cost the reader its grants in every OTHER schema. Autocommit within the
|
|
751
|
+
// unit, unchanged: the locks these statements take are too heavy to hold across a transaction.
|
|
752
|
+
return applyUnits(plan, deps, runSequentially, (n) => ({ granted: n.applied, refusals: n.refusals }));
|
|
386
753
|
}
|
|
387
754
|
|
|
388
755
|
function toProbes(rows: any[]): AdopteeProbe[] {
|
|
@@ -466,9 +833,37 @@ async function adoptOwners(
|
|
|
466
833
|
|
|
467
834
|
// One transaction: per-statement lock_timeout, all-or-nothing. `statements` may include SET
|
|
468
835
|
// self-grants ahead of the ALTERs, so the count of reassignments is plan.reassigned, not statements.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
836
|
+
// The lock_timeout is DESIGNED to fire under contention, which makes the rollback runTransaction
|
|
837
|
+
// does on failure load-bearing here: this connection is the caller's, and it keeps using it.
|
|
838
|
+
const master = deps.master;
|
|
839
|
+
await runTransaction((s) => master.execute(s), [`SET LOCAL lock_timeout = '5000ms'`, ...plan.statements]);
|
|
840
|
+
|
|
841
|
+
// The self-granted SET was scaffolding: it exists so the ALTERs above are legal, and it must not
|
|
842
|
+
// outlive them, or every ADOPT run leaves another membership on the master. It cannot be revoked
|
|
843
|
+
// INSIDE the transaction (the ALTERs still need it there), so it is revoked strictly after the
|
|
844
|
+
// COMMIT, each as its own statement.
|
|
845
|
+
//
|
|
846
|
+
// GRANTED BY CURRENT_USER makes the revoke surgical: PG16 keys a membership on
|
|
847
|
+
// (role, member, grantor), so the master's own self-grant is a DIFFERENT row from the durable
|
|
848
|
+
// ADMIN grant somebody else made it — and that durable one is what lets the NEXT run self-grant.
|
|
849
|
+
// Naming the grantor takes back exactly what this run gave.
|
|
850
|
+
//
|
|
851
|
+
// Non-fatal by construction: the reassignment is already committed, so a failed revoke is a
|
|
852
|
+
// warning with the hand-fix, never a refusal and never a throw.
|
|
853
|
+
const warnings: string[] = [];
|
|
854
|
+
for (const owner of plan.selfGrantedOwners) {
|
|
855
|
+
const revoke = `REVOKE ${owner} FROM CURRENT_USER GRANTED BY CURRENT_USER;`;
|
|
856
|
+
try {
|
|
857
|
+
await deps.master.execute(revoke);
|
|
858
|
+
} catch (err: any) {
|
|
859
|
+
warnings.push(
|
|
860
|
+
`ADOPT self-granted SET on '${owner}' to reassign, and could not revoke it afterwards ` +
|
|
861
|
+
`(${err?.message ?? String(err)}). The reassignment IS committed — this leaves the master ` +
|
|
862
|
+
`holding a SET-only membership in '${owner}'. Revoke it by hand on the master: ${revoke}`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return { reassigned: plan.reassigned.length, manifest, findings, refusals: [], ...(warnings.length ? { warnings } : {}) };
|
|
472
867
|
}
|
|
473
868
|
|
|
474
869
|
/** Read superuser / bypassrls for a set of roles, on the master. */
|
package/src/role-chain.ts
CHANGED
|
@@ -125,6 +125,120 @@ export function generateRotationPairSQL(
|
|
|
125
125
|
return lines.join('\n') + '\n';
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
export interface ReaderRoleOptions {
|
|
129
|
+
/**
|
|
130
|
+
* The LOGIN role read-only inspection (`db:query`, and anything else that only reads) connects
|
|
131
|
+
* as — the THIRD credential in the split. Default 'reader'.
|
|
132
|
+
*/
|
|
133
|
+
readerRole?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate idempotent DDL for the SELECT-only login role.
|
|
138
|
+
*
|
|
139
|
+
* WHY it exists: `db:query` (the read-only lane behind `db:psql -c`, `db:pull`, `db:doctor`,
|
|
140
|
+
* `security:audit`) runs its statement on the OPERATOR connection when no reader is configured.
|
|
141
|
+
* The read-only TRANSACTION stops the statement from writing, but the operator is still a role
|
|
142
|
+
* that COULD write — and a read-only transaction does not fence a function that opens its own
|
|
143
|
+
* connection (`dblink_exec`, `pg_background`), so on a database where those extensions exist the
|
|
144
|
+
* operator's write privilege is reachable from a "read-only" query. The fix is not a longer
|
|
145
|
+
* keyword blocklist; it is a role that HAS no write privilege to reach.
|
|
146
|
+
*
|
|
147
|
+
* The posture, and why each attribute:
|
|
148
|
+
* - LOGIN — it is a connection identity, not a tier reached by SET ROLE.
|
|
149
|
+
* - NOINHERIT — it never ambiently gains another role's privileges. Same posture as the
|
|
150
|
+
* authenticator.
|
|
151
|
+
* - NOBYPASSRLS — it reads through row-level security like any other client. A reader that
|
|
152
|
+
* bypassed RLS would turn an inspection tool into a data-exfiltration path.
|
|
153
|
+
* - NO membership in the app-role chain (anon/authenticated/admin) and none in the owner
|
|
154
|
+
* roles — so there is no SET ROLE that reaches write privilege. Its access is
|
|
155
|
+
* exactly its own direct grants (generateReaderGrantSQL), nothing more.
|
|
156
|
+
*
|
|
157
|
+
* Idempotent, like the rest of this file: `IF NOT EXISTS`-guarded creation, then the attributes
|
|
158
|
+
* are re-asserted so a role created some other way is brought into compliance. Does NOT set a
|
|
159
|
+
* password — that is `db:provision`'s job, from a deploy-generated value.
|
|
160
|
+
*/
|
|
161
|
+
export function generateReaderRoleSQL(options: ReaderRoleOptions = {}): string {
|
|
162
|
+
const readerRole = options.readerRole ?? 'reader';
|
|
163
|
+
assertIdent(readerRole, 'readerRole');
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${readerRole}') THEN CREATE ROLE ${readerRole} LOGIN NOINHERIT NOBYPASSRLS; END IF; END $$;`,
|
|
167
|
+
`ALTER ROLE ${readerRole} WITH LOGIN NOINHERIT NOBYPASSRLS;`,
|
|
168
|
+
].join('\n') + '\n';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ReaderGrantOptions {
|
|
172
|
+
/** The SELECT-only login the grants are made TO. Default 'reader'. */
|
|
173
|
+
readerRole?: string;
|
|
174
|
+
/** The app's declared schemas, ordered and deduped (declaredSearchPath). */
|
|
175
|
+
schemas: string[];
|
|
176
|
+
/**
|
|
177
|
+
* The roles that CREATE objects in those schemas — the operator (`migrator`) and any declared
|
|
178
|
+
* object owner. `ALTER DEFAULT PRIVILEGES` is recorded per creating role, so a default privilege
|
|
179
|
+
* set for the wrong role silently covers nothing.
|
|
180
|
+
*/
|
|
181
|
+
creators: string[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The reader's grants for ONE schema, grouped by the privilege the GRANTING connection needs. */
|
|
185
|
+
export interface ReaderSchemaGrants {
|
|
186
|
+
schema: string;
|
|
187
|
+
/** GRANT USAGE ON SCHEMA — the granter must own the schema or hold USAGE WITH GRANT OPTION. */
|
|
188
|
+
usage: string;
|
|
189
|
+
/** GRANT SELECT on what exists today — the granter needs SELECT WITH GRANT OPTION on each relation. */
|
|
190
|
+
select: string[];
|
|
191
|
+
/**
|
|
192
|
+
* ALTER DEFAULT PRIVILEGES, per creating role — the granter must be a member of that role.
|
|
193
|
+
*
|
|
194
|
+
* The two object kinds are NAMED fields rather than one statement list, because the router gates
|
|
195
|
+
* them independently: PostgreSQL records them as separate `pg_default_acl` rows, so a database
|
|
196
|
+
* can hold one and lack the other (see ReaderGrantFactRow in owner-enactment.ts).
|
|
197
|
+
*/
|
|
198
|
+
defaults: Array<{ creator: string; tables: string; sequences: string }>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generate the reader's read grants, scoped to the app's declared schemas.
|
|
203
|
+
*
|
|
204
|
+
* WHY these exact statements:
|
|
205
|
+
* - `GRANT USAGE ON SCHEMA` is mandatory: without schema USAGE no object inside it is reachable,
|
|
206
|
+
* whatever table grants exist. It confers no ability to create or write.
|
|
207
|
+
* - `GRANT SELECT ON ALL TABLES` covers tables, views, matviews and foreign tables — everything
|
|
208
|
+
* `db:query` reads.
|
|
209
|
+
* - `GRANT SELECT ON ALL SEQUENCES` — SELECT, deliberately NOT USAGE. USAGE on a sequence permits
|
|
210
|
+
* `nextval()`, which MUTATES it; SELECT reads `last_value` and nothing else. This is the one
|
|
211
|
+
* place where the read-only-looking keyword is the wrong one.
|
|
212
|
+
* - `ALTER DEFAULT PRIVILEGES ... FOR ROLE <creator>` so a table created by the next migration is
|
|
213
|
+
* readable without a re-provision. Default privileges attach to the role that CREATES the
|
|
214
|
+
* object, hence one statement per creating role rather than a bare `IN SCHEMA` (which would
|
|
215
|
+
* silently cover only the role that happened to run the grant).
|
|
216
|
+
*
|
|
217
|
+
* Nothing here grants INSERT/UPDATE/DELETE/TRUNCATE/REFERENCES/TRIGGER, CREATE, or role membership.
|
|
218
|
+
* Returns statements WITHOUT trailing semicolons — the router (planReaderGrants) decides which
|
|
219
|
+
* connection runs each, and the caller terminates them.
|
|
220
|
+
*/
|
|
221
|
+
export function generateReaderGrantSQL(options: ReaderGrantOptions): ReaderSchemaGrants[] {
|
|
222
|
+
const readerRole = options.readerRole ?? 'reader';
|
|
223
|
+
assertIdent(readerRole, 'readerRole');
|
|
224
|
+
options.schemas.forEach((s) => assertIdent(s, 'schema'));
|
|
225
|
+
options.creators.forEach((c) => assertIdent(c, 'creator'));
|
|
226
|
+
|
|
227
|
+
return options.schemas.map((schema) => ({
|
|
228
|
+
schema,
|
|
229
|
+
usage: `GRANT USAGE ON SCHEMA ${schema} TO ${readerRole}`,
|
|
230
|
+
select: [
|
|
231
|
+
`GRANT SELECT ON ALL TABLES IN SCHEMA ${schema} TO ${readerRole}`,
|
|
232
|
+
`GRANT SELECT ON ALL SEQUENCES IN SCHEMA ${schema} TO ${readerRole}`,
|
|
233
|
+
],
|
|
234
|
+
defaults: options.creators.map((creator) => ({
|
|
235
|
+
creator,
|
|
236
|
+
tables: `ALTER DEFAULT PRIVILEGES FOR ROLE ${creator} IN SCHEMA ${schema} GRANT SELECT ON TABLES TO ${readerRole}`,
|
|
237
|
+
sequences: `ALTER DEFAULT PRIVILEGES FOR ROLE ${creator} IN SCHEMA ${schema} GRANT SELECT ON SEQUENCES TO ${readerRole}`,
|
|
238
|
+
})),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
128
242
|
export interface AdminRoleOptions {
|
|
129
243
|
/**
|
|
130
244
|
* The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half
|