@crewhaus/secrets-manager 0.1.3 → 0.1.5
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/dist/backends/env-var.d.ts +13 -0
- package/dist/backends/env-var.js +37 -0
- package/dist/backends/file.d.ts +6 -0
- package/dist/backends/file.js +54 -0
- package/dist/backends/vault.d.ts +19 -0
- package/dist/backends/vault.js +67 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +99 -0
- package/package.json +11 -8
- package/src/backends/env-var.test.ts +0 -125
- package/src/backends/env-var.ts +0 -52
- package/src/backends/file.test.ts +0 -179
- package/src/backends/file.ts +0 -57
- package/src/backends/vault.test.ts +0 -242
- package/src/backends/vault.ts +0 -85
- package/src/index.test.ts +0 -340
- package/src/index.ts +0 -161
package/src/index.test.ts
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 27 — `secrets-manager` tests:
|
|
3
|
-
* - T1 per backend (env-var, file, vault)
|
|
4
|
-
* - T8 cross-tenant secret isolation
|
|
5
|
-
* - T3 rotation callback within 5s of rotate()
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { type AuditRecord, openAuditLog } from "@crewhaus/audit-log";
|
|
12
|
-
import {
|
|
13
|
-
SecretsError,
|
|
14
|
-
createEnvVarBackend,
|
|
15
|
-
createFileBackend,
|
|
16
|
-
createSecrets,
|
|
17
|
-
createVaultBackend,
|
|
18
|
-
} from "./index";
|
|
19
|
-
|
|
20
|
-
let tmpRoot = "";
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "secrets-test-"));
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("env-var backend (T1)", () => {
|
|
31
|
-
test("get returns the env value", async () => {
|
|
32
|
-
const backend = createEnvVarBackend({
|
|
33
|
-
env: { MY_SECRET: "abc123" } as NodeJS.ProcessEnv,
|
|
34
|
-
});
|
|
35
|
-
expect(await backend.get("MY_SECRET")).toBe("abc123");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("get throws when missing", async () => {
|
|
39
|
-
const backend = createEnvVarBackend({ env: {} as NodeJS.ProcessEnv });
|
|
40
|
-
expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("get throws on empty-string value", async () => {
|
|
44
|
-
const backend = createEnvVarBackend({
|
|
45
|
-
env: { EMPTY: "" } as NodeJS.ProcessEnv,
|
|
46
|
-
});
|
|
47
|
-
expect(backend.get("EMPTY")).rejects.toBeInstanceOf(SecretsError);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("rotate(newValue) overwrites the env entry and returns it", async () => {
|
|
51
|
-
const env: NodeJS.ProcessEnv = { TOKEN: "old" };
|
|
52
|
-
const backend = createEnvVarBackend({ env });
|
|
53
|
-
const v = await backend.rotate("TOKEN", { newValue: "new" });
|
|
54
|
-
expect(v).toBe("new");
|
|
55
|
-
expect(env["TOKEN"]).toBe("new");
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("file backend (T1)", () => {
|
|
60
|
-
test("get returns the file contents", async () => {
|
|
61
|
-
const root = join(tmpRoot, "secrets");
|
|
62
|
-
require("node:fs").mkdirSync(root);
|
|
63
|
-
writeFileSync(join(root, "API_KEY"), "secret-value");
|
|
64
|
-
const backend = createFileBackend({ rootDir: root });
|
|
65
|
-
expect(await backend.get("API_KEY")).toBe("secret-value");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("get throws when file missing", async () => {
|
|
69
|
-
const root = join(tmpRoot, "secrets");
|
|
70
|
-
require("node:fs").mkdirSync(root);
|
|
71
|
-
const backend = createFileBackend({ rootDir: root });
|
|
72
|
-
expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("rotate writes atomically (mode 0o600) and returns the new value", async () => {
|
|
76
|
-
const root = join(tmpRoot, "secrets");
|
|
77
|
-
require("node:fs").mkdirSync(root);
|
|
78
|
-
writeFileSync(join(root, "TOKEN"), "old", { mode: 0o600 });
|
|
79
|
-
const backend = createFileBackend({ rootDir: root });
|
|
80
|
-
const v = await backend.rotate("TOKEN", { newValue: "fresh-token-xyz" });
|
|
81
|
-
expect(v).toBe("fresh-token-xyz");
|
|
82
|
-
expect(readFileSync(join(root, "TOKEN"), "utf8")).toBe("fresh-token-xyz");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("rotate generates a hex token when newValue is omitted", async () => {
|
|
86
|
-
const root = join(tmpRoot, "secrets");
|
|
87
|
-
require("node:fs").mkdirSync(root);
|
|
88
|
-
const backend = createFileBackend({ rootDir: root });
|
|
89
|
-
const v = await backend.rotate("AUTO");
|
|
90
|
-
expect(v).toMatch(/^[a-f0-9]{64}$/);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("rejects malformed names (T8 path-traversal defense)", async () => {
|
|
94
|
-
const backend = createFileBackend({ rootDir: tmpRoot });
|
|
95
|
-
expect(backend.get("../../../etc/passwd")).rejects.toBeInstanceOf(SecretsError);
|
|
96
|
-
expect(backend.rotate("path/with/slash")).rejects.toBeInstanceOf(SecretsError);
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("vault backend (T1)", () => {
|
|
101
|
-
test("get reads via KV v2 endpoint", async () => {
|
|
102
|
-
let observedUrl = "";
|
|
103
|
-
let observedToken = "";
|
|
104
|
-
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
105
|
-
observedUrl = url;
|
|
106
|
-
observedToken = (init?.headers as Record<string, string>)?.["X-Vault-Token"] ?? "";
|
|
107
|
-
return new Response(JSON.stringify({ data: { data: { value: "vault-secret" } } }), {
|
|
108
|
-
status: 200,
|
|
109
|
-
});
|
|
110
|
-
}) as unknown as typeof fetch;
|
|
111
|
-
const backend = createVaultBackend({
|
|
112
|
-
addr: "http://127.0.0.1:8200",
|
|
113
|
-
token: "test-token",
|
|
114
|
-
fetchImpl,
|
|
115
|
-
});
|
|
116
|
-
const v = await backend.get("MY_KEY");
|
|
117
|
-
expect(v).toBe("vault-secret");
|
|
118
|
-
expect(observedUrl).toBe("http://127.0.0.1:8200/v1/secret/data/MY_KEY");
|
|
119
|
-
expect(observedToken).toBe("test-token");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("get throws on 404", async () => {
|
|
123
|
-
const fetchImpl = (async () =>
|
|
124
|
-
new Response("not found", { status: 404 })) as unknown as typeof fetch;
|
|
125
|
-
const backend = createVaultBackend({
|
|
126
|
-
addr: "http://127.0.0.1:8200",
|
|
127
|
-
token: "t",
|
|
128
|
-
fetchImpl,
|
|
129
|
-
});
|
|
130
|
-
expect(backend.get("MISSING")).rejects.toBeInstanceOf(SecretsError);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("rotate PUTs the new value", async () => {
|
|
134
|
-
let observedBody = "";
|
|
135
|
-
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
136
|
-
if (init?.method === "PUT") {
|
|
137
|
-
observedBody = init.body as string;
|
|
138
|
-
return new Response("{}", { status: 204 });
|
|
139
|
-
}
|
|
140
|
-
return new Response("not found", { status: 404 });
|
|
141
|
-
}) as unknown as typeof fetch;
|
|
142
|
-
const backend = createVaultBackend({
|
|
143
|
-
addr: "http://127.0.0.1:8200",
|
|
144
|
-
token: "t",
|
|
145
|
-
fetchImpl,
|
|
146
|
-
});
|
|
147
|
-
const v = await backend.rotate("KEY", { newValue: "fresh" });
|
|
148
|
-
expect(v).toBe("fresh");
|
|
149
|
-
expect(JSON.parse(observedBody)).toEqual({ data: { value: "fresh" } });
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("missing token throws", async () => {
|
|
153
|
-
const oldToken = process.env["VAULT_TOKEN"];
|
|
154
|
-
process.env["VAULT_TOKEN"] = undefined;
|
|
155
|
-
const backend = createVaultBackend({ addr: "http://127.0.0.1:8200" });
|
|
156
|
-
expect(backend.get("X")).rejects.toBeInstanceOf(SecretsError);
|
|
157
|
-
if (oldToken !== undefined) process.env["VAULT_TOKEN"] = oldToken;
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe("createSecrets — rotation handlers (T3)", () => {
|
|
162
|
-
test("onRotation handlers fire within 5s of rotate()", async () => {
|
|
163
|
-
const root = join(tmpRoot, "secrets");
|
|
164
|
-
require("node:fs").mkdirSync(root);
|
|
165
|
-
writeFileSync(join(root, "TOKEN"), "old");
|
|
166
|
-
const secrets = createSecrets({
|
|
167
|
-
backend: createFileBackend({ rootDir: root }),
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const events: Array<{ name: string; newValue: string; rotatedAt: number }> = [];
|
|
171
|
-
secrets.onRotation((e) => {
|
|
172
|
-
events.push({ name: e.name, newValue: e.newValue, rotatedAt: e.rotatedAt });
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
const t0 = Date.now();
|
|
176
|
-
await secrets.rotate("TOKEN", { newValue: "new-value" });
|
|
177
|
-
const elapsed = Date.now() - t0;
|
|
178
|
-
|
|
179
|
-
expect(elapsed).toBeLessThan(5_000);
|
|
180
|
-
expect(events.length).toBe(1);
|
|
181
|
-
expect(events[0]?.name).toBe("TOKEN");
|
|
182
|
-
expect(events[0]?.newValue).toBe("new-value");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("multiple handlers fire in order; one throwing does not block others", async () => {
|
|
186
|
-
const root = join(tmpRoot, "secrets");
|
|
187
|
-
require("node:fs").mkdirSync(root);
|
|
188
|
-
writeFileSync(join(root, "TOKEN"), "old");
|
|
189
|
-
const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
|
|
190
|
-
|
|
191
|
-
const calls: string[] = [];
|
|
192
|
-
secrets.onRotation(() => {
|
|
193
|
-
calls.push("first");
|
|
194
|
-
throw new Error("first handler boom");
|
|
195
|
-
});
|
|
196
|
-
secrets.onRotation(() => {
|
|
197
|
-
calls.push("second");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
await secrets.rotate("TOKEN", { newValue: "new" });
|
|
201
|
-
expect(calls).toEqual(["first", "second"]);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("unsubscribe stops further notifications", async () => {
|
|
205
|
-
const root = join(tmpRoot, "secrets");
|
|
206
|
-
require("node:fs").mkdirSync(root);
|
|
207
|
-
writeFileSync(join(root, "TOKEN"), "old");
|
|
208
|
-
const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
|
|
209
|
-
let calls = 0;
|
|
210
|
-
const off = secrets.onRotation(() => {
|
|
211
|
-
calls++;
|
|
212
|
-
});
|
|
213
|
-
await secrets.rotate("TOKEN", { newValue: "v1" });
|
|
214
|
-
off();
|
|
215
|
-
await secrets.rotate("TOKEN", { newValue: "v2" });
|
|
216
|
-
expect(calls).toBe(1);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("async handler that resolves is awaited before rotate() returns", async () => {
|
|
220
|
-
const root = join(tmpRoot, "secrets");
|
|
221
|
-
require("node:fs").mkdirSync(root);
|
|
222
|
-
writeFileSync(join(root, "TOKEN"), "old");
|
|
223
|
-
const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
|
|
224
|
-
|
|
225
|
-
let settled = false;
|
|
226
|
-
secrets.onRotation(async (e) => {
|
|
227
|
-
// microtask + macrotask hop to prove rotate() actually awaits us
|
|
228
|
-
await Promise.resolve();
|
|
229
|
-
expect(e.newValue).toBe("async-value");
|
|
230
|
-
settled = true;
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
await secrets.rotate("TOKEN", { newValue: "async-value" });
|
|
234
|
-
expect(settled).toBe(true);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("async handler that rejects is swallowed and does not block siblings", async () => {
|
|
238
|
-
const root = join(tmpRoot, "secrets");
|
|
239
|
-
require("node:fs").mkdirSync(root);
|
|
240
|
-
writeFileSync(join(root, "TOKEN"), "old");
|
|
241
|
-
const secrets = createSecrets({ backend: createFileBackend({ rootDir: root }) });
|
|
242
|
-
|
|
243
|
-
const order: string[] = [];
|
|
244
|
-
// rejecting async handler -> exercises the promise .catch(() => {}) path
|
|
245
|
-
secrets.onRotation(async () => {
|
|
246
|
-
order.push("rejecting");
|
|
247
|
-
await Promise.resolve();
|
|
248
|
-
throw new Error("async handler boom");
|
|
249
|
-
});
|
|
250
|
-
// resolving async sibling -> still runs
|
|
251
|
-
secrets.onRotation(async () => {
|
|
252
|
-
order.push("resolving");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// rotate must resolve (not reject) despite the rejecting handler
|
|
256
|
-
const v = await secrets.rotate("TOKEN", { newValue: "v" });
|
|
257
|
-
expect(v).toBe("v");
|
|
258
|
-
expect(order).toEqual(["rejecting", "resolving"]);
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
describe("createSecrets — audit-log integration (T8 tenant isolation)", () => {
|
|
263
|
-
async function readAuditRecords(rootDir: string): Promise<AuditRecord[]> {
|
|
264
|
-
const audit = await openAuditLog({ rootDir });
|
|
265
|
-
const out: AuditRecord[] = [];
|
|
266
|
-
for await (const rec of audit.read()) out.push(rec);
|
|
267
|
-
return out;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
test("audit-log records secrets_access only when tenant scoped", async () => {
|
|
271
|
-
const root = join(tmpRoot, "secrets");
|
|
272
|
-
const auditRoot = join(tmpRoot, "audit");
|
|
273
|
-
require("node:fs").mkdirSync(root);
|
|
274
|
-
writeFileSync(join(root, "K"), "v");
|
|
275
|
-
|
|
276
|
-
const audit = await openAuditLog({ rootDir: auditRoot });
|
|
277
|
-
const secretsTenantA = createSecrets({
|
|
278
|
-
backend: createFileBackend({ rootDir: root }),
|
|
279
|
-
auditLog: audit,
|
|
280
|
-
tenantId: "tenant-a",
|
|
281
|
-
});
|
|
282
|
-
const secretsNoTenant = createSecrets({
|
|
283
|
-
backend: createFileBackend({ rootDir: root }),
|
|
284
|
-
auditLog: audit,
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
await secretsTenantA.get("K");
|
|
288
|
-
await secretsNoTenant.get("K");
|
|
289
|
-
|
|
290
|
-
const records = await readAuditRecords(auditRoot);
|
|
291
|
-
expect(records.length).toBe(1);
|
|
292
|
-
expect(records[0]?.kind).toBe("secrets_access");
|
|
293
|
-
expect((records[0]?.payload as { tenantId: string }).tenantId).toBe("tenant-a");
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
test("rotate also audit-logs and includes timestamp", async () => {
|
|
297
|
-
const root = join(tmpRoot, "secrets");
|
|
298
|
-
const auditRoot = join(tmpRoot, "audit");
|
|
299
|
-
require("node:fs").mkdirSync(root);
|
|
300
|
-
writeFileSync(join(root, "K"), "v");
|
|
301
|
-
|
|
302
|
-
const audit = await openAuditLog({ rootDir: auditRoot });
|
|
303
|
-
const secrets = createSecrets({
|
|
304
|
-
backend: createFileBackend({ rootDir: root }),
|
|
305
|
-
auditLog: audit,
|
|
306
|
-
tenantId: "tenant-a",
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
await secrets.rotate("K", { newValue: "v2" });
|
|
310
|
-
const records = await readAuditRecords(auditRoot);
|
|
311
|
-
expect(records.length).toBe(1);
|
|
312
|
-
expect(records[0]?.kind).toBe("secrets_rotation");
|
|
313
|
-
const payload = records[0]?.payload as {
|
|
314
|
-
tenantId: string;
|
|
315
|
-
name: string;
|
|
316
|
-
backend: string;
|
|
317
|
-
rotatedAt: number;
|
|
318
|
-
};
|
|
319
|
-
expect(payload.tenantId).toBe("tenant-a");
|
|
320
|
-
expect(payload.name).toBe("K");
|
|
321
|
-
expect(payload.backend).toBe("file");
|
|
322
|
-
expect(typeof payload.rotatedAt).toBe("number");
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
describe("createSecrets — doctor()", () => {
|
|
327
|
-
test("reports available + missing for known names", async () => {
|
|
328
|
-
const root = join(tmpRoot, "secrets");
|
|
329
|
-
require("node:fs").mkdirSync(root);
|
|
330
|
-
writeFileSync(join(root, "EXISTS"), "v");
|
|
331
|
-
const secrets = createSecrets({
|
|
332
|
-
backend: createFileBackend({ rootDir: root }),
|
|
333
|
-
knownSecrets: ["EXISTS", "MISSING"],
|
|
334
|
-
});
|
|
335
|
-
const report = await secrets.doctor();
|
|
336
|
-
expect(report.backend).toBe("file");
|
|
337
|
-
expect(report.available).toEqual(["EXISTS"]);
|
|
338
|
-
expect(report.missing).toEqual(["MISSING"]);
|
|
339
|
-
});
|
|
340
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import type { AuditLog } from "@crewhaus/audit-log";
|
|
2
|
-
/**
|
|
3
|
-
* Section 27 — `secrets-manager`. Pluggable secret storage with rotation
|
|
4
|
-
* callbacks and audit-log integration. Three backends:
|
|
5
|
-
* - **env-var** (default; rotation is a no-op + warning)
|
|
6
|
-
* - **file** (reads from `.crewhaus/secrets/<name>`; rotation = atomic rewrite)
|
|
7
|
-
* - **vault** (HashiCorp Vault HTTP API, KV v2 backend)
|
|
8
|
-
*
|
|
9
|
-
* Long-running daemons (CHN gateway, MGD gateway, RES daemon) subscribe to
|
|
10
|
-
* `onRotation(handler)` so they refresh in-flight credentials without
|
|
11
|
-
* restart. Every `get` and `rotate` is audit-logged when a tenant id is
|
|
12
|
-
* configured.
|
|
13
|
-
*/
|
|
14
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
15
|
-
import { createEnvVarBackend } from "./backends/env-var";
|
|
16
|
-
import { createFileBackend } from "./backends/file";
|
|
17
|
-
import { createVaultBackend } from "./backends/vault";
|
|
18
|
-
|
|
19
|
-
export class SecretsError extends CrewhausError {
|
|
20
|
-
override readonly name = "SecretsError";
|
|
21
|
-
constructor(message: string, cause?: unknown) {
|
|
22
|
-
super("config", message, cause);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type SecretValue = string;
|
|
27
|
-
|
|
28
|
-
export type RotationHandler = (event: {
|
|
29
|
-
readonly name: string;
|
|
30
|
-
readonly newValue: SecretValue;
|
|
31
|
-
readonly rotatedAt: number;
|
|
32
|
-
}) => void | Promise<void>;
|
|
33
|
-
|
|
34
|
-
export interface SecretsBackend {
|
|
35
|
-
readonly id: "env-var" | "file" | "vault";
|
|
36
|
-
/** Returns the current value, or throws SecretsError if missing. */
|
|
37
|
-
get(name: string): Promise<SecretValue>;
|
|
38
|
-
/**
|
|
39
|
-
* Rotate the named secret. Implementations may generate a new value or
|
|
40
|
-
* accept an externally-supplied one via `opts.newValue`. Returns the
|
|
41
|
-
* new value so callers can verify the rotation took.
|
|
42
|
-
*/
|
|
43
|
-
rotate(name: string, opts?: { readonly newValue?: SecretValue }): Promise<SecretValue>;
|
|
44
|
-
/** Optional health check. Returns the names this backend can resolve. */
|
|
45
|
-
list?(): Promise<ReadonlyArray<string>>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface Secrets {
|
|
49
|
-
/** Resolve the named secret. Audit-logs the access when tenantId is set. */
|
|
50
|
-
get(name: string): Promise<SecretValue>;
|
|
51
|
-
/**
|
|
52
|
-
* Rotate the named secret, fire all `onRotation` handlers, and audit-log
|
|
53
|
-
* the rotation when tenantId is set. Returns the new value.
|
|
54
|
-
*/
|
|
55
|
-
rotate(name: string, opts?: { readonly newValue?: SecretValue }): Promise<SecretValue>;
|
|
56
|
-
/** Subscribe to rotation events. Returns an unsubscribe function. */
|
|
57
|
-
onRotation(handler: RotationHandler): () => void;
|
|
58
|
-
/** Switch to a fresh backend. Used by tests + the doctor command. */
|
|
59
|
-
doctor(): Promise<DoctorReport>;
|
|
60
|
-
/** Backend identifier for diagnostics. */
|
|
61
|
-
readonly backendId: SecretsBackend["id"];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type DoctorReport = {
|
|
65
|
-
readonly backend: SecretsBackend["id"];
|
|
66
|
-
readonly available: ReadonlyArray<string>;
|
|
67
|
-
readonly missing: ReadonlyArray<string>;
|
|
68
|
-
/** Rotation TTLs known to be due (file/vault track this; env-var returns []). */
|
|
69
|
-
readonly rotationDue: ReadonlyArray<string>;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export type CreateSecretsOptions = {
|
|
73
|
-
readonly backend: SecretsBackend;
|
|
74
|
-
readonly auditLog?: AuditLog;
|
|
75
|
-
readonly tenantId?: string;
|
|
76
|
-
/** Names to validate in `doctor()`. */
|
|
77
|
-
readonly knownSecrets?: ReadonlyArray<string>;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
export function createSecrets(opts: CreateSecretsOptions): Secrets {
|
|
81
|
-
const handlers = new Set<RotationHandler>();
|
|
82
|
-
return {
|
|
83
|
-
backendId: opts.backend.id,
|
|
84
|
-
|
|
85
|
-
async get(name): Promise<SecretValue> {
|
|
86
|
-
const value = await opts.backend.get(name);
|
|
87
|
-
if (opts.auditLog && opts.tenantId !== undefined) {
|
|
88
|
-
await opts.auditLog.append({
|
|
89
|
-
kind: "secrets_access",
|
|
90
|
-
payload: { tenantId: opts.tenantId, name, backend: opts.backend.id },
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
return value;
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
async rotate(name, rotateOpts): Promise<SecretValue> {
|
|
97
|
-
const newValue = await opts.backend.rotate(name, rotateOpts);
|
|
98
|
-
const rotatedAt = Date.now();
|
|
99
|
-
if (opts.auditLog && opts.tenantId !== undefined) {
|
|
100
|
-
await opts.auditLog.append({
|
|
101
|
-
kind: "secrets_rotation",
|
|
102
|
-
payload: {
|
|
103
|
-
tenantId: opts.tenantId,
|
|
104
|
-
name,
|
|
105
|
-
backend: opts.backend.id,
|
|
106
|
-
rotatedAt,
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
// Fire handlers in order. A handler that throws does not block siblings.
|
|
111
|
-
const event = { name, newValue, rotatedAt };
|
|
112
|
-
const promises: Array<Promise<void>> = [];
|
|
113
|
-
for (const h of handlers) {
|
|
114
|
-
try {
|
|
115
|
-
const result = h(event);
|
|
116
|
-
if (result && typeof (result as Promise<void>).then === "function") {
|
|
117
|
-
promises.push(
|
|
118
|
-
(result as Promise<void>).catch(() => {
|
|
119
|
-
/* swallow per-handler errors */
|
|
120
|
-
}),
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
/* swallow per-handler errors */
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
await Promise.all(promises);
|
|
128
|
-
return newValue;
|
|
129
|
-
},
|
|
130
|
-
|
|
131
|
-
onRotation(h): () => void {
|
|
132
|
-
handlers.add(h);
|
|
133
|
-
return () => {
|
|
134
|
-
handlers.delete(h);
|
|
135
|
-
};
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
async doctor(): Promise<DoctorReport> {
|
|
139
|
-
const known = opts.knownSecrets ?? [];
|
|
140
|
-
const available: string[] = [];
|
|
141
|
-
const missing: string[] = [];
|
|
142
|
-
for (const name of known) {
|
|
143
|
-
try {
|
|
144
|
-
await opts.backend.get(name);
|
|
145
|
-
available.push(name);
|
|
146
|
-
} catch {
|
|
147
|
-
missing.push(name);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
backend: opts.backend.id,
|
|
152
|
-
available,
|
|
153
|
-
missing,
|
|
154
|
-
rotationDue: [],
|
|
155
|
-
};
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Re-export backends so callers can construct directly.
|
|
161
|
-
export { createEnvVarBackend, createFileBackend, createVaultBackend };
|