@everystack/server 0.4.30 → 0.4.31

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,13 +178,24 @@ 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[] };
156
199
  error?: string;
157
200
  }
158
201
 
@@ -162,7 +205,7 @@ export async function runProvision(
162
205
  payload: ProvisionPayload,
163
206
  deps: ProvisionDeps,
164
207
  ): Promise<ProvisionResult> {
165
- const { authPassword, adminPassword, loginRole, adminRole, appRoles, searchPath } = payload;
208
+ const { authPassword, adminPassword, readerPassword, loginRole, adminRole, readerRole, appRoles, searchPath } = payload;
166
209
 
167
210
  // Passwords are generated (RandomPassword special:false / hex) — reject anything else
168
211
  // BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
@@ -172,12 +215,17 @@ export async function runProvision(
172
215
  if (adminPassword && !ALNUM.test(adminPassword)) {
173
216
  return { error: 'adminPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
174
217
  }
218
+ if (readerPassword && !ALNUM.test(readerPassword)) {
219
+ return { error: 'readerPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
220
+ }
175
221
 
176
222
  let roleSql: string;
177
223
  let adminSql: string | null = null;
224
+ let readerSql: string | null = null;
178
225
  try {
179
226
  roleSql = generateRoleChainSQL({ loginRole, appRoles });
180
227
  if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
228
+ if (readerPassword) readerSql = generateReaderRoleSQL({ readerRole });
181
229
  } catch (err: any) {
182
230
  return { error: err?.message || String(err) };
183
231
  }
@@ -187,8 +235,10 @@ export async function runProvision(
187
235
  // we must not reset (additive-only); one created now gets its password set.
188
236
  const login = loginRole ?? 'authenticator';
189
237
  const admin = adminRole ?? 'migrator';
238
+ const reader = readerRole ?? 'reader';
190
239
  const authExisted = !!authPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(login));
191
240
  const adminExisted = !!adminPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(admin));
241
+ const readerExisted = !!readerPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(reader));
192
242
 
193
243
  await deps.execute(roleSql);
194
244
 
@@ -290,6 +340,26 @@ export async function runProvision(
290
340
  result.passwordSet = true;
291
341
  }
292
342
 
343
+ // The SELECT-only reader LAST — it is the one login nothing else depends on, so it can never
344
+ // disturb the ordering contract above. Role + password only; its GRANTs are made by
345
+ // convergeOwnership, AFTER the CLI has written the secret: `GRANT ... ON ALL TABLES` takes an
346
+ // ACCESS EXCLUSIVE lock on every table it touches, and a step that can stall behind a long reader
347
+ // must never sit between the autocommitted `ALTER ROLE … PASSWORD` and the secret-store write
348
+ // (the lockout this file's ordering exists to prevent — see the closing note below).
349
+ if (readerPassword && readerSql) {
350
+ await deps.execute(readerSql);
351
+ result.readerRole = reader;
352
+ const readerSearchPathSql = searchPath ? generateSearchPathSQL(reader, searchPath) : null;
353
+ if (readerSearchPathSql) await deps.execute(readerSearchPathSql);
354
+ if (readerExisted) {
355
+ // Additive-only, same rule as the other two logins: never rewrite a live password.
356
+ result.readerPasswordSet = false;
357
+ } else {
358
+ await deps.execute(`ALTER ROLE ${reader} WITH LOGIN PASSWORD '${readerPassword}';`);
359
+ result.readerPasswordSet = true;
360
+ }
361
+ }
362
+
293
363
  // Ownership convergence (ENACT + ADOPT) is DELIBERATELY NOT here. It is the one part of the
294
364
  // provisioning flow that can wait on a lock (ADOPT's transaction) or outlive the Lambda clock,
295
365
  // and it sits AFTER the credential swap — so running it inside runProvision would put a
@@ -312,10 +382,10 @@ export async function runProvision(
312
382
  * (empty result) when nothing is declared or the caller cannot read (no `query` dep — an older CLI).
313
383
  */
314
384
  export async function convergeOwnership(
315
- payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole'>,
385
+ payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole' | 'readerRole' | 'searchPath'>,
316
386
  deps: ProvisionDeps,
317
- ): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'>> {
318
- const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'> = {};
387
+ ): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption' | 'readerGrants'>> {
388
+ const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption' | 'readerGrants'> = {};
319
389
  if (!deps.query) return out;
320
390
  const operator = payload.adminRole ?? 'migrator';
321
391
 
@@ -334,11 +404,52 @@ export async function convergeOwnership(
334
404
  out.ownerAdoption = { reassigned: 0, manifest: [], findings: [], refusals: [`owner adoption failed (the transaction rolled back; credentials are untouched): ${err?.message ?? String(err)}`] };
335
405
  }
336
406
  }
407
+
408
+ // The reader's read grants — LAST, and here rather than in runProvision for the same reason ADOPT
409
+ // is here: `GRANT ... ON ALL TABLES` locks every table it touches, so it must never sit between
410
+ // the password swap and the secret write. A refusal (or a stall killed by the Lambda clock) leaves
411
+ // a reader that cannot read — loud, recoverable by a re-run, and stranding no credential.
412
+ if (payload.readerRole) {
413
+ try {
414
+ out.readerGrants = await grantReaderAccess(payload.readerRole, payload, operator, deps);
415
+ } catch (err: any) {
416
+ out.readerGrants = { granted: 0, refusals: [`reader grants failed (credentials are already saved; nothing is stranded): ${err?.message ?? String(err)}`] };
417
+ }
418
+ }
337
419
  return out;
338
420
  }
