@everystack/server 0.4.29 → 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/CHANGELOG.md +35 -0
- package/package.json +3 -3
- package/src/db.ts +43 -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 +280 -60
- package/src/provision.ts +265 -16
- 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,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
|
-
|
|
384
|
-
for (
|
|
385
|
-
|
|
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[] {
|
|
@@ -418,13 +637,15 @@ async function adoptOwners(
|
|
|
418
637
|
const vRefusal = pgVersionRefusal(Number(vRow?.v ?? 0));
|
|
419
638
|
if (vRefusal) return { ...EMPTY_ADOPTION, refusals: [vRefusal] };
|
|
420
639
|
|
|
421
|
-
// ADOPT
|
|
422
|
-
//
|
|
640
|
+
// ADOPT reassigns on a master-capable connection (the linked SST Postgres master, else the
|
|
641
|
+
// ADMIN_DATABASE_URL operator secret — see getAdoptMasterUrl). A connection is present unless the
|
|
642
|
+
// app configures NEITHER. Its actual capability is probed below; this refusal is only the
|
|
643
|
+
// no-connection-at-all case.
|
|
423
644
|
if (!deps.master) {
|
|
424
645
|
return { ...EMPTY_ADOPTION, refusals: [
|
|
425
|
-
'ADOPT
|
|
426
|
-
'
|
|
427
|
-
'
|
|
646
|
+
'ADOPT has no master-capable connection to reassign through. Provide an ADMIN_DATABASE_URL ' +
|
|
647
|
+
'secret for the stage (a privileged operator that owns/administers the objects), or link an ' +
|
|
648
|
+
'sst.aws.Postgres component. The least-privilege DATABASE_URL cannot reassign a postgres-owned object.',
|
|
428
649
|
] };
|
|
429
650
|
}
|
|
430
651
|
|
|
@@ -464,9 +685,37 @@ async function adoptOwners(
|
|
|
464
685
|
|
|
465
686
|
// One transaction: per-statement lock_timeout, all-or-nothing. `statements` may include SET
|
|
466
687
|
// self-grants ahead of the ALTERs, so the count of reassignments is plan.reassigned, not statements.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 } : {}) };
|
|
470
719
|
}
|
|
471
720
|
|
|
472
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
|