@happyvertical/smrt-secrets 0.30.0
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/AGENTS.md +66 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +144 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/chunks/SecretService-C91H6WJK.js +1275 -0
- package/dist/chunks/SecretService-C91H6WJK.js.map +1 -0
- package/dist/chunks/TenantKey-DzglnpAV.js +377 -0
- package/dist/chunks/TenantKey-DzglnpAV.js.map +1 -0
- package/dist/collections/SecretAuditLogCollection.d.ts +71 -0
- package/dist/collections/SecretAuditLogCollection.d.ts.map +1 -0
- package/dist/collections/SecretCollection.d.ts +63 -0
- package/dist/collections/SecretCollection.d.ts.map +1 -0
- package/dist/collections/TenantKeyCollection.d.ts +42 -0
- package/dist/collections/TenantKeyCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +8 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1272 -0
- package/dist/models/Secret.d.ts +104 -0
- package/dist/models/Secret.d.ts.map +1 -0
- package/dist/models/SecretAuditLog.d.ts +123 -0
- package/dist/models/SecretAuditLog.d.ts.map +1 -0
- package/dist/models/TenantKey.d.ts +101 -0
- package/dist/models/TenantKey.d.ts.map +1 -0
- package/dist/models/index.d.ts +4 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +8 -0
- package/dist/models/index.js.map +1 -0
- package/dist/services/SecretService.d.ts +266 -0
- package/dist/services/SecretService.d.ts.map +1 -0
- package/dist/services/SecretService.js +9 -0
- package/dist/services/SecretService.js.map +1 -0
- package/dist/smrt-knowledge.json +447 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
import { a as SecretAuditLog, S as Secret, T as TenantKey, c as createAuditEntry } from "./TenantKey-DzglnpAV.js";
|
|
2
|
+
import { loadEnvConfig } from "@happyvertical/utils";
|
|
3
|
+
import { getSecretStore, EnvelopeEncryption, AMKUnavailableError, TenantKeyMissingError, EncryptionError, DecryptionError } from "@happyvertical/secrets";
|
|
4
|
+
import { requireTenantId, withTenant, getCurrentTenant } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
import { SmrtCollection } from "@happyvertical/smrt-core";
|
|
6
|
+
class SecretAuditLogCollection extends SmrtCollection {
|
|
7
|
+
static _itemClass = SecretAuditLog;
|
|
8
|
+
/**
|
|
9
|
+
* List audit logs with filtering options
|
|
10
|
+
*/
|
|
11
|
+
async listLogs(options = {}) {
|
|
12
|
+
const where = {};
|
|
13
|
+
if (options.tenantId) {
|
|
14
|
+
where.tenantId = options.tenantId;
|
|
15
|
+
}
|
|
16
|
+
if (options.secretName) {
|
|
17
|
+
where.secretName = options.secretName;
|
|
18
|
+
}
|
|
19
|
+
if (options.userId) {
|
|
20
|
+
where.userId = options.userId;
|
|
21
|
+
}
|
|
22
|
+
if (options.action) {
|
|
23
|
+
where.action = options.action;
|
|
24
|
+
}
|
|
25
|
+
if (options.result) {
|
|
26
|
+
where.result = options.result;
|
|
27
|
+
}
|
|
28
|
+
if (options.since) {
|
|
29
|
+
where["created_at >"] = options.since.toISOString();
|
|
30
|
+
}
|
|
31
|
+
if (options.until) {
|
|
32
|
+
where["created_at <"] = options.until.toISOString();
|
|
33
|
+
}
|
|
34
|
+
return this.list({
|
|
35
|
+
where,
|
|
36
|
+
limit: options.limit ?? 100,
|
|
37
|
+
offset: options.offset,
|
|
38
|
+
orderBy: "created_at DESC"
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get audit logs for a specific secret
|
|
43
|
+
*/
|
|
44
|
+
async getSecretHistory(secretName, limit = 50) {
|
|
45
|
+
return this.listLogs({ secretName, limit });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get audit logs for a specific user
|
|
49
|
+
*/
|
|
50
|
+
async getUserActivity(userId, limit = 50) {
|
|
51
|
+
return this.listLogs({ userId, limit });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get recent failures
|
|
55
|
+
*/
|
|
56
|
+
async getRecentFailures(limit = 20) {
|
|
57
|
+
return this.listLogs({ result: "failure", limit });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get recent denied access attempts
|
|
61
|
+
*/
|
|
62
|
+
async getRecentDenials(limit = 20) {
|
|
63
|
+
return this.listLogs({ result: "denied", limit });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Count operations by action type
|
|
67
|
+
*/
|
|
68
|
+
async countByAction(since) {
|
|
69
|
+
const logs = await this.listLogs({ since, limit: 1e4 });
|
|
70
|
+
const counts = {
|
|
71
|
+
create: 0,
|
|
72
|
+
read: 0,
|
|
73
|
+
update: 0,
|
|
74
|
+
delete: 0,
|
|
75
|
+
rotate_key: 0,
|
|
76
|
+
disable: 0,
|
|
77
|
+
enable: 0,
|
|
78
|
+
expire: 0
|
|
79
|
+
};
|
|
80
|
+
for (const log of logs) {
|
|
81
|
+
counts[log.action]++;
|
|
82
|
+
}
|
|
83
|
+
return counts;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Count operations by result
|
|
87
|
+
*/
|
|
88
|
+
async countByResult(since) {
|
|
89
|
+
const logs = await this.listLogs({ since, limit: 1e4 });
|
|
90
|
+
const counts = {
|
|
91
|
+
success: 0,
|
|
92
|
+
failure: 0,
|
|
93
|
+
denied: 0
|
|
94
|
+
};
|
|
95
|
+
for (const log of logs) {
|
|
96
|
+
counts[log.result]++;
|
|
97
|
+
}
|
|
98
|
+
return counts;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Delete old audit logs
|
|
102
|
+
* @param olderThanDays Delete logs older than this many days
|
|
103
|
+
*/
|
|
104
|
+
async cleanup(olderThanDays = 365) {
|
|
105
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
106
|
+
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
107
|
+
const oldLogs = await this.list({
|
|
108
|
+
where: {
|
|
109
|
+
"created_at <": cutoffDate.toISOString()
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (const log of oldLogs) {
|
|
114
|
+
await log.delete();
|
|
115
|
+
count++;
|
|
116
|
+
}
|
|
117
|
+
return count;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
class SecretCollection extends SmrtCollection {
|
|
121
|
+
static _itemClass = Secret;
|
|
122
|
+
/**
|
|
123
|
+
* Find a secret by name within the given tenant
|
|
124
|
+
*/
|
|
125
|
+
async findByName(tenantId, name) {
|
|
126
|
+
return this.get({ name, tenantId });
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* List secrets for a tenant with filtering options
|
|
130
|
+
*/
|
|
131
|
+
async listSecrets(tenantId, options = {}) {
|
|
132
|
+
const where = { tenantId };
|
|
133
|
+
if (options.category) {
|
|
134
|
+
where.category = options.category;
|
|
135
|
+
}
|
|
136
|
+
if (options.status) {
|
|
137
|
+
where.status = options.status;
|
|
138
|
+
}
|
|
139
|
+
const secrets = await this.list({
|
|
140
|
+
where,
|
|
141
|
+
limit: options.limit,
|
|
142
|
+
offset: options.offset,
|
|
143
|
+
orderBy: "name ASC"
|
|
144
|
+
});
|
|
145
|
+
if (!options.includeExpired) {
|
|
146
|
+
return secrets.filter((secret) => !secret.isExpired());
|
|
147
|
+
}
|
|
148
|
+
return secrets;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* List all active secrets for a tenant
|
|
152
|
+
*/
|
|
153
|
+
async listActive(tenantId) {
|
|
154
|
+
return this.listSecrets(tenantId, { status: "active" });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* List a tenant's secrets by category
|
|
158
|
+
*/
|
|
159
|
+
async listByCategory(tenantId, category) {
|
|
160
|
+
return this.listSecrets(tenantId, { category, status: "active" });
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* List a tenant's secrets that need attention (expired or about to expire)
|
|
164
|
+
*/
|
|
165
|
+
async listExpiring(tenantId, daysAhead = 30) {
|
|
166
|
+
const futureDate = /* @__PURE__ */ new Date();
|
|
167
|
+
futureDate.setDate(futureDate.getDate() + daysAhead);
|
|
168
|
+
const secrets = await this.list({
|
|
169
|
+
where: {
|
|
170
|
+
tenantId,
|
|
171
|
+
status: "active",
|
|
172
|
+
"expiresAt !=": null,
|
|
173
|
+
"expiresAt <": futureDate.toISOString()
|
|
174
|
+
},
|
|
175
|
+
orderBy: "expiresAt ASC"
|
|
176
|
+
});
|
|
177
|
+
return secrets;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Get categories used in a tenant's secrets
|
|
181
|
+
*/
|
|
182
|
+
async getCategories(tenantId) {
|
|
183
|
+
const secrets = await this.list({ where: { tenantId } });
|
|
184
|
+
const categories = new Set(secrets.map((s) => s.category).filter(Boolean));
|
|
185
|
+
return Array.from(categories).sort();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Count a tenant's secrets by status
|
|
189
|
+
*/
|
|
190
|
+
async countByStatus(tenantId) {
|
|
191
|
+
const secrets = await this.list({ where: { tenantId } });
|
|
192
|
+
const counts = {
|
|
193
|
+
active: 0,
|
|
194
|
+
disabled: 0,
|
|
195
|
+
expired: 0
|
|
196
|
+
};
|
|
197
|
+
for (const secret of secrets) {
|
|
198
|
+
if (secret.isExpired()) {
|
|
199
|
+
counts.expired++;
|
|
200
|
+
} else {
|
|
201
|
+
counts[secret.status]++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return counts;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Delete a tenant's secret by name
|
|
208
|
+
*/
|
|
209
|
+
async deleteByName(tenantId, name) {
|
|
210
|
+
const secret = await this.findByName(tenantId, name);
|
|
211
|
+
if (!secret) return false;
|
|
212
|
+
await secret.delete();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
class TenantKeyCollection extends SmrtCollection {
|
|
217
|
+
static _itemClass = TenantKey;
|
|
218
|
+
/**
|
|
219
|
+
* Get the active key for a tenant
|
|
220
|
+
*/
|
|
221
|
+
async getActiveKey(tenantId) {
|
|
222
|
+
return this.get({
|
|
223
|
+
tenantId,
|
|
224
|
+
status: "active"
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* List all key versions for a tenant
|
|
229
|
+
*/
|
|
230
|
+
async listKeyVersions(tenantId) {
|
|
231
|
+
return this.list({
|
|
232
|
+
where: { tenantId },
|
|
233
|
+
orderBy: "version DESC"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Get a specific key version for a tenant
|
|
238
|
+
*/
|
|
239
|
+
async getKeyVersion(tenantId, version) {
|
|
240
|
+
return this.get({
|
|
241
|
+
tenantId,
|
|
242
|
+
version
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Find keys that need rotation
|
|
247
|
+
*/
|
|
248
|
+
async findKeysNeedingRotation() {
|
|
249
|
+
const now = /* @__PURE__ */ new Date();
|
|
250
|
+
return this.list({
|
|
251
|
+
where: {
|
|
252
|
+
status: "active",
|
|
253
|
+
"rotateAfter !=": null,
|
|
254
|
+
"rotateAfter <": now.toISOString()
|
|
255
|
+
},
|
|
256
|
+
orderBy: "rotateAfter ASC"
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* List all active keys across all tenants
|
|
261
|
+
*/
|
|
262
|
+
async listAllActiveKeys() {
|
|
263
|
+
return this.list({
|
|
264
|
+
where: { status: "active" },
|
|
265
|
+
orderBy: "created_at DESC"
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Count keys by status
|
|
270
|
+
*/
|
|
271
|
+
async countByStatus() {
|
|
272
|
+
const keys = await this.list({});
|
|
273
|
+
const counts = {
|
|
274
|
+
active: 0,
|
|
275
|
+
rotating: 0,
|
|
276
|
+
retired: 0,
|
|
277
|
+
compromised: 0
|
|
278
|
+
};
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
counts[key.status]++;
|
|
281
|
+
}
|
|
282
|
+
return counts;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Mark a key as compromised (should trigger re-encryption)
|
|
286
|
+
*/
|
|
287
|
+
async markCompromised(tenantId, keyId) {
|
|
288
|
+
const key = await this.get({
|
|
289
|
+
id: keyId,
|
|
290
|
+
tenantId
|
|
291
|
+
});
|
|
292
|
+
if (!key) return false;
|
|
293
|
+
key.markCompromised();
|
|
294
|
+
await key.save();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Delete old retired keys that are no longer needed
|
|
299
|
+
* @param olderThanDays Delete keys retired more than this many days ago
|
|
300
|
+
*/
|
|
301
|
+
async cleanupRetiredKeys(olderThanDays = 90) {
|
|
302
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
303
|
+
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
304
|
+
const oldKeys = await this.list({
|
|
305
|
+
where: {
|
|
306
|
+
status: "retired",
|
|
307
|
+
"retiredAt <": cutoffDate.toISOString()
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
let count = 0;
|
|
311
|
+
for (const key of oldKeys) {
|
|
312
|
+
await key.delete();
|
|
313
|
+
count++;
|
|
314
|
+
}
|
|
315
|
+
return count;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
class ConsoleLogger {
|
|
319
|
+
constructor(level = "info") {
|
|
320
|
+
this.level = level;
|
|
321
|
+
}
|
|
322
|
+
static LEVELS = [
|
|
323
|
+
"debug",
|
|
324
|
+
"info",
|
|
325
|
+
"warn",
|
|
326
|
+
"error"
|
|
327
|
+
];
|
|
328
|
+
/**
|
|
329
|
+
* Check if a log level should be output
|
|
330
|
+
*
|
|
331
|
+
* @param level - Log level to check
|
|
332
|
+
* @returns True if level meets threshold
|
|
333
|
+
*/
|
|
334
|
+
shouldLog(level) {
|
|
335
|
+
const currentIndex = ConsoleLogger.LEVELS.indexOf(this.level);
|
|
336
|
+
const messageIndex = ConsoleLogger.LEVELS.indexOf(level);
|
|
337
|
+
return messageIndex >= currentIndex;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Format context for console output
|
|
341
|
+
*
|
|
342
|
+
* @param context - Structured metadata
|
|
343
|
+
* @returns Formatted context string
|
|
344
|
+
*/
|
|
345
|
+
formatContext(context) {
|
|
346
|
+
if (!context || Object.keys(context).length === 0) {
|
|
347
|
+
return "";
|
|
348
|
+
}
|
|
349
|
+
return ` ${JSON.stringify(context)}`;
|
|
350
|
+
}
|
|
351
|
+
debug(message, context) {
|
|
352
|
+
if (this.shouldLog("debug")) {
|
|
353
|
+
console.debug(`[DEBUG] ${message}${this.formatContext(context)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
info(message, context) {
|
|
357
|
+
if (this.shouldLog("info")) {
|
|
358
|
+
console.info(`[INFO] ${message}${this.formatContext(context)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
warn(message, context) {
|
|
362
|
+
if (this.shouldLog("warn")) {
|
|
363
|
+
console.warn(`[WARN] ${message}${this.formatContext(context)}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
error(message, context) {
|
|
367
|
+
if (this.shouldLog("error")) {
|
|
368
|
+
console.error(`[ERROR] ${message}${this.formatContext(context)}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
class NoopLogger {
|
|
373
|
+
debug(_message, _context) {
|
|
374
|
+
}
|
|
375
|
+
info(_message, _context) {
|
|
376
|
+
}
|
|
377
|
+
warn(_message, _context) {
|
|
378
|
+
}
|
|
379
|
+
error(_message, _context) {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function createLogger(config) {
|
|
383
|
+
if (typeof config === "boolean") {
|
|
384
|
+
if (!config) {
|
|
385
|
+
return new NoopLogger();
|
|
386
|
+
}
|
|
387
|
+
const envConfig = loadEnvConfig(
|
|
388
|
+
{},
|
|
389
|
+
{
|
|
390
|
+
packageName: "logger",
|
|
391
|
+
schema: { level: "string" }
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
return new ConsoleLogger(envConfig.level || "info");
|
|
395
|
+
}
|
|
396
|
+
const mergedConfig = loadEnvConfig(config, {
|
|
397
|
+
packageName: "logger",
|
|
398
|
+
schema: { level: "string" }
|
|
399
|
+
});
|
|
400
|
+
const level = mergedConfig.level || "info";
|
|
401
|
+
return new ConsoleLogger(level);
|
|
402
|
+
}
|
|
403
|
+
const logger = createLogger({ level: "info" });
|
|
404
|
+
class SecretKeyDriftError extends Error {
|
|
405
|
+
code = "SECRET_KEY_DRIFT";
|
|
406
|
+
tenantId;
|
|
407
|
+
report;
|
|
408
|
+
cause;
|
|
409
|
+
constructor(message, tenantId, report, cause) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.name = "SecretKeyDriftError";
|
|
412
|
+
this.tenantId = tenantId;
|
|
413
|
+
this.report = report;
|
|
414
|
+
this.cause = cause;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
class SecretService {
|
|
418
|
+
db;
|
|
419
|
+
secretStore;
|
|
420
|
+
secrets;
|
|
421
|
+
tenantKeys;
|
|
422
|
+
auditLogs;
|
|
423
|
+
auditEnabled;
|
|
424
|
+
amkEnvVar;
|
|
425
|
+
amkKeyId;
|
|
426
|
+
constructor(db, secretStore, secrets, tenantKeys, auditLogs, auditEnabled, amkEnvVar, amkKeyId) {
|
|
427
|
+
this.db = db;
|
|
428
|
+
this.secretStore = secretStore;
|
|
429
|
+
this.secrets = secrets;
|
|
430
|
+
this.tenantKeys = tenantKeys;
|
|
431
|
+
this.auditLogs = auditLogs;
|
|
432
|
+
this.auditEnabled = auditEnabled;
|
|
433
|
+
this.amkEnvVar = amkEnvVar;
|
|
434
|
+
this.amkKeyId = amkKeyId;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Create a new SecretService instance
|
|
438
|
+
*/
|
|
439
|
+
static async create(options) {
|
|
440
|
+
const {
|
|
441
|
+
db,
|
|
442
|
+
amkEnvVar = "SMRT_SECRET_MASTER_KEY",
|
|
443
|
+
amkKeyId = "smrt-amk-v1",
|
|
444
|
+
auditEnabled = true
|
|
445
|
+
} = options;
|
|
446
|
+
const secretStore = await getSecretStore({
|
|
447
|
+
type: "database",
|
|
448
|
+
db,
|
|
449
|
+
amk: {
|
|
450
|
+
provider: "env",
|
|
451
|
+
keyEnvVar: amkEnvVar,
|
|
452
|
+
keyId: amkKeyId
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
const baseOptions = { db };
|
|
456
|
+
const secrets = await SecretCollection.create(baseOptions);
|
|
457
|
+
const tenantKeys = await TenantKeyCollection.create(baseOptions);
|
|
458
|
+
const auditLogs = await SecretAuditLogCollection.create(baseOptions);
|
|
459
|
+
return new SecretService(
|
|
460
|
+
db,
|
|
461
|
+
secretStore,
|
|
462
|
+
secrets,
|
|
463
|
+
tenantKeys,
|
|
464
|
+
auditLogs,
|
|
465
|
+
auditEnabled,
|
|
466
|
+
amkEnvVar,
|
|
467
|
+
amkKeyId
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Store a secret for the current tenant
|
|
472
|
+
*/
|
|
473
|
+
async store(name, value, options = {}) {
|
|
474
|
+
const tenantId = requireTenantId();
|
|
475
|
+
const userId = this.getCurrentUserId();
|
|
476
|
+
let isUpdate = false;
|
|
477
|
+
try {
|
|
478
|
+
let existing = await this.secrets.findByName(tenantId, name);
|
|
479
|
+
if (existing && existing.tenantId !== tenantId) {
|
|
480
|
+
existing = null;
|
|
481
|
+
}
|
|
482
|
+
isUpdate = existing !== null;
|
|
483
|
+
const envelope = await this.secretStore.encrypt(tenantId, name, value, {
|
|
484
|
+
metadata: options.metadata ? this.serializeMetadata(options.metadata) : void 0
|
|
485
|
+
});
|
|
486
|
+
if (existing) {
|
|
487
|
+
existing.encryptedValue = JSON.stringify(envelope);
|
|
488
|
+
existing.description = options.description ?? existing.description;
|
|
489
|
+
existing.category = options.category ?? existing.category;
|
|
490
|
+
existing.expiresAt = options.expiresAt ?? existing.expiresAt;
|
|
491
|
+
existing.metadata = options.metadata ?? existing.metadata;
|
|
492
|
+
await existing.save();
|
|
493
|
+
await this.audit(
|
|
494
|
+
existing.id ?? null,
|
|
495
|
+
name,
|
|
496
|
+
userId,
|
|
497
|
+
"update",
|
|
498
|
+
"success"
|
|
499
|
+
);
|
|
500
|
+
return existing;
|
|
501
|
+
}
|
|
502
|
+
const secret = await this.secrets.create({
|
|
503
|
+
name,
|
|
504
|
+
description: options.description ?? "",
|
|
505
|
+
category: options.category ?? "",
|
|
506
|
+
encryptedValue: JSON.stringify(envelope),
|
|
507
|
+
keyVersion: 1,
|
|
508
|
+
status: "active",
|
|
509
|
+
expiresAt: options.expiresAt ?? null,
|
|
510
|
+
metadata: options.metadata ?? {},
|
|
511
|
+
context: tenantId,
|
|
512
|
+
// Per-tenant uniqueness
|
|
513
|
+
tenantId
|
|
514
|
+
});
|
|
515
|
+
await this.audit(secret.id ?? null, name, userId, "create", "success");
|
|
516
|
+
return secret;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
const classifiedError = await this.classifyTenantKeyFailure(
|
|
519
|
+
tenantId,
|
|
520
|
+
name,
|
|
521
|
+
error
|
|
522
|
+
);
|
|
523
|
+
await this.audit(
|
|
524
|
+
null,
|
|
525
|
+
name,
|
|
526
|
+
userId,
|
|
527
|
+
isUpdate ? "update" : "create",
|
|
528
|
+
"failure",
|
|
529
|
+
{
|
|
530
|
+
error: classifiedError.message
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
throw classifiedError;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Store a secret for a specific tenant.
|
|
538
|
+
*
|
|
539
|
+
* This is useful for integrations that already resolved tenant ownership but
|
|
540
|
+
* may be running outside the application's ambient tenant context.
|
|
541
|
+
*/
|
|
542
|
+
async storeForTenant(tenantId, name, value, options = {}) {
|
|
543
|
+
return withTenant({ tenantId }, () => this.store(name, value, options));
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Retrieve a secret for the current tenant
|
|
547
|
+
*/
|
|
548
|
+
async retrieve(name) {
|
|
549
|
+
const tenantId = requireTenantId();
|
|
550
|
+
const userId = this.getCurrentUserId();
|
|
551
|
+
let audited = false;
|
|
552
|
+
try {
|
|
553
|
+
const secret = await this.secrets.findByName(tenantId, name);
|
|
554
|
+
if (!secret || secret.tenantId !== tenantId) {
|
|
555
|
+
await this.audit(null, name, userId, "read", "failure", {
|
|
556
|
+
error: "Secret not found"
|
|
557
|
+
});
|
|
558
|
+
audited = true;
|
|
559
|
+
throw new Error(`Secret '${name}' not found`);
|
|
560
|
+
}
|
|
561
|
+
if (!secret.isUsable()) {
|
|
562
|
+
const reason = secret.isExpired() ? "Secret expired" : "Secret disabled";
|
|
563
|
+
await this.audit(secret.id ?? null, name, userId, "read", "failure", {
|
|
564
|
+
error: reason
|
|
565
|
+
});
|
|
566
|
+
audited = true;
|
|
567
|
+
throw new Error(reason);
|
|
568
|
+
}
|
|
569
|
+
const envelope = JSON.parse(secret.encryptedValue);
|
|
570
|
+
const decrypted = await this.secretStore.decrypt(tenantId, envelope);
|
|
571
|
+
const previousLastAccessedAt = secret.lastAccessedAt;
|
|
572
|
+
const previousAccessCount = secret.accessCount;
|
|
573
|
+
try {
|
|
574
|
+
secret.recordAccess();
|
|
575
|
+
await secret.save();
|
|
576
|
+
} catch (trackingError) {
|
|
577
|
+
secret.lastAccessedAt = previousLastAccessedAt;
|
|
578
|
+
secret.accessCount = previousAccessCount;
|
|
579
|
+
logger.error("Failed to update secret access tracking", {
|
|
580
|
+
error: trackingError
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
await this.audit(secret.id ?? null, name, userId, "read", "success");
|
|
584
|
+
return {
|
|
585
|
+
value: decrypted.value,
|
|
586
|
+
name: secret.name,
|
|
587
|
+
description: secret.description,
|
|
588
|
+
category: secret.category,
|
|
589
|
+
expiresAt: secret.expiresAt,
|
|
590
|
+
createdAt: secret.created_at ?? /* @__PURE__ */ new Date(),
|
|
591
|
+
lastAccessedAt: secret.lastAccessedAt,
|
|
592
|
+
accessCount: secret.accessCount,
|
|
593
|
+
metadata: secret.metadata
|
|
594
|
+
};
|
|
595
|
+
} catch (error) {
|
|
596
|
+
const classifiedError = await this.classifyTenantKeyFailure(
|
|
597
|
+
tenantId,
|
|
598
|
+
name,
|
|
599
|
+
error
|
|
600
|
+
);
|
|
601
|
+
if (!audited) {
|
|
602
|
+
await this.audit(null, name, userId, "read", "failure", {
|
|
603
|
+
error: classifiedError.message
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
throw classifiedError;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Retrieve a secret for a specific tenant.
|
|
611
|
+
*/
|
|
612
|
+
async retrieveForTenant(tenantId, name) {
|
|
613
|
+
return withTenant({ tenantId }, () => this.retrieve(name));
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Diagnose tenant secret/key drift without exposing decrypted values.
|
|
617
|
+
*/
|
|
618
|
+
async diagnoseTenantSecretKeyDrift(tenantId, options = {}) {
|
|
619
|
+
const activeSecrets = await this.listActiveSecretRowsForDiagnosis(
|
|
620
|
+
tenantId,
|
|
621
|
+
options.secretNames
|
|
622
|
+
);
|
|
623
|
+
const tenantEncryptionKeys = await this.listTenantEncryptionKeyRows(tenantId);
|
|
624
|
+
const smrtTenantKeyRows = await this.listSmrtTenantKeysForDiagnosis(tenantId);
|
|
625
|
+
const smrtTenantKeys = smrtTenantKeyRows.keys;
|
|
626
|
+
const issues = [];
|
|
627
|
+
const activeTenantEncryptionKeys = tenantEncryptionKeys.filter(
|
|
628
|
+
(key) => key.status === "active"
|
|
629
|
+
);
|
|
630
|
+
const activeSmrtTenantKeys = smrtTenantKeys.filter(
|
|
631
|
+
(key) => key.status === "active"
|
|
632
|
+
);
|
|
633
|
+
const amk = this.getConfiguredAmkForDiagnosis();
|
|
634
|
+
if (!amk.usable) {
|
|
635
|
+
issues.push({
|
|
636
|
+
code: "amk_unavailable",
|
|
637
|
+
severity: "error",
|
|
638
|
+
message: amk.error ?? `AMK ${this.amkEnvVar} is unavailable`,
|
|
639
|
+
repairAction: "none",
|
|
640
|
+
details: {
|
|
641
|
+
amkEnvVar: this.amkEnvVar,
|
|
642
|
+
amkKeyId: this.amkKeyId
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (activeSecrets.length > 0 && activeTenantEncryptionKeys.length === 0) {
|
|
647
|
+
issues.push({
|
|
648
|
+
code: "missing_active_tenant_encryption_key",
|
|
649
|
+
severity: "error",
|
|
650
|
+
message: "Active tenant secrets exist, but tenant_encryption_keys has no active key for encryption.",
|
|
651
|
+
repairAction: "store-fresh-secret-value",
|
|
652
|
+
sourceTable: "tenant_encryption_keys",
|
|
653
|
+
details: {
|
|
654
|
+
activeSecretCount: activeSecrets.length
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (smrtTenantKeyRows.error) {
|
|
659
|
+
issues.push({
|
|
660
|
+
code: "smrt_tenant_keys_query_failed",
|
|
661
|
+
severity: "error",
|
|
662
|
+
message: "Unable to query SMRT tenant_keys while diagnosing tenant secret key drift.",
|
|
663
|
+
repairAction: "none",
|
|
664
|
+
sourceTable: "tenant_keys",
|
|
665
|
+
details: {
|
|
666
|
+
error: smrtTenantKeyRows.error.message
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (activeTenantEncryptionKeys.length > 1) {
|
|
671
|
+
issues.push({
|
|
672
|
+
code: "multiple_active_tenant_encryption_keys",
|
|
673
|
+
severity: "error",
|
|
674
|
+
message: "tenant_encryption_keys has multiple active keys for this tenant; encryption may use an arbitrary active key.",
|
|
675
|
+
repairAction: "none",
|
|
676
|
+
sourceTable: "tenant_encryption_keys",
|
|
677
|
+
details: {
|
|
678
|
+
activeTenantEncryptionKeyCount: activeTenantEncryptionKeys.length
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const activeKeyChecks = [];
|
|
683
|
+
for (const key of tenantEncryptionKeys) {
|
|
684
|
+
const check = amk.value ? this.checkWrappedKey(key.wrapped_key, amk.value) : { usable: false, error: amk.error };
|
|
685
|
+
if (key.status === "active") {
|
|
686
|
+
activeKeyChecks.push(check);
|
|
687
|
+
}
|
|
688
|
+
if (key.status === "active" && key.amk_key_id !== this.amkKeyId) {
|
|
689
|
+
issues.push({
|
|
690
|
+
code: "active_tenant_encryption_key_amk_mismatch",
|
|
691
|
+
severity: "warning",
|
|
692
|
+
message: "Active tenant_encryption_keys row was wrapped by a different AMK key id than this SecretService is configured to use.",
|
|
693
|
+
repairAction: "none",
|
|
694
|
+
keyId: key.id,
|
|
695
|
+
sourceTable: "tenant_encryption_keys",
|
|
696
|
+
details: {
|
|
697
|
+
rowAmkKeyId: key.amk_key_id,
|
|
698
|
+
configuredAmkKeyId: this.amkKeyId
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (key.status === "active" && !check.usable && amk.value) {
|
|
703
|
+
issues.push({
|
|
704
|
+
code: "active_tenant_encryption_key_unwrap_failed",
|
|
705
|
+
severity: "error",
|
|
706
|
+
message: "Active tenant_encryption_keys row cannot be unwrapped by the currently configured AMK.",
|
|
707
|
+
repairAction: "delete-unusable-tenant-encryption-key",
|
|
708
|
+
keyId: key.id,
|
|
709
|
+
sourceTable: "tenant_encryption_keys",
|
|
710
|
+
details: {
|
|
711
|
+
version: key.version,
|
|
712
|
+
error: check.error ?? null
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const usableActiveTenantEncryptionKeyCount = activeKeyChecks.filter(
|
|
718
|
+
(check) => check.usable
|
|
719
|
+
).length;
|
|
720
|
+
if (activeSecrets.length > 0 && usableActiveTenantEncryptionKeyCount === 0) {
|
|
721
|
+
issues.push({
|
|
722
|
+
code: "active_secrets_without_usable_active_key",
|
|
723
|
+
severity: "error",
|
|
724
|
+
message: "Active secrets exist, but no active tenant_encryption_keys row can be used with the current AMK.",
|
|
725
|
+
repairAction: "store-fresh-secret-value",
|
|
726
|
+
sourceTable: "tenant_encryption_keys",
|
|
727
|
+
details: {
|
|
728
|
+
activeSecretCount: activeSecrets.length,
|
|
729
|
+
activeTenantEncryptionKeyCount: activeTenantEncryptionKeys.length
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
const keyFingerprintToRow = /* @__PURE__ */ new Map();
|
|
734
|
+
for (const key of tenantEncryptionKeys) {
|
|
735
|
+
const fingerprint = this.getWrappedKeyFingerprint(key.wrapped_key);
|
|
736
|
+
if (fingerprint) {
|
|
737
|
+
keyFingerprintToRow.set(fingerprint, key);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
for (const secret of activeSecrets) {
|
|
741
|
+
const envelope = this.parseSecretEnvelopeForDiagnosis(secret, issues);
|
|
742
|
+
if (!envelope) continue;
|
|
743
|
+
const envelopeFingerprint = this.getWrappedKeyFingerprint(
|
|
744
|
+
envelope.wrappedKey
|
|
745
|
+
);
|
|
746
|
+
const envelopeCheck = amk.value ? this.checkWrappedKey(envelope.wrappedKey, amk.value) : {
|
|
747
|
+
usable: false,
|
|
748
|
+
error: amk.error,
|
|
749
|
+
fingerprint: envelopeFingerprint
|
|
750
|
+
};
|
|
751
|
+
if (!envelopeCheck.fingerprint) {
|
|
752
|
+
issues.push({
|
|
753
|
+
code: "secret_envelope_invalid_wrapped_key",
|
|
754
|
+
severity: "error",
|
|
755
|
+
message: "Secret encryptedValue contains an invalid wrapped key format.",
|
|
756
|
+
repairAction: "delete-unrecoverable-secret",
|
|
757
|
+
secretId: secret.id,
|
|
758
|
+
secretName: secret.name,
|
|
759
|
+
sourceTable: "secrets",
|
|
760
|
+
details: {
|
|
761
|
+
error: envelopeCheck.error ?? null
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const matchingKey = keyFingerprintToRow.get(envelopeCheck.fingerprint);
|
|
767
|
+
if (!matchingKey) {
|
|
768
|
+
issues.push({
|
|
769
|
+
code: "secret_envelope_missing_tenant_encryption_key",
|
|
770
|
+
severity: "error",
|
|
771
|
+
message: "Secret envelope does not match any tenant_encryption_keys row for this tenant.",
|
|
772
|
+
repairAction: !amk.value ? "none" : envelopeCheck.usable ? "none" : "delete-unrecoverable-secret",
|
|
773
|
+
secretId: secret.id,
|
|
774
|
+
secretName: secret.name,
|
|
775
|
+
sourceTable: "secrets"
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (!envelopeCheck.usable && amk.value) {
|
|
779
|
+
issues.push({
|
|
780
|
+
code: "secret_envelope_unwrap_failed",
|
|
781
|
+
severity: "error",
|
|
782
|
+
message: "Secret envelope cannot be unwrapped by the currently configured AMK.",
|
|
783
|
+
repairAction: "delete-unrecoverable-secret",
|
|
784
|
+
secretId: secret.id,
|
|
785
|
+
secretName: secret.name,
|
|
786
|
+
keyId: matchingKey?.id,
|
|
787
|
+
sourceTable: "secrets",
|
|
788
|
+
details: {
|
|
789
|
+
error: envelopeCheck.error ?? null
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (activeSecrets.length > 0 && tenantEncryptionKeys.length > 0 && smrtTenantKeys.length === 0 && !smrtTenantKeyRows.error) {
|
|
795
|
+
issues.push({
|
|
796
|
+
code: "smrt_tenant_keys_not_mirrored",
|
|
797
|
+
severity: "info",
|
|
798
|
+
message: "SMRT tenant_keys has no rows for this tenant, while the lower-level tenant_encryption_keys table does. SecretService uses tenant_encryption_keys for encryption.",
|
|
799
|
+
repairAction: "none",
|
|
800
|
+
sourceTable: "tenant_keys"
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
tenantId,
|
|
805
|
+
checkedAt: /* @__PURE__ */ new Date(),
|
|
806
|
+
ok: !issues.some((issue) => issue.severity === "error"),
|
|
807
|
+
summary: {
|
|
808
|
+
activeSecretCount: activeSecrets.length,
|
|
809
|
+
tenantEncryptionKeyCount: tenantEncryptionKeys.length,
|
|
810
|
+
activeTenantEncryptionKeyCount: activeTenantEncryptionKeys.length,
|
|
811
|
+
usableActiveTenantEncryptionKeyCount,
|
|
812
|
+
smrtTenantKeyCount: smrtTenantKeys.length,
|
|
813
|
+
activeSmrtTenantKeyCount: activeSmrtTenantKeys.length
|
|
814
|
+
},
|
|
815
|
+
issues
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Diagnose drift for the current tenant context.
|
|
820
|
+
*/
|
|
821
|
+
async diagnoseCurrentTenantSecretKeyDrift(options = {}) {
|
|
822
|
+
return this.diagnoseTenantSecretKeyDrift(requireTenantId(), options);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Delete unrecoverable secret/key rows identified by diagnosis.
|
|
826
|
+
*
|
|
827
|
+
* This never attempts to recover or expose secret values. Use dryRun first
|
|
828
|
+
* to preview destructive changes.
|
|
829
|
+
*/
|
|
830
|
+
async repairTenantSecretKeyDrift(tenantId, options = {}) {
|
|
831
|
+
const dryRun = options.dryRun ?? false;
|
|
832
|
+
const before = await this.diagnoseTenantSecretKeyDrift(tenantId, options);
|
|
833
|
+
const secretIds = /* @__PURE__ */ new Set();
|
|
834
|
+
const secretNames = /* @__PURE__ */ new Map();
|
|
835
|
+
const tenantEncryptionKeyIds = /* @__PURE__ */ new Set();
|
|
836
|
+
for (const issue of before.issues) {
|
|
837
|
+
if (issue.repairAction === "delete-unrecoverable-secret" && issue.secretId) {
|
|
838
|
+
secretIds.add(issue.secretId);
|
|
839
|
+
if (issue.secretName) {
|
|
840
|
+
secretNames.set(issue.secretId, issue.secretName);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (issue.repairAction === "delete-unusable-tenant-encryption-key" && issue.keyId) {
|
|
844
|
+
tenantEncryptionKeyIds.add(issue.keyId);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const wouldDeleteSecrets = secretIds.size;
|
|
848
|
+
const wouldDeleteTenantEncryptionKeys = tenantEncryptionKeyIds.size;
|
|
849
|
+
const wouldDeleteUnrecoverableData = wouldDeleteSecrets + wouldDeleteTenantEncryptionKeys > 0;
|
|
850
|
+
if (!dryRun && wouldDeleteUnrecoverableData && !options.confirmDeleteUnrecoverableData) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
"repairTenantSecretKeyDrift requires confirmDeleteUnrecoverableData: true before deleting encrypted secrets or tenant key rows."
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
let deletedSecrets = 0;
|
|
856
|
+
let deletedTenantEncryptionKeys = 0;
|
|
857
|
+
if (!dryRun) {
|
|
858
|
+
const runDeletes = async (db) => {
|
|
859
|
+
const deletedSecretRows = await this.deleteRowsByIds(
|
|
860
|
+
"secrets",
|
|
861
|
+
tenantId,
|
|
862
|
+
secretIds,
|
|
863
|
+
db
|
|
864
|
+
);
|
|
865
|
+
const deletedTenantEncryptionKeyRows = await this.deleteRowsByIds(
|
|
866
|
+
"tenant_encryption_keys",
|
|
867
|
+
tenantId,
|
|
868
|
+
tenantEncryptionKeyIds,
|
|
869
|
+
db
|
|
870
|
+
);
|
|
871
|
+
return {
|
|
872
|
+
deletedSecrets: deletedSecretRows,
|
|
873
|
+
deletedTenantEncryptionKeys: deletedTenantEncryptionKeyRows
|
|
874
|
+
};
|
|
875
|
+
};
|
|
876
|
+
const txDb = this.db;
|
|
877
|
+
const deleteResult = typeof txDb.transaction === "function" ? await txDb.transaction((db) => runDeletes(db)) : await runDeletes(this.db);
|
|
878
|
+
deletedSecrets = deleteResult.deletedSecrets;
|
|
879
|
+
deletedTenantEncryptionKeys = deleteResult.deletedTenantEncryptionKeys;
|
|
880
|
+
await this.auditSecretDriftRepairDeletes(
|
|
881
|
+
tenantId,
|
|
882
|
+
secretIds,
|
|
883
|
+
secretNames
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
const after = dryRun ? before : await this.diagnoseTenantSecretKeyDrift(tenantId, options);
|
|
887
|
+
return {
|
|
888
|
+
tenantId,
|
|
889
|
+
dryRun,
|
|
890
|
+
issuesBefore: before.issues,
|
|
891
|
+
remainingIssues: after.issues,
|
|
892
|
+
wouldDeleteSecrets,
|
|
893
|
+
wouldDeleteTenantEncryptionKeys,
|
|
894
|
+
deletedSecrets,
|
|
895
|
+
deletedTenantEncryptionKeys,
|
|
896
|
+
secretNames: Array.from(secretNames.values()).sort(),
|
|
897
|
+
tenantEncryptionKeyIds: Array.from(tenantEncryptionKeyIds).sort()
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* List secrets for the current tenant (names only, not values)
|
|
902
|
+
*/
|
|
903
|
+
async list(options = {}) {
|
|
904
|
+
return this.secrets.listSecrets(requireTenantId(), {
|
|
905
|
+
category: options.category,
|
|
906
|
+
status: "active"
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Delete a secret
|
|
911
|
+
*/
|
|
912
|
+
async delete(name) {
|
|
913
|
+
const tenantId = requireTenantId();
|
|
914
|
+
const userId = this.getCurrentUserId();
|
|
915
|
+
const secret = await this.secrets.findByName(tenantId, name);
|
|
916
|
+
if (!secret || secret.tenantId !== tenantId) {
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
await secret.delete();
|
|
921
|
+
await this.audit(secret.id ?? null, name, userId, "delete", "success");
|
|
922
|
+
return true;
|
|
923
|
+
} catch (error) {
|
|
924
|
+
await this.audit(secret.id ?? null, name, userId, "delete", "failure", {
|
|
925
|
+
error: error.message
|
|
926
|
+
});
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Disable a secret (soft delete)
|
|
932
|
+
*/
|
|
933
|
+
async disable(name) {
|
|
934
|
+
const tenantId = requireTenantId();
|
|
935
|
+
const userId = this.getCurrentUserId();
|
|
936
|
+
const secret = await this.secrets.findByName(tenantId, name);
|
|
937
|
+
if (!secret || secret.tenantId !== tenantId) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
secret.disable();
|
|
941
|
+
await secret.save();
|
|
942
|
+
await this.audit(secret.id ?? null, name, userId, "disable", "success");
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Enable a disabled secret
|
|
947
|
+
*/
|
|
948
|
+
async enable(name) {
|
|
949
|
+
const tenantId = requireTenantId();
|
|
950
|
+
const userId = this.getCurrentUserId();
|
|
951
|
+
const secret = await this.secrets.findByName(tenantId, name);
|
|
952
|
+
if (!secret || secret.tenantId !== tenantId) {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
secret.enable();
|
|
956
|
+
await secret.save();
|
|
957
|
+
await this.audit(secret.id ?? null, name, userId, "enable", "success");
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Rotate the tenant's encryption key
|
|
962
|
+
*
|
|
963
|
+
* This creates a new TDEK and marks the old one as retired.
|
|
964
|
+
* Existing secrets remain encrypted with the old key and can still
|
|
965
|
+
* be decrypted (the old key is kept in retired state).
|
|
966
|
+
*
|
|
967
|
+
* For full re-encryption, call reencryptAll() after rotation.
|
|
968
|
+
*/
|
|
969
|
+
async rotateKey() {
|
|
970
|
+
const tenantId = requireTenantId();
|
|
971
|
+
const userId = this.getCurrentUserId();
|
|
972
|
+
try {
|
|
973
|
+
await this.secretStore.rotateTenantKey(tenantId);
|
|
974
|
+
await this.audit(null, "", userId, "rotate_key", "success", {
|
|
975
|
+
tenantId
|
|
976
|
+
});
|
|
977
|
+
} catch (error) {
|
|
978
|
+
await this.audit(null, "", userId, "rotate_key", "failure", {
|
|
979
|
+
tenantId,
|
|
980
|
+
error: error.message
|
|
981
|
+
});
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Re-encrypt all secrets with the current active key
|
|
987
|
+
*
|
|
988
|
+
* Call this after key rotation to ensure all secrets use the new key.
|
|
989
|
+
* This is optional but recommended for security.
|
|
990
|
+
*/
|
|
991
|
+
async reencryptAll() {
|
|
992
|
+
const tenantId = requireTenantId();
|
|
993
|
+
const userId = this.getCurrentUserId();
|
|
994
|
+
const secrets = await this.secrets.list({ where: { tenantId } });
|
|
995
|
+
let success = 0;
|
|
996
|
+
let failed = 0;
|
|
997
|
+
for (const secret of secrets) {
|
|
998
|
+
try {
|
|
999
|
+
const envelope = JSON.parse(secret.encryptedValue);
|
|
1000
|
+
const decrypted = await this.secretStore.decrypt(tenantId, envelope);
|
|
1001
|
+
const newEnvelope = await this.secretStore.encrypt(
|
|
1002
|
+
tenantId,
|
|
1003
|
+
secret.name,
|
|
1004
|
+
decrypted.value
|
|
1005
|
+
);
|
|
1006
|
+
secret.encryptedValue = JSON.stringify(newEnvelope);
|
|
1007
|
+
await secret.save();
|
|
1008
|
+
success++;
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
failed++;
|
|
1011
|
+
await this.audit(
|
|
1012
|
+
secret.id ?? null,
|
|
1013
|
+
secret.name,
|
|
1014
|
+
userId,
|
|
1015
|
+
"update",
|
|
1016
|
+
"failure",
|
|
1017
|
+
{
|
|
1018
|
+
action: "reencrypt",
|
|
1019
|
+
error: error.message
|
|
1020
|
+
}
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return { success, failed };
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Get audit logs for the current tenant
|
|
1028
|
+
*/
|
|
1029
|
+
async getAuditLogs(options = {}) {
|
|
1030
|
+
return this.auditLogs.listLogs({
|
|
1031
|
+
// Scope to the current tenant (issue #1501): audit logs reference
|
|
1032
|
+
// secret names and must not leak across tenants.
|
|
1033
|
+
tenantId: requireTenantId(),
|
|
1034
|
+
secretName: options.secretName,
|
|
1035
|
+
limit: options.limit ?? 100
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Get secret categories for the current tenant
|
|
1040
|
+
*/
|
|
1041
|
+
async getCategories() {
|
|
1042
|
+
return this.secrets.getCategories(requireTenantId());
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Check if a secret exists for the current tenant
|
|
1046
|
+
*/
|
|
1047
|
+
async exists(name) {
|
|
1048
|
+
const tenantId = requireTenantId();
|
|
1049
|
+
const secret = await this.secrets.findByName(tenantId, name);
|
|
1050
|
+
return secret !== null && secret.tenantId === tenantId;
|
|
1051
|
+
}
|
|
1052
|
+
// Private methods
|
|
1053
|
+
async listActiveSecretRowsForDiagnosis(tenantId, secretNames) {
|
|
1054
|
+
const params = [tenantId];
|
|
1055
|
+
let nameFilter = "";
|
|
1056
|
+
if (secretNames && secretNames.length > 0) {
|
|
1057
|
+
const placeholders = secretNames.map(() => "?").join(", ");
|
|
1058
|
+
nameFilter = ` AND name IN (${placeholders})`;
|
|
1059
|
+
params.push(...secretNames);
|
|
1060
|
+
}
|
|
1061
|
+
const result = await this.db.query(
|
|
1062
|
+
`
|
|
1063
|
+
SELECT id, name, encrypted_value, status, tenant_id
|
|
1064
|
+
FROM "secrets"
|
|
1065
|
+
WHERE tenant_id = ? AND status = 'active'${nameFilter}
|
|
1066
|
+
ORDER BY name ASC
|
|
1067
|
+
`,
|
|
1068
|
+
...params
|
|
1069
|
+
);
|
|
1070
|
+
return this.rowsFromResult(result);
|
|
1071
|
+
}
|
|
1072
|
+
async listTenantEncryptionKeyRows(tenantId) {
|
|
1073
|
+
const result = await this.db.query(
|
|
1074
|
+
`
|
|
1075
|
+
SELECT id, tenant_id, wrapped_key, amk_key_id, status, version,
|
|
1076
|
+
rotate_after, retired_at, created_at, updated_at
|
|
1077
|
+
FROM "tenant_encryption_keys"
|
|
1078
|
+
WHERE tenant_id = ?
|
|
1079
|
+
ORDER BY version DESC
|
|
1080
|
+
`,
|
|
1081
|
+
tenantId
|
|
1082
|
+
);
|
|
1083
|
+
return this.rowsFromResult(result);
|
|
1084
|
+
}
|
|
1085
|
+
async listSmrtTenantKeysForDiagnosis(tenantId) {
|
|
1086
|
+
try {
|
|
1087
|
+
return { keys: await this.tenantKeys.listKeyVersions(tenantId) };
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
return { keys: [], error: this.toError(error) };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
getConfiguredAmkForDiagnosis() {
|
|
1093
|
+
const keyHex = process.env[this.amkEnvVar];
|
|
1094
|
+
if (!keyHex) {
|
|
1095
|
+
return {
|
|
1096
|
+
usable: false,
|
|
1097
|
+
error: `Application Master Key not found in environment variable: ${this.amkEnvVar}`
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
return {
|
|
1102
|
+
usable: true,
|
|
1103
|
+
value: EnvelopeEncryption.parseHexKey(keyHex)
|
|
1104
|
+
};
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
return {
|
|
1107
|
+
usable: false,
|
|
1108
|
+
error: `Invalid AMK in ${this.amkEnvVar}: ${this.toError(error).message}`
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
checkWrappedKey(wrappedKey, amk) {
|
|
1113
|
+
const fingerprint = this.getWrappedKeyFingerprint(wrappedKey);
|
|
1114
|
+
try {
|
|
1115
|
+
const parsed = EnvelopeEncryption.parseWrappedKey(wrappedKey);
|
|
1116
|
+
const dataKey = EnvelopeEncryption.unwrapKey(
|
|
1117
|
+
parsed.wrappedKey,
|
|
1118
|
+
parsed.iv,
|
|
1119
|
+
parsed.authTag,
|
|
1120
|
+
amk
|
|
1121
|
+
);
|
|
1122
|
+
dataKey.fill(0);
|
|
1123
|
+
return { usable: true, fingerprint };
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
return {
|
|
1126
|
+
usable: false,
|
|
1127
|
+
fingerprint,
|
|
1128
|
+
error: this.toError(error).message
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
getWrappedKeyFingerprint(wrappedKey) {
|
|
1133
|
+
try {
|
|
1134
|
+
return EnvelopeEncryption.parseWrappedKey(wrappedKey).wrappedKey;
|
|
1135
|
+
} catch {
|
|
1136
|
+
return void 0;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
parseSecretEnvelopeForDiagnosis(secret, issues) {
|
|
1140
|
+
try {
|
|
1141
|
+
return JSON.parse(secret.encrypted_value);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
issues.push({
|
|
1144
|
+
code: "secret_envelope_invalid_json",
|
|
1145
|
+
severity: "error",
|
|
1146
|
+
message: "Secret encryptedValue is not valid EncryptedEnvelope JSON.",
|
|
1147
|
+
repairAction: "delete-unrecoverable-secret",
|
|
1148
|
+
secretId: secret.id,
|
|
1149
|
+
secretName: secret.name,
|
|
1150
|
+
sourceTable: "secrets",
|
|
1151
|
+
details: {
|
|
1152
|
+
error: this.toError(error).message
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
async deleteRowsByIds(tableName, tenantId, ids, db = this.db) {
|
|
1159
|
+
if (ids.size === 0) return 0;
|
|
1160
|
+
const idList = Array.from(ids);
|
|
1161
|
+
const placeholders = idList.map(() => "?").join(", ");
|
|
1162
|
+
const result = await db.query(
|
|
1163
|
+
`DELETE FROM "${tableName}" WHERE tenant_id = ? AND id IN (${placeholders})`,
|
|
1164
|
+
tenantId,
|
|
1165
|
+
...idList
|
|
1166
|
+
);
|
|
1167
|
+
return typeof result.rowCount === "number" ? result.rowCount : idList.length;
|
|
1168
|
+
}
|
|
1169
|
+
async auditSecretDriftRepairDeletes(tenantId, secretIds, secretNames) {
|
|
1170
|
+
if (secretIds.size === 0) return;
|
|
1171
|
+
const userId = this.getCurrentUserId();
|
|
1172
|
+
await withTenant({ tenantId }, async () => {
|
|
1173
|
+
for (const secretId of secretIds) {
|
|
1174
|
+
await this.audit(
|
|
1175
|
+
secretId,
|
|
1176
|
+
secretNames.get(secretId) ?? "",
|
|
1177
|
+
userId,
|
|
1178
|
+
"delete",
|
|
1179
|
+
"success",
|
|
1180
|
+
{
|
|
1181
|
+
action: "repairTenantSecretKeyDrift",
|
|
1182
|
+
reason: "unrecoverable-secret-key-drift"
|
|
1183
|
+
}
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
rowsFromResult(result) {
|
|
1189
|
+
if (Array.isArray(result)) return result;
|
|
1190
|
+
if (result && typeof result === "object" && Array.isArray(result.rows)) {
|
|
1191
|
+
return result.rows;
|
|
1192
|
+
}
|
|
1193
|
+
return [];
|
|
1194
|
+
}
|
|
1195
|
+
async classifyTenantKeyFailure(tenantId, secretName, error) {
|
|
1196
|
+
const normalized = this.toError(error);
|
|
1197
|
+
if (!this.shouldClassifyTenantKeyFailure(normalized)) {
|
|
1198
|
+
return normalized;
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
const report = await this.diagnoseTenantSecretKeyDrift(tenantId, {
|
|
1202
|
+
secretNames: [secretName]
|
|
1203
|
+
});
|
|
1204
|
+
const errorCodes = report.issues.filter((issue) => issue.severity === "error").map((issue) => issue.code);
|
|
1205
|
+
if (errorCodes.length === 0) {
|
|
1206
|
+
return normalized;
|
|
1207
|
+
}
|
|
1208
|
+
return new SecretKeyDriftError(
|
|
1209
|
+
`Secret '${secretName}' for tenant '${tenantId}' failed because secret key drift was detected: ${[
|
|
1210
|
+
...new Set(errorCodes)
|
|
1211
|
+
].join(
|
|
1212
|
+
", "
|
|
1213
|
+
)}. Run diagnoseTenantSecretKeyDrift() for details and repairTenantSecretKeyDrift() for explicit cleanup of unrecoverable rows.`,
|
|
1214
|
+
tenantId,
|
|
1215
|
+
report,
|
|
1216
|
+
normalized
|
|
1217
|
+
);
|
|
1218
|
+
} catch {
|
|
1219
|
+
return normalized;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
shouldClassifyTenantKeyFailure(error) {
|
|
1223
|
+
const code = this.getSecretErrorCode(error);
|
|
1224
|
+
if (error instanceof AMKUnavailableError || code === "AMK_UNAVAILABLE") {
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
return error instanceof TenantKeyMissingError || error instanceof EncryptionError || error instanceof DecryptionError || code === "TENANT_KEY_MISSING" || code === "ENCRYPTION_FAILED" || code === "DECRYPTION_FAILED";
|
|
1228
|
+
}
|
|
1229
|
+
getSecretErrorCode(error) {
|
|
1230
|
+
const code = error.code;
|
|
1231
|
+
return typeof code === "string" ? code : void 0;
|
|
1232
|
+
}
|
|
1233
|
+
toError(error) {
|
|
1234
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1235
|
+
}
|
|
1236
|
+
getCurrentUserId() {
|
|
1237
|
+
const ctx = getCurrentTenant();
|
|
1238
|
+
return ctx?.userId ?? "system";
|
|
1239
|
+
}
|
|
1240
|
+
async audit(secretId, secretName, userId, action, result, details) {
|
|
1241
|
+
if (!this.auditEnabled) return;
|
|
1242
|
+
try {
|
|
1243
|
+
const tenantId = getCurrentTenant()?.tenantId ?? null;
|
|
1244
|
+
const log = await this.auditLogs.create(
|
|
1245
|
+
createAuditEntry({
|
|
1246
|
+
secretId,
|
|
1247
|
+
secretName,
|
|
1248
|
+
userId,
|
|
1249
|
+
action,
|
|
1250
|
+
result,
|
|
1251
|
+
details,
|
|
1252
|
+
tenantId
|
|
1253
|
+
})
|
|
1254
|
+
);
|
|
1255
|
+
await log.save();
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
logger.error("Failed to write audit log", { error });
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
serializeMetadata(metadata) {
|
|
1261
|
+
const result = {};
|
|
1262
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
1263
|
+
result[key] = typeof value === "string" ? value : JSON.stringify(value);
|
|
1264
|
+
}
|
|
1265
|
+
return result;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
export {
|
|
1269
|
+
SecretAuditLogCollection as S,
|
|
1270
|
+
TenantKeyCollection as T,
|
|
1271
|
+
SecretCollection as a,
|
|
1272
|
+
SecretKeyDriftError as b,
|
|
1273
|
+
SecretService as c
|
|
1274
|
+
};
|
|
1275
|
+
//# sourceMappingURL=SecretService-C91H6WJK.js.map
|