@kibibit/configit 2.12.2 → 2.13.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/lib/config.service.d.ts +2 -1
- package/lib/config.service.d.ts.map +1 -1
- package/lib/config.service.js +5 -0
- package/lib/config.service.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/vault/__tests__/vault-integration.test.d.ts.map +1 -1
- package/lib/vault/__tests__/vault-integration.test.js +124 -0
- package/lib/vault/__tests__/vault-integration.test.js.map +1 -1
- package/lib/vault/build-vault-config.d.ts +8 -0
- package/lib/vault/build-vault-config.d.ts.map +1 -0
- package/lib/vault/build-vault-config.js +41 -0
- package/lib/vault/build-vault-config.js.map +1 -0
- package/lib/vault/index.d.ts +2 -0
- package/lib/vault/index.d.ts.map +1 -1
- package/lib/vault/index.js +3 -1
- package/lib/vault/index.js.map +1 -1
- package/lib/vault/secret-refresh-manager.d.ts +6 -2
- package/lib/vault/secret-refresh-manager.d.ts.map +1 -1
- package/lib/vault/secret-refresh-manager.js +91 -27
- package/lib/vault/secret-refresh-manager.js.map +1 -1
- package/lib/vault/types.d.ts +9 -0
- package/lib/vault/types.d.ts.map +1 -1
- package/lib/vault/vault-integration.d.ts +2 -1
- package/lib/vault/vault-integration.d.ts.map +1 -1
- package/lib/vault/vault-integration.js +6 -0
- package/lib/vault/vault-integration.js.map +1 -1
- package/package.json +1 -1
- package/src/config.service.ts +21 -1
- package/src/config.service.vault.spec.ts +47 -1
- package/src/vault/__tests__/build-vault-config.spec.ts +116 -0
- package/src/vault/__tests__/secret-refresh-manager.spec.ts +328 -0
- package/src/vault/__tests__/vault-integration-callback.spec.ts +73 -0
- package/src/vault/__tests__/vault-integration.test.ts +174 -2
- package/src/vault/build-vault-config.ts +73 -0
- package/src/vault/index.ts +4 -0
- package/src/vault/secret-refresh-manager.ts +123 -71
- package/src/vault/types.ts +38 -0
- package/src/vault/vault-integration.ts +14 -1
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { IsString } from 'class-validator';
|
|
11
11
|
|
|
12
|
-
import { VaultKey, VaultPath } from '../decorators';
|
|
13
|
-
import {
|
|
12
|
+
import { VaultEngine, VaultKey, VaultPath } from '../decorators';
|
|
13
|
+
import { buildVaultConfigFromEnv } from '../build-vault-config';
|
|
14
|
+
import { IVaultConfigOptions, SecretRefreshEvent } from '../types';
|
|
14
15
|
import { VaultIntegration } from '../vault-integration';
|
|
15
16
|
|
|
16
17
|
import 'reflect-metadata';
|
|
@@ -176,6 +177,177 @@ describeVault('VaultIntegration (requires running Vault)', () => {
|
|
|
176
177
|
.toThrow(/authentication.*failed/i);
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('onSecretRefreshed callback', () => {
|
|
182
|
+
it('should fire callback with correct event when configured via constructor', async () => {
|
|
183
|
+
const events: SecretRefreshEvent[] = [];
|
|
184
|
+
const configWithCallback: IVaultConfigOptions = {
|
|
185
|
+
...VAULT_CONFIG,
|
|
186
|
+
onSecretRefreshed: (event) => { events.push(event); }
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
vaultIntegration = new VaultIntegration(configWithCallback);
|
|
190
|
+
await vaultIntegration.initialize();
|
|
191
|
+
await vaultIntegration.loadSecrets(TestVaultConfig);
|
|
192
|
+
|
|
193
|
+
expect(events).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should fire callback via runtime registration', async () => {
|
|
197
|
+
const events: SecretRefreshEvent[] = [];
|
|
198
|
+
|
|
199
|
+
vaultIntegration = new VaultIntegration(VAULT_CONFIG);
|
|
200
|
+
vaultIntegration.onSecretRefreshed((event) => { events.push(event); });
|
|
201
|
+
await vaultIntegration.initialize();
|
|
202
|
+
await vaultIntegration.loadSecrets(TestVaultConfig);
|
|
203
|
+
|
|
204
|
+
expect(events).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('dynamic secrets with TTL-based refresh', () => {
|
|
209
|
+
/**
|
|
210
|
+
* Config class using the database engine with dynamic credentials.
|
|
211
|
+
* database/creds/configit-readonly has a 60s TTL in the test Vault setup.
|
|
212
|
+
*/
|
|
213
|
+
class DynamicConfig {
|
|
214
|
+
@VaultPath('creds/configit-readonly')
|
|
215
|
+
@VaultKey('username')
|
|
216
|
+
@VaultEngine('database')
|
|
217
|
+
@IsString()
|
|
218
|
+
DB_USERNAME!: string;
|
|
219
|
+
|
|
220
|
+
@VaultPath('creds/configit-readonly')
|
|
221
|
+
@VaultKey('password')
|
|
222
|
+
@VaultEngine('database')
|
|
223
|
+
@IsString()
|
|
224
|
+
DB_PASSWORD!: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('should load dynamic database credentials', async () => {
|
|
228
|
+
vaultIntegration = new VaultIntegration({
|
|
229
|
+
...VAULT_CONFIG,
|
|
230
|
+
refreshBuffer: 30
|
|
231
|
+
});
|
|
232
|
+
await vaultIntegration.initialize();
|
|
233
|
+
await vaultIntegration.loadSecrets(DynamicConfig);
|
|
234
|
+
|
|
235
|
+
const username = vaultIntegration.getSecret('DB_USERNAME');
|
|
236
|
+
const password = vaultIntegration.getSecret('DB_PASSWORD');
|
|
237
|
+
|
|
238
|
+
expect(username).toBeDefined();
|
|
239
|
+
expect(username).toMatch(/^v-token-/);
|
|
240
|
+
expect(password).toBeDefined();
|
|
241
|
+
expect(password!.length).toBeGreaterThan(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should schedule refresh for dynamic secrets', async () => {
|
|
245
|
+
vaultIntegration = new VaultIntegration({
|
|
246
|
+
...VAULT_CONFIG,
|
|
247
|
+
refreshBuffer: 30
|
|
248
|
+
});
|
|
249
|
+
await vaultIntegration.initialize();
|
|
250
|
+
await vaultIntegration.loadSecrets(DynamicConfig);
|
|
251
|
+
|
|
252
|
+
const health = vaultIntegration.getHealthDetails();
|
|
253
|
+
expect(health.refreshQueueSize).toBeGreaterThanOrEqual(1);
|
|
254
|
+
|
|
255
|
+
const dbUserStatus = health.refreshStatus.find((s) => s.propertyName === 'DB_USERNAME');
|
|
256
|
+
expect(dbUserStatus).toBeDefined();
|
|
257
|
+
expect(dbUserStatus!.scheduled).toBe(true);
|
|
258
|
+
expect(dbUserStatus!.timeUntilRefresh).toBeLessThanOrEqual(60000);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should perform path-level atomic refresh and fire callback', async () => {
|
|
262
|
+
const events: SecretRefreshEvent[] = [];
|
|
263
|
+
|
|
264
|
+
vaultIntegration = new VaultIntegration({
|
|
265
|
+
...VAULT_CONFIG,
|
|
266
|
+
refreshBuffer: 55, // 60s TTL minus 55s buffer = refresh after ~5s
|
|
267
|
+
onSecretRefreshed: (event) => { events.push(event); }
|
|
268
|
+
});
|
|
269
|
+
await vaultIntegration.initialize();
|
|
270
|
+
await vaultIntegration.loadSecrets(DynamicConfig);
|
|
271
|
+
|
|
272
|
+
const initialUser = vaultIntegration.getSecret('DB_USERNAME');
|
|
273
|
+
const initialPass = vaultIntegration.getSecret('DB_PASSWORD');
|
|
274
|
+
|
|
275
|
+
// Wait for the refresh to trigger (~5s + some margin)
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
277
|
+
|
|
278
|
+
const refreshedUser = vaultIntegration.getSecret('DB_USERNAME');
|
|
279
|
+
const refreshedPass = vaultIntegration.getSecret('DB_PASSWORD');
|
|
280
|
+
|
|
281
|
+
// Credentials should have changed
|
|
282
|
+
expect(refreshedUser).not.toBe(initialUser);
|
|
283
|
+
expect(refreshedPass).not.toBe(initialPass);
|
|
284
|
+
|
|
285
|
+
// Exactly one callback event for the path
|
|
286
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
287
|
+
const dbEvent = events.find((e) => e.engine === 'database');
|
|
288
|
+
expect(dbEvent).toBeDefined();
|
|
289
|
+
expect(dbEvent!.properties).toContain('DB_USERNAME');
|
|
290
|
+
expect(dbEvent!.properties).toContain('DB_PASSWORD');
|
|
291
|
+
expect(dbEvent!.vaultPath).toContain('configit-readonly');
|
|
292
|
+
expect(dbEvent!.refreshCount).toBeGreaterThanOrEqual(1);
|
|
293
|
+
}, 20000);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('buildVaultConfigFromEnv end-to-end', () => {
|
|
297
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
298
|
+
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
savedEnv.VAULT_ADDR = process.env.VAULT_ADDR;
|
|
301
|
+
savedEnv.VAULT_TOKEN = process.env.VAULT_TOKEN;
|
|
302
|
+
savedEnv.VAULT_GCP_ROLE = process.env.VAULT_GCP_ROLE;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
afterEach(() => {
|
|
306
|
+
for (const [key, val] of Object.entries(savedEnv)) {
|
|
307
|
+
if (val !== undefined) {
|
|
308
|
+
process.env[key] = val;
|
|
309
|
+
} else {
|
|
310
|
+
delete process.env[key];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should produce a config that VaultIntegration can use', async () => {
|
|
316
|
+
process.env.VAULT_ADDR = 'http://127.0.0.1:8200';
|
|
317
|
+
process.env.VAULT_TOKEN = 'configit-dev-token';
|
|
318
|
+
delete process.env.VAULT_GCP_ROLE;
|
|
319
|
+
|
|
320
|
+
const config = buildVaultConfigFromEnv();
|
|
321
|
+
expect(config).toBeDefined();
|
|
322
|
+
|
|
323
|
+
vaultIntegration = new VaultIntegration(config!);
|
|
324
|
+
await vaultIntegration.initialize();
|
|
325
|
+
expect(vaultIntegration.isInitialized()).toBe(true);
|
|
326
|
+
|
|
327
|
+
await vaultIntegration.loadSecrets(TestVaultConfig);
|
|
328
|
+
expect(vaultIntegration.getSecret('API_KEY')).toBe('test-api-key-123');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should wire onSecretRefreshed callback through to VaultIntegration', async () => {
|
|
332
|
+
process.env.VAULT_ADDR = 'http://127.0.0.1:8200';
|
|
333
|
+
process.env.VAULT_TOKEN = 'configit-dev-token';
|
|
334
|
+
delete process.env.VAULT_GCP_ROLE;
|
|
335
|
+
|
|
336
|
+
const events: SecretRefreshEvent[] = [];
|
|
337
|
+
const config = buildVaultConfigFromEnv({
|
|
338
|
+
onSecretRefreshed: (event) => { events.push(event); }
|
|
339
|
+
});
|
|
340
|
+
expect(config).toBeDefined();
|
|
341
|
+
expect(config!.onSecretRefreshed).toBeDefined();
|
|
342
|
+
|
|
343
|
+
vaultIntegration = new VaultIntegration(config!);
|
|
344
|
+
await vaultIntegration.initialize();
|
|
345
|
+
await vaultIntegration.loadSecrets(TestVaultConfig);
|
|
346
|
+
|
|
347
|
+
// No events yet (KV secrets have no TTL-based refresh in this setup)
|
|
348
|
+
expect(events).toHaveLength(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
179
351
|
});
|
|
180
352
|
|
|
181
353
|
/**
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildVaultConfigFromEnv
|
|
3
|
+
*
|
|
4
|
+
* Builds IVaultConfigOptions from standard environment variables.
|
|
5
|
+
* Returns undefined when Vault is not configured, causing configit
|
|
6
|
+
* to fall back to env vars / config files.
|
|
7
|
+
*
|
|
8
|
+
* Supported auth flows (checked in order):
|
|
9
|
+
* 1. VAULT_TOKEN → Token auth (local development)
|
|
10
|
+
* 2. VAULT_GCP_ROLE → GCP IAM auth (production / GKE)
|
|
11
|
+
*
|
|
12
|
+
* Common env vars:
|
|
13
|
+
* VAULT_ADDR → Vault server URL (required)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { IVaultConfigOptions, IVaultFallbackConfig, SecretRefreshCallback } from './types';
|
|
17
|
+
|
|
18
|
+
export interface IBuildVaultConfigOptions {
|
|
19
|
+
/** Seconds before expiry to trigger refresh. Default: 10 (token) / 60 (GCP) */
|
|
20
|
+
refreshBuffer?: number;
|
|
21
|
+
|
|
22
|
+
/** Fallback behavior when Vault is unavailable */
|
|
23
|
+
fallback?: IVaultFallbackConfig;
|
|
24
|
+
|
|
25
|
+
/** Callback fired when secrets are refreshed */
|
|
26
|
+
onSecretRefreshed?: SecretRefreshCallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_FALLBACK: IVaultFallbackConfig = {
|
|
30
|
+
required: false,
|
|
31
|
+
useCacheOnFailure: true,
|
|
32
|
+
maxCacheAge: 3600000,
|
|
33
|
+
failFast: false
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function buildVaultConfigFromEnv(
|
|
37
|
+
options?: IBuildVaultConfigOptions
|
|
38
|
+
): IVaultConfigOptions | undefined {
|
|
39
|
+
const vaultAddr = process.env.VAULT_ADDR;
|
|
40
|
+
const vaultToken = process.env.VAULT_TOKEN;
|
|
41
|
+
const vaultRole = process.env.VAULT_GCP_ROLE;
|
|
42
|
+
|
|
43
|
+
if (!vaultAddr) {
|
|
44
|
+
// eslint-disable-next-line no-undefined
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fallback = options?.fallback ?? DEFAULT_FALLBACK;
|
|
49
|
+
const onSecretRefreshed = options?.onSecretRefreshed;
|
|
50
|
+
|
|
51
|
+
if (vaultToken) {
|
|
52
|
+
return {
|
|
53
|
+
endpoint: vaultAddr,
|
|
54
|
+
auth: { method: 'token' as const, token: vaultToken },
|
|
55
|
+
refreshBuffer: options?.refreshBuffer ?? 10,
|
|
56
|
+
fallback,
|
|
57
|
+
onSecretRefreshed
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (vaultRole) {
|
|
62
|
+
return {
|
|
63
|
+
endpoint: vaultAddr,
|
|
64
|
+
auth: { method: 'gcp' as const, role: vaultRole },
|
|
65
|
+
refreshBuffer: options?.refreshBuffer ?? 60,
|
|
66
|
+
fallback,
|
|
67
|
+
onSecretRefreshed
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// eslint-disable-next-line no-undefined
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
package/src/vault/index.ts
CHANGED
|
@@ -29,3 +29,7 @@ export { VaultProvider } from './vault-provider';
|
|
|
29
29
|
export { VaultCache } from './vault-cache';
|
|
30
30
|
export { SecretRefreshManager } from './secret-refresh-manager';
|
|
31
31
|
export { VaultIntegration } from './vault-integration';
|
|
32
|
+
|
|
33
|
+
// Helpers
|
|
34
|
+
export { buildVaultConfigFromEnv } from './build-vault-config';
|
|
35
|
+
export type { IBuildVaultConfigOptions } from './build-vault-config';
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SecretRefreshManager
|
|
3
|
-
* Manages background refresh of Vault secrets based on TTL
|
|
3
|
+
* Manages background refresh of Vault secrets based on TTL.
|
|
4
|
+
*
|
|
5
|
+
* Refreshes are path-aware: when a secret at a given Vault path is refreshed,
|
|
6
|
+
* ALL properties sharing that path are updated from the same Vault read.
|
|
7
|
+
* This prevents credential mismatch (e.g. username from one read, password
|
|
8
|
+
* from another) for engines like `database` where each read generates new creds.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
IRefreshStatus,
|
|
13
|
+
SecretRefreshCallback,
|
|
14
|
+
SecretRefreshEvent,
|
|
15
|
+
VaultPropertyMetadata
|
|
16
|
+
} from './types';
|
|
7
17
|
import { VaultCache } from './vault-cache';
|
|
8
18
|
import { VaultProvider } from './vault-provider';
|
|
9
19
|
|
|
@@ -13,9 +23,6 @@ interface IRefreshContext {
|
|
|
13
23
|
fullPath: string;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
/**
|
|
17
|
-
* SecretRefreshManager - Handles TTL-based secret refresh scheduling
|
|
18
|
-
*/
|
|
19
26
|
export class SecretRefreshManager {
|
|
20
27
|
private refreshTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
21
28
|
private refreshLocks: Map<string, Promise<void>> = new Map();
|
|
@@ -24,45 +31,45 @@ export class SecretRefreshManager {
|
|
|
24
31
|
private refreshContexts: Map<string, IRefreshContext> = new Map();
|
|
25
32
|
private vaultProvider: VaultProvider;
|
|
26
33
|
private cache: VaultCache;
|
|
27
|
-
private refreshBuffer: number;
|
|
34
|
+
private refreshBuffer: number;
|
|
35
|
+
private onRefreshCallbacks: SecretRefreshCallback[] = [];
|
|
28
36
|
|
|
29
37
|
constructor(provider: VaultProvider, cache: VaultCache, refreshBuffer?: number) {
|
|
30
38
|
this.vaultProvider = provider;
|
|
31
39
|
this.cache = cache;
|
|
32
|
-
|
|
33
|
-
this.refreshBuffer = refreshBuffer || 300; // 5 minutes default
|
|
40
|
+
this.refreshBuffer = refreshBuffer || 300;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
37
|
-
*
|
|
44
|
+
* Register a callback to be invoked after secrets are refreshed.
|
|
45
|
+
* Multiple callbacks can be registered.
|
|
38
46
|
*/
|
|
47
|
+
onSecretRefreshed(callback: SecretRefreshCallback): void {
|
|
48
|
+
this.onRefreshCallbacks.push(callback);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
scheduleRefresh(propertyName: string, metadata: VaultPropertyMetadata, targetInstance?: object): void {
|
|
40
52
|
const entry = this.cache.getEntry(propertyName);
|
|
41
53
|
if (!entry) {
|
|
42
|
-
return;
|
|
54
|
+
return;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
// Store context for refresh
|
|
46
57
|
this.refreshContexts.set(propertyName, {
|
|
47
58
|
metadata,
|
|
48
59
|
targetInstance,
|
|
49
60
|
fullPath: entry.vaultPath
|
|
50
61
|
});
|
|
51
62
|
|
|
52
|
-
// Cancel existing refresh if any
|
|
53
63
|
this.cancelRefresh(propertyName);
|
|
54
64
|
|
|
55
|
-
// Calculate refresh time
|
|
56
65
|
const now = Date.now();
|
|
57
66
|
const timeUntilRefresh = Math.max(0, entry.refreshAt - now);
|
|
58
67
|
|
|
59
68
|
if (timeUntilRefresh <= 0) {
|
|
60
|
-
// Already past refresh time, refresh immediately
|
|
61
69
|
this.executeRefresh(propertyName);
|
|
62
70
|
return;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
// Schedule refresh
|
|
66
73
|
const timer = setTimeout(() => {
|
|
67
74
|
this.executeRefresh(propertyName);
|
|
68
75
|
}, timeUntilRefresh);
|
|
@@ -70,9 +77,6 @@ export class SecretRefreshManager {
|
|
|
70
77
|
this.refreshTimers.set(propertyName, timer);
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
/**
|
|
74
|
-
* Cancel scheduled refresh for a property
|
|
75
|
-
*/
|
|
76
80
|
cancelRefresh(propertyName: string): void {
|
|
77
81
|
const timer = this.refreshTimers.get(propertyName);
|
|
78
82
|
if (timer) {
|
|
@@ -81,77 +85,123 @@ export class SecretRefreshManager {
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
/**
|
|
85
|
-
* Execute refresh for a secret (with locking to prevent concurrent refreshes)
|
|
86
|
-
*/
|
|
87
88
|
private async executeRefresh(propertyName: string): Promise<void> {
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const context = this.refreshContexts.get(propertyName);
|
|
90
|
+
if (!context) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Lock by path so sibling properties don't trigger duplicate reads
|
|
95
|
+
const lockKey = `path:${ context.fullPath }`;
|
|
96
|
+
const existingLock = this.refreshLocks.get(lockKey);
|
|
90
97
|
if (existingLock) {
|
|
91
98
|
await existingLock;
|
|
92
|
-
return;
|
|
99
|
+
return;
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
const refreshPromise = this.performRefresh(propertyName)
|
|
102
|
+
const refreshPromise = this.performPathRefresh(context.fullPath)
|
|
97
103
|
.finally(() => {
|
|
98
|
-
this.refreshLocks.delete(
|
|
99
|
-
this.refreshTimers.delete(propertyName);
|
|
104
|
+
this.refreshLocks.delete(lockKey);
|
|
100
105
|
});
|
|
101
106
|
|
|
102
|
-
this.refreshLocks.set(
|
|
107
|
+
this.refreshLocks.set(lockKey, refreshPromise);
|
|
103
108
|
await refreshPromise;
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
/**
|
|
107
|
-
*
|
|
112
|
+
* Refresh ALL properties that share the given Vault path in a single read.
|
|
108
113
|
*/
|
|
109
|
-
private async
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
console.error(`No refresh context for ${ propertyName }`);
|
|
114
|
+
private async performPathRefresh(fullPath: string): Promise<void> {
|
|
115
|
+
const siblingProperties = this.getSiblingsForPath(fullPath);
|
|
116
|
+
if (siblingProperties.length === 0) {
|
|
113
117
|
return;
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
this.refreshCounts.set(propertyName, refreshCount);
|
|
120
|
+
const pathRefreshCount = (this.refreshCounts.get(fullPath) || 0) + 1;
|
|
121
|
+
this.refreshCounts.set(fullPath, pathRefreshCount);
|
|
119
122
|
|
|
120
123
|
try {
|
|
121
|
-
// Read fresh secret from Vault (using full path)
|
|
122
124
|
const secret = await this.vaultProvider.read(fullPath);
|
|
125
|
+
const updatedProperties: string[] = [];
|
|
126
|
+
let engine = siblingProperties[0].metadata.engine;
|
|
127
|
+
|
|
128
|
+
for (const { propertyName, metadata, targetInstance } of siblingProperties) {
|
|
129
|
+
this.cache.set(propertyName, fullPath, secret, metadata);
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
if (targetInstance) {
|
|
132
|
+
const key = metadata.key || metadata.propertyName;
|
|
133
|
+
const value = secret.data[key];
|
|
134
|
+
(targetInstance as any)[propertyName] = value;
|
|
135
|
+
}
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
137
|
+
updatedProperties.push(propertyName);
|
|
138
|
+
engine = metadata.engine;
|
|
139
|
+
|
|
140
|
+
this.cancelRefresh(propertyName);
|
|
141
|
+
this.refreshTimers.delete(propertyName);
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
this.lastRefreshTimes.set(
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
this.lastRefreshTimes.set(fullPath, now);
|
|
146
|
+
|
|
147
|
+
// Reschedule all sibling properties
|
|
148
|
+
for (const { propertyName, metadata, targetInstance } of siblingProperties) {
|
|
149
|
+
this.scheduleRefresh(propertyName, metadata, targetInstance);
|
|
150
|
+
}
|
|
136
151
|
|
|
137
|
-
//
|
|
138
|
-
|
|
152
|
+
// Fire callbacks once per path
|
|
153
|
+
const event: SecretRefreshEvent = {
|
|
154
|
+
vaultPath: fullPath,
|
|
155
|
+
properties: updatedProperties,
|
|
156
|
+
engine,
|
|
157
|
+
timestamp: new Date(now).toISOString(),
|
|
158
|
+
refreshCount: pathRefreshCount
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
for (const callback of this.onRefreshCallbacks) {
|
|
162
|
+
try {
|
|
163
|
+
const result = callback(event);
|
|
164
|
+
if (result && typeof (result as Promise<void>).catch === 'function') {
|
|
165
|
+
(result as Promise<void>).catch((err) => {
|
|
166
|
+
console.error(`Secret refresh callback error for ${ fullPath }: ${ this.sanitizeError(err?.message || 'Unknown') }`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} catch (err: any) {
|
|
170
|
+
console.error(`Secret refresh callback error for ${ fullPath }: ${ this.sanitizeError(err?.message || 'Unknown') }`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
139
173
|
} catch (error: any) {
|
|
140
|
-
// Log error (sanitized)
|
|
141
174
|
const errorMessage = error?.message || 'Unknown error';
|
|
142
|
-
console.error(`Failed to refresh
|
|
175
|
+
console.error(`Failed to refresh secrets for path ${ this.sanitizePath(fullPath) }: ${ this.sanitizeError(errorMessage) }`);
|
|
143
176
|
|
|
144
|
-
|
|
145
|
-
const retryDelay = Math.min(1000 * Math.pow(2, refreshCount - 1), 30000); // Max 30s
|
|
177
|
+
const retryDelay = Math.min(1000 * Math.pow(2, pathRefreshCount - 1), 30000);
|
|
146
178
|
setTimeout(() => {
|
|
147
|
-
|
|
179
|
+
for (const { propertyName, metadata, targetInstance } of siblingProperties) {
|
|
180
|
+
this.scheduleRefresh(propertyName, metadata, targetInstance);
|
|
181
|
+
}
|
|
148
182
|
}, retryDelay);
|
|
149
183
|
}
|
|
150
184
|
}
|
|
151
185
|
|
|
152
186
|
/**
|
|
153
|
-
*
|
|
187
|
+
* Find all properties that share the given Vault path.
|
|
154
188
|
*/
|
|
189
|
+
private getSiblingsForPath(fullPath: string): Array<{ propertyName: string; metadata: VaultPropertyMetadata; targetInstance?: object }> {
|
|
190
|
+
const siblings: Array<{ propertyName: string; metadata: VaultPropertyMetadata; targetInstance?: object }> = [];
|
|
191
|
+
|
|
192
|
+
for (const [ propertyName, context ] of this.refreshContexts.entries()) {
|
|
193
|
+
if (context.fullPath === fullPath) {
|
|
194
|
+
siblings.push({
|
|
195
|
+
propertyName,
|
|
196
|
+
metadata: context.metadata,
|
|
197
|
+
targetInstance: context.targetInstance
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return siblings;
|
|
203
|
+
}
|
|
204
|
+
|
|
155
205
|
getRefreshStatus(): IRefreshStatus[] {
|
|
156
206
|
const statuses: IRefreshStatus[] = [];
|
|
157
207
|
const cachedProperties = this.cache.getCachedProperties();
|
|
@@ -173,17 +223,14 @@ export class SecretRefreshManager {
|
|
|
173
223
|
scheduled: timer !== undefined,
|
|
174
224
|
refreshAt,
|
|
175
225
|
timeUntilRefresh,
|
|
176
|
-
lastRefresh: this.lastRefreshTimes.get(
|
|
177
|
-
refreshCount: this.refreshCounts.get(
|
|
226
|
+
lastRefresh: this.lastRefreshTimes.get(entry.vaultPath) || entry.cachedAt,
|
|
227
|
+
refreshCount: this.refreshCounts.get(entry.vaultPath) || 0
|
|
178
228
|
});
|
|
179
229
|
}
|
|
180
230
|
|
|
181
231
|
return statuses;
|
|
182
232
|
}
|
|
183
233
|
|
|
184
|
-
/**
|
|
185
|
-
* Get refresh status for a specific property
|
|
186
|
-
*/
|
|
187
234
|
getRefreshStatusForProperty(propertyName: string): IRefreshStatus | null {
|
|
188
235
|
const entry = this.cache.getEntry(propertyName);
|
|
189
236
|
if (!entry) {
|
|
@@ -201,17 +248,13 @@ export class SecretRefreshManager {
|
|
|
201
248
|
scheduled: timer !== undefined,
|
|
202
249
|
refreshAt,
|
|
203
250
|
timeUntilRefresh,
|
|
204
|
-
lastRefresh: this.lastRefreshTimes.get(
|
|
205
|
-
refreshCount: this.refreshCounts.get(
|
|
251
|
+
lastRefresh: this.lastRefreshTimes.get(entry.vaultPath) || entry.cachedAt,
|
|
252
|
+
refreshCount: this.refreshCounts.get(entry.vaultPath) || 0
|
|
206
253
|
};
|
|
207
254
|
}
|
|
208
255
|
|
|
209
|
-
/**
|
|
210
|
-
* Stop all refresh workers
|
|
211
|
-
*/
|
|
212
256
|
shutdown(): void {
|
|
213
|
-
|
|
214
|
-
for (const [ propertyName, timer ] of this.refreshTimers.entries()) {
|
|
257
|
+
for (const [ , timer ] of this.refreshTimers.entries()) {
|
|
215
258
|
clearTimeout(timer);
|
|
216
259
|
}
|
|
217
260
|
|
|
@@ -219,13 +262,10 @@ export class SecretRefreshManager {
|
|
|
219
262
|
this.refreshLocks.clear();
|
|
220
263
|
this.refreshCounts.clear();
|
|
221
264
|
this.lastRefreshTimes.clear();
|
|
265
|
+
this.onRefreshCallbacks = [];
|
|
222
266
|
}
|
|
223
267
|
|
|
224
|
-
/**
|
|
225
|
-
* Sanitize error message (remove potential secret values)
|
|
226
|
-
*/
|
|
227
268
|
private sanitizeError(message: string): string {
|
|
228
|
-
// Remove potential secret values from error messages
|
|
229
269
|
const sensitivePatterns = [ /password/i, /secret/i, /key/i, /token/i, /credential/i ];
|
|
230
270
|
let sanitized = message;
|
|
231
271
|
|
|
@@ -238,4 +278,16 @@ export class SecretRefreshManager {
|
|
|
238
278
|
|
|
239
279
|
return sanitized;
|
|
240
280
|
}
|
|
281
|
+
|
|
282
|
+
private sanitizePath(path: string): string {
|
|
283
|
+
const segments = path.split('/');
|
|
284
|
+
if (segments.length > 0) {
|
|
285
|
+
const lastSegment = segments[segments.length - 1];
|
|
286
|
+
const sensitivePatterns = [ /password/i, /secret/i, /key/i, /token/i, /credential/i ];
|
|
287
|
+
if (sensitivePatterns.some((pattern) => pattern.test(lastSegment))) {
|
|
288
|
+
segments[segments.length - 1] = '***';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return segments.join('/');
|
|
292
|
+
}
|
|
241
293
|
}
|
package/src/vault/types.ts
CHANGED
|
@@ -68,6 +68,14 @@ export interface IVaultConfigOptions {
|
|
|
68
68
|
* Circuit breaker configuration
|
|
69
69
|
*/
|
|
70
70
|
circuitBreaker?: ICircuitBreakerConfig;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Callback invoked when a secret is refreshed.
|
|
74
|
+
* Fired once per Vault path after all properties from that path are updated.
|
|
75
|
+
* Config values are already set when this fires, so consumers can
|
|
76
|
+
* safely read the new values from the config instance.
|
|
77
|
+
*/
|
|
78
|
+
onSecretRefreshed?: SecretRefreshCallback;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
@@ -225,6 +233,36 @@ export interface IRetryPolicy {
|
|
|
225
233
|
retryableErrors: string[];
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Event emitted when a Vault secret is refreshed.
|
|
238
|
+
* Fired once per Vault path (not per property), so consumers
|
|
239
|
+
* can react to credential rotation (e.g., reconnect a database pool).
|
|
240
|
+
*
|
|
241
|
+
* Secret values are intentionally excluded for security.
|
|
242
|
+
*/
|
|
243
|
+
export interface SecretRefreshEvent {
|
|
244
|
+
/** The Vault path that was refreshed (e.g. 'database/creds/my-role') */
|
|
245
|
+
vaultPath: string;
|
|
246
|
+
|
|
247
|
+
/** Config property names updated from this path (e.g. ['DB_USERNAME', 'DB_PASSWORD']) */
|
|
248
|
+
properties: string[];
|
|
249
|
+
|
|
250
|
+
/** Vault engine type */
|
|
251
|
+
engine: VaultEngineType;
|
|
252
|
+
|
|
253
|
+
/** ISO timestamp of the refresh */
|
|
254
|
+
timestamp: string;
|
|
255
|
+
|
|
256
|
+
/** Number of times this path has been refreshed (1-based) */
|
|
257
|
+
refreshCount: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Callback invoked when Vault secrets are refreshed.
|
|
262
|
+
* Config values are already updated when this fires.
|
|
263
|
+
*/
|
|
264
|
+
export type SecretRefreshCallback = (event: SecretRefreshEvent) => void | Promise<void>;
|
|
265
|
+
|
|
228
266
|
/**
|
|
229
267
|
* Circuit breaker configuration
|
|
230
268
|
*/
|
|
@@ -8,6 +8,7 @@ import { SecretRefreshManager } from './secret-refresh-manager';
|
|
|
8
8
|
import {
|
|
9
9
|
IVaultConfigOptions,
|
|
10
10
|
IVaultHealthDetails,
|
|
11
|
+
SecretRefreshCallback,
|
|
11
12
|
VaultHealth,
|
|
12
13
|
VaultPropertyMetadata
|
|
13
14
|
} from './types';
|
|
@@ -31,8 +32,12 @@ export class VaultIntegration {
|
|
|
31
32
|
this.config = config;
|
|
32
33
|
this.provider = new VaultProvider(config);
|
|
33
34
|
this.cache = new VaultCache();
|
|
34
|
-
const refreshBuffer = config.refreshBuffer || 300;
|
|
35
|
+
const refreshBuffer = config.refreshBuffer || 300;
|
|
35
36
|
this.refreshManager = new SecretRefreshManager(this.provider, this.cache, refreshBuffer);
|
|
37
|
+
|
|
38
|
+
if (config.onSecretRefreshed) {
|
|
39
|
+
this.refreshManager.onSecretRefreshed(config.onSecretRefreshed);
|
|
40
|
+
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
@@ -171,6 +176,14 @@ export class VaultIntegration {
|
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Register a callback for secret refresh events.
|
|
181
|
+
* Can be called before or after initialization.
|
|
182
|
+
*/
|
|
183
|
+
onSecretRefreshed(callback: SecretRefreshCallback): void {
|
|
184
|
+
this.refreshManager.onSecretRefreshed(callback);
|
|
185
|
+
}
|
|
186
|
+
|
|
174
187
|
/**
|
|
175
188
|
* Get Vault health status
|
|
176
189
|
*/
|