@everystack/server 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/cdn/index.ts +1 -0
- package/src/cdn/transforms.ts +57 -0
- package/src/db-doctor.ts +172 -0
- package/src/plugin.ts +66 -32
- package/src/provision.ts +129 -0
- package/src/role-chain.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -143,8 +143,8 @@
|
|
|
143
143
|
"sst": "4.13.1",
|
|
144
144
|
"ts-jest": "29.4.9",
|
|
145
145
|
"typescript": "5.9.3",
|
|
146
|
-
"@everystack/
|
|
147
|
-
"@everystack/
|
|
146
|
+
"@everystack/auth": "0.4.0",
|
|
147
|
+
"@everystack/cli": "0.4.5"
|
|
148
148
|
},
|
|
149
149
|
"scripts": {
|
|
150
150
|
"test": "jest",
|
package/src/cdn/index.ts
CHANGED
package/src/cdn/transforms.ts
CHANGED
|
@@ -155,3 +155,60 @@ export function applyWafExclusions(
|
|
|
155
155
|
return rule;
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite rate-based-rule block actions to answer 429 + Retry-After instead
|
|
161
|
+
* of WAF's default 403.
|
|
162
|
+
*
|
|
163
|
+
* Google is explicit that the status code matters for SEO ("Don't use 403s
|
|
164
|
+
* or 404s for rate limiting"): every 4xx except 429 signals the content no
|
|
165
|
+
* longer exists and deindexes the URL over time, while 429/503 mean "back
|
|
166
|
+
* off and retry" — crawling slows almost immediately and Google retries for
|
|
167
|
+
* ~2 days before dropping anything. A per-IP rate rule clears within
|
|
168
|
+
* minutes of the burst ending, so a legitimate crawler never sees multi-day
|
|
169
|
+
* 429s. Getting this wrong turns a load shield into a deindexing engine.
|
|
170
|
+
*
|
|
171
|
+
* Only rules with a rateBasedStatement AND a block action are touched;
|
|
172
|
+
* managed rule groups (overrideAction) and count-mode rules pass through.
|
|
173
|
+
*
|
|
174
|
+
* @param rules Resolved WAF rules array from Pulumi.
|
|
175
|
+
* @param retryAfterSeconds Retry-After header value. Default 120.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* // In sst.config.ts — composes with applyWafExclusions in one transform.
|
|
180
|
+
* // MUST be registered BEFORE `new sst.aws.Router` (a $transform only
|
|
181
|
+
* // applies to resources created after it, and the Router creates the WebAcl).
|
|
182
|
+
* import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
|
|
183
|
+
*
|
|
184
|
+
* $transform(aws.wafv2.WebAcl, (args: any) => {
|
|
185
|
+
* if (!args.rules) return;
|
|
186
|
+
* args.rules = $resolve(args.rules).apply(
|
|
187
|
+
* (rules) => applyWafRateLimit429(applyWafExclusions(rules)),
|
|
188
|
+
* );
|
|
189
|
+
* });
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export function applyWafRateLimit429(
|
|
193
|
+
rules: any[],
|
|
194
|
+
retryAfterSeconds = 120,
|
|
195
|
+
): any[] {
|
|
196
|
+
return rules.map((rule: any) => {
|
|
197
|
+
if (!rule.statement?.rateBasedStatement || !rule.action?.block) return rule;
|
|
198
|
+
return {
|
|
199
|
+
...rule,
|
|
200
|
+
action: {
|
|
201
|
+
...rule.action,
|
|
202
|
+
block: {
|
|
203
|
+
...rule.action.block,
|
|
204
|
+
customResponse: {
|
|
205
|
+
responseCode: 429,
|
|
206
|
+
responseHeaders: [
|
|
207
|
+
{ name: 'Retry-After', value: String(retryAfterSeconds) },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
package/src/db-doctor.ts
CHANGED
|
@@ -67,8 +67,40 @@ export interface DoctorProbe {
|
|
|
67
67
|
* `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
|
|
68
68
|
* (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
|
|
69
69
|
* pin its search_path. Absent on an older probe (the check is then skipped).
|
|
70
|
+
*
|
|
71
|
+
* B4 also reads it globally: an unpinned SECDEF ANYWHERE is the runtime echo of the
|
|
72
|
+
* compile gate (defineFunction refuses to compile one) — warn-level outside the
|
|
73
|
+
* functions-teeth schemas, which keep their fail.
|
|
70
74
|
*/
|
|
71
75
|
secdefFunctions?: SecdefFn[];
|
|
76
|
+
/**
|
|
77
|
+
* Functions PUBLIC can EXECUTE — PostgreSQL's silent-open default (a NULL proacl MEANS
|
|
78
|
+
* EXECUTE to PUBLIC; the query expands acldefault to see it). The declared derived layer
|
|
79
|
+
* revokes it unconditionally; anything here was created outside it or hand-granted back.
|
|
80
|
+
* Absent on an older probe (the check is then skipped). B4.
|
|
81
|
+
*/
|
|
82
|
+
publicExecutableFunctions?: PublicExecutableFn[];
|
|
83
|
+
/**
|
|
84
|
+
* Views/matviews NO app role can read (no non-owner ACL entries) — superuser-only
|
|
85
|
+
* relations, the runtime echo of the derived read-gap gate. Declared-dark descriptors
|
|
86
|
+
* (`private: true`) also land here by design; the warn names both resolutions. Absent on
|
|
87
|
+
* an older probe (the check is then skipped). B4.
|
|
88
|
+
*/
|
|
89
|
+
darkRelations?: DarkRelation[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
|
|
93
|
+
export interface PublicExecutableFn {
|
|
94
|
+
schema: string;
|
|
95
|
+
name: string;
|
|
96
|
+
secdef: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A view ('v') or matview ('m') with no grants to any non-owner role. */
|
|
100
|
+
export interface DarkRelation {
|
|
101
|
+
schema: string;
|
|
102
|
+
name: string;
|
|
103
|
+
kind: string;
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
/** A SECURITY DEFINER function and whether its `search_path` is pinned. */
|
|
@@ -143,6 +175,59 @@ WHERE p.prosecdef
|
|
|
143
175
|
ORDER BY n.nspname, p.proname
|
|
144
176
|
`.trim();
|
|
145
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Functions PUBLIC can EXECUTE. The trap this exists for: a NULL `proacl` is not "no
|
|
180
|
+
* grants" — it is PostgreSQL's BUILT-IN DEFAULT, which includes EXECUTE to PUBLIC, so the
|
|
181
|
+
* query expands `acldefault('f', proowner)` before exploding. Trigger-returning functions
|
|
182
|
+
* are excluded (not directly callable — PUBLIC EXECUTE on them is inert), as are
|
|
183
|
+
* extension-owned functions (not ours to judge) and system schemas. `secdef` marks the
|
|
184
|
+
* dangerous half: PUBLIC-callable code that runs as its OWNER.
|
|
185
|
+
*/
|
|
186
|
+
export const PUBLIC_EXECUTABLE_FUNCTIONS_SQL = `
|
|
187
|
+
SELECT
|
|
188
|
+
n.nspname AS schema,
|
|
189
|
+
p.proname AS name,
|
|
190
|
+
p.prosecdef AS secdef
|
|
191
|
+
FROM pg_proc p
|
|
192
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
193
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
|
|
194
|
+
WHERE p.prokind IN ('f', 'p')
|
|
195
|
+
AND a.grantee = 0
|
|
196
|
+
AND a.privilege_type = 'EXECUTE'
|
|
197
|
+
AND p.prorettype <> 'trigger'::regtype
|
|
198
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
199
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
200
|
+
AND NOT EXISTS (
|
|
201
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
|
|
202
|
+
)
|
|
203
|
+
GROUP BY n.nspname, p.proname, p.prosecdef
|
|
204
|
+
ORDER BY n.nspname, p.proname
|
|
205
|
+
`.trim();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Views/matviews with no ACL entry for any non-owner role — readable by the superuser
|
|
209
|
+
* and nobody else. The runtime echo of the derived read-gap gate: on a modeled project
|
|
210
|
+
* this is either an object created outside the read model or a declared-dark descriptor.
|
|
211
|
+
*/
|
|
212
|
+
export const DARK_RELATIONS_SQL = `
|
|
213
|
+
SELECT
|
|
214
|
+
n.nspname AS schema,
|
|
215
|
+
c.relname AS name,
|
|
216
|
+
c.relkind AS kind
|
|
217
|
+
FROM pg_class c
|
|
218
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
219
|
+
WHERE c.relkind IN ('v', 'm')
|
|
220
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
221
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
222
|
+
AND NOT EXISTS (
|
|
223
|
+
SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee <> c.relowner
|
|
224
|
+
)
|
|
225
|
+
AND NOT EXISTS (
|
|
226
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
|
|
227
|
+
)
|
|
228
|
+
ORDER BY n.nspname, c.relname
|
|
229
|
+
`.trim();
|
|
230
|
+
|
|
146
231
|
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
|
|
147
232
|
export function pgBool(v: unknown): boolean {
|
|
148
233
|
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
@@ -385,6 +470,75 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
385
470
|
}
|
|
386
471
|
}
|
|
387
472
|
|
|
473
|
+
// B4 — the derived layer's runtime echoes (read-model-everywhere). Each check echoes a
|
|
474
|
+
// compile/check gate on the LIVE database: the gates cover what the models declare;
|
|
475
|
+
// these cover what actually exists, however it got there.
|
|
476
|
+
|
|
477
|
+
// PUBLIC-executable functions: fail when any runs as its owner (SECDEF — PUBLIC-callable
|
|
478
|
+
// privileged code), warn otherwise (invoker functions still run with the caller's
|
|
479
|
+
// rights, but PUBLIC EXECUTE is never a decision someone reviewed).
|
|
480
|
+
if (probe.publicExecutableFunctions) {
|
|
481
|
+
const offenders = probe.publicExecutableFunctions;
|
|
482
|
+
if (offenders.length > 0) {
|
|
483
|
+
const label = (f: PublicExecutableFn): string => `${f.schema}.${f.name}${f.secdef ? ' (SECURITY DEFINER)' : ''}`;
|
|
484
|
+
checks.push({
|
|
485
|
+
name: 'Functions are not PUBLIC-executable',
|
|
486
|
+
status: offenders.some((f) => f.secdef) ? 'fail' : 'warn',
|
|
487
|
+
detail: `PUBLIC can EXECUTE (PostgreSQL's silent-open default — a NULL proacl grants it): ${offenders.map(label).join(', ')}`,
|
|
488
|
+
remediation: offenders.map((f) => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`).join(' ')
|
|
489
|
+
+ ` Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both.`,
|
|
490
|
+
});
|
|
491
|
+
} else {
|
|
492
|
+
checks.push({
|
|
493
|
+
name: 'Functions are not PUBLIC-executable',
|
|
494
|
+
status: 'pass',
|
|
495
|
+
detail: 'no function is executable by PUBLIC',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Dark relations: readable by the superuser and nobody else — either created outside
|
|
501
|
+
// the read model, or declared dark on purpose. Warn, because only the author knows.
|
|
502
|
+
if (probe.darkRelations) {
|
|
503
|
+
if (probe.darkRelations.length > 0) {
|
|
504
|
+
checks.push({
|
|
505
|
+
name: 'Derived relations are readable or declared dark',
|
|
506
|
+
status: 'warn',
|
|
507
|
+
detail: `no app role can read: ${probe.darkRelations.map((r) => `${r.schema}.${r.name}`).join(', ')} — superuser-only relation(s)`,
|
|
508
|
+
remediation: `Declare can('read') on the descriptor (the compiler emits the GRANT), or private: true if it is intentionally dark — either way the decision lands in source.`,
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
checks.push({
|
|
512
|
+
name: 'Derived relations are readable or declared dark',
|
|
513
|
+
status: 'pass',
|
|
514
|
+
detail: 'every view/matview is granted to at least one role',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The global SECDEF-pin echo: the compile gate refuses to DECLARE an unpinned SECDEF;
|
|
520
|
+
// this catches the ones that exist anyway (hand-created, pre-descriptor). The
|
|
521
|
+
// functions-teeth schemas keep their fail above — excluded here, never double-reported.
|
|
522
|
+
if (probe.secdefFunctions) {
|
|
523
|
+
const teeth = new Set(options.functionSchemas ?? []);
|
|
524
|
+
const unpinned = probe.secdefFunctions.filter((f) => !f.hasSearchPath && !teeth.has(f.schema));
|
|
525
|
+
if (unpinned.length > 0) {
|
|
526
|
+
checks.push({
|
|
527
|
+
name: 'Every SECURITY DEFINER function pins search_path',
|
|
528
|
+
status: 'warn',
|
|
529
|
+
detail: `unpinned SECDEF function(s) — owner-privilege hijack vector: ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
|
|
530
|
+
remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = pg_catalog;`).join(' ')
|
|
531
|
+
+ ` Better: declare it with defineFunction({ security: 'definer', searchPath: [...] }) — the compiler refuses an unpinned one.`,
|
|
532
|
+
});
|
|
533
|
+
} else {
|
|
534
|
+
checks.push({
|
|
535
|
+
name: 'Every SECURITY DEFINER function pins search_path',
|
|
536
|
+
status: 'pass',
|
|
537
|
+
detail: `all ${probe.secdefFunctions.length} SECURITY DEFINER function(s) pin their search_path`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
388
542
|
return {
|
|
389
543
|
api,
|
|
390
544
|
admin,
|
|
@@ -393,6 +547,24 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
393
547
|
};
|
|
394
548
|
}
|
|
395
549
|
|
|
550
|
+
/** Map a PUBLIC_EXECUTABLE_FUNCTIONS_SQL row to PublicExecutableFn. */
|
|
551
|
+
export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
|
|
552
|
+
return {
|
|
553
|
+
schema: String(row.schema),
|
|
554
|
+
name: String(row.name),
|
|
555
|
+
secdef: pgBool(row.secdef),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Map a DARK_RELATIONS_SQL row to DarkRelation. */
|
|
560
|
+
export function rowToDarkRelation(row: any): DarkRelation {
|
|
561
|
+
return {
|
|
562
|
+
schema: String(row.schema),
|
|
563
|
+
name: String(row.name),
|
|
564
|
+
kind: String(row.kind),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
396
568
|
/** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
|
|
397
569
|
export function rowToSecdefFn(row: any): SecdefFn {
|
|
398
570
|
return {
|
package/src/plugin.ts
CHANGED
|
@@ -186,6 +186,18 @@ export function createPluginLambdaHandler(
|
|
|
186
186
|
): Promise<APIGatewayProxyStructuredResultV2 | unknown> => {
|
|
187
187
|
// Direct CLI invoke via Lambda Invoke — IAM is the auth layer.
|
|
188
188
|
if ('_action' in event && typeof event._action === 'string') {
|
|
189
|
+
// Built-in health probe — `everystack deploy` invokes it right after a deploy so a bundle
|
|
190
|
+
// that can't initialize is caught at deploy time, not at the next db verb. Handled before
|
|
191
|
+
// the action lookup (it must answer even when initialization itself is the failure) and
|
|
192
|
+
// reserved: an app action named `_health` is never reached.
|
|
193
|
+
if (event._action === '_health') {
|
|
194
|
+
try {
|
|
195
|
+
const { actions } = await ensureInitialized();
|
|
196
|
+
return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort() };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { ok: false, error: String(error) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
189
201
|
const { ctx, actions } = await ensureInitialized();
|
|
190
202
|
const actionName = event._action;
|
|
191
203
|
const actionHandler = actions[actionName];
|
|
@@ -505,7 +517,14 @@ export interface DbPluginOptions {
|
|
|
505
517
|
* Contributes: migrate, seed, db:reset, db:psql, db:query, console.
|
|
506
518
|
*
|
|
507
519
|
* Usage:
|
|
508
|
-
* dbPlugin({
|
|
520
|
+
* dbPlugin({
|
|
521
|
+
* migrationsFolder: join(__dirname, '..', 'drizzle'),
|
|
522
|
+
* // Load the seed module LAZILY. Seed scripts are often also runnable directly
|
|
523
|
+
* // (`node db/seed.ts`) and may connect or process.exit at import time — a static
|
|
524
|
+
* // `import { runSeed }` runs that at Lambda INIT and crashes the ops plane before
|
|
525
|
+
* // any handler exists. The dynamic import defers it to the moment db:seed runs.
|
|
526
|
+
* seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
|
|
527
|
+
* })
|
|
509
528
|
*/
|
|
510
529
|
export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
511
530
|
return async (ctx: PluginContext): Promise<PluginContribution> => {
|
|
@@ -662,7 +681,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
662
681
|
const { sql } = await import('drizzle-orm');
|
|
663
682
|
const {
|
|
664
683
|
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
|
|
684
|
+
rowToPublicExecutableFn, rowToDarkRelation,
|
|
665
685
|
CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
|
|
686
|
+
PUBLIC_EXECUTABLE_FUNCTIONS_SQL, DARK_RELATIONS_SQL,
|
|
666
687
|
} = await import('./db-doctor');
|
|
667
688
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
668
689
|
|
|
@@ -686,6 +707,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
686
707
|
// SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
|
|
687
708
|
// to assert every SECDEF function in a functions-written schema pins its search_path.
|
|
688
709
|
const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
|
|
710
|
+
// B4 — the derived layer's runtime echoes: PUBLIC-executable functions (NULL proacl
|
|
711
|
+
// included via acldefault) and superuser-only views/matviews.
|
|
712
|
+
const publicExecutableFunctions = rowsOf(await opsDb.execute(sql.raw(PUBLIC_EXECUTABLE_FUNCTIONS_SQL))).map(rowToPublicExecutableFn);
|
|
713
|
+
const darkRelations = rowsOf(await opsDb.execute(sql.raw(DARK_RELATIONS_SQL))).map(rowToDarkRelation);
|
|
689
714
|
|
|
690
715
|
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
691
716
|
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
@@ -701,51 +726,60 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
701
726
|
}
|
|
702
727
|
|
|
703
728
|
return buildDoctorReport(
|
|
704
|
-
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
|
|
729
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
|
|
705
730
|
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
706
731
|
);
|
|
707
732
|
};
|
|
708
733
|
|
|
709
|
-
// --- db:provision —
|
|
734
|
+
// --- db:provision — BOTH sides of the credential split, in the safe order ---
|
|
710
735
|
// Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
|
|
711
736
|
// it is safe on an existing database: an app that brings its own DATABASE_URL can run
|
|
712
737
|
// this to adopt the least-privilege split without re-provisioning anything. The
|
|
713
738
|
// auto-provisioning construct invokes it at deploy time with a generated password.
|
|
739
|
+
// With adminPassword, also creates the operator login (migrator) and VERIFIES its
|
|
740
|
+
// real connectivity BEFORE the authenticator password is set — the ordering that
|
|
741
|
+
// keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
|
|
714
742
|
actions['db:provision'] = async (payload, _ctx) => {
|
|
715
|
-
const { authPassword, loginRole, appRoles } = (payload || {}) as {
|
|
716
|
-
authPassword?: string;
|
|
717
|
-
loginRole?: string;
|
|
718
|
-
appRoles?: string[];
|
|
719
|
-
};
|
|
720
743
|
const { sql } = await import('drizzle-orm');
|
|
721
|
-
const {
|
|
744
|
+
const { runProvision } = await import('./provision');
|
|
722
745
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
746
|
+
// Same host/port/database resolution as db:psql — the verification URL must
|
|
747
|
+
// point at the database this connection is actually provisioning.
|
|
748
|
+
let connection: { host: string; port?: number | string; database: string } | null = null;
|
|
749
|
+
if (options.connectionInfo) {
|
|
750
|
+
const info = await options.connectionInfo();
|
|
751
|
+
if (info?.host && info?.database) {
|
|
752
|
+
connection = { host: info.host, port: info.port, database: info.database };
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
try {
|
|
756
|
+
const { Resource } = await import('sst');
|
|
757
|
+
const db = (Resource as any).Database;
|
|
758
|
+
if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
|
|
759
|
+
} catch {
|
|
760
|
+
// No linked component and no callback — the admin login lands unverified.
|
|
738
761
|
}
|
|
739
|
-
await opsDb.execute(sql.raw(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${authPassword}';`));
|
|
740
|
-
passwordSet = true;
|
|
741
762
|
}
|
|
742
763
|
|
|
743
|
-
return
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
764
|
+
return await runProvision(
|
|
765
|
+
(payload || {}) as Parameters<typeof runProvision>[0],
|
|
766
|
+
{
|
|
767
|
+
execute: async (statement) => opsDb.execute(sql.raw(statement)),
|
|
768
|
+
connection,
|
|
769
|
+
connectProbe: async (url) => {
|
|
770
|
+
const { default: postgres } = await import('postgres');
|
|
771
|
+
// 'prefer' handles both TLS-required RDS and plain local Postgres.
|
|
772
|
+
const probe = postgres(url, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
773
|
+
try {
|
|
774
|
+
await probe`SELECT 1`;
|
|
775
|
+
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
776
|
+
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
777
|
+
} finally {
|
|
778
|
+
await probe.end({ timeout: 1 });
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
);
|
|
749
783
|
};
|
|
750
784
|
|
|
751
785
|
// --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runProvision — the db:provision orchestration: both sides of the credential split,
|
|
3
|
+
* in the only safe order.
|
|
4
|
+
*
|
|
5
|
+
* The split has two logins: the least-privilege `authenticator` the API connects as
|
|
6
|
+
* (DATABASE_URL), and the operator `migrator` that owns migrations/DDL
|
|
7
|
+
* (ADMIN_DATABASE_URL). Provisioning only the first half strands the operator path on
|
|
8
|
+
* the linked Postgres master — and for a BYO-database app (no linked component) the
|
|
9
|
+
* operator fallback bottoms out in DATABASE_URL itself, so rewriting it to the
|
|
10
|
+
* authenticator saws off the branch provision sits on: the next ops invoke (including
|
|
11
|
+
* the re-run that would fix it) dies "permission denied".
|
|
12
|
+
*
|
|
13
|
+
* Hence the ordering contract, pinned by __tests__/provision.test.ts:
|
|
14
|
+
* 1. role chain (NOLOGIN tiers + authenticator role, no password)
|
|
15
|
+
* 2. admin role + admin password
|
|
16
|
+
* 3. VERIFY the admin login with a real connection
|
|
17
|
+
* 4. only then the authenticator password — a failed verification aborts the swap.
|
|
18
|
+
*
|
|
19
|
+
* Dependency-injected (execute / connection / connectProbe) so the ordering is testable
|
|
20
|
+
* without a database; plugin.ts wires the real deps.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { generateRoleChainSQL, generateAdminRoleSQL } from './role-chain';
|
|
24
|
+
|
|
25
|
+
export interface ProvisionPayload {
|
|
26
|
+
/** Password for the least-privilege API login. Alphanumeric only. */
|
|
27
|
+
authPassword?: string;
|
|
28
|
+
/** Password for the operator login. Alphanumeric only. Absent = today's half-split. */
|
|
29
|
+
adminPassword?: string;
|
|
30
|
+
loginRole?: string;
|
|
31
|
+
adminRole?: string;
|
|
32
|
+
appRoles?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProvisionDeps {
|
|
36
|
+
/** Run one SQL string on the (privileged) provisioning connection. */
|
|
37
|
+
execute: (sql: string) => Promise<unknown>;
|
|
38
|
+
/** host/port/database for the verification URL; null when unresolvable. */
|
|
39
|
+
connection?: { host: string; port?: number | string; database: string } | null;
|
|
40
|
+
/** Real login attempt as the admin role — resolves on success, throws on failure. */
|
|
41
|
+
connectProbe?: (url: string) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProvisionResult {
|
|
45
|
+
provisioned?: boolean;
|
|
46
|
+
loginRole?: string;
|
|
47
|
+
adminRole?: string;
|
|
48
|
+
appRoles?: string[];
|
|
49
|
+
passwordSet?: boolean;
|
|
50
|
+
adminPasswordSet?: boolean;
|
|
51
|
+
/** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
|
|
52
|
+
adminVerified?: boolean | null;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ALNUM = /^[A-Za-z0-9]+$/;
|
|
57
|
+
|
|
58
|
+
export async function runProvision(
|
|
59
|
+
payload: ProvisionPayload,
|
|
60
|
+
deps: ProvisionDeps,
|
|
61
|
+
): Promise<ProvisionResult> {
|
|
62
|
+
const { authPassword, adminPassword, loginRole, adminRole, appRoles } = payload;
|
|
63
|
+
|
|
64
|
+
// Passwords are generated (RandomPassword special:false / hex) — reject anything else
|
|
65
|
+
// BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
|
|
66
|
+
if (authPassword && !ALNUM.test(authPassword)) {
|
|
67
|
+
return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
68
|
+
}
|
|
69
|
+
if (adminPassword && !ALNUM.test(adminPassword)) {
|
|
70
|
+
return { error: 'adminPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let roleSql: string;
|
|
74
|
+
let adminSql: string | null = null;
|
|
75
|
+
try {
|
|
76
|
+
roleSql = generateRoleChainSQL({ loginRole, appRoles });
|
|
77
|
+
if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
return { error: err?.message || String(err) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await deps.execute(roleSql);
|
|
83
|
+
|
|
84
|
+
const login = loginRole ?? 'authenticator';
|
|
85
|
+
const result: ProvisionResult = {
|
|
86
|
+
provisioned: true,
|
|
87
|
+
loginRole: login,
|
|
88
|
+
appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
|
|
89
|
+
passwordSet: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Admin side FIRST — created and verified before the authenticator swap can happen.
|
|
93
|
+
if (adminPassword && adminSql) {
|
|
94
|
+
const admin = adminRole ?? 'migrator';
|
|
95
|
+
await deps.execute(adminSql);
|
|
96
|
+
await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
|
|
97
|
+
result.adminRole = admin;
|
|
98
|
+
result.adminPasswordSet = true;
|
|
99
|
+
|
|
100
|
+
if (deps.connection && deps.connectProbe) {
|
|
101
|
+
const { host, port, database } = deps.connection;
|
|
102
|
+
const url = `postgresql://${admin}:${adminPassword}@${host}:${port ?? 5432}/${database}`;
|
|
103
|
+
try {
|
|
104
|
+
await deps.connectProbe(url);
|
|
105
|
+
result.adminVerified = true;
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
// The whole point of the ordering: a broken admin login must abort the swap,
|
|
108
|
+
// or a BYO-database app loses its only privileged path.
|
|
109
|
+
return {
|
|
110
|
+
error:
|
|
111
|
+
`admin login verification failed for role '${admin}': ${err?.message || String(err)} — ` +
|
|
112
|
+
'the authenticator password was NOT set and DATABASE_URL must not be rewritten.',
|
|
113
|
+
adminRole: admin,
|
|
114
|
+
adminVerified: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
result.adminVerified = null; // no way to probe — caller should verify via db:doctor
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Authenticator LAST — only reached when the admin side (if requested) held up.
|
|
123
|
+
if (authPassword) {
|
|
124
|
+
await deps.execute(`ALTER ROLE ${login} WITH LOGIN PASSWORD '${authPassword}';`);
|
|
125
|
+
result.passwordSet = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|
package/src/role-chain.ts
CHANGED
|
@@ -73,3 +73,35 @@ export function generateRoleChainSQL(options: RoleChainOptions = {}): string {
|
|
|
73
73
|
|
|
74
74
|
return lines.join('\n') + '\n';
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
export interface AdminRoleOptions {
|
|
78
|
+
/**
|
|
79
|
+
* The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half
|
|
80
|
+
* of the credential split, so the master credential stops being the working credential.
|
|
81
|
+
* INHERIT (unlike the authenticator): its power comes from owner-role membership, and it
|
|
82
|
+
* must hold it ambiently to run DDL over owner-created objects. Default 'migrator'.
|
|
83
|
+
*/
|
|
84
|
+
adminRole?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate idempotent DDL for the operator (admin) login role. Membership is granted BOTH
|
|
89
|
+
* ways with the CURRENT user (the master/owner running the provision): owner → admin gives
|
|
90
|
+
* the admin ownership rights over every owner-created object (no REASSIGN OWNED), and
|
|
91
|
+
* admin → owner keeps the owner able to manage objects the admin creates later. The grants
|
|
92
|
+
* are guarded so a RE-provision running AS the admin role (post-split, the ops connection
|
|
93
|
+
* is this role) never attempts the self-grant Postgres hard-errors on. Does NOT set a
|
|
94
|
+
* password — that's `db:provision`'s job, from a deploy-generated value.
|
|
95
|
+
*/
|
|
96
|
+
export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
|
|
97
|
+
const adminRole = options.adminRole ?? 'migrator';
|
|
98
|
+
assertIdent(adminRole, 'adminRole');
|
|
99
|
+
|
|
100
|
+
const lines: string[] = [
|
|
101
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
|
|
102
|
+
`ALTER ROLE ${adminRole} WITH LOGIN INHERIT NOBYPASSRLS;`,
|
|
103
|
+
`DO $$ BEGIN IF current_user <> '${adminRole}' THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); EXECUTE format('GRANT ${adminRole} TO %I', current_user); END IF; END $$;`,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
return lines.join('\n') + '\n';
|
|
107
|
+
}
|