@everystack/server 0.2.21 → 0.2.24
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/LICENSE +681 -0
- package/README.md +5 -1
- package/jest-preset.js +63 -0
- package/package.json +23 -10
- package/src/db-doctor.ts +229 -0
- package/src/db.ts +100 -10
- package/src/plugin.ts +98 -3
- package/src/role-chain.ts +75 -0
package/README.md
CHANGED
|
@@ -365,4 +365,8 @@ Part of [everystack](https://github.com/scalable-technology/everystack) — a se
|
|
|
365
365
|
|
|
366
366
|
## License
|
|
367
367
|
|
|
368
|
-
|
|
368
|
+
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
|
|
369
|
+
|
|
370
|
+
A commercial license is available for organizations that cannot or do not wish to
|
|
371
|
+
comply with the AGPL-3.0 terms. For commercial licensing, contact
|
|
372
|
+
licensing@scalable.technology.
|
package/jest-preset.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in jest config for a consumer's server-boundary test project (the one
|
|
3
|
+
* that imports `@everystack/server/testing`).
|
|
4
|
+
*
|
|
5
|
+
* // jest.config.js
|
|
6
|
+
* module.exports = { preset: '@everystack/server' };
|
|
7
|
+
*
|
|
8
|
+
* Note the value is the package name, NOT '@everystack/server/jest-preset':
|
|
9
|
+
* jest appends `/jest-preset.js` to the preset string itself, so it resolves
|
|
10
|
+
* this file as `@everystack/server/jest-preset.js`.
|
|
11
|
+
*
|
|
12
|
+
* Why a preset is needed
|
|
13
|
+
* ----------------------
|
|
14
|
+
* @everystack packages ship TypeScript source, and their subpaths (like
|
|
15
|
+
* `@everystack/server/testing`) are reachable only through the package
|
|
16
|
+
* `exports` map. Two things have to be true for a consumer to import them:
|
|
17
|
+
*
|
|
18
|
+
* 1. TypeScript must read the `exports` map. Only `node16`/`nodenext`/`bundler`
|
|
19
|
+
* resolution does. ts-jest under `module: commonjs` silently falls back to
|
|
20
|
+
* classic `node` resolution, which ignores `exports` → TS2307. NodeNext is
|
|
21
|
+
* the one mode that reads `exports` AND emits CommonJS for jest. Its hybrid
|
|
22
|
+
* module kind makes ts-jest warn TS151002; we silence only that one code
|
|
23
|
+
* (NOT via `isolatedModules: true`, which would switch ts-jest to
|
|
24
|
+
* transpile-only and drop type-checking entirely).
|
|
25
|
+
*
|
|
26
|
+
* 2. jest must TRANSFORM the shipped `.ts` source. Source in `node_modules` is
|
|
27
|
+
* ignored by default, so the `transformIgnorePatterns` allowlist below opts
|
|
28
|
+
* `@everystack/*` back in. (Inside this monorepo the pnpm symlink escapes
|
|
29
|
+
* `node_modules` and hides this requirement — real installs need it.)
|
|
30
|
+
*
|
|
31
|
+
* Requires `ts-jest` and `typescript` in the consuming project (already present
|
|
32
|
+
* for any TS jest setup).
|
|
33
|
+
*/
|
|
34
|
+
module.exports = {
|
|
35
|
+
testEnvironment: 'node',
|
|
36
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
37
|
+
transform: {
|
|
38
|
+
'^.+\\.tsx?$': [
|
|
39
|
+
'ts-jest',
|
|
40
|
+
{
|
|
41
|
+
tsconfig: {
|
|
42
|
+
module: 'NodeNext',
|
|
43
|
+
moduleResolution: 'NodeNext',
|
|
44
|
+
target: 'ES2022',
|
|
45
|
+
jsx: 'react-jsx',
|
|
46
|
+
esModuleInterop: true,
|
|
47
|
+
skipLibCheck: true,
|
|
48
|
+
types: ['jest', 'node'],
|
|
49
|
+
},
|
|
50
|
+
// 151002: "hybrid module kind needs isolatedModules" — expected under
|
|
51
|
+
// NodeNext; suppressing it keeps full type-checking on (real type errors
|
|
52
|
+
// in the consumer's tests still fail the suite).
|
|
53
|
+
diagnostics: { ignoreCodes: [151002] },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
// @everystack packages ship source, not built dist — transform them.
|
|
58
|
+
transformIgnorePatterns: ['/node_modules/(?!@everystack/)'],
|
|
59
|
+
// NodeNext consumers may write `.js` on relative ESM specifiers; map to source.
|
|
60
|
+
moduleNameMapper: {
|
|
61
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
62
|
+
},
|
|
63
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scalable-technology/everystack.git",
|
|
10
|
+
"directory": "packages/server"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/scalable-technology/everystack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/scalable-technology/everystack/issues"
|
|
15
|
+
},
|
|
6
16
|
"publishConfig": {
|
|
7
17
|
"access": "public"
|
|
8
18
|
},
|
|
9
19
|
"files": [
|
|
10
20
|
"src",
|
|
11
21
|
"stubs",
|
|
22
|
+
"jest-preset.js",
|
|
12
23
|
"README.md"
|
|
13
24
|
],
|
|
14
25
|
"exports": {
|
|
@@ -44,6 +55,8 @@
|
|
|
44
55
|
"types": "./src/testing/index.ts",
|
|
45
56
|
"default": "./src/testing/index.ts"
|
|
46
57
|
},
|
|
58
|
+
"./jest-preset": "./jest-preset.js",
|
|
59
|
+
"./jest-preset.js": "./jest-preset.js",
|
|
47
60
|
"./plugin": {
|
|
48
61
|
"types": "./src/plugin.ts",
|
|
49
62
|
"default": "./src/plugin.ts"
|
|
@@ -61,11 +74,6 @@
|
|
|
61
74
|
"default": "./src/media.ts"
|
|
62
75
|
}
|
|
63
76
|
},
|
|
64
|
-
"scripts": {
|
|
65
|
-
"test": "jest",
|
|
66
|
-
"build": "tsc --build",
|
|
67
|
-
"lint": "tsc --noEmit"
|
|
68
|
-
},
|
|
69
77
|
"peerDependencies": {
|
|
70
78
|
"esbuild": ">=0.20.0",
|
|
71
79
|
"@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
|
|
@@ -115,8 +123,6 @@
|
|
|
115
123
|
"devDependencies": {
|
|
116
124
|
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
|
|
117
125
|
"@aws-sdk/signature-v4a": "3.1063.0",
|
|
118
|
-
"@everystack/auth": "workspace:*",
|
|
119
|
-
"@everystack/cli": "workspace:*",
|
|
120
126
|
"@types/aws-lambda": "8.10.161",
|
|
121
127
|
"@types/jest": "29.5.14",
|
|
122
128
|
"@types/node": "22.19.18",
|
|
@@ -126,6 +132,13 @@
|
|
|
126
132
|
"postgres": "3.4.9",
|
|
127
133
|
"sst": "4.13.1",
|
|
128
134
|
"ts-jest": "29.4.9",
|
|
129
|
-
"typescript": "5.9.3"
|
|
135
|
+
"typescript": "5.9.3",
|
|
136
|
+
"@everystack/auth": "0.2.6",
|
|
137
|
+
"@everystack/cli": "0.2.39"
|
|
138
|
+
},
|
|
139
|
+
"scripts": {
|
|
140
|
+
"test": "jest",
|
|
141
|
+
"build": "tsc --build",
|
|
142
|
+
"lint": "tsc --noEmit"
|
|
130
143
|
}
|
|
131
|
-
}
|
|
144
|
+
}
|
package/src/db-doctor.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:doctor — "is my database actually secure?" in one command.
|
|
3
|
+
*
|
|
4
|
+
* The credential split is invisible until something tells you which role each Lambda
|
|
5
|
+
* really connects as. This is that instrument: it probes the api connection (createDb)
|
|
6
|
+
* and the admin connection (createAdminDb) from inside the VPC and reports, green/red,
|
|
7
|
+
* whether the api path is least-privilege and RLS-subject. It catches the exact traps
|
|
8
|
+
* that bit us by hand — an unwired DATABASE_URL secret (api still connects as the
|
|
9
|
+
* master), a missing FORCE ROW LEVEL SECURITY, a BYPASSRLS role on the api path.
|
|
10
|
+
*
|
|
11
|
+
* Pairs with `security:audit` (the SECDEF surface). This module is the pure classifier;
|
|
12
|
+
* the dbPlugin `db:doctor` action gathers the probe and the CLI prints the report.
|
|
13
|
+
*
|
|
14
|
+
* See docs/plans/secure-by-default-database.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ConnInfo {
|
|
18
|
+
/** current_user the connection runs as. */
|
|
19
|
+
role: string;
|
|
20
|
+
isSuperuser: boolean;
|
|
21
|
+
bypassRls: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ForceRlsRow {
|
|
25
|
+
schema: string;
|
|
26
|
+
table: string;
|
|
27
|
+
/** RLS enabled on the table. */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
/** FORCE ROW LEVEL SECURITY (applies even to the owner). */
|
|
30
|
+
forced: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DoctorProbe {
|
|
34
|
+
/** The api credential's connection facts (createDb / DATABASE_URL). */
|
|
35
|
+
api: ConnInfo;
|
|
36
|
+
/** The operator connection's facts (createAdminDb / ADMIN_DATABASE_URL). */
|
|
37
|
+
admin: ConnInfo;
|
|
38
|
+
/** Tables with RLS enabled, and whether it's FORCEd. Gathered as admin. */
|
|
39
|
+
forceRls: ForceRlsRow[];
|
|
40
|
+
/**
|
|
41
|
+
* Result of a bare `SELECT` on an RLS table as the api connection, WITHOUT setting a
|
|
42
|
+
* role. A least-privilege role (NOINHERIT, no grants of its own) must fail closed here;
|
|
43
|
+
* a role that returns rows carries ambient privileges (e.g. INHERIT + membership in a
|
|
44
|
+
* `USING(true)` role like the master). Absent when there is no RLS table to probe.
|
|
45
|
+
*/
|
|
46
|
+
apiAmbientRead?: { table: string; accessible: boolean };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type CheckStatus = 'pass' | 'fail' | 'warn';
|
|
50
|
+
|
|
51
|
+
export interface Check {
|
|
52
|
+
name: string;
|
|
53
|
+
status: CheckStatus;
|
|
54
|
+
detail: string;
|
|
55
|
+
/** When the check fails, concrete guidance on how to fix it (shown by the CLI). */
|
|
56
|
+
remediation?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DoctorReport {
|
|
60
|
+
api: ConnInfo;
|
|
61
|
+
admin: ConnInfo;
|
|
62
|
+
checks: Check[];
|
|
63
|
+
/** True when no check failed (warnings are allowed). */
|
|
64
|
+
ok: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** SQL run as a single connection to read its own security facts. */
|
|
68
|
+
export const CONN_SQL = `
|
|
69
|
+
SELECT
|
|
70
|
+
current_user AS role,
|
|
71
|
+
current_setting('is_superuser') = 'on' AS is_superuser,
|
|
72
|
+
COALESCE((SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user), false) AS bypass_rls
|
|
73
|
+
`.trim();
|
|
74
|
+
|
|
75
|
+
/** SQL run as admin: every table with RLS enabled, and whether it is FORCEd. */
|
|
76
|
+
export const FORCE_RLS_SQL = `
|
|
77
|
+
SELECT
|
|
78
|
+
n.nspname AS schema,
|
|
79
|
+
c.relname AS "table",
|
|
80
|
+
c.relrowsecurity AS enabled,
|
|
81
|
+
c.relforcerowsecurity AS forced
|
|
82
|
+
FROM pg_class c
|
|
83
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
84
|
+
WHERE c.relkind = 'r'
|
|
85
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
86
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
87
|
+
AND c.relrowsecurity
|
|
88
|
+
ORDER BY n.nspname, c.relname
|
|
89
|
+
`.trim();
|
|
90
|
+
|
|
91
|
+
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
|
|
92
|
+
export function pgBool(v: unknown): boolean {
|
|
93
|
+
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Options for the report builder. */
|
|
97
|
+
export interface DoctorReportOptions {
|
|
98
|
+
/**
|
|
99
|
+
* App-owned tables that are intentionally ENABLE-not-FORCE: an operational table whose
|
|
100
|
+
* owner (e.g. the worker, or a SECURITY DEFINER function) must read/write it, where FORCE
|
|
101
|
+
* would block the owner. These are added to the framework default (`auth.users`) and not
|
|
102
|
+
* flagged by the FORCE check. Example: `['ops.runs']` (the jobs queue the worker writes).
|
|
103
|
+
*/
|
|
104
|
+
forceRlsCarveouts?: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the green/red report from a gathered probe. Pure. */
|
|
108
|
+
export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptions = {}): DoctorReport {
|
|
109
|
+
const checks: Check[] = [];
|
|
110
|
+
const { api, admin } = probe;
|
|
111
|
+
|
|
112
|
+
// The headline check — does the api path actually run as a distinct least-privilege
|
|
113
|
+
// role, or is it the operator/master? Same role on both = the split isn't active
|
|
114
|
+
// (the classic unwired-secret trap: the api still connects as the master).
|
|
115
|
+
if (api.role === admin.role) {
|
|
116
|
+
checks.push({
|
|
117
|
+
name: 'API connection is least-privilege',
|
|
118
|
+
status: 'fail',
|
|
119
|
+
detail: `the api and operator connections both run as "${api.role}" — the credential split is not active (DATABASE_URL likely unwired; the API is using the operator/master credential)`,
|
|
120
|
+
remediation:
|
|
121
|
+
'Run `everystack db:provision --stage <stage>` — it creates the least-privilege role chain on this '
|
|
122
|
+
+ 'database (idempotent, creates no database) AND writes the DATABASE_URL secret for you, without ever '
|
|
123
|
+
+ 'printing the credential. Then in sst.config.ts declare `new sst.Secret("DatabaseUrl")`, link it to the '
|
|
124
|
+
+ 'API function, and drop the raw Postgres component from the API link (keep it on ops/worker); redeploy. '
|
|
125
|
+
+ 'New apps get all of this automatically from the `everystack.Postgres` construct.',
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
checks.push({
|
|
129
|
+
name: 'API connection is least-privilege',
|
|
130
|
+
status: 'pass',
|
|
131
|
+
detail: `api runs as "${api.role}", operator as "${admin.role}"`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
checks.push({
|
|
136
|
+
name: 'API role is not a superuser',
|
|
137
|
+
status: api.isSuperuser ? 'fail' : 'pass',
|
|
138
|
+
detail: api.isSuperuser ? `"${api.role}" is a superuser — it bypasses RLS entirely` : `"${api.role}" is not a superuser`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
checks.push({
|
|
142
|
+
name: 'API role has no BYPASSRLS',
|
|
143
|
+
status: api.bypassRls ? 'fail' : 'pass',
|
|
144
|
+
detail: api.bypassRls ? `"${api.role}" has the BYPASSRLS attribute — every policy is skipped` : `"${api.role}" is subject to RLS`,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The definitive least-privilege test: can the api connection read an RLS table with no
|
|
148
|
+
// SET ROLE? A role can pass "not superuser / no bypassrls / FORCE on" and still see every
|
|
149
|
+
// row through INHERITed membership in a USING(true) role (the master's leak path). Only a
|
|
150
|
+
// bare read proves it fails closed.
|
|
151
|
+
if (probe.apiAmbientRead) {
|
|
152
|
+
const { table, accessible } = probe.apiAmbientRead;
|
|
153
|
+
checks.push({
|
|
154
|
+
name: 'API connection fails closed (no ambient table access)',
|
|
155
|
+
status: accessible ? 'fail' : 'pass',
|
|
156
|
+
detail: accessible
|
|
157
|
+
? `the api connection read "${table}" without setting a role — it carries ambient privileges (likely INHERIT + membership in a USING(true) role). A least-privilege role has no grants of its own and must SET ROLE first.`
|
|
158
|
+
: `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior`,
|
|
159
|
+
remediation: accessible
|
|
160
|
+
? `Connect the API as a NOINHERIT role with no table grants of its own (e.g. \`authenticator\`), so memberships only apply via SET ROLE: \`ALTER ROLE <api_role> NOINHERIT;\` and revoke any direct table grants. The handler/SSR then escalate per-request via withRole().`
|
|
161
|
+
: undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// FORCE coverage — RLS enabled but not FORCEd means the owner silently bypasses it.
|
|
166
|
+
//
|
|
167
|
+
// Carve-out: a credential table whose SECURITY DEFINER auth functions (verify the
|
|
168
|
+
// password, create the user) run as the table owner and MUST read/write it. FORCE would
|
|
169
|
+
// subject the owner to RLS and break sign_in/sign_up — and on managed Postgres (e.g. RDS)
|
|
170
|
+
// the owner is not a superuser, so it can't bypass FORCE either. Such a table is correctly
|
|
171
|
+
// ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
|
|
172
|
+
// policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
|
|
173
|
+
// the framework's one such table.
|
|
174
|
+
// auth.users is the framework's own credential carve-out (always); apps add operational
|
|
175
|
+
// tables whose owner must write them (e.g. ops.runs, the worker-written jobs queue).
|
|
176
|
+
const FORCE_RLS_CARVEOUTS = new Set(['auth.users', ...(options.forceRlsCarveouts ?? [])]);
|
|
177
|
+
const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
|
|
178
|
+
const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
179
|
+
const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
180
|
+
const carveNote = carved.length > 0
|
|
181
|
+
? ` (${carved.map((r) => `${r.schema}.${r.table}`).join(', ')} intentionally ENABLE-not-FORCE: owner-written table(s) whose owner — a SECURITY DEFINER function or the worker — must bypass)`
|
|
182
|
+
: '';
|
|
183
|
+
if (probe.forceRls.length === 0) {
|
|
184
|
+
checks.push({
|
|
185
|
+
name: 'FORCE ROW LEVEL SECURITY on RLS tables',
|
|
186
|
+
status: 'warn',
|
|
187
|
+
detail: 'no tables have RLS enabled yet (nothing to force)',
|
|
188
|
+
});
|
|
189
|
+
} else if (unforced.length > 0) {
|
|
190
|
+
checks.push({
|
|
191
|
+
name: 'FORCE ROW LEVEL SECURITY on RLS tables',
|
|
192
|
+
status: 'fail',
|
|
193
|
+
detail: `RLS enabled but not FORCEd (owner bypasses policies): ${unforced.map((r) => `${r.schema}.${r.table}`).join(', ')}`,
|
|
194
|
+
remediation: unforced.map((r) => `ALTER TABLE ${r.schema}.${r.table} FORCE ROW LEVEL SECURITY;`).join(' '),
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
checks.push({
|
|
198
|
+
name: 'FORCE ROW LEVEL SECURITY on RLS tables',
|
|
199
|
+
status: 'pass',
|
|
200
|
+
detail: `all ${probe.forceRls.length - carved.length} protected RLS table(s) are FORCEd${carveNote}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
api,
|
|
206
|
+
admin,
|
|
207
|
+
checks,
|
|
208
|
+
ok: !checks.some((c) => c.status === 'fail'),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
|
|
213
|
+
export function rowToConnInfo(row: any): ConnInfo {
|
|
214
|
+
return {
|
|
215
|
+
role: String(row.role),
|
|
216
|
+
isSuperuser: pgBool(row.is_superuser),
|
|
217
|
+
bypassRls: pgBool(row.bypass_rls),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Map a FORCE_RLS_SQL row to ForceRlsRow. */
|
|
222
|
+
export function rowToForceRls(row: any): ForceRlsRow {
|
|
223
|
+
return {
|
|
224
|
+
schema: String(row.schema),
|
|
225
|
+
table: String(row.table),
|
|
226
|
+
enabled: pgBool(row.enabled),
|
|
227
|
+
forced: pgBool(row.forced),
|
|
228
|
+
};
|
|
229
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -26,11 +26,12 @@ function tryResource<T>(fn: () => T): T | undefined {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function getDatabaseUrl(): string {
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// An EXPLICIT DATABASE_URL wins over the component-derived master. This is what
|
|
30
|
+
// lets the API connection be a least-privilege role (`authenticator`) while the SST
|
|
31
|
+
// Postgres component still exists to back the owner/ops credential. If the master
|
|
32
|
+
// resolved first, setting DATABASE_URL to a least-privilege role would be silently
|
|
33
|
+
// ignored. See docs/plans/credential-split-rls-everywhere.md.
|
|
34
|
+
|
|
34
35
|
// SST Secret — PascalCase (Resource.DatabaseUrl.value)
|
|
35
36
|
const pascal = tryResource(() => (Resource as any).DatabaseUrl?.value as string);
|
|
36
37
|
if (pascal) return pascal;
|
|
@@ -39,9 +40,28 @@ export function getDatabaseUrl(): string {
|
|
|
39
40
|
if (raw) return raw;
|
|
40
41
|
// process.env fallback
|
|
41
42
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
|
43
|
+
// Fallback: the SST Postgres component's master credential. Single-credential apps
|
|
44
|
+
// (no explicit DATABASE_URL) keep working; this is the privileged owner, so the
|
|
45
|
+
// createAdminDb fallback warning applies until the credentials are split.
|
|
46
|
+
const master = getMasterDatabaseUrl();
|
|
47
|
+
if (master) return master;
|
|
42
48
|
throw new Error('DATABASE_URL not set — link an sst.aws.Postgres or sst.Secret, or set process.env.DATABASE_URL');
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The SST Postgres component's master credential URL (the privileged owner), or null when no
|
|
53
|
+
* Postgres component is linked. This is the owner credential operator tasks fall back to when
|
|
54
|
+
* no dedicated ADMIN_DATABASE_URL is provisioned — kept separate from getDatabaseUrl() so the
|
|
55
|
+
* operator path never accidentally resolves the least-privilege DATABASE_URL secret.
|
|
56
|
+
*/
|
|
57
|
+
export function getMasterDatabaseUrl(): string | null {
|
|
58
|
+
const db = tryResource(() => Resource.Database);
|
|
59
|
+
if (db?.host) {
|
|
60
|
+
return `postgresql://${db.username}:${db.password}@${db.host}:${db.port}/${db.database}`;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
/**
|
|
46
66
|
* Resolve the operator (privileged) database URL, if configured.
|
|
47
67
|
*
|
|
@@ -117,6 +137,69 @@ export function getSql(): ReturnType<typeof postgres> {
|
|
|
117
137
|
return sqlClient;
|
|
118
138
|
}
|
|
119
139
|
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// withRole — the access primitive (RLS on every path)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The postgres.js transaction handle is callable as a tagged template, but its type
|
|
146
|
+
* (`TransactionSql` via `Omit<Sql, …>`) drops the call signature. Type callbacks
|
|
147
|
+
* against the plain callable `Sql` handle instead.
|
|
148
|
+
*/
|
|
149
|
+
export type RoleSql = ReturnType<typeof postgres>;
|
|
150
|
+
|
|
151
|
+
// Postgres identifier rule — the only value ever interpolated into SET ROLE. Roles
|
|
152
|
+
// come from server code, never user input, but validate defensively against injection.
|
|
153
|
+
const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run `fn` inside a transaction with the effective Postgres role and JWT claims set
|
|
157
|
+
* for its duration. This is THE access primitive on a least-privilege connection: RLS
|
|
158
|
+
* policies and column grants evaluate against `role`, and SQL functions read `auth.*`
|
|
159
|
+
* from the claims. A path that never calls this runs as the grant-less login role and
|
|
160
|
+
* **fails closed** — nothing here bypasses RLS.
|
|
161
|
+
*
|
|
162
|
+
* role one of your application roles (e.g. anon | authenticated | service) —
|
|
163
|
+
* validated against the Postgres identifier rule, never user input.
|
|
164
|
+
* claims the `request.jwt.claims` payload; defaults to `{ role }` for roleless paths.
|
|
165
|
+
*
|
|
166
|
+
* `SET LOCAL` / `set_config(…, true)` are transaction-scoped, so they reset when the
|
|
167
|
+
* pooled connection returns — roles never leak between requests.
|
|
168
|
+
*/
|
|
169
|
+
export async function withRole<T>(
|
|
170
|
+
sql: ReturnType<typeof postgres>,
|
|
171
|
+
role: string,
|
|
172
|
+
claims: Record<string, unknown> | null,
|
|
173
|
+
fn: (tx: RoleSql) => Promise<T>,
|
|
174
|
+
): Promise<T> {
|
|
175
|
+
if (!ROLE_NAME.test(role)) {
|
|
176
|
+
throw new Error(`Invalid role name: ${role}`);
|
|
177
|
+
}
|
|
178
|
+
const claimsJson = JSON.stringify(claims ?? { role });
|
|
179
|
+
return sql.begin(async (tx: any) => {
|
|
180
|
+
await tx.unsafe(`SET LOCAL ROLE ${role}`);
|
|
181
|
+
await tx.unsafe(`SELECT set_config('request.jwt.claims', $1, true)`, [claimsJson]);
|
|
182
|
+
return fn(tx as unknown as RoleSql);
|
|
183
|
+
}) as Promise<T>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Effective role + claims for a request, derived from the verified JWT (or anon).
|
|
188
|
+
*
|
|
189
|
+
* Returns only `anon`/`authenticated` — the roles that may come from a token. The
|
|
190
|
+
* privileged `service` role is NEVER returned here; server code sets it explicitly via
|
|
191
|
+
* `withRole(sql, 'service', …)` after its own authorization check, so a forged or
|
|
192
|
+
* absent JWT can never reach it.
|
|
193
|
+
*/
|
|
194
|
+
export function roleFor(user?: Record<string, unknown> | null): {
|
|
195
|
+
role: 'anon' | 'authenticated';
|
|
196
|
+
claims: Record<string, unknown>;
|
|
197
|
+
} {
|
|
198
|
+
return user
|
|
199
|
+
? { role: 'authenticated', claims: user }
|
|
200
|
+
: { role: 'anon', claims: { role: 'anon' } };
|
|
201
|
+
}
|
|
202
|
+
|
|
120
203
|
// Lazy singleton operator DB connection (separate from the API connection)
|
|
121
204
|
let adminDbInstance: ReturnType<typeof drizzle> | null = null;
|
|
122
205
|
let adminSqlClient: ReturnType<typeof postgres> | null = null;
|
|
@@ -139,16 +222,23 @@ export function createAdminDb<T extends Record<string, unknown>>(
|
|
|
139
222
|
} {
|
|
140
223
|
if (adminDbInstance) return { db: adminDbInstance, schema };
|
|
141
224
|
|
|
142
|
-
|
|
225
|
+
// Resolve the operator credential: an explicit ADMIN_DATABASE_URL, else the Postgres master
|
|
226
|
+
// (the privileged owner). NOT getDatabaseUrl()/createDb — post-split that resolves the
|
|
227
|
+
// least-privilege DATABASE_URL (authenticator), which cannot run operator tasks (migrate/
|
|
228
|
+
// seed/DDL) and fails "permission denied".
|
|
229
|
+
let adminUrl = getAdminDatabaseUrl();
|
|
143
230
|
if (!adminUrl) {
|
|
144
|
-
|
|
231
|
+
adminUrl = getMasterDatabaseUrl();
|
|
232
|
+
if (adminUrl && !adminFallbackWarned) {
|
|
145
233
|
adminFallbackWarned = true;
|
|
146
234
|
console.warn(
|
|
147
|
-
'[everystack/db] ADMIN_DATABASE_URL not set — operator tasks
|
|
148
|
-
+ '
|
|
149
|
-
+ 'are not enforced. Split the credentials: docs/plans/admin-database-url.md'
|
|
235
|
+
'[everystack/db] ADMIN_DATABASE_URL not set — operator tasks use the Postgres master '
|
|
236
|
+
+ 'credential. Provision a dedicated ops owner role for full credential separation.'
|
|
150
237
|
);
|
|
151
238
|
}
|
|
239
|
+
}
|
|
240
|
+
if (!adminUrl) {
|
|
241
|
+
// No Postgres component at all (e.g. local dev with no DB linked) — last resort.
|
|
152
242
|
return createDb(schema, options);
|
|
153
243
|
}
|
|
154
244
|
|
package/src/plugin.ts
CHANGED
|
@@ -457,10 +457,17 @@ export interface DbPluginOptions {
|
|
|
457
457
|
connectionInfo?: () => Promise<Record<string, string>>;
|
|
458
458
|
/** Enable console action (default: true) */
|
|
459
459
|
allowConsole?: boolean;
|
|
460
|
-
/**
|
|
460
|
+
/** Optional read-replica URL for db:query. When unset, db:query runs on the operator
|
|
461
|
+
* connection. (No env fallback — pass it explicitly if you have a replica.) */
|
|
461
462
|
readDatabaseUrl?: string;
|
|
462
463
|
/** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
|
|
463
464
|
pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
|
|
465
|
+
/**
|
|
466
|
+
* App-owned tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor
|
|
467
|
+
* does not flag them. The framework always carves out `auth.users`; add operational tables
|
|
468
|
+
* whose owner must write them, e.g. `['ops.runs']` (the worker-written jobs queue).
|
|
469
|
+
*/
|
|
470
|
+
forceRlsCarveouts?: string[];
|
|
464
471
|
/** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
|
|
465
472
|
consoleAuth?: {
|
|
466
473
|
usersTable?: string;
|
|
@@ -561,8 +568,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
561
568
|
try {
|
|
562
569
|
const { sql } = await import('drizzle-orm');
|
|
563
570
|
let queryDb = opsDb;
|
|
564
|
-
const readUrl = options.readDatabaseUrl
|
|
565
|
-
if (readUrl
|
|
571
|
+
const readUrl = options.readDatabaseUrl;
|
|
572
|
+
if (readUrl) {
|
|
566
573
|
const { drizzle } = await import('drizzle-orm/postgres-js');
|
|
567
574
|
const postgres = (await import('postgres')).default;
|
|
568
575
|
queryDb = drizzle(postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 }));
|
|
@@ -575,6 +582,94 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
575
582
|
}
|
|
576
583
|
};
|
|
577
584
|
|
|
585
|
+
// --- db:doctor — "is my database actually secure?" ---
|
|
586
|
+
// Probes the api connection (createDb / DATABASE_URL) and the operator connection
|
|
587
|
+
// (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
|
|
588
|
+
// is a distinct least-privilege, RLS-subject role. Catches the unwired-secret trap
|
|
589
|
+
// (api still on the master), BYPASSRLS, and un-FORCEd RLS. See db-doctor.ts.
|
|
590
|
+
actions['db:doctor'] = async (_payload, ctx) => {
|
|
591
|
+
const { sql } = await import('drizzle-orm');
|
|
592
|
+
const {
|
|
593
|
+
buildDoctorReport, rowToConnInfo, rowToForceRls, CONN_SQL, FORCE_RLS_SQL,
|
|
594
|
+
} = await import('./db-doctor');
|
|
595
|
+
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
596
|
+
|
|
597
|
+
const quoteIdent = (s: string) => '"' + String(s).replace(/"/g, '""') + '"';
|
|
598
|
+
|
|
599
|
+
let apiDb: any;
|
|
600
|
+
let api;
|
|
601
|
+
try {
|
|
602
|
+
const { createDb } = await import('./db');
|
|
603
|
+
apiDb = createDb(ctx.schema).db;
|
|
604
|
+
api = rowToConnInfo(rowsOf(await apiDb.execute(sql.raw(CONN_SQL)))[0]);
|
|
605
|
+
} catch (err: any) {
|
|
606
|
+
return { error: `api connection probe failed (DATABASE_URL): ${err?.message || String(err)}` };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const admin = rowToConnInfo(rowsOf(await opsDb.execute(sql.raw(CONN_SQL)))[0]);
|
|
610
|
+
const forceRls = rowsOf(await opsDb.execute(sql.raw(FORCE_RLS_SQL))).map(rowToForceRls);
|
|
611
|
+
|
|
612
|
+
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
613
|
+
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
614
|
+
if (forceRls.length > 0) {
|
|
615
|
+
const t = forceRls[0];
|
|
616
|
+
const ref = `${t.schema}.${t.table}`;
|
|
617
|
+
try {
|
|
618
|
+
await apiDb.execute(sql.raw(`SELECT 1 FROM ${quoteIdent(t.schema)}.${quoteIdent(t.table)} LIMIT 1`));
|
|
619
|
+
apiAmbientRead = { table: ref, accessible: true };
|
|
620
|
+
} catch {
|
|
621
|
+
apiAmbientRead = { table: ref, accessible: false };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return buildDoctorReport(
|
|
626
|
+
{ api, admin, forceRls, apiAmbientRead },
|
|
627
|
+
{ forceRlsCarveouts: options.forceRlsCarveouts },
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// --- db:provision — create the role chain + set the login password ---
|
|
632
|
+
// Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
|
|
633
|
+
// it is safe on an existing database: an app that brings its own DATABASE_URL can run
|
|
634
|
+
// this to adopt the least-privilege split without re-provisioning anything. The
|
|
635
|
+
// auto-provisioning construct invokes it at deploy time with a generated password.
|
|
636
|
+
actions['db:provision'] = async (payload, _ctx) => {
|
|
637
|
+
const { authPassword, loginRole, appRoles } = (payload || {}) as {
|
|
638
|
+
authPassword?: string;
|
|
639
|
+
loginRole?: string;
|
|
640
|
+
appRoles?: string[];
|
|
641
|
+
};
|
|
642
|
+
const { sql } = await import('drizzle-orm');
|
|
643
|
+
const { generateRoleChainSQL } = await import('./role-chain');
|
|
644
|
+
|
|
645
|
+
let roleSql: string;
|
|
646
|
+
try {
|
|
647
|
+
roleSql = generateRoleChainSQL({ loginRole, appRoles });
|
|
648
|
+
} catch (err: any) {
|
|
649
|
+
return { error: err?.message || String(err) };
|
|
650
|
+
}
|
|
651
|
+
await opsDb.execute(sql.raw(roleSql));
|
|
652
|
+
|
|
653
|
+
const role = loginRole ?? 'authenticator';
|
|
654
|
+
let passwordSet = false;
|
|
655
|
+
if (authPassword) {
|
|
656
|
+
// Generated via RandomPassword(special:false) — alphanumeric. Reject anything
|
|
657
|
+
// else so nothing untrusted is interpolated into the ALTER ROLE literal.
|
|
658
|
+
if (!/^[A-Za-z0-9]+$/.test(authPassword)) {
|
|
659
|
+
return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
660
|
+
}
|
|
661
|
+
await opsDb.execute(sql.raw(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${authPassword}';`));
|
|
662
|
+
passwordSet = true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
provisioned: true,
|
|
667
|
+
loginRole: role,
|
|
668
|
+
appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
|
|
669
|
+
passwordSet,
|
|
670
|
+
};
|
|
671
|
+
};
|
|
672
|
+
|
|
578
673
|
// --- console ---
|
|
579
674
|
if (options.allowConsole !== false) {
|
|
580
675
|
actions['console:meta'] = async (_payload, ctx) => {
|