@everystack/cli 0.4.35 → 0.4.38
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/bin/everystack.mjs +52 -0
- package/package.json +4 -3
- package/src/cli/commands/db-authz.ts +94 -38
- package/src/cli/commands/db-swap.ts +229 -24
- package/src/cli/db-source.ts +67 -16
- package/src/cli/derived-introspect.ts +30 -0
- package/src/cli/derived-plan.ts +16 -1
- package/src/cli/index.ts +6 -5
- package/src/cli/migration-compile.ts +12 -1
- package/src/cli/migration-generate.ts +31 -2
- package/src/cli/mutation-lease.ts +8 -0
- package/src/cli/schema-rewrite.ts +142 -9
- package/src/cli/schema-source.ts +30 -7
- package/src/cli/schema-swap.ts +55 -1
- package/src/cli/swap-execute.ts +268 -10
- package/src/cli/swap-heartbeat.ts +106 -17
- package/src/cli/swap-pair.ts +443 -0
- package/src/cli/swap-snapshot.ts +260 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `everystack` entry point.
|
|
4
|
+
*
|
|
5
|
+
* This package ships TypeScript source deliberately — every `exports` target is a
|
|
6
|
+
* `./src/*.ts` file — so the CLI needs a TypeScript loader at runtime. It used to get one
|
|
7
|
+
* from `#!/usr/bin/env tsx` on `src/cli/index.ts`, which cannot work once the package is
|
|
8
|
+
* installed: `env` searches the CONSUMER's PATH, and tsx is a dependency of
|
|
9
|
+
* @everystack/cli, so under pnpm's isolated layout it lives in this package's own
|
|
10
|
+
* node_modules and is never on that PATH. `everystack --help` died with
|
|
11
|
+
* "env: tsx: No such file or directory"; only `pnpm exec everystack` worked, because that
|
|
12
|
+
* puts the local .bin on PATH first.
|
|
13
|
+
*
|
|
14
|
+
* So: boot under plain `node` — always present, it is what runs npm — and find tsx by
|
|
15
|
+
* MODULE RESOLUTION from this file rather than by PATH lookup.
|
|
16
|
+
*
|
|
17
|
+
* The loader is installed by re-executing node with `--import`, not by calling tsx's
|
|
18
|
+
* register() in-process. register() followed by `import()` of the TypeScript entry makes
|
|
19
|
+
* the entry load through a require(esm) path and Node rejects it with
|
|
20
|
+
* ERR_REQUIRE_CYCLE_MODULE. `--import` installs the hooks before any module graph exists,
|
|
21
|
+
* which is the only ordering that works. The extra process is the price of shipping
|
|
22
|
+
* source, and it is paid once per invocation.
|
|
23
|
+
*/
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
let tsx;
|
|
31
|
+
try {
|
|
32
|
+
tsx = pathToFileURL(require.resolve('tsx')).href;
|
|
33
|
+
} catch {
|
|
34
|
+
console.error('everystack: could not resolve the "tsx" TypeScript loader from this package.');
|
|
35
|
+
console.error('This usually means a partial install — try reinstalling @everystack/cli.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const entry = fileURLToPath(new URL('../src/cli/index.ts', import.meta.url));
|
|
40
|
+
|
|
41
|
+
const result = spawnSync(process.execPath, ['--import', tsx, entry, ...process.argv.slice(2)], {
|
|
42
|
+
stdio: 'inherit',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Re-raise a signal death as a signal death so `everystack … &` + Ctrl-C behaves, and
|
|
46
|
+
// otherwise pass the child's exit code through unchanged — the CLI's non-zero exits are
|
|
47
|
+
// load-bearing (db:check, db:authz:diff and friends are CI gates).
|
|
48
|
+
if (result.signal) {
|
|
49
|
+
process.kill(process.pid, result.signal);
|
|
50
|
+
} else {
|
|
51
|
+
process.exit(result.status ?? 1);
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.38",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
+
"bin",
|
|
20
21
|
"src",
|
|
21
22
|
"README.md"
|
|
22
23
|
],
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
}
|
|
92
93
|
},
|
|
93
94
|
"bin": {
|
|
94
|
-
"everystack": "./
|
|
95
|
+
"everystack": "./bin/everystack.mjs"
|
|
95
96
|
},
|
|
96
97
|
"dependencies": {
|
|
97
98
|
"@aws-sdk/client-cloudfront": "3.1053.0",
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
"@everystack/model": "0.4.5"
|
|
112
113
|
},
|
|
113
114
|
"peerDependencies": {
|
|
114
|
-
"@everystack/server": ">=0.
|
|
115
|
+
"@everystack/server": ">=0.4.0",
|
|
115
116
|
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
116
117
|
"@aws-sdk/client-rds": "3.1053.0",
|
|
117
118
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
@@ -9,9 +9,20 @@
|
|
|
9
9
|
* can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
|
|
10
10
|
* audit and the CI gate (non-zero exit on any drift).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Two venues, one verdict. The core (introspect/assemble/diff/evaluate) is backend-agnostic
|
|
13
|
+
* and pure — only the VENUE differs, and both satisfy the same `AuthzVenue` contract, so
|
|
14
|
+
* every command below runs identical evaluation code either way:
|
|
15
|
+
*
|
|
16
|
+
* - **Deployed** (`--stage`, the default): the ops Lambda's `db:query` + `db:authz:probe`
|
|
17
|
+
* actions. Credentials never leave AWS.
|
|
18
|
+
* - **Direct** (`--database-url`, or an inherited ADMIN_DATABASE_URL / DATABASE_URL): a
|
|
19
|
+
* postgres.js connection from this process.
|
|
20
|
+
*
|
|
21
|
+
* The direct venue is what makes a brownfield authz migration rehearsable. Translating
|
|
22
|
+
* hand-written RLS into Model abilities is the hardest part of an adoption, and until the
|
|
23
|
+
* local venue existed it was the ONLY part that required a deployed stage to iterate
|
|
24
|
+
* against. Every run names its venue, because a security verdict against an unintended
|
|
25
|
+
* database is worse than no verdict.
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
28
|
import path from 'node:path';
|
|
@@ -43,6 +54,7 @@ import { renderContractMarkdown } from '../authz-render.js';
|
|
|
43
54
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
44
55
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
45
56
|
import { resolveModelsPath } from '../models-path.js';
|
|
57
|
+
import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
|
|
46
58
|
import { invokeAction } from '../aws.js';
|
|
47
59
|
import { step, success, fail, info, warn } from '../output.js';
|
|
48
60
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
@@ -59,20 +71,65 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Where an authz command runs. Both venues expose the same two capabilities, so the
|
|
76
|
+
* commands never branch on venue after this point — that is what keeps the local
|
|
77
|
+
* rehearsal honest: same SQL, same evaluation, same verdict.
|
|
78
|
+
*/
|
|
79
|
+
interface AuthzVenue {
|
|
80
|
+
/** Read-only introspection. */
|
|
81
|
+
runner: QueryRunner;
|
|
82
|
+
/** Self-reverting red-team probe (writes, always rolled back). */
|
|
83
|
+
probe: (setup: string, read: string) => Promise<any[]>;
|
|
84
|
+
/** Human-readable venue, printed with every verdict. */
|
|
85
|
+
label: string;
|
|
86
|
+
/** Release any connection this venue holds. */
|
|
87
|
+
end: () => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Precedence is `resolveDbSource`'s, shared with db:pull / db:generate / db:check:
|
|
92
|
+
* `--database-url` > explicit `--stage` > ADMIN_DATABASE_URL > DATABASE_URL > default stage.
|
|
93
|
+
*/
|
|
94
|
+
async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue> {
|
|
95
|
+
const source = resolveDbSource(flags);
|
|
96
|
+
if (source.kind === 'url') {
|
|
97
|
+
step(connectingVia(source));
|
|
98
|
+
const { runner, probe, end } = await createUrlProbeRunner(source.url);
|
|
99
|
+
return { runner, probe, end, label: 'direct connection' };
|
|
100
|
+
}
|
|
63
101
|
step('Resolving deployed config...');
|
|
64
102
|
const config = await resolveConfig(flags.stage);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
const fn = opsFunction(config);
|
|
104
|
+
info(`Region: ${config.region}, Function: ${fn}`);
|
|
105
|
+
return {
|
|
106
|
+
runner: lambdaRunner(config.region, fn),
|
|
107
|
+
probe: async (setup: string, read: string) => {
|
|
108
|
+
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
|
|
109
|
+
if (result?.error) throw new Error(result.error);
|
|
110
|
+
return result?.rows ?? [];
|
|
111
|
+
},
|
|
112
|
+
label: flags.stage ? `stage ${flags.stage}` : 'deployed stage (.sst/outputs.json)',
|
|
113
|
+
end: async () => {},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function introspectVenue(flags: Record<string, string>): Promise<{ contract: AuthzContract; label: string }> {
|
|
118
|
+
const venue = await resolveVenue(flags);
|
|
119
|
+
try {
|
|
120
|
+
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
121
|
+
return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
|
|
122
|
+
} finally {
|
|
123
|
+
await venue.end();
|
|
124
|
+
}
|
|
69
125
|
}
|
|
70
126
|
|
|
71
127
|
export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
|
|
72
128
|
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
73
129
|
let contract: AuthzContract;
|
|
130
|
+
let venueLabel: string;
|
|
74
131
|
try {
|
|
75
|
-
contract = await
|
|
132
|
+
({ contract, label: venueLabel } = await introspectVenue(flags));
|
|
76
133
|
} catch (err: any) {
|
|
77
134
|
fail(err.message);
|
|
78
135
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
@@ -82,7 +139,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
|
|
|
82
139
|
const files = await writeContract(dir, contract);
|
|
83
140
|
const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
|
|
84
141
|
console.log('');
|
|
85
|
-
success(`Wrote ${files.length} file(s) to ${dir}`);
|
|
142
|
+
success(`Wrote ${files.length} file(s) to ${dir} (from ${venueLabel})`);
|
|
86
143
|
info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
|
|
87
144
|
console.log('');
|
|
88
145
|
warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
|
|
@@ -97,9 +154,10 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
|
|
|
97
154
|
|
|
98
155
|
let declared: AuthzContract;
|
|
99
156
|
let live: AuthzContract;
|
|
157
|
+
let venueLabel: string;
|
|
100
158
|
try {
|
|
101
159
|
declared = await loadContract(dir);
|
|
102
|
-
live = await
|
|
160
|
+
({ contract: live, label: venueLabel } = await introspectVenue(flags));
|
|
103
161
|
} catch (err: any) {
|
|
104
162
|
fail(err.message);
|
|
105
163
|
process.exit(1);
|
|
@@ -113,11 +171,11 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
|
|
|
113
171
|
const findings = diffContracts(declared, live);
|
|
114
172
|
console.log('');
|
|
115
173
|
if (findings.length === 0) {
|
|
116
|
-
success(`db:authz:diff —
|
|
174
|
+
success(`db:authz:diff — ${venueLabel} matches the declared contract (${declared.tables.length} tables)`);
|
|
117
175
|
process.exit(0);
|
|
118
176
|
}
|
|
119
177
|
|
|
120
|
-
fail(`db:authz:diff — ${findings.length} drift finding(s):
|
|
178
|
+
fail(`db:authz:diff — ${findings.length} drift finding(s): ${venueLabel} does NOT match the declared contract`);
|
|
121
179
|
console.log('');
|
|
122
180
|
for (const f of findings) {
|
|
123
181
|
warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
|
|
@@ -163,24 +221,23 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
163
221
|
|
|
164
222
|
let rows: any[];
|
|
165
223
|
let gapRows: any[];
|
|
224
|
+
let venueLabel: string;
|
|
225
|
+
let venue: AuthzVenue | undefined;
|
|
166
226
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const fn = opsFunction(config);
|
|
170
|
-
info(`Region: ${config.region}, Function: ${fn}`);
|
|
227
|
+
venue = await resolveVenue(flags);
|
|
228
|
+
venueLabel = venue.label;
|
|
171
229
|
step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
|
|
172
230
|
const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
|
|
173
|
-
|
|
174
|
-
if (result?.error) throw new Error(result.error);
|
|
175
|
-
rows = result?.rows ?? [];
|
|
231
|
+
rows = await venue.probe(setup, PROBE_SELECT_SQL);
|
|
176
232
|
step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
|
|
177
|
-
|
|
178
|
-
if (gc?.error) throw new Error(gc.error);
|
|
179
|
-
gapRows = gc?.rows ?? [];
|
|
233
|
+
gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
|
|
180
234
|
} catch (err: any) {
|
|
181
235
|
fail(`db:authz:test failed: ${err.message}`);
|
|
182
|
-
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe
|
|
236
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
|
|
237
|
+
info('or rehearse locally: db:authz:test --database-url <url>.');
|
|
183
238
|
process.exit(1);
|
|
239
|
+
} finally {
|
|
240
|
+
await venue?.end();
|
|
184
241
|
}
|
|
185
242
|
|
|
186
243
|
const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
|
|
@@ -200,10 +257,10 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
200
257
|
console.log('');
|
|
201
258
|
|
|
202
259
|
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
203
|
-
success(`db:authz:test —
|
|
260
|
+
success(`db:authz:test — ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
|
|
204
261
|
process.exit(0);
|
|
205
262
|
}
|
|
206
|
-
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s).
|
|
263
|
+
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
|
|
207
264
|
process.exit(1);
|
|
208
265
|
}
|
|
209
266
|
|
|
@@ -223,6 +280,8 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
223
280
|
let probes: OwnerProbe[];
|
|
224
281
|
let publicReadTables = new Set<string>();
|
|
225
282
|
let rows: any[];
|
|
283
|
+
let venueLabel = '';
|
|
284
|
+
let venue: AuthzVenue | undefined;
|
|
226
285
|
try {
|
|
227
286
|
step(`Loading models from ${modelsPath}...`);
|
|
228
287
|
const models = await loadModels(modelsPath);
|
|
@@ -240,20 +299,17 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
240
299
|
success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
|
|
241
300
|
process.exit(0);
|
|
242
301
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const fn = opsFunction(config);
|
|
246
|
-
info(`Region: ${config.region}, Function: ${fn}`);
|
|
302
|
+
venue = await resolveVenue(flags);
|
|
303
|
+
venueLabel = venue.label;
|
|
247
304
|
step('Red-teaming owner isolation (two JWT identities per table, rolled back)...');
|
|
248
|
-
|
|
249
|
-
setup: buildOwnerProbeSql(probes), read: OWNER_PROBE_SELECT_SQL,
|
|
250
|
-
});
|
|
251
|
-
if (result?.error) throw new Error(result.error);
|
|
252
|
-
rows = result?.rows ?? [];
|
|
305
|
+
rows = await venue.probe(buildOwnerProbeSql(probes), OWNER_PROBE_SELECT_SQL);
|
|
253
306
|
} catch (err: any) {
|
|
254
307
|
fail(`db:authz:owner failed: ${err.message}`);
|
|
255
|
-
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe
|
|
308
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
|
|
309
|
+
info('or rehearse locally: db:authz:owner --database-url <url>.');
|
|
256
310
|
process.exit(1);
|
|
311
|
+
} finally {
|
|
312
|
+
await venue?.end();
|
|
257
313
|
}
|
|
258
314
|
|
|
259
315
|
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
|
|
@@ -270,9 +326,9 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
270
326
|
console.log('');
|
|
271
327
|
|
|
272
328
|
if (leaks.length === 0 && vacuous.length === 0) {
|
|
273
|
-
success(`db:authz:owner — owner isolation holds (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
|
|
329
|
+
success(`db:authz:owner — owner isolation holds on ${venueLabel} (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
|
|
274
330
|
process.exit(0);
|
|
275
331
|
}
|
|
276
|
-
fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies). One user can reach another's rows.`);
|
|
332
|
+
fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies) on ${venueLabel}. One user can reach another's rows.`);
|
|
277
333
|
process.exit(1);
|
|
278
334
|
}
|