@happyvertical/smrt-secrets 0.37.2 → 0.37.3

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