339
421
 
340
422
  const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
341
423
 
424
+ /**
425
+ * Run `statements` as ONE transaction on `execute`, and leave the SESSION usable whatever happens.
426
+ *
427
+ * A statement that fails inside a multi-statement `BEGIN; … COMMIT;` string does NOT end the
428
+ * transaction: PostgreSQL leaves the session in an ABORTED transaction, where every later statement
429
+ * on that same connection fails with "current transaction is aborted, commands ignored until end of
430
+ * transaction block" — until something sends ROLLBACK. Both callers run on connections the caller
431
+ * opens once and keeps: the ops action holds ONE master connection across ENACT and then ADOPT, so
432
+ * an un-rolled-back ENACT failure would take ADOPT — a separate phase, on unrelated objects — down
433
+ * with it, reporting a stale abort instead of running its own probe.
434
+ *
435
+ * So a throw is followed by a best-effort ROLLBACK on the SAME connection, and the ORIGINAL error is
436
+ * rethrown unchanged. The rollback is a repair attempt, not a result: it is wrapped in its own
437
+ * try/catch so a failed rollback (a dead connection) can never mask or replace the failure that
438
+ * caused it — the caller must still see WHY the transaction failed.
439
+ */
440
+ async function runTransaction(execute: (sql: string) => Promise<unknown>, statements: string[]): Promise<void> {
441
+ try {
442
+ await execute(`BEGIN;\n${statements.map((s) => `${s};`).join('\n')}\nCOMMIT;`);
443
+ } catch (err) {
444
+ try {
445
+ await execute('ROLLBACK;');
446
+ } catch {
447
+ // Best-effort only. The transaction's own error is the one that matters.
448
+ }
449
+ throw err;
450
+ }
451
+ }
452
+
342
453
  function toFacts(rows: any[]): OwnerFactRow[] {
343
454
  return rows.map((r) => ({
344
455
  owner: String(r.owner),
@@ -380,9 +491,117 @@ async function enactOwners(
380
491
  const masterCaps = deps.master ? toCaps(await deps.master.query(granterCapabilitySql(owners))) : null;
381
492
 
382
493
  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 };
494
+ // One transaction per OWNER (per connection) — not one per connection for the whole run. See
495
+ // applyUnits for why the grain moved; each set is still idempotent (a GRANT already in place is a
496
+ // no-op), so a rolled-back unit is simply re-run, and `runTransaction` is what keeps a failed one
497
+ // from aborting the session for whatever the caller runs on it next.
498
+ return applyUnits(plan, deps, runTransaction, (n) => ({ enacted: n.applied, refusals: n.refusals }));
499
+ }
500
+
501
+ /** Autocommit, statement by statement — the reader grants' runner. See applyUnits. */
502
+ async function runSequentially(execute: (sql: string) => Promise<unknown>, statements: string[]): Promise<void> {
503
+ for (const s of statements) await execute(`${s};`);
504
+ }
505
+
506
+ /**
507
+ * Apply a routed plan ONE UNIT AT A TIME — a unit being one declared owner, or one of the reader's
508
+ * grant kinds for one schema, on one connection. Never the whole run as a single batch.
509
+ *
510
+ * THE GRAIN IS THE FIX. The subjects are independent of each other and every statement is
511
+ * idempotent, so batching them across subjects bought nothing and cost this: one owner whose GRANT
512
+ * the server rejected rolled back a co-batched, perfectly grantable owner, and the refusal named
513
+ * only the failure — not the casualty. Because the plan is rebuilt from the catalog on every run,
514
+ * the same batch then failed the same way on every later db:provision, and that owner never
515
+ * converged, silently. ADOPT stays all-or-nothing for the opposite reason: a half-adopted schema is
516
+ * residue db:reconcile chokes on. These grants have no such coupling.
517
+ *
518
+ * A unit that throws becomes a precise refusal naming its subject and carrying the runnable fix
519
+ * (`unitRefusal`); the run continues with the next unit. `run` is the unit's runner: ENACT wraps a
520
+ * unit in ONE transaction (`runTransaction`, which also leaves the shared session usable after a
521
+ * failure); the reader's grants stay autocommit, one statement at a time, because `GRANT ... ON ALL
522
+ * TABLES` takes an ACCESS EXCLUSIVE lock on every relation it touches and a transaction would hold
523
+ * those locks across the whole unit for no atomicity anyone needs.
524
+ */
525
+ async function applyUnits<T>(
526
+ plan: EnactmentPlan,
527
+ deps: ProvisionDeps,
528
+ run: (execute: (sql: string) => Promise<unknown>, statements: string[]) => Promise<void>,
529
+ shape: (n: { applied: number; refusals: string[] }) => T,
530
+ ): Promise<T> {
531
+ const refusals = [...plan.refusals];
532
+ let applied = 0;
533
+ for (const unit of plan.units) {
534
+ const execute = unit.connection === 'master'
535
+ ? (s: string) => deps.master!.execute(s)
536
+ : (s: string) => deps.execute(s);
537
+ try {
538
+ await run(execute, unit.statements);
539
+ applied += unit.statements.length;
540
+ } catch (err: any) {
541
+ refusals.push(unitRefusal(unit, err?.message ?? String(err)));
542
+ }
543
+ }
544
+ return shape({ applied, refusals });
545
+ }
546
+
547
+ function toReaderFacts(rows: any[]): ReaderGrantFactRow[] {
548
+ return rows.map((r) => ({
549
+ schema: String(r.schema),
550
+ creator: String(r.creator ?? ''),
551
+ schemaExists: truthy(r.schema_exists),
552
+ creatorExists: truthy(r.creator_exists),
553
+ usageMissing: truthy(r.usage_missing),
554
+ selectMissing: truthy(r.select_missing),
555
+ // Two facts, never one — see ReaderGrantFactRow: either default-privilege kind can already be
556
+ // in place without the other, and folding them together starves the missing one forever.
557
+ tablesDefaultsMissing: truthy(r.tables_defaults_missing),
558
+ sequencesDefaultsMissing: truthy(r.sequences_defaults_missing),
559
+ }));
560
+ }
561
+
562
+ function toReaderCaps(rows: any[]): ReaderGranterCapabilityRow[] {
563
+ return rows.map((r) => ({
564
+ schema: String(r.schema),
565
+ creator: String(r.creator ?? ''),
566
+ canGrantUsage: truthy(r.can_grant_usage),
567
+ canGrantSelect: truthy(r.can_grant_select),
568
+ canSetDefaults: truthy(r.can_set_defaults),
569
+ }));
570
+ }
571
+
572
+ /**
573
+ * Probe (operator + master), route, and apply the SELECT-only reader's read grants over the app's
574
+ * declared schemas.
575
+ *
576
+ * The schemas are the app's declared set (the same ordered list the role-default search_path uses),
577
+ * falling back to `public` for an all-public app — the reader is scoped to what the app declares,
578
+ * never granted database-wide. The CREATING roles are the operator plus every declared object owner,
579
+ * because `ALTER DEFAULT PRIVILEGES` is recorded per creating role: covering only the operator would
580
+ * leave a later table created by a declared owner invisible to the reader.
581
+ *
582
+ * Idempotent by construction (the facts probe reports only what is missing), so a converged database
583
+ * runs zero statements and takes zero locks.
584
+ */
585
+ async function grantReaderAccess(
586
+ reader: string,
587
+ payload: Pick<ProvisionPayload, 'declaredOwners' | 'searchPath'>,
588
+ operator: string,
589
+ deps: ProvisionDeps,
590
+ ): Promise<{ granted: number; refusals: string[] }> {
591
+ const query = deps.query!;
592
+ const schemas = payload.searchPath?.length ? payload.searchPath : ['public'];
593
+ const creators = [...new Set([operator, ...(payload.declaredOwners ?? []).map((o) => o.owner)])];
594
+
595
+ const facts = toReaderFacts(await query(readerGrantFactsSql(reader, schemas, creators)));
596
+ const operatorCaps = toReaderCaps(await query(readerGrantCapabilitySql(schemas, creators)));
597
+ const masterCaps = deps.master ? toReaderCaps(await deps.master.query(readerGrantCapabilitySql(schemas, creators))) : null;
598
+
599
+ const plan = planReaderGrants(reader, facts, operatorCaps, masterCaps);
600
+ // Unit by unit, exactly like ENACT: a schema whose relations a DBA owns — the grant the honest
601
+ // capability probe now refuses up front, and which can still fail on the server for a reason no
602
+ // probe sees — must not cost the reader its grants in every OTHER schema. Autocommit within the
603
+ // unit, unchanged: the locks these statements take are too heavy to hold across a transaction.
604
+ return applyUnits(plan, deps, runSequentially, (n) => ({ granted: n.applied, refusals: n.refusals }));
386
605
  }
387
606
 
388
607
  function toProbes(rows: any[]): AdopteeProbe[] {
@@ -466,9 +685,37 @@ async function adoptOwners(
466
685
 
467
686
  // One transaction: per-statement lock_timeout, all-or-nothing. `statements` may include SET
468
687
  // 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: [] };
688
+ // The lock_timeout is DESIGNED to fire under contention, which makes the rollback runTransaction
689
+ // does on failure load-bearing here: this connection is the caller's, and it keeps using it.
690
+ const master = deps.master;
691
+ await runTransaction((s) => master.execute(s), [`SET LOCAL lock_timeout = '5000ms'`, ...plan.statements]);
692
+
693
+ // The self-granted SET was scaffolding: it exists so the ALTERs above are legal, and it must not
694
+ // outlive them, or every ADOPT run leaves another membership on the master. It cannot be revoked
695
+ // INSIDE the transaction (the ALTERs still need it there), so it is revoked strictly after the
696
+ // COMMIT, each as its own statement.
697
+ //
698
+ // GRANTED BY CURRENT_USER makes the revoke surgical: PG16 keys a membership on
699
+ // (role, member, grantor), so the master's own self-grant is a DIFFERENT row from the durable
700
+ // ADMIN grant somebody else made it — and that durable one is what lets the NEXT run self-grant.
701
+ // Naming the grantor takes back exactly what this run gave.
702
+ //
703
+ // Non-fatal by construction: the reassignment is already committed, so a failed revoke is a
704
+ // warning with the hand-fix, never a refusal and never a throw.
705
+ const warnings: string[] = [];
706
+ for (const owner of plan.selfGrantedOwners) {
707
+ const revoke = `REVOKE ${owner} FROM CURRENT_USER GRANTED BY CURRENT_USER;`;
708
+ try {
709
+ await deps.master.execute(revoke);
710
+ } catch (err: any) {
711
+ warnings.push(
712
+ `ADOPT self-granted SET on '${owner}' to reassign, and could not revoke it afterwards ` +
713
+ `(${err?.message ?? String(err)}). The reassignment IS committed — this leaves the master ` +
714
+ `holding a SET-only membership in '${owner}'. Revoke it by hand on the master: ${revoke}`,
715
+ );
716
+ }
717
+ }
718
+ return { reassigned: plan.reassigned.length, manifest, findings, refusals: [], ...(warnings.length ? { warnings } : {}) };
472
719
  }
473
720
 
474
721
  /** 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