@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/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
- for (const stmt of plan.asOperator) await deps.execute(`${stmt};`);
384
- for (const stmt of plan.asMaster) await deps.master!.execute(`${stmt};`);
385
- return { enacted: plan.asOperator.length + plan.asMaster.length, refusals: plan.refusals };
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
- const body = plan.statements.map((s) => `${s};`).join('\n');
470
- await deps.master.execute(`BEGIN;\nSET LOCAL lock_timeout = '5000ms';\n${body}\nCOMMIT;`);
471
- return { reassigned: plan.reassigned.length, manifest, findings, refusals: [] };
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