@everystack/cli 0.2.37 → 0.2.40
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 +32 -1
- package/package.json +11 -1
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/commands/db-authz.ts +206 -0
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/index.ts +34 -2
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
- package/src/storage/index.ts +16 -3
- package/src/storage/s3.ts +66 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:authz:pull` / `db:authz:diff` — the Authorization Contract commands.
|
|
3
|
+
*
|
|
4
|
+
* db:authz:pull --stage <name> introspect the live authz → write authz/*.contract.json
|
|
5
|
+
* db:authz:diff --stage <name> validate the live DB against the committed contract
|
|
6
|
+
*
|
|
7
|
+
* The contract is the brownfield slice of the Model (docs/plans/v3-authz-contract.md):
|
|
8
|
+
* authorization that lived only in scattered migrations becomes a reviewable file you
|
|
9
|
+
* can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
|
|
10
|
+
* audit and the CI gate (non-zero exit on any drift).
|
|
11
|
+
*
|
|
12
|
+
* Both introspect through the ops Lambda `db:query` action (read-only SQL), the same
|
|
13
|
+
* path security:audit uses. The core (introspect/assemble/diff) is backend-agnostic and
|
|
14
|
+
* pure; only the runner here is AWS-bound.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import {
|
|
19
|
+
introspectContract,
|
|
20
|
+
diffContracts,
|
|
21
|
+
type QueryRunner,
|
|
22
|
+
type AuthzContract,
|
|
23
|
+
} from '../authz-contract.js';
|
|
24
|
+
import {
|
|
25
|
+
buildProbeSql,
|
|
26
|
+
PROBE_SELECT_SQL,
|
|
27
|
+
probeRoles,
|
|
28
|
+
toProbeResult,
|
|
29
|
+
evaluateRedTeam,
|
|
30
|
+
GRANT_COMPLETENESS_SQL,
|
|
31
|
+
toGrantGap,
|
|
32
|
+
} from '../authz-redteam.js';
|
|
33
|
+
import { writeContract, loadContract } from '../authz-contract-io.js';
|
|
34
|
+
import { renderContractMarkdown } from '../authz-render.js';
|
|
35
|
+
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
36
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
+
import { invokeAction } from '../aws.js';
|
|
38
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
39
|
+
|
|
40
|
+
const DEFAULT_DIR = 'authz';
|
|
41
|
+
|
|
42
|
+
/** Map a FUNCTIONS_SQL row to the minimal shape the contract records. */
|
|
43
|
+
const mapFunctionRow = (row: any) => {
|
|
44
|
+
const d = catalogFunctionToDescriptor(row);
|
|
45
|
+
return {
|
|
46
|
+
schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath,
|
|
47
|
+
owner: d.owner, ownerBypassesRls: d.ownerBypassesRls,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
52
|
+
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
53
|
+
return async (sql: string) => {
|
|
54
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
55
|
+
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
56
|
+
return result?.rows ?? [];
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function introspectStage(flags: Record<string, string>): Promise<AuthzContract> {
|
|
61
|
+
step('Resolving deployed config...');
|
|
62
|
+
const config = await resolveConfig(flags.stage);
|
|
63
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
64
|
+
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
65
|
+
const runner = lambdaRunner(config.region, opsFunction(config));
|
|
66
|
+
return introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
|
|
70
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
71
|
+
let contract: AuthzContract;
|
|
72
|
+
try {
|
|
73
|
+
contract = await introspectStage(flags);
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
fail(err.message);
|
|
76
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const files = await writeContract(dir, contract);
|
|
81
|
+
const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
|
|
82
|
+
console.log('');
|
|
83
|
+
success(`Wrote ${files.length} file(s) to ${dir}`);
|
|
84
|
+
info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
|
|
85
|
+
console.log('');
|
|
86
|
+
warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
|
|
87
|
+
warn('Do not run `db:authz:diff` to find drift right after a pull: it compares the live DB to what');
|
|
88
|
+
warn('you just wrote, so it is always clean. Review `git diff`, then commit only if the change is intended.');
|
|
89
|
+
info('Note: column exposure (hidden/protected) is handler-layer and not part of the DB pull.');
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise<void> {
|
|
94
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
95
|
+
|
|
96
|
+
let declared: AuthzContract;
|
|
97
|
+
let live: AuthzContract;
|
|
98
|
+
try {
|
|
99
|
+
declared = await loadContract(dir);
|
|
100
|
+
live = await introspectStage(flags);
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
fail(err.message);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (declared.tables.length === 0 && declared.functions.length === 0) {
|
|
107
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const findings = diffContracts(declared, live);
|
|
112
|
+
console.log('');
|
|
113
|
+
if (findings.length === 0) {
|
|
114
|
+
success(`db:authz:diff — live database matches the declared contract (${declared.tables.length} tables)`);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fail(`db:authz:diff — ${findings.length} drift finding(s): the live database does NOT match the declared contract`);
|
|
119
|
+
console.log('');
|
|
120
|
+
for (const f of findings) {
|
|
121
|
+
warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
|
|
122
|
+
}
|
|
123
|
+
console.log('');
|
|
124
|
+
info('Either the live DB drifted (reconcile it) or the change is intended (re-pull + commit the contract delta).');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function dbAuthzReportCommand(flags: Record<string, string>): Promise<void> {
|
|
129
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
130
|
+
let contract: AuthzContract;
|
|
131
|
+
try {
|
|
132
|
+
contract = await loadContract(dir);
|
|
133
|
+
} catch (err: any) {
|
|
134
|
+
fail(err.message);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (contract.tables.length === 0) {
|
|
138
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Render from the committed contract — no database needed. Print to stdout so it can
|
|
142
|
+
// be piped or redirected; `pull` writes the same content to authz/AUTHORIZATION.md.
|
|
143
|
+
console.log(renderContractMarkdown(contract));
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function dbAuthzTestCommand(flags: Record<string, string>): Promise<void> {
|
|
148
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
149
|
+
|
|
150
|
+
let contract: AuthzContract;
|
|
151
|
+
try {
|
|
152
|
+
contract = await loadContract(dir);
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
fail(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
if (contract.tables.length === 0) {
|
|
158
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let rows: any[];
|
|
163
|
+
let gapRows: any[];
|
|
164
|
+
try {
|
|
165
|
+
step('Resolving deployed config...');
|
|
166
|
+
const config = await resolveConfig(flags.stage);
|
|
167
|
+
const fn = opsFunction(config);
|
|
168
|
+
info(`Region: ${config.region}, Function: ${fn}`);
|
|
169
|
+
step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
|
|
170
|
+
const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
|
|
171
|
+
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read: PROBE_SELECT_SQL });
|
|
172
|
+
if (result?.error) throw new Error(result.error);
|
|
173
|
+
rows = result?.rows ?? [];
|
|
174
|
+
step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
|
|
175
|
+
const gc: any = await invokeAction(config.region, fn, 'db:query', { sql: GRANT_COMPLETENESS_SQL });
|
|
176
|
+
if (gc?.error) throw new Error(gc.error);
|
|
177
|
+
gapRows = gc?.rows ?? [];
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
fail(`db:authz:test failed: ${err.message}`);
|
|
180
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
|
|
185
|
+
const holes = findings.filter((f) => f.severity === 'hole');
|
|
186
|
+
const broken = findings.filter((f) => f.severity === 'broken');
|
|
187
|
+
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
188
|
+
const gaps = gapRows.map(toGrantGap);
|
|
189
|
+
|
|
190
|
+
console.log('');
|
|
191
|
+
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
192
|
+
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
193
|
+
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
194
|
+
if (unprobed.length) {
|
|
195
|
+
const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
|
|
196
|
+
info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
|
|
197
|
+
}
|
|
198
|
+
console.log('');
|
|
199
|
+
|
|
200
|
+
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
201
|
+
success(`db:authz:test — enforcement matches the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). The database does not enforce the contract.`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -9,6 +9,34 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
9
9
|
import { invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, fail, info, warn } from '../output.js';
|
|
11
11
|
|
|
12
|
+
/** Secret-store keys that may hold the privileged (operator) connection URL,
|
|
13
|
+
* in precedence order. Names vary by deployment; --secret-name overrides. */
|
|
14
|
+
const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a postgres connection URL into psql's PG* environment variables.
|
|
18
|
+
*
|
|
19
|
+
* Passing the credential via env (not argv) keeps the password out of the
|
|
20
|
+
* process table and shell history. Pure and side-effect free — it never logs;
|
|
21
|
+
* callers must never log the result either (it contains PGPASSWORD).
|
|
22
|
+
*/
|
|
23
|
+
export function pgEnvFromUrl(url: string): Record<string, string> {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
if (!/^postgres(ql)?:$/.test(u.protocol)) {
|
|
26
|
+
throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
|
|
27
|
+
}
|
|
28
|
+
const env: Record<string, string> = {};
|
|
29
|
+
if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
|
|
30
|
+
if (u.port) env.PGPORT = u.port;
|
|
31
|
+
if (u.username) env.PGUSER = decodeURIComponent(u.username);
|
|
32
|
+
if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
|
|
33
|
+
const database = u.pathname.replace(/^\//, '');
|
|
34
|
+
if (database) env.PGDATABASE = decodeURIComponent(database);
|
|
35
|
+
const sslmode = u.searchParams.get('sslmode');
|
|
36
|
+
if (sslmode) env.PGSSLMODE = sslmode;
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
|
|
13
41
|
step('Resolving deployed config...');
|
|
14
42
|
let config;
|
|
@@ -115,6 +143,130 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
115
143
|
}
|
|
116
144
|
}
|
|
117
145
|
|
|
146
|
+
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
147
|
+
if (!flags.stage) {
|
|
148
|
+
fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
step('Resolving deployed config...');
|
|
153
|
+
let config;
|
|
154
|
+
try {
|
|
155
|
+
config = await resolveConfig(flags.stage);
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
fail(err.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
162
|
+
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
163
|
+
step('Provisioning roles...');
|
|
164
|
+
|
|
165
|
+
// Generate the login password and keep it in memory only — it is written straight into
|
|
166
|
+
// the secret store and is NEVER printed, logged, or returned to a human.
|
|
167
|
+
const { randomBytes } = await import('node:crypto');
|
|
168
|
+
const authPassword = randomBytes(24).toString('hex');
|
|
169
|
+
|
|
170
|
+
let result: any;
|
|
171
|
+
try {
|
|
172
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
|
|
173
|
+
} catch (err: any) {
|
|
174
|
+
fail(`db:provision failed: ${err.message}`);
|
|
175
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
if (result?.error) {
|
|
179
|
+
fail(`db:provision failed: ${result.error}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
183
|
+
|
|
184
|
+
// Resolve host/port/db (not secret) to build the connection string in memory.
|
|
185
|
+
let conn: any;
|
|
186
|
+
try {
|
|
187
|
+
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
188
|
+
} catch {
|
|
189
|
+
/* handled below */
|
|
190
|
+
}
|
|
191
|
+
if (!conn?.host) {
|
|
192
|
+
fail('Could not resolve the database host to build DATABASE_URL.');
|
|
193
|
+
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
|
|
197
|
+
|
|
198
|
+
// Write the secret directly to the SST secret store — never display it.
|
|
199
|
+
step('Writing the DATABASE_URL secret (not displayed)...');
|
|
200
|
+
try {
|
|
201
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
202
|
+
const { parseAppName } = await import('../discover.js');
|
|
203
|
+
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
204
|
+
const secrets = await loadSstSecrets(ctx);
|
|
205
|
+
secrets.DATABASE_URL = url;
|
|
206
|
+
await putSstSecrets(ctx, secrets);
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
|
|
209
|
+
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
|
|
214
|
+
console.log('');
|
|
215
|
+
info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
|
|
216
|
+
info('link it to the Api function, and remove the raw Postgres component from the Api link');
|
|
217
|
+
info('(keep it on the ops/worker functions). Then redeploy.');
|
|
218
|
+
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
|
|
222
|
+
step('Resolving deployed config...');
|
|
223
|
+
let config;
|
|
224
|
+
try {
|
|
225
|
+
config = await resolveConfig(flags.stage);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
fail(err.message);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
232
|
+
step('Probing database connections (api + operator)...');
|
|
233
|
+
|
|
234
|
+
let report: any;
|
|
235
|
+
try {
|
|
236
|
+
report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
fail(`db:doctor failed: ${err.message}`);
|
|
239
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (report?.error) {
|
|
244
|
+
fail(`db:doctor failed: ${report.error}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log('');
|
|
249
|
+
info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
|
|
250
|
+
info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
|
|
251
|
+
console.log('');
|
|
252
|
+
|
|
253
|
+
for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
|
|
254
|
+
if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
|
|
255
|
+
else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
|
|
256
|
+
else fail(`${c.name} — ${c.detail}`);
|
|
257
|
+
if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('');
|
|
261
|
+
if (report.ok) {
|
|
262
|
+
success('db:doctor — database is least-privilege and RLS-subject');
|
|
263
|
+
process.exit(0);
|
|
264
|
+
} else {
|
|
265
|
+
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
118
270
|
export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
|
|
119
271
|
step('Resolving deployed config...');
|
|
120
272
|
let config;
|
|
@@ -180,9 +332,67 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
180
332
|
}
|
|
181
333
|
}
|
|
182
334
|
|
|
183
|
-
// Interactive mode
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
335
|
+
// Interactive mode — break-glass admin psql.
|
|
336
|
+
//
|
|
337
|
+
// Authorization IS your AWS identity: resolving the admin URL requires reading
|
|
338
|
+
// the SST secret store (SSM passphrase + S3 blob + KMS decrypt). If your
|
|
339
|
+
// profile can't, you don't get a session — the permission is the policy, no
|
|
340
|
+
// confirmation prompt. CloudTrail records the decrypt, so the access is
|
|
341
|
+
// audited for free. The URL is resolved in-process and handed to psql via PG*
|
|
342
|
+
// env vars: it never touches your terminal, argv, clipboard, or psql history.
|
|
343
|
+
if (!flags.stage) {
|
|
344
|
+
fail('--stage is required for an interactive session. Example: everystack db:psql --stage production');
|
|
345
|
+
info('Or run a one-off query via Lambda: everystack db:psql -c "SELECT 1" --stage production');
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
step('Resolving the admin connection from the secret store...');
|
|
350
|
+
let secrets: Record<string, string>;
|
|
351
|
+
try {
|
|
352
|
+
const { loadSstSecrets } = await import('../utils/secrets.js');
|
|
353
|
+
const { parseAppName } = await import('../discover.js');
|
|
354
|
+
const appName = await parseAppName();
|
|
355
|
+
secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
|
|
356
|
+
} catch (err: any) {
|
|
357
|
+
fail('Could not read the secret store — your AWS profile lacks secrets access.');
|
|
358
|
+
info('db:psql opens an ADMIN session; it requires SSM + S3 + KMS access to the SST state.');
|
|
359
|
+
info('If you should have it, check your IAM permissions; otherwise you should not be in psql.');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const keyName = flags['secret-name'] || ADMIN_URL_KEYS.find((k) => secrets[k]);
|
|
364
|
+
const adminUrl = keyName ? secrets[keyName] : undefined;
|
|
365
|
+
if (!adminUrl) {
|
|
366
|
+
fail(`No admin database URL found for stage "${flags.stage}".`);
|
|
367
|
+
// Names only — never values.
|
|
368
|
+
info(`Looked for: ${ADMIN_URL_KEYS.join(', ')}. Available secret names: ${Object.keys(secrets).join(', ') || '(none)'}.`);
|
|
369
|
+
info('Provision one with `everystack db:provision`, or point at an existing key with --secret-name <KEY>.');
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let pgEnv: Record<string, string>;
|
|
374
|
+
try {
|
|
375
|
+
pgEnv = pgEnvFromUrl(adminUrl);
|
|
376
|
+
} catch {
|
|
377
|
+
fail(`The resolved ${keyName} is not a valid postgres connection URL.`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Confirm the target (host/user/db are infra identifiers, not the credential);
|
|
382
|
+
// the password is in PGPASSWORD and is never displayed.
|
|
383
|
+
success(
|
|
384
|
+
`Admin psql → ${flags.stage} as ${pgEnv.PGUSER ?? '?'}@${pgEnv.PGHOST ?? '?'}/${pgEnv.PGDATABASE ?? ''} (credential not displayed)`,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const { spawn } = await import('node:child_process');
|
|
388
|
+
const child = spawn('psql', [], { stdio: 'inherit', env: { ...process.env, ...pgEnv } });
|
|
389
|
+
child.on('error', (err: any) => {
|
|
390
|
+
if (err.code === 'ENOENT') {
|
|
391
|
+
fail('psql not found on PATH. Install the PostgreSQL client to use interactive mode.');
|
|
392
|
+
} else {
|
|
393
|
+
fail(`Failed to launch psql: ${err.message}`);
|
|
394
|
+
}
|
|
395
|
+
process.exit(1);
|
|
396
|
+
});
|
|
397
|
+
child.on('close', (code) => process.exit(code ?? 0));
|
|
188
398
|
}
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -133,6 +133,62 @@ function resolveFunctionForOrigin(
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** One FilterLogEvents page (the subset of the AWS shape this command reads). */
|
|
137
|
+
interface FilterLogEventsPage {
|
|
138
|
+
events?: Array<{ eventId?: string; timestamp?: number; message?: string }>;
|
|
139
|
+
nextToken?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Carries the tail window across polls so a re-query can't reprint events. */
|
|
143
|
+
export interface TailState {
|
|
144
|
+
/** Highest event timestamp printed so far. Only moves forward. */
|
|
145
|
+
lastTimestamp: number;
|
|
146
|
+
/** eventId → timestamp for events at the boundary millisecond — the only ones
|
|
147
|
+
* an inclusive-startTime re-query can return again. Pruned each drain so it
|
|
148
|
+
* never grows past one millisecond's worth of events. */
|
|
149
|
+
seen: Map<string, number>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Drain one poll's worth of pages, printing only events not seen before and
|
|
154
|
+
* advancing the window forward.
|
|
155
|
+
*
|
|
156
|
+
* The loop bug this fixes: FilterLogEvents `startTime` is INCLUSIVE, so a poll
|
|
157
|
+
* with `startTime = lastTimestamp` re-returns every event sharing that newest
|
|
158
|
+
* millisecond. The old code advanced `lastTimestamp` only on a strictly-greater
|
|
159
|
+
* comparison and never deduped, so the boundary batch reprinted on every 2s poll
|
|
160
|
+
* forever. Here we dedup by `eventId` and advance to the max timestamp, then
|
|
161
|
+
* prune `seen` to just the boundary millisecond (the only ids that can recur).
|
|
162
|
+
*
|
|
163
|
+
* Exported for unit testing without the AWS SDK or the setTimeout poll loop.
|
|
164
|
+
*/
|
|
165
|
+
export async function drainLogEvents(
|
|
166
|
+
send: (params: { startTime: number; nextToken?: string }) => Promise<FilterLogEventsPage>,
|
|
167
|
+
state: TailState,
|
|
168
|
+
emit: (line: string) => void,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
let nextToken: string | undefined;
|
|
171
|
+
do {
|
|
172
|
+
const page = await send({ startTime: state.lastTimestamp, nextToken });
|
|
173
|
+
for (const event of page.events ?? []) {
|
|
174
|
+
const id = event.eventId;
|
|
175
|
+
const ts = event.timestamp ?? state.lastTimestamp;
|
|
176
|
+
if (id && state.seen.has(id)) continue; // already printed this one
|
|
177
|
+
const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : '';
|
|
178
|
+
emit(`[${timestamp}] ${(event.message || '').trimEnd()}`);
|
|
179
|
+
if (id) state.seen.set(id, ts);
|
|
180
|
+
if (ts > state.lastTimestamp) state.lastTimestamp = ts;
|
|
181
|
+
}
|
|
182
|
+
nextToken = page.nextToken;
|
|
183
|
+
} while (nextToken);
|
|
184
|
+
|
|
185
|
+
// Only events at exactly lastTimestamp can be returned again (startTime is
|
|
186
|
+
// inclusive); drop everything older so `seen` stays bounded.
|
|
187
|
+
for (const [id, ts] of state.seen) {
|
|
188
|
+
if (ts < state.lastTimestamp) state.seen.delete(id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
136
192
|
export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
|
|
137
193
|
step('Resolving deployed config...');
|
|
138
194
|
let config;
|
|
@@ -174,42 +230,20 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
|
|
|
174
230
|
|
|
175
231
|
success(`Streaming logs from ${logGroupName}...\n`);
|
|
176
232
|
|
|
177
|
-
// Poll for new logs every 2 seconds
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
// Poll for new logs every 2 seconds. State carries the window + dedup set
|
|
234
|
+
// across polls — see drainLogEvents for why both are required.
|
|
235
|
+
const state: TailState = { lastTimestamp: startTime, seen: new Map() };
|
|
236
|
+
const send = (params: { startTime: number; nextToken?: string }) =>
|
|
237
|
+
client.send(new FilterLogEventsCommand({
|
|
238
|
+
logGroupName,
|
|
239
|
+
startTime: params.startTime,
|
|
240
|
+
filterPattern,
|
|
241
|
+
...(params.nextToken ? { nextToken: params.nextToken } : {}),
|
|
242
|
+
}));
|
|
180
243
|
|
|
181
244
|
const poll = async () => {
|
|
182
245
|
try {
|
|
183
|
-
|
|
184
|
-
logGroupName,
|
|
185
|
-
startTime: lastTimestamp,
|
|
186
|
-
filterPattern,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
if (nextToken) {
|
|
190
|
-
params.nextToken = nextToken;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const response = await client.send(new FilterLogEventsCommand(params));
|
|
194
|
-
|
|
195
|
-
if (response.events && response.events.length > 0) {
|
|
196
|
-
for (const event of response.events) {
|
|
197
|
-
if (event.timestamp && event.timestamp > lastTimestamp) {
|
|
198
|
-
lastTimestamp = event.timestamp;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const timestamp = event.timestamp
|
|
202
|
-
? new Date(event.timestamp).toISOString()
|
|
203
|
-
: '';
|
|
204
|
-
const message = event.message || '';
|
|
205
|
-
|
|
206
|
-
console.log(`[${timestamp}] ${message.trimEnd()}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
nextToken = response.nextToken;
|
|
211
|
-
|
|
212
|
-
// Continue polling
|
|
246
|
+
await drainLogEvents(send, state, (line) => console.log(line));
|
|
213
247
|
setTimeout(poll, 2000);
|
|
214
248
|
} catch (err: any) {
|
|
215
249
|
if (err.name === 'ResourceNotFoundException') {
|