@everystack/server 0.2.26 → 0.3.1
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/README.md +41 -0
- package/package.json +9 -3
- package/src/backup-run.ts +161 -0
- package/src/backup.ts +149 -0
- package/src/db-doctor.ts +211 -7
- package/src/plugin.ts +149 -6
package/README.md
CHANGED
|
@@ -125,6 +125,47 @@ Returns PostgreSQL connection URL from SST Resources.
|
|
|
125
125
|
|
|
126
126
|
Returns JWT secret as `Uint8Array` from `Resource.JwtSecret.value`.
|
|
127
127
|
|
|
128
|
+
## Database operations (`dbPlugin`)
|
|
129
|
+
|
|
130
|
+
`dbPlugin` registers the operator actions the CLI invokes over IAM-authed Lambda invoke (no HTTP, no shared secret). Add it to the ops Lambda's plugin list; it runs on the privileged operator connection (`ctx.adminDb`).
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { dbPlugin } from '@everystack/server/plugin';
|
|
134
|
+
|
|
135
|
+
dbPlugin({
|
|
136
|
+
migrationsFolder: join(__dirname, 'drizzle'),
|
|
137
|
+
seed: runSeed, // optional — registers `seed` (dev only)
|
|
138
|
+
schemas: ['auth', 'public', 'drizzle'], // db:reset drop set (dev only)
|
|
139
|
+
backupBucket: Resource.Backups.name, // private bucket for logical backups
|
|
140
|
+
forceRlsCarveouts: ['ops.runs'], // ENABLE-not-FORCE tables db:doctor shouldn't flag
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Actions contributed (each is an `everystack db:*` command):
|
|
145
|
+
|
|
146
|
+
| Action | Does |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `migrate` / `seed` / `db:reset` | run migrations, seed (dev), drop+remigrate (dev) |
|
|
149
|
+
| `db:psql` / `db:query` | connection info / read-only SQL via Lambda |
|
|
150
|
+
| `db:doctor` | audit: is the API connection least-privilege + RLS-subject? Flags BYPASSRLS, un-FORCEd RLS, and **app-reachable tables without RLS** |
|
|
151
|
+
| `db:provision` | create the least-privilege role chain on an existing DB (idempotent; roles only) |
|
|
152
|
+
| `db:backup:probe` | verify the `pg_dump` layer is attached and `pg_dump` major ≥ the server major |
|
|
153
|
+
| `db:backup` | `pg_dump -Fc` → gzip → S3 (streamed); awaits the child exit code, deletes the partial on failure |
|
|
154
|
+
| `db:backups` | list a stage's backups |
|
|
155
|
+
| `db:restore` | S3 → gunzip → `pg_restore --clean --if-exists` (requires `confirm:true`) |
|
|
156
|
+
| `db:authz:probe` | run the CLI-built authorization red-team SQL in a rolled-back transaction |
|
|
157
|
+
| `console` / `console:meta` | the operator REPL (db, schema, models, auth in scope) |
|
|
158
|
+
|
|
159
|
+
### Backup options
|
|
160
|
+
|
|
161
|
+
| Option | Type | Description |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `backupBucket` | `string` | Private S3 bucket for logical backups. Pass `Resource.Backups.name`. Unset → `db:backup`/`db:backups`/`db:restore` return "backup storage not configured"; the rest of the plugin is unaffected. |
|
|
164
|
+
| `backupRegion` | `string` | Region for the backups bucket. Defaults to the Lambda's `AWS_REGION`. |
|
|
165
|
+
| `forceRlsCarveouts` | `string[]` | App-owned tables intentionally ENABLE-not-FORCE RLS (owner must write them), so `db:doctor` doesn't flag them. `auth.users` is always carved out. |
|
|
166
|
+
|
|
167
|
+
Logical backups need the `pg_dump` Lambda layer — see [docs/backups.md](../../docs/backups.md). Credentials reach `pg_dump`/`pg_restore` as `PG*` env vars, never on argv.
|
|
168
|
+
|
|
128
169
|
## Web SSR (`@everystack/server/ssr`)
|
|
129
170
|
|
|
130
171
|
Serves Expo web builds deployed via `@everystack/cli`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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>",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"esbuild": ">=0.20.0",
|
|
79
79
|
"@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
|
|
80
80
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
81
|
+
"@aws-sdk/lib-storage": "3.1053.0",
|
|
81
82
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
82
83
|
"@aws-sdk/signature-v4a": ">=3.1048.0",
|
|
83
84
|
"@everystack/auth": ">=0.1.0",
|
|
@@ -116,12 +117,17 @@
|
|
|
116
117
|
"@aws-sdk/client-s3": {
|
|
117
118
|
"optional": true
|
|
118
119
|
},
|
|
120
|
+
"@aws-sdk/lib-storage": {
|
|
121
|
+
"optional": true
|
|
122
|
+
},
|
|
119
123
|
"@aws-sdk/s3-request-presigner": {
|
|
120
124
|
"optional": true
|
|
121
125
|
}
|
|
122
126
|
},
|
|
123
127
|
"devDependencies": {
|
|
124
128
|
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
|
|
129
|
+
"@aws-sdk/client-s3": "3.1053.0",
|
|
130
|
+
"@aws-sdk/lib-storage": "3.1053.0",
|
|
125
131
|
"@aws-sdk/signature-v4a": "3.1063.0",
|
|
126
132
|
"@types/aws-lambda": "8.10.161",
|
|
127
133
|
"@types/jest": "29.5.14",
|
|
@@ -133,8 +139,8 @@
|
|
|
133
139
|
"sst": "4.13.1",
|
|
134
140
|
"ts-jest": "29.4.9",
|
|
135
141
|
"typescript": "5.9.3",
|
|
136
|
-
"@everystack/auth": "0.
|
|
137
|
-
"@everystack/cli": "0.
|
|
142
|
+
"@everystack/auth": "0.3.0",
|
|
143
|
+
"@everystack/cli": "0.3.3"
|
|
138
144
|
},
|
|
139
145
|
"scripts": {
|
|
140
146
|
"test": "jest",
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:backup execution — spawn pg_dump/pg_restore in the ops Lambda and stream to/from S3.
|
|
3
|
+
*
|
|
4
|
+
* The streaming is the whole point: pg_dump stdout → gzip → S3 multipart upload (never buffer the
|
|
5
|
+
* dump), and the reverse for restore. The load-bearing correctness rule (see docs/plans/
|
|
6
|
+
* db-backup-restore.md): the S3 Upload resolving does NOT mean pg_dump succeeded — pg_dump can exit
|
|
7
|
+
* non-zero mid-stream while the multipart "completes". So we await the child exit code SEPARATELY
|
|
8
|
+
* and, on failure, delete the partial object. A backup tool that ships a truncated dump is worse
|
|
9
|
+
* than none.
|
|
10
|
+
*
|
|
11
|
+
* The pure helpers (key scheme, PG* env, arg builders) live in ./backup and are unit-tested; this
|
|
12
|
+
* module is the IO and is proven by a deployed smoke test.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createGzip, createGunzip } from 'node:zlib';
|
|
17
|
+
import { pipeline } from 'node:stream/promises';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import type { Readable } from 'node:stream';
|
|
20
|
+
import {
|
|
21
|
+
backupPrefix, backupKey, metaKey, backupId, idForKey, keyForId, utcStamp,
|
|
22
|
+
pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
|
|
23
|
+
} from './backup';
|
|
24
|
+
|
|
25
|
+
/** Where backups live + the operator connection pg_dump/pg_restore use. */
|
|
26
|
+
export interface BackupRunDeps {
|
|
27
|
+
bucket: string;
|
|
28
|
+
region?: string;
|
|
29
|
+
/** The resolved operator (privileged) connection URL — never logged, never on argv. */
|
|
30
|
+
adminUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BackupSummary {
|
|
34
|
+
id: string;
|
|
35
|
+
key: string;
|
|
36
|
+
bytes?: number;
|
|
37
|
+
lastModified?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function getS3(region?: string): Promise<any> {
|
|
41
|
+
const { S3Client } = await import('@aws-sdk/client-s3');
|
|
42
|
+
return new S3Client(region ? { region } : {});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* pg_dump -Fc → gzip → S3 (multipart, streamed). Writes a `.meta.json` sidecar on success.
|
|
47
|
+
* Throws (after deleting the partial object) if pg_dump exits non-zero — the dump is never trusted
|
|
48
|
+
* on a successful upload alone.
|
|
49
|
+
*/
|
|
50
|
+
export async function runBackup(deps: BackupRunDeps, stage: string): Promise<BackupSummary> {
|
|
51
|
+
const { bucket, region, adminUrl } = deps;
|
|
52
|
+
const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
|
|
53
|
+
const stamp = utcStamp(new Date());
|
|
54
|
+
const shortId = randomBytes(3).toString('hex');
|
|
55
|
+
const key = backupKey(stage, stamp, shortId);
|
|
56
|
+
const mkey = metaKey(key);
|
|
57
|
+
const id = backupId(stage, stamp, shortId);
|
|
58
|
+
|
|
59
|
+
const s3 = await getS3(region);
|
|
60
|
+
const { Upload } = await import('@aws-sdk/lib-storage');
|
|
61
|
+
const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
62
|
+
|
|
63
|
+
const child = spawn('pg_dump', pgDumpArgs(), { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
|
+
let stderr = '';
|
|
65
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
66
|
+
const childExit = new Promise<void>((resolve, reject) => {
|
|
67
|
+
child.on('error', reject);
|
|
68
|
+
child.on('close', (code) =>
|
|
69
|
+
code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const gzip = createGzip();
|
|
73
|
+
const upload = new Upload({
|
|
74
|
+
client: s3,
|
|
75
|
+
params: {
|
|
76
|
+
Bucket: bucket, Key: key, Body: gzip,
|
|
77
|
+
ContentType: 'application/gzip', ServerSideEncryption: 'AES256',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await Promise.all([
|
|
83
|
+
pipeline(child.stdout!, gzip), // pg_dump → gzip; errors propagate, streams destroyed
|
|
84
|
+
upload.done(), // gzip → S3 multipart
|
|
85
|
+
childExit, // the guard: non-zero pg_dump rejects the whole thing
|
|
86
|
+
]);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
try { await upload.abort(); } catch { /* may already have completed */ }
|
|
89
|
+
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })).catch(() => {});
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let bytes: number | undefined;
|
|
94
|
+
try {
|
|
95
|
+
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
96
|
+
bytes = head.ContentLength;
|
|
97
|
+
} catch { /* size is best-effort */ }
|
|
98
|
+
|
|
99
|
+
const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key };
|
|
100
|
+
await s3.send(new PutObjectCommand({
|
|
101
|
+
Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
|
|
102
|
+
ContentType: 'application/json', ServerSideEncryption: 'AES256',
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
return { id, key, bytes };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
|
|
109
|
+
export async function listBackups(deps: BackupRunDeps, stage: string): Promise<BackupSummary[]> {
|
|
110
|
+
const s3 = await getS3(deps.region);
|
|
111
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
112
|
+
const prefix = `${backupPrefix(stage)}/`;
|
|
113
|
+
const out: BackupSummary[] = [];
|
|
114
|
+
let token: string | undefined;
|
|
115
|
+
do {
|
|
116
|
+
const res: any = await s3.send(new ListObjectsV2Command({
|
|
117
|
+
Bucket: deps.bucket, Prefix: prefix, ContinuationToken: token,
|
|
118
|
+
}));
|
|
119
|
+
for (const o of res.Contents ?? []) {
|
|
120
|
+
if (!o.Key?.endsWith('.dump.gz')) continue;
|
|
121
|
+
const id = idForKey(o.Key);
|
|
122
|
+
if (id) out.push({ id, key: o.Key, bytes: o.Size, lastModified: o.LastModified?.toISOString() });
|
|
123
|
+
}
|
|
124
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
|
125
|
+
} while (token);
|
|
126
|
+
out.sort((a, b) => (b.lastModified ?? '').localeCompare(a.lastModified ?? ''));
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* S3 → gunzip → pg_restore (--clean --if-exists) into the operator connection. Strict: a non-zero
|
|
132
|
+
* pg_restore throws with its stderr. (We start strict and observe benign-error behavior on the
|
|
133
|
+
* deploy smoke test; the flags --no-owner/--no-privileges/--no-comments remove the usual offenders.)
|
|
134
|
+
*/
|
|
135
|
+
export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id: string; restored: true }> {
|
|
136
|
+
const key = keyForId(id);
|
|
137
|
+
if (!key) throw new Error(`Malformed backup id: ${id}`);
|
|
138
|
+
const env = { ...process.env, ...pgEnvFromUrl(deps.adminUrl) };
|
|
139
|
+
const dbName = dbNameFromUrl(deps.adminUrl);
|
|
140
|
+
|
|
141
|
+
const s3 = await getS3(deps.region);
|
|
142
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
143
|
+
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
|
|
144
|
+
if (!obj.Body) throw new Error(`Backup not found: ${id}`);
|
|
145
|
+
const body = obj.Body as Readable;
|
|
146
|
+
|
|
147
|
+
const child = spawn('pg_restore', pgRestoreArgs(dbName), { env, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
148
|
+
let stderr = '';
|
|
149
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
150
|
+
const childExit = new Promise<void>((resolve, reject) => {
|
|
151
|
+
child.on('error', reject);
|
|
152
|
+
child.on('close', (code) =>
|
|
153
|
+
code === 0 ? resolve() : reject(new Error(`pg_restore exited ${code}: ${stderr.trim()}`)));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await Promise.all([
|
|
157
|
+
pipeline(body, createGunzip(), child.stdin!), // S3 → gunzip → pg_restore stdin
|
|
158
|
+
childExit,
|
|
159
|
+
]);
|
|
160
|
+
return { id, restored: true };
|
|
161
|
+
}
|
package/src/backup.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:backup — logical pg_dump/pg_restore helpers (server side).
|
|
3
|
+
*
|
|
4
|
+
* The version gate is load-bearing: pg_dump must be >= the server's MAJOR version, and there is no
|
|
5
|
+
* bypass flag (a newer server refuses an older client; a custom-format archive won't restore under
|
|
6
|
+
* an older pg_restore). So before any dump we compare the layer's pg_dump major against the live
|
|
7
|
+
* server's `server_version_num`. The parsing/compat logic is pure and unit-tested here; the actual
|
|
8
|
+
* spawn (which needs the pg_dump layer at runtime) is exercised by the deployed db:backup:probe.
|
|
9
|
+
* See docs/plans/db-backup-restore.md.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse the major version from `pg_dump --version` output, e.g.
|
|
14
|
+
* "pg_dump (PostgreSQL) 16.9" → 16. Returns null when no version can be found.
|
|
15
|
+
*/
|
|
16
|
+
export function parsePgDumpMajor(versionOutput: string): number | null {
|
|
17
|
+
const m = versionOutput.match(/\b(\d+)(?:\.\d+)?\s*$/) ?? versionOutput.match(/\b(\d+)\.\d+\b/);
|
|
18
|
+
return m ? Number(m[1]) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The server major from libpq's `server_version_num` (e.g. 160009 → 16). */
|
|
22
|
+
export function serverMajorFromNum(serverVersionNum: number): number {
|
|
23
|
+
return Math.floor(serverVersionNum / 10000);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a pg_dump of `clientMajor` can safely dump/restore a server at `serverVersionNum`.
|
|
28
|
+
* The rule is simply clientMajor >= serverMajor — a newer client can read an older server, never
|
|
29
|
+
* the reverse.
|
|
30
|
+
*/
|
|
31
|
+
export function isDumpCompatible(clientMajor: number, serverVersionNum: number): boolean {
|
|
32
|
+
return clientMajor >= serverMajorFromNum(serverVersionNum);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- S3 key scheme (canonical; the CLI's cli/backup.ts mirrors these for display/guards) --------
|
|
36
|
+
|
|
37
|
+
/** The S3 prefix all of a stage's logical backups live under (admin-only, encrypted). */
|
|
38
|
+
export function backupPrefix(stage: string): string {
|
|
39
|
+
return `backups/${stage}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The object key for a new backup dump. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string. */
|
|
43
|
+
export function backupKey(stage: string, stamp: string, shortId: string): string {
|
|
44
|
+
return `${backupPrefix(stage)}/${stamp}-${shortId}.dump.gz`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The metadata sidecar key for a backup dump key. */
|
|
48
|
+
export function metaKey(dumpKey: string): string {
|
|
49
|
+
return dumpKey.replace(/\.dump\.gz$/, '.meta.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A stage-qualified backup id — what `list` returns and `restore` consumes: `dev/20260628T120000Z-ab12cd`. */
|
|
53
|
+
export function backupId(stage: string, stamp: string, shortId: string): string {
|
|
54
|
+
return `${stage}/${stamp}-${shortId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse a backup id into its stage and name, or null when malformed. */
|
|
58
|
+
export function parseBackupRef(id: string): { stage: string; name: string } | null {
|
|
59
|
+
const m = id.match(/^([A-Za-z0-9_-]+)\/([A-Za-z0-9_.:-]+)$/);
|
|
60
|
+
return m ? { stage: m[1], name: m[2] } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The S3 dump key for a backup id (inverse of backupId), or null when malformed. */
|
|
64
|
+
export function keyForId(id: string): string | null {
|
|
65
|
+
const ref = parseBackupRef(id);
|
|
66
|
+
return ref ? `${backupPrefix(ref.stage)}/${ref.name}.dump.gz` : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Derive the backup id from a dump key (inverse of backupKey), or null when it isn't a dump key. */
|
|
70
|
+
export function idForKey(dumpKey: string): string | null {
|
|
71
|
+
const m = dumpKey.match(/^backups\/([A-Za-z0-9_-]+)\/(.+)\.dump\.gz$/);
|
|
72
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
|
|
76
|
+
export function utcStamp(date: Date): string {
|
|
77
|
+
const p = (n: number): string => String(n).padStart(2, '0');
|
|
78
|
+
return (
|
|
79
|
+
`${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
|
|
80
|
+
`T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- pg_dump / pg_restore invocation (PG* env from the admin URL, never argv) -------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a postgres connection URL into libpq's PG* environment variables. Passing the credential
|
|
88
|
+
* via env (not argv) keeps the password out of the process table. Pure; callers must never log the
|
|
89
|
+
* result (it contains PGPASSWORD). Mirrors the CLI's pgEnvFromUrl.
|
|
90
|
+
*/
|
|
91
|
+
export function pgEnvFromUrl(url: string): Record<string, string> {
|
|
92
|
+
const u = new URL(url);
|
|
93
|
+
if (!/^postgres(ql)?:$/.test(u.protocol)) {
|
|
94
|
+
throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
|
|
95
|
+
}
|
|
96
|
+
const env: Record<string, string> = {};
|
|
97
|
+
if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
|
|
98
|
+
if (u.port) env.PGPORT = u.port;
|
|
99
|
+
if (u.username) env.PGUSER = decodeURIComponent(u.username);
|
|
100
|
+
if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
|
|
101
|
+
const database = u.pathname.replace(/^\//, '');
|
|
102
|
+
if (database) env.PGDATABASE = decodeURIComponent(database);
|
|
103
|
+
const sslmode = u.searchParams.get('sslmode');
|
|
104
|
+
if (sslmode) env.PGSSLMODE = sslmode;
|
|
105
|
+
return env;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The database name from a postgres connection URL (for pg_restore -d). */
|
|
109
|
+
export function dbNameFromUrl(url: string): string {
|
|
110
|
+
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Args for `pg_dump` — custom format (portable, restorable under a different role/stage). The
|
|
115
|
+
* connection comes from PG* env, so no host/db on argv. --no-owner/--no-privileges/--no-comments
|
|
116
|
+
* make the dump restore cleanly into a different stage (and dodge the RDS plpgsql-comment error).
|
|
117
|
+
*/
|
|
118
|
+
export function pgDumpArgs(): string[] {
|
|
119
|
+
return ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Args for `pg_restore` into `dbName` — drop-then-restore (--clean --if-exists), ownership-neutral.
|
|
124
|
+
* With no archive-file argument pg_restore reads the dump from stdin.
|
|
125
|
+
*/
|
|
126
|
+
export function pgRestoreArgs(dbName: string): string[] {
|
|
127
|
+
return ['--clean', '--if-exists', '--no-owner', '--no-privileges', '--no-comments', '-d', dbName];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Spawn `<bin> --version` and return its trimmed stdout (e.g. "pg_dump (PostgreSQL) 16.9").
|
|
132
|
+
* Rejects when the binary is missing — which, in the ops Lambda, means the pg_dump layer is not
|
|
133
|
+
* attached. `bin` defaults to `pg_dump`, resolved on PATH (the layer lands at /opt/bin, on PATH).
|
|
134
|
+
*/
|
|
135
|
+
export async function pgClientVersion(bin = 'pg_dump'): Promise<string> {
|
|
136
|
+
const { spawn } = await import('node:child_process');
|
|
137
|
+
return await new Promise<string>((resolve, reject) => {
|
|
138
|
+
let out = '';
|
|
139
|
+
let err = '';
|
|
140
|
+
const child = spawn(bin, ['--version']);
|
|
141
|
+
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
142
|
+
child.stderr.on('data', (d) => { err += d.toString(); });
|
|
143
|
+
child.on('error', (e) => reject(e));
|
|
144
|
+
child.on('close', (code) => {
|
|
145
|
+
if (code === 0) resolve(out.trim());
|
|
146
|
+
else reject(new Error(`${bin} --version exited ${code}: ${err.trim()}`));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/db-doctor.ts
CHANGED
|
@@ -30,6 +30,18 @@ export interface ForceRlsRow {
|
|
|
30
30
|
forced: boolean;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** A base table an app role can reach (table- or column-level grant), with its RLS posture. */
|
|
34
|
+
export interface AppGrantedRow {
|
|
35
|
+
schema: string;
|
|
36
|
+
table: string;
|
|
37
|
+
/** RLS enabled on the table. */
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
/** FORCE ROW LEVEL SECURITY. */
|
|
40
|
+
forced: boolean;
|
|
41
|
+
/** The app roles holding a privilege on it (sorted). */
|
|
42
|
+
grantedTo: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
export interface DoctorProbe {
|
|
34
46
|
/** The api credential's connection facts (createDb / DATABASE_URL). */
|
|
35
47
|
api: ConnInfo;
|
|
@@ -37,6 +49,12 @@ export interface DoctorProbe {
|
|
|
37
49
|
admin: ConnInfo;
|
|
38
50
|
/** Tables with RLS enabled, and whether it's FORCEd. Gathered as admin. */
|
|
39
51
|
forceRls: ForceRlsRow[];
|
|
52
|
+
/**
|
|
53
|
+
* Every base table reachable by an app role (anon/authenticated/admin), with its RLS
|
|
54
|
+
* posture. Drives the "we require RLS" check — a table granted to a request role with RLS
|
|
55
|
+
* OFF is the PostgREST fail-open shape. Absent on an older probe (the check is then skipped).
|
|
56
|
+
*/
|
|
57
|
+
appGrantedRls?: AppGrantedRow[];
|
|
40
58
|
/**
|
|
41
59
|
* Result of a bare `SELECT` on an RLS table as the api connection, WITHOUT setting a
|
|
42
60
|
* role. A least-privilege role (NOINHERIT, no grants of its own) must fail closed here;
|
|
@@ -44,6 +62,20 @@ export interface DoctorProbe {
|
|
|
44
62
|
* `USING(true)` role like the master). Absent when there is no RLS table to probe.
|
|
45
63
|
*/
|
|
46
64
|
apiAmbientRead?: { table: string; accessible: boolean };
|
|
65
|
+
/**
|
|
66
|
+
* Every SECURITY DEFINER function (schema, name, whether search_path is pinned). The
|
|
67
|
+
* `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
|
|
68
|
+
* (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
|
|
69
|
+
* pin its search_path. Absent on an older probe (the check is then skipped).
|
|
70
|
+
*/
|
|
71
|
+
secdefFunctions?: SecdefFn[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A SECURITY DEFINER function and whether its `search_path` is pinned. */
|
|
75
|
+
export interface SecdefFn {
|
|
76
|
+
schema: string;
|
|
77
|
+
name: string;
|
|
78
|
+
hasSearchPath: boolean;
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
export type CheckStatus = 'pass' | 'fail' | 'warn';
|
|
@@ -88,20 +120,107 @@ WHERE c.relkind = 'r'
|
|
|
88
120
|
ORDER BY n.nspname, c.relname
|
|
89
121
|
`.trim();
|
|
90
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every SECURITY DEFINER function with whether its `search_path` is pinned. A SECDEF
|
|
125
|
+
* function runs as its owner (above RLS), so an unpinned `search_path` is a hijack vector:
|
|
126
|
+
* a caller can shadow an unqualified name the body calls and run code as the owner. This is
|
|
127
|
+
* the exact bug class that bit dev (migration 0022 went unapplied via a journal gap, leaving
|
|
128
|
+
* the auth SECDEF functions mutable) — the `functions` teeth read this to catch it on the
|
|
129
|
+
* deployed database, not just in the migration SQL (which `security:audit` already covers).
|
|
130
|
+
*/
|
|
131
|
+
export const SECDEF_FUNCTIONS_SQL = `
|
|
132
|
+
SELECT
|
|
133
|
+
n.nspname AS schema,
|
|
134
|
+
p.proname AS name,
|
|
135
|
+
EXISTS (
|
|
136
|
+
SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[]) ) cfg WHERE cfg LIKE 'search_path=%'
|
|
137
|
+
) AS has_search_path
|
|
138
|
+
FROM pg_proc p
|
|
139
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
140
|
+
WHERE p.prosecdef
|
|
141
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
142
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
143
|
+
ORDER BY n.nspname, p.proname
|
|
144
|
+
`.trim();
|
|
145
|
+
|
|
91
146
|
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
|
|
92
147
|
export function pgBool(v: unknown): boolean {
|
|
93
148
|
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
94
149
|
}
|
|
95
150
|
|
|
151
|
+
/** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
|
|
152
|
+
function pgTextArray(v: unknown): string[] {
|
|
153
|
+
if (Array.isArray(v)) return v.map(String);
|
|
154
|
+
if (typeof v === 'string') {
|
|
155
|
+
return v.replace(/^\{|\}$/g, '').split(',').map((s) => s.replace(/^"|"$/g, '').trim()).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ROLE_IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Every base table an app role can reach — a table-level grant (`relacl`) OR a column-level
|
|
164
|
+
* grant (`attacl`, the "revoke the table, grant safe columns" shape) — with its RLS flags.
|
|
165
|
+
* The "we require RLS" check reads these: a table granted to a request role with RLS disabled
|
|
166
|
+
* exposes every row to that role, the PostgREST fail-open shape the FORCE check can't see
|
|
167
|
+
* (it only inspects tables that already have RLS on). App-role names are validated idents
|
|
168
|
+
* (they're interpolated); they default to the role-chain set. Extension tables are excluded.
|
|
169
|
+
*/
|
|
170
|
+
export function appGrantsRlsSql(appRoles: string[] = ['anon', 'authenticated', 'admin']): string {
|
|
171
|
+
for (const r of appRoles) {
|
|
172
|
+
if (!ROLE_IDENT.test(r)) throw new Error(`Invalid app role: ${JSON.stringify(r)}`);
|
|
173
|
+
}
|
|
174
|
+
const roleList = appRoles.map((r) => `'${r}'`).join(', ');
|
|
175
|
+
return `
|
|
176
|
+
WITH app_granted AS (
|
|
177
|
+
SELECT c.oid, r.rolname
|
|
178
|
+
FROM pg_class c
|
|
179
|
+
CROSS JOIN LATERAL aclexplode(c.relacl) a
|
|
180
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
181
|
+
WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
|
|
182
|
+
UNION
|
|
183
|
+
SELECT c.oid, r.rolname
|
|
184
|
+
FROM pg_class c
|
|
185
|
+
JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
|
|
186
|
+
CROSS JOIN LATERAL aclexplode(att.attacl) a
|
|
187
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
188
|
+
WHERE c.relkind = 'r' AND r.rolname IN (${roleList})
|
|
189
|
+
)
|
|
190
|
+
SELECT
|
|
191
|
+
n.nspname AS schema,
|
|
192
|
+
c.relname AS "table",
|
|
193
|
+
c.relrowsecurity AS enabled,
|
|
194
|
+
c.relforcerowsecurity AS forced,
|
|
195
|
+
array_agg(DISTINCT g.rolname ORDER BY g.rolname) AS granted_to
|
|
196
|
+
FROM app_granted g
|
|
197
|
+
JOIN pg_class c ON c.oid = g.oid
|
|
198
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
199
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
200
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
201
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
202
|
+
GROUP BY n.nspname, c.relname, c.relrowsecurity, c.relforcerowsecurity
|
|
203
|
+
ORDER BY n.nspname, c.relname;
|
|
204
|
+
`.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
96
207
|
/** Options for the report builder. */
|
|
97
208
|
export interface DoctorReportOptions {
|
|
98
209
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
210
|
+
* Tables that are intentionally ENABLE-not-FORCE: a table whose owner (the worker, or a
|
|
211
|
+
* SECURITY DEFINER function) must read/write it, where FORCE would block the owner. These
|
|
212
|
+
* are not flagged by the FORCE check. Read from each model's `writtenBy` declaration via
|
|
213
|
+
* `deriveForceRlsCarveouts(models)` — including `auth.users` and the six other `auth.*`
|
|
214
|
+
* tables (all `writtenBy: 'functions'`), `ops.runs`, `dist.blobs`, and the notification
|
|
215
|
+
* tables — so the list can't drift from the contract.
|
|
103
216
|
*/
|
|
104
217
|
forceRlsCarveouts?: string[];
|
|
218
|
+
/**
|
|
219
|
+
* Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`), via
|
|
220
|
+
* `deriveFunctionSchemas(models)`. The `functions` teeth assert each such schema has
|
|
221
|
+
* SECDEF functions and that every one pins its `search_path`. Omitted/empty skips the check.
|
|
222
|
+
*/
|
|
223
|
+
functionSchemas?: string[];
|
|
105
224
|
}
|
|
106
225
|
|
|
107
226
|
/** Build the green/red report from a gathered probe. Pure. */
|
|
@@ -162,6 +281,33 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
162
281
|
});
|
|
163
282
|
}
|
|
164
283
|
|
|
284
|
+
// The "we require RLS" invariant — the line that separates everystack from PostgREST,
|
|
285
|
+
// which serves a granted table with no RLS. A table reachable by an app role
|
|
286
|
+
// (anon/authenticated/admin) with RLS DISABLED exposes every row to that role. The FORCE
|
|
287
|
+
// check below can't catch it (it only sees tables that already have RLS on), so this is the
|
|
288
|
+
// check that actually enforces "every app-reachable table is row-secured." A consumer who
|
|
289
|
+
// hand-writes a migration and grants a table without RLS is caught here, not in production.
|
|
290
|
+
if (probe.appGrantedRls) {
|
|
291
|
+
const exposed = probe.appGrantedRls.filter((t) => !t.enabled);
|
|
292
|
+
if (exposed.length > 0) {
|
|
293
|
+
checks.push({
|
|
294
|
+
name: 'App-reachable tables require RLS',
|
|
295
|
+
status: 'fail',
|
|
296
|
+
detail: `granted to an app role but RLS is NOT enabled — every row is exposed to that role: ${exposed.map((t) => `${t.schema}.${t.table} [${t.grantedTo.join('/')}]`).join(', ')}`,
|
|
297
|
+
remediation: exposed
|
|
298
|
+
.map((t) => `ALTER TABLE ${t.schema}.${t.table} ENABLE ROW LEVEL SECURITY; ALTER TABLE ${t.schema}.${t.table} FORCE ROW LEVEL SECURITY;`)
|
|
299
|
+
.join(' ')
|
|
300
|
+
+ ' Then declare a policy — or model the table with the v3 Model so `db:generate` emits RLS + policies + grants together.',
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
checks.push({
|
|
304
|
+
name: 'App-reachable tables require RLS',
|
|
305
|
+
status: 'pass',
|
|
306
|
+
detail: `all ${probe.appGrantedRls.length} app-reachable table(s) have RLS enabled`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
165
311
|
// FORCE coverage — RLS enabled but not FORCEd means the owner silently bypasses it.
|
|
166
312
|
//
|
|
167
313
|
// Carve-out: a credential table whose SECURITY DEFINER auth functions (verify the
|
|
@@ -171,9 +317,11 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
171
317
|
// ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
|
|
172
318
|
// policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
|
|
173
319
|
// the framework's one such table.
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
320
|
+
// The carveouts come entirely from the models' `writtenBy` declarations
|
|
321
|
+
// (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
|
|
322
|
+
// ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
|
|
323
|
+
// default that could drift from — or fall short of — the modeled set.
|
|
324
|
+
const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
|
|
177
325
|
const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
|
|
178
326
|
const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
179
327
|
const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
@@ -201,6 +349,42 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
201
349
|
});
|
|
202
350
|
}
|
|
203
351
|
|
|
352
|
+
// The `functions` teeth — the other half of `writtenBy: 'functions'`. A table written only
|
|
353
|
+
// by SECURITY DEFINER functions is ENABLE-not-FORCE (carved out above) precisely BECAUSE the
|
|
354
|
+
// package's functions run as the owner. That bargain is only safe if those functions exist
|
|
355
|
+
// and each pins its search_path; an unpinned one is an owner-privilege hijack vector. This
|
|
356
|
+
// asserts it on the deployed database — the gate that was missing when 0022 silently went
|
|
357
|
+
// unapplied and left the auth SECDEF functions mutable.
|
|
358
|
+
if (probe.secdefFunctions && (options.functionSchemas?.length ?? 0) > 0) {
|
|
359
|
+
const schemas = new Set(options.functionSchemas);
|
|
360
|
+
const relevant = probe.secdefFunctions.filter((f) => schemas.has(f.schema));
|
|
361
|
+
const unpinned = relevant.filter((f) => !f.hasSearchPath);
|
|
362
|
+
const present = new Set(relevant.map((f) => f.schema));
|
|
363
|
+
const missing = [...schemas].filter((s) => !present.has(s)).sort();
|
|
364
|
+
if (unpinned.length > 0) {
|
|
365
|
+
checks.push({
|
|
366
|
+
name: 'SECURITY DEFINER functions pin search_path',
|
|
367
|
+
status: 'fail',
|
|
368
|
+
detail: `SECDEF function(s) in a functions-written schema have a mutable search_path (owner-privilege hijack vector): ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
|
|
369
|
+
remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = ${f.schema}, public, pg_temp;`).join(' ')
|
|
370
|
+
+ ' (Then redeploy so the migration that defines them carries the pin.)',
|
|
371
|
+
});
|
|
372
|
+
} else if (missing.length > 0) {
|
|
373
|
+
checks.push({
|
|
374
|
+
name: 'SECURITY DEFINER functions exist for functions-written tables',
|
|
375
|
+
status: 'fail',
|
|
376
|
+
detail: `no SECDEF functions found in schema(s) that own a writtenBy:'functions' table: ${missing.join(', ')} — the package SQL that writes those tables was not applied`,
|
|
377
|
+
remediation: `Apply the package's functions migration (e.g. everystack db:migrate). A functions-written table with no SECDEF functions cannot be written at all.`,
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
checks.push({
|
|
381
|
+
name: 'SECURITY DEFINER functions pin search_path',
|
|
382
|
+
status: 'pass',
|
|
383
|
+
detail: `all ${relevant.length} SECDEF function(s) in ${[...present].sort().join(', ')} pin their search_path`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
204
388
|
return {
|
|
205
389
|
api,
|
|
206
390
|
admin,
|
|
@@ -209,6 +393,15 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
209
393
|
};
|
|
210
394
|
}
|
|
211
395
|
|
|
396
|
+
/** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
|
|
397
|
+
export function rowToSecdefFn(row: any): SecdefFn {
|
|
398
|
+
return {
|
|
399
|
+
schema: String(row.schema),
|
|
400
|
+
name: String(row.name),
|
|
401
|
+
hasSearchPath: pgBool(row.has_search_path),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
212
405
|
/** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
|
|
213
406
|
export function rowToConnInfo(row: any): ConnInfo {
|
|
214
407
|
return {
|
|
@@ -227,3 +420,14 @@ export function rowToForceRls(row: any): ForceRlsRow {
|
|
|
227
420
|
forced: pgBool(row.forced),
|
|
228
421
|
};
|
|
229
422
|
}
|
|
423
|
+
|
|
424
|
+
/** Map an appGrantsRlsSql row to AppGrantedRow (tolerant of array vs text[] driver encodings). */
|
|
425
|
+
export function rowToAppGranted(row: any): AppGrantedRow {
|
|
426
|
+
return {
|
|
427
|
+
schema: String(row.schema),
|
|
428
|
+
table: String(row.table),
|
|
429
|
+
enabled: pgBool(row.enabled),
|
|
430
|
+
forced: pgBool(row.forced),
|
|
431
|
+
grantedTo: pgTextArray(row.granted_to),
|
|
432
|
+
};
|
|
433
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -463,11 +463,18 @@ export interface DbPluginOptions {
|
|
|
463
463
|
/** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
|
|
464
464
|
pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
|
|
465
465
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
466
|
+
* Tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor does not
|
|
467
|
+
* flag them. Pass `deriveForceRlsCarveouts(models)` — it reads each model's `writtenBy`
|
|
468
|
+
* declaration (auth.users + the six other auth.* tables, ops.runs, dist.blobs, the
|
|
469
|
+
* notification tables), so the list can't drift from the contract.
|
|
469
470
|
*/
|
|
470
471
|
forceRlsCarveouts?: string[];
|
|
472
|
+
/**
|
|
473
|
+
* Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
|
|
474
|
+
* `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
|
|
475
|
+
* SECDEF functions and that every one pins its search_path (the unpinned-search_path gate).
|
|
476
|
+
*/
|
|
477
|
+
functionSchemas?: string[];
|
|
471
478
|
/** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
|
|
472
479
|
consoleAuth?: {
|
|
473
480
|
usersTable?: string;
|
|
@@ -475,6 +482,14 @@ export interface DbPluginOptions {
|
|
|
475
482
|
passwordColumn?: string;
|
|
476
483
|
claimColumns?: string[];
|
|
477
484
|
};
|
|
485
|
+
/**
|
|
486
|
+
* S3 bucket (name) for logical backups (db:backup/db:backups/db:restore). Pass the dedicated
|
|
487
|
+
* private backups bucket, e.g. `Resource.Backups.name`. When unset those actions return a
|
|
488
|
+
* "backup storage not configured" error — the rest of the plugin is unaffected.
|
|
489
|
+
*/
|
|
490
|
+
backupBucket?: string;
|
|
491
|
+
/** AWS region for the backups bucket (defaults to the Lambda's AWS_REGION). */
|
|
492
|
+
backupRegion?: string;
|
|
478
493
|
}
|
|
479
494
|
|
|
480
495
|
/**
|
|
@@ -582,6 +597,36 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
582
597
|
}
|
|
583
598
|
};
|
|
584
599
|
|
|
600
|
+
// --- db:authz:probe — the authorization red-team's execution path ---
|
|
601
|
+
// Runs the CLI-built probe SQL (SET ROLE + attempt per role/table/command, each in
|
|
602
|
+
// its own savepoint) inside ONE transaction that ALWAYS rolls back, then returns the
|
|
603
|
+
// outcome rows. Unlike db:query this permits writes — they are required to test
|
|
604
|
+
// INSERT/UPDATE/DELETE privileges — but the forced rollback guarantees nothing
|
|
605
|
+
// persists. IAM-gated like every action. The CLI owns the SQL (authz-redteam.ts);
|
|
606
|
+
// this is the thin, transactional, self-reverting runner it needs.
|
|
607
|
+
actions['db:authz:probe'] = async (payload, _ctx) => {
|
|
608
|
+
const { setup, read } = (payload ?? {}) as { setup?: string; read?: string };
|
|
609
|
+
if (!setup || !read) {
|
|
610
|
+
return { error: 'db:authz:probe requires { setup, read } SQL strings' };
|
|
611
|
+
}
|
|
612
|
+
const { sql } = await import('drizzle-orm');
|
|
613
|
+
const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
|
|
614
|
+
let rows: any[] = [];
|
|
615
|
+
try {
|
|
616
|
+
await opsDb.transaction(async (tx: any) => {
|
|
617
|
+
await tx.execute(sql.raw(setup));
|
|
618
|
+
const result: any = await tx.execute(sql.raw(read));
|
|
619
|
+
rows = Array.isArray(result) ? result : result?.rows ?? [];
|
|
620
|
+
throw PROBE_ROLLBACK; // discard every probe write
|
|
621
|
+
});
|
|
622
|
+
} catch (err: unknown) {
|
|
623
|
+
if (err !== PROBE_ROLLBACK) {
|
|
624
|
+
return { error: (err as any)?.message || String(err) };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return { rows };
|
|
628
|
+
};
|
|
629
|
+
|
|
585
630
|
// --- db:doctor — "is my database actually secure?" ---
|
|
586
631
|
// Probes the api connection (createDb / DATABASE_URL) and the operator connection
|
|
587
632
|
// (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
|
|
@@ -590,7 +635,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
590
635
|
actions['db:doctor'] = async (_payload, ctx) => {
|
|
591
636
|
const { sql } = await import('drizzle-orm');
|
|
592
637
|
const {
|
|
593
|
-
buildDoctorReport, rowToConnInfo, rowToForceRls,
|
|
638
|
+
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
|
|
639
|
+
CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
|
|
594
640
|
} = await import('./db-doctor');
|
|
595
641
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
596
642
|
|
|
@@ -608,6 +654,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
608
654
|
|
|
609
655
|
const admin = rowToConnInfo(rowsOf(await opsDb.execute(sql.raw(CONN_SQL)))[0]);
|
|
610
656
|
const forceRls = rowsOf(await opsDb.execute(sql.raw(FORCE_RLS_SQL))).map(rowToForceRls);
|
|
657
|
+
// The "we require RLS" set — every app-reachable table + its RLS posture, so a table
|
|
658
|
+
// granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
|
|
659
|
+
const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
|
|
660
|
+
// SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
|
|
661
|
+
// to assert every SECDEF function in a functions-written schema pins its search_path.
|
|
662
|
+
const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
|
|
611
663
|
|
|
612
664
|
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
613
665
|
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
@@ -623,8 +675,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
623
675
|
}
|
|
624
676
|
|
|
625
677
|
return buildDoctorReport(
|
|
626
|
-
{ api, admin, forceRls, apiAmbientRead },
|
|
627
|
-
{ forceRlsCarveouts: options.forceRlsCarveouts },
|
|
678
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
|
|
679
|
+
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
628
680
|
);
|
|
629
681
|
};
|
|
630
682
|
|
|
@@ -670,6 +722,97 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
670
722
|
};
|
|
671
723
|
};
|
|
672
724
|
|
|
725
|
+
// --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
|
|
726
|
+
// The version gate for logical backups: pg_dump must be >= the server's MAJOR (no bypass flag
|
|
727
|
+
// exists). Returns the layer's pg_dump version + the live server_version_num so a deploy can
|
|
728
|
+
// confirm the layer is present AND version-compatible before db:backup is trusted. See backup.ts.
|
|
729
|
+
actions['db:backup:probe'] = async () => {
|
|
730
|
+
const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
|
|
731
|
+
const { sql } = await import('drizzle-orm');
|
|
732
|
+
|
|
733
|
+
let pgDump: string;
|
|
734
|
+
try {
|
|
735
|
+
pgDump = await pgClientVersion('pg_dump');
|
|
736
|
+
} catch (err: any) {
|
|
737
|
+
return { error: `pg_dump not available — is the pg_dump layer attached to this function? (${err?.message || String(err)})` };
|
|
738
|
+
}
|
|
739
|
+
const pgDumpMajor = parsePgDumpMajor(pgDump);
|
|
740
|
+
|
|
741
|
+
const rows: any = await opsDb.execute(sql.raw('SHOW server_version_num'));
|
|
742
|
+
const row = (Array.isArray(rows) ? rows : rows?.rows ?? [])[0];
|
|
743
|
+
const serverVersionNum = Number(row?.server_version_num);
|
|
744
|
+
const serverMajor = Number.isFinite(serverVersionNum) ? serverMajorFromNum(serverVersionNum) : null;
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
pgDump,
|
|
748
|
+
pgDumpMajor,
|
|
749
|
+
serverVersionNum: Number.isFinite(serverVersionNum) ? serverVersionNum : null,
|
|
750
|
+
serverMajor,
|
|
751
|
+
compatible: pgDumpMajor != null && Number.isFinite(serverVersionNum)
|
|
752
|
+
? isDumpCompatible(pgDumpMajor, serverVersionNum)
|
|
753
|
+
: false,
|
|
754
|
+
};
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// --- db:backup / db:backups / db:restore — logical pg_dump backups streamed to S3 ---
|
|
758
|
+
// The operator (privileged) connection drives pg_dump/pg_restore; the dump streams to the
|
|
759
|
+
// dedicated private backups bucket. Restore is destructive and requires an explicit confirm in
|
|
760
|
+
// the payload (the CLI sets it after the user confirms). See backup-run.ts.
|
|
761
|
+
{
|
|
762
|
+
const resolveBackupDeps = async () => {
|
|
763
|
+
const bucket = options.backupBucket;
|
|
764
|
+
if (!bucket) {
|
|
765
|
+
return { error: 'backup storage not configured — pass backupBucket to dbPlugin (e.g. Resource.Backups.name)' as string };
|
|
766
|
+
}
|
|
767
|
+
const { getAdminDatabaseUrl, getMasterDatabaseUrl } = await import('./db');
|
|
768
|
+
const adminUrl = getAdminDatabaseUrl() ?? getMasterDatabaseUrl();
|
|
769
|
+
if (!adminUrl) {
|
|
770
|
+
return { error: 'no operator connection available (ADMIN_DATABASE_URL / Resource.Database)' as string };
|
|
771
|
+
}
|
|
772
|
+
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
actions['db:backup'] = async (payload, ctx) => {
|
|
776
|
+
const r = await resolveBackupDeps();
|
|
777
|
+
if ('error' in r) return { error: r.error };
|
|
778
|
+
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
779
|
+
const { runBackup } = await import('./backup-run');
|
|
780
|
+
try {
|
|
781
|
+
return await runBackup(r.deps, stage);
|
|
782
|
+
} catch (err: any) {
|
|
783
|
+
return { error: err?.message || String(err) };
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
actions['db:backups'] = async (payload, ctx) => {
|
|
788
|
+
const r = await resolveBackupDeps();
|
|
789
|
+
if ('error' in r) return { error: r.error };
|
|
790
|
+
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
791
|
+
const { listBackups } = await import('./backup-run');
|
|
792
|
+
try {
|
|
793
|
+
return { backups: await listBackups(r.deps, stage) };
|
|
794
|
+
} catch (err: any) {
|
|
795
|
+
return { error: err?.message || String(err) };
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
actions['db:restore'] = async (payload, _ctx) => {
|
|
800
|
+
const { id, confirm } = (payload ?? {}) as { id?: string; confirm?: boolean };
|
|
801
|
+
if (!id) return { error: 'db:restore requires { id }' };
|
|
802
|
+
if (confirm !== true) {
|
|
803
|
+
return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
|
|
804
|
+
}
|
|
805
|
+
const r = await resolveBackupDeps();
|
|
806
|
+
if ('error' in r) return { error: r.error };
|
|
807
|
+
const { runRestore } = await import('./backup-run');
|
|
808
|
+
try {
|
|
809
|
+
return await runRestore(r.deps, id);
|
|
810
|
+
} catch (err: any) {
|
|
811
|
+
return { error: err?.message || String(err) };
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
673
816
|
// --- console ---
|
|
674
817
|
if (options.allowConsole !== false) {
|
|
675
818
|
actions['console:meta'] = async (_payload, ctx) => {
|