@everystack/cli 0.2.39 → 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/README.md +27 -0
- package/package.json +8 -7
- 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/index.ts +17 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
- 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/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { certsCommand } from './commands/certs.js';
|
|
|
7
7
|
import { channelsCommand } from './commands/channels.js';
|
|
8
8
|
import { branchesCommand } from './commands/branches.js';
|
|
9
9
|
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
|
|
10
|
+
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzReportCommand } from './commands/db-authz.js';
|
|
10
11
|
import { consoleCommand } from './commands/console.js';
|
|
11
12
|
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
13
|
import { statusCommand } from './commands/status.js';
|
|
@@ -121,6 +122,18 @@ async function main() {
|
|
|
121
122
|
case 'db:provision':
|
|
122
123
|
await dbProvisionCommand(flags);
|
|
123
124
|
break;
|
|
125
|
+
case 'db:authz:pull':
|
|
126
|
+
await dbAuthzPullCommand(flags);
|
|
127
|
+
break;
|
|
128
|
+
case 'db:authz:diff':
|
|
129
|
+
await dbAuthzDiffCommand(flags);
|
|
130
|
+
break;
|
|
131
|
+
case 'db:authz:test':
|
|
132
|
+
await dbAuthzTestCommand(flags);
|
|
133
|
+
break;
|
|
134
|
+
case 'db:authz:report':
|
|
135
|
+
await dbAuthzReportCommand(flags);
|
|
136
|
+
break;
|
|
124
137
|
case 'console':
|
|
125
138
|
await consoleCommand(flags);
|
|
126
139
|
break;
|
|
@@ -190,6 +203,10 @@ Usage:
|
|
|
190
203
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
191
204
|
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
192
205
|
everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
|
|
206
|
+
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
207
|
+
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
208
|
+
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
|
209
|
+
everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
|
|
193
210
|
everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
|
|
194
211
|
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
195
212
|
everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
|
|
@@ -36,6 +36,13 @@ export interface FunctionDescriptor {
|
|
|
36
36
|
securityDefiner: boolean;
|
|
37
37
|
/** True when the function pins `SET search_path`. */
|
|
38
38
|
hasSearchPath: boolean;
|
|
39
|
+
/** Owning role (catalog path only — static SQL parsing can't know it). */
|
|
40
|
+
owner?: string;
|
|
41
|
+
/**
|
|
42
|
+
* True when the owner runs above RLS (superuser, BYPASSRLS, or rds_superuser member).
|
|
43
|
+
* A SECURITY DEFINER function owned by such a role is the maximum blast radius.
|
|
44
|
+
*/
|
|
45
|
+
ownerBypassesRls?: boolean;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
export interface FunctionFinding {
|
|
@@ -32,7 +32,18 @@ SELECT
|
|
|
32
32
|
EXISTS (
|
|
33
33
|
SELECT 1 FROM unnest(coalesce(p.proconfig, '{}'::text[])) cfg
|
|
34
34
|
WHERE lower(cfg) LIKE 'search_path=%'
|
|
35
|
-
) AS has_search_path
|
|
35
|
+
) AS has_search_path,
|
|
36
|
+
pg_get_userbyid(p.proowner) AS owner,
|
|
37
|
+
-- Does the owner run above RLS? A SECURITY DEFINER function owned by such a role is
|
|
38
|
+
-- the maximum blast radius — search_path pinning is necessary but not sufficient.
|
|
39
|
+
-- RDS has no true superuser (rolsuper=false), so also check rds_superuser membership.
|
|
40
|
+
(
|
|
41
|
+
SELECT r.rolsuper OR r.rolbypassrls OR EXISTS (
|
|
42
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
43
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
44
|
+
)
|
|
45
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
46
|
+
) AS owner_bypasses_rls
|
|
36
47
|
FROM pg_proc p
|
|
37
48
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
38
49
|
JOIN pg_type t ON t.oid = p.prorettype
|
|
@@ -111,6 +122,8 @@ interface FunctionRow {
|
|
|
111
122
|
returns_set: unknown;
|
|
112
123
|
return_typtype: unknown;
|
|
113
124
|
has_search_path: unknown;
|
|
125
|
+
owner?: unknown;
|
|
126
|
+
owner_bypasses_rls?: unknown;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
/** Map a pg_proc row to a descriptor, computing a precise return class. */
|
|
@@ -132,6 +145,8 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
132
145
|
returnClassHint,
|
|
133
146
|
securityDefiner: truthy(row.security_definer),
|
|
134
147
|
hasSearchPath: truthy(row.has_search_path),
|
|
148
|
+
owner: row.owner != null ? String(row.owner) : undefined,
|
|
149
|
+
ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
|
|
135
150
|
};
|
|
136
151
|
}
|
|
137
152
|
|
package/src/storage/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { S3StorageOptions } from './s3';
|
|
2
|
+
|
|
1
3
|
export interface StorageAdapter {
|
|
2
4
|
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
3
5
|
get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
|
|
@@ -32,7 +34,18 @@ export interface StorageAdapter {
|
|
|
32
34
|
|
|
33
35
|
export type StorageOptions =
|
|
34
36
|
| { type: 'filesystem'; directory: string }
|
|
35
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
type: 's3';
|
|
39
|
+
bucket: string;
|
|
40
|
+
region?: string;
|
|
41
|
+
endpoint?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Bound the S3 client's failure modes (connect/request timeouts, retries,
|
|
44
|
+
* list caps). Every field defaults to a safe bound; env vars override too.
|
|
45
|
+
* See {@link S3StorageOptions}.
|
|
46
|
+
*/
|
|
47
|
+
options?: S3StorageOptions;
|
|
48
|
+
};
|
|
36
49
|
|
|
37
50
|
export async function createStorage(options: StorageOptions): Promise<StorageAdapter> {
|
|
38
51
|
if (options.type === 'filesystem') {
|
|
@@ -40,8 +53,8 @@ export async function createStorage(options: StorageOptions): Promise<StorageAda
|
|
|
40
53
|
return new FilesystemStorageAdapter(options.directory);
|
|
41
54
|
}
|
|
42
55
|
const { S3StorageAdapter } = await import('./s3');
|
|
43
|
-
return new S3StorageAdapter(options.bucket, options.region, options.endpoint);
|
|
56
|
+
return new S3StorageAdapter(options.bucket, options.region, options.endpoint, options.options);
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
export { FilesystemStorageAdapter } from './filesystem';
|
|
47
|
-
export { S3StorageAdapter } from './s3';
|
|
60
|
+
export { S3StorageAdapter, type S3StorageOptions } from './s3';
|
package/src/storage/s3.ts
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
1
|
import type { StorageAdapter } from './index';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Tuning for the S3 client's failure modes. Every value is bounded by default so
|
|
5
|
+
* a single call can never hang: under an S3 503 SlowDown storm an unbounded
|
|
6
|
+
* client blocks until the Lambda's own timeout (60s), and because each hung
|
|
7
|
+
* invocation pins a concurrency slot, the observability rollup can drain the
|
|
8
|
+
* whole account pool and starve the app it observes. Bounded here, a stuck call
|
|
9
|
+
* fails fast instead. Overridable per-field, then by env (so ops can tighten a
|
|
10
|
+
* live Lambda without a code redeploy), then these defaults.
|
|
11
|
+
*/
|
|
12
|
+
export interface S3StorageOptions {
|
|
13
|
+
/** TCP connect timeout, ms. Env: EVERYSTACK_S3_CONNECT_TIMEOUT_MS. */
|
|
14
|
+
connectionTimeoutMs?: number;
|
|
15
|
+
/** Socket-idle timeout for a single request, ms. Env: EVERYSTACK_S3_REQUEST_TIMEOUT_MS. */
|
|
16
|
+
requestTimeoutMs?: number;
|
|
17
|
+
/** Total attempts per call (1 + retries). Env: EVERYSTACK_S3_MAX_ATTEMPTS. */
|
|
18
|
+
maxAttempts?: number;
|
|
19
|
+
/** Keys per ListObjectsV2 page (S3 caps this at 1000). */
|
|
20
|
+
listMaxKeys?: number;
|
|
21
|
+
/** Hard backstop on pages a single list() will walk before truncating (warns). */
|
|
22
|
+
listMaxPages?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envInt(name: string): number | undefined {
|
|
26
|
+
const raw = process.env[name];
|
|
27
|
+
if (raw === undefined) return undefined;
|
|
28
|
+
const n = Number(raw);
|
|
29
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
export class S3StorageAdapter implements StorageAdapter {
|
|
4
33
|
private bucket: string;
|
|
5
34
|
private region: string;
|
|
6
35
|
private endpoint?: string;
|
|
7
36
|
private client: any;
|
|
8
37
|
|
|
9
|
-
|
|
38
|
+
private readonly connectionTimeoutMs: number;
|
|
39
|
+
private readonly requestTimeoutMs: number;
|
|
40
|
+
private readonly maxAttempts: number;
|
|
41
|
+
private readonly listMaxKeys: number;
|
|
42
|
+
private readonly listMaxPages: number;
|
|
43
|
+
|
|
44
|
+
constructor(bucket: string, region?: string, endpoint?: string, options: S3StorageOptions = {}) {
|
|
10
45
|
this.bucket = bucket;
|
|
11
46
|
this.region = region || 'us-east-1';
|
|
12
47
|
this.endpoint = endpoint;
|
|
48
|
+
|
|
49
|
+
this.connectionTimeoutMs =
|
|
50
|
+
options.connectionTimeoutMs ?? envInt('EVERYSTACK_S3_CONNECT_TIMEOUT_MS') ?? 3000;
|
|
51
|
+
this.requestTimeoutMs =
|
|
52
|
+
options.requestTimeoutMs ?? envInt('EVERYSTACK_S3_REQUEST_TIMEOUT_MS') ?? 10000;
|
|
53
|
+
this.maxAttempts = options.maxAttempts ?? envInt('EVERYSTACK_S3_MAX_ATTEMPTS') ?? 3;
|
|
54
|
+
this.listMaxKeys = options.listMaxKeys ?? 1000;
|
|
55
|
+
this.listMaxPages = options.listMaxPages ?? 1000;
|
|
13
56
|
}
|
|
14
57
|
|
|
15
58
|
private async getClient(): Promise<any> {
|
|
@@ -17,6 +60,15 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
17
60
|
const { S3Client } = await import('@aws-sdk/client-s3');
|
|
18
61
|
this.client = new S3Client({
|
|
19
62
|
region: this.region,
|
|
63
|
+
// Bounded retries — the SDK default (3) is fine, but pin it so a config
|
|
64
|
+
// drift can't reintroduce unbounded blocking under throttling.
|
|
65
|
+
maxAttempts: this.maxAttempts,
|
|
66
|
+
// Connect + per-request timeouts. Without these a call can block forever
|
|
67
|
+
// on a stalled socket (the 60s-hang class the rollup outage rode in on).
|
|
68
|
+
requestHandler: {
|
|
69
|
+
connectionTimeout: this.connectionTimeoutMs,
|
|
70
|
+
requestTimeout: this.requestTimeoutMs,
|
|
71
|
+
},
|
|
20
72
|
...(this.endpoint ? { endpoint: this.endpoint, forcePathStyle: true } : {}),
|
|
21
73
|
});
|
|
22
74
|
}
|
|
@@ -136,11 +188,13 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
136
188
|
const client = await this.getClient();
|
|
137
189
|
const keys: string[] = [];
|
|
138
190
|
let continuationToken: string | undefined;
|
|
191
|
+
let pages = 0;
|
|
139
192
|
|
|
140
193
|
do {
|
|
141
194
|
const response = await client.send(new ListObjectsV2Command({
|
|
142
195
|
Bucket: this.bucket,
|
|
143
196
|
Prefix: prefix,
|
|
197
|
+
MaxKeys: this.listMaxKeys,
|
|
144
198
|
ContinuationToken: continuationToken,
|
|
145
199
|
}));
|
|
146
200
|
if (response.Contents) {
|
|
@@ -149,6 +203,17 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
149
203
|
}
|
|
150
204
|
}
|
|
151
205
|
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
206
|
+
pages += 1;
|
|
207
|
+
// Backstop: a prefix that walks past the page cap is almost always a sign
|
|
208
|
+
// the rollup's per-hour debounce isn't engaged (one object → one invoke).
|
|
209
|
+
// Truncate loudly rather than let a single list() run unbounded.
|
|
210
|
+
if (continuationToken && pages >= this.listMaxPages) {
|
|
211
|
+
console.warn(
|
|
212
|
+
`[s3] list('${prefix}') hit the ${this.listMaxPages}-page cap at ${keys.length} keys; truncating. ` +
|
|
213
|
+
`A prefix this large usually means the observability rollup is being invoked per-object instead of per-hour.`,
|
|
214
|
+
);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
152
217
|
} while (continuationToken);
|
|
153
218
|
|
|
154
219
|
return keys;
|