Skip to content

Commit 670a745

Browse files
Enforcement of min secret refresh interval (#264)
* enforcement of min secret refresh interval * add test * update
1 parent dcc0540 commit 670a745

File tree

2 files changed

+155
-3
lines changed

2 files changed

+155
-3
lines changed

src/keyvault/keyVaultSecretProvider.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { KeyVaultOptions } from "./keyVaultOptions.js";
4+
import { KeyVaultOptions, MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyVaultOptions.js";
55
import { RefreshTimer } from "../refresh/refreshTimer.js";
66
import { ArgumentError } from "../common/errors.js";
77
import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
@@ -10,6 +10,7 @@ import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js";
1010
export class AzureKeyVaultSecretProvider {
1111
#keyVaultOptions: KeyVaultOptions | undefined;
1212
#secretRefreshTimer: RefreshTimer | undefined;
13+
#minSecretRefreshTimer: RefreshTimer;
1314
#secretClients: Map<string, SecretClient>; // map key vault hostname to corresponding secret client
1415
#cachedSecretValues: Map<string, any> = new Map<string, any>(); // map secret identifier to secret value
1516

@@ -24,6 +25,7 @@ export class AzureKeyVaultSecretProvider {
2425
}
2526
this.#keyVaultOptions = keyVaultOptions;
2627
this.#secretRefreshTimer = refreshTimer;
28+
this.#minSecretRefreshTimer = new RefreshTimer(MIN_SECRET_REFRESH_INTERVAL_IN_MS);
2729
this.#secretClients = new Map();
2830
for (const client of this.#keyVaultOptions?.secretClients ?? []) {
2931
const clientUrl = new URL(client.vaultUrl);
@@ -47,7 +49,10 @@ export class AzureKeyVaultSecretProvider {
4749
}
4850

4951
clearCache(): void {
50-
this.#cachedSecretValues.clear();
52+
if (this.#minSecretRefreshTimer.canRefresh()) {
53+
this.#cachedSecretValues.clear();
54+
this.#minSecretRefreshTimer.reset();
55+
}
5156
}
5257

5358
async #getSecretValueFromKeyVault(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {

test/keyvault.test.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import chaiAsPromised from "chai-as-promised";
77
chai.use(chaiAsPromised);
88
const expect = chai.expect;
99
import { load } from "../src/index.js";
10-
import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, sleepInMs } from "./utils/testHelper.js";
10+
import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, createMockedKeyValue, sleepInMs } from "./utils/testHelper.js";
1111
import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets";
1212
import { ErrorMessages, KeyVaultReferenceErrorMessages } from "../src/common/errorMessages.js";
1313

@@ -199,4 +199,151 @@ describe("key vault secret refresh", function () {
199199
expect(settings.get("TestKey")).eq("SecretValue - Updated");
200200
});
201201
});
202+
203+
describe("min secret refresh interval during key-value refresh", function () {
204+
let getSecretCallCount = 0;
205+
let sentinelEtag = "initial-etag";
206+
207+
afterEach(() => {
208+
restoreMocks();
209+
getSecretCallCount = 0;
210+
});
211+
212+
/**
213+
* This test verifies the enforcement of the minimum secret refresh interval during key-value refresh.
214+
* When key-value refresh is triggered (by a watched setting change), the provider calls clearCache()
215+
* on the KeyVaultSecretProvider. However, clearCache() only clears the cache if the minimum secret
216+
* refresh interval (60 seconds) has passed. This prevents overwhelming Key Vaults with too many requests.
217+
*/
218+
it("should not re-fetch secrets when key-value refresh happens within min secret refresh interval", async () => {
219+
// Setup: key vault reference + sentinel key for watching
220+
const kvWithSentinel = [
221+
createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"),
222+
createMockedKeyValue({ key: "sentinel", value: "initialValue", etag: sentinelEtag })
223+
];
224+
mockAppConfigurationClientListConfigurationSettings([kvWithSentinel]);
225+
mockAppConfigurationClientGetConfigurationSetting(kvWithSentinel);
226+
227+
// Mock SecretClient with call counting
228+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
229+
sinon.stub(client, "getSecret").callsFake(async () => {
230+
getSecretCallCount++;
231+
return { value: "SecretValue" } as KeyVaultSecret;
232+
});
233+
234+
// Load with key-value refresh enabled (watching sentinel)
235+
const settings = await load(createMockedConnectionString(), {
236+
refreshOptions: {
237+
enabled: true,
238+
refreshIntervalInMs: 1000, // 1 second refresh interval for key-values
239+
watchedSettings: [{ key: "sentinel" }]
240+
},
241+
keyVaultOptions: {
242+
secretClients: [client]
243+
}
244+
});
245+
246+
expect(settings.get("TestKey")).eq("SecretValue");
247+
expect(getSecretCallCount).eq(1); // Initial load fetched the secret
248+
249+
// Simulate sentinel change to trigger key-value refresh
250+
sentinelEtag = "changed-etag-1";
251+
const updatedKvs = [
252+
createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"),
253+
createMockedKeyValue({ key: "sentinel", value: "changedValue1", etag: sentinelEtag })
254+
];
255+
restoreMocks();
256+
mockAppConfigurationClientListConfigurationSettings([updatedKvs]);
257+
mockAppConfigurationClientGetConfigurationSetting(updatedKvs);
258+
sinon.stub(client, "getSecret").callsFake(async () => {
259+
getSecretCallCount++;
260+
return { value: "SecretValue" } as KeyVaultSecret;
261+
});
262+
263+
// Wait for refresh interval and trigger refresh
264+
await sleepInMs(1000 + 100);
265+
await settings.refresh();
266+
267+
// Key-value refresh happened, but secret should NOT be re-fetched
268+
// because min secret refresh interval (60s) hasn't passed
269+
expect(getSecretCallCount).eq(1); // Still 1, no additional getSecret call
270+
271+
// Trigger another key-value refresh
272+
sentinelEtag = "changed-etag-2";
273+
const updatedKvs2 = [
274+
createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"),
275+
createMockedKeyValue({ key: "sentinel", value: "changedValue2", etag: sentinelEtag })
276+
];
277+
restoreMocks();
278+
mockAppConfigurationClientListConfigurationSettings([updatedKvs2]);
279+
mockAppConfigurationClientGetConfigurationSetting(updatedKvs2);
280+
sinon.stub(client, "getSecret").callsFake(async () => {
281+
getSecretCallCount++;
282+
return { value: "SecretValue" } as KeyVaultSecret;
283+
});
284+
285+
await sleepInMs(1000 + 100);
286+
await settings.refresh();
287+
288+
// Still no additional getSecret call due to min interval enforcement
289+
expect(getSecretCallCount).eq(1);
290+
});
291+
292+
it("should re-fetch secrets after min secret refresh interval passes during key-value refresh", async () => {
293+
// Setup: key vault reference + sentinel key for watching
294+
let currentSentinelValue = "initialValue";
295+
sentinelEtag = "initial-etag";
296+
297+
const getKvs = () => [
298+
createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"),
299+
createMockedKeyValue({ key: "sentinel", value: currentSentinelValue, etag: sentinelEtag })
300+
];
301+
302+
mockAppConfigurationClientListConfigurationSettings([getKvs()]);
303+
mockAppConfigurationClientGetConfigurationSetting(getKvs());
304+
305+
// Mock SecretClient with call counting
306+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
307+
sinon.stub(client, "getSecret").callsFake(async () => {
308+
getSecretCallCount++;
309+
return { value: `SecretValue-${getSecretCallCount}` } as KeyVaultSecret;
310+
});
311+
312+
// Load with key-value refresh enabled
313+
const settings = await load(createMockedConnectionString(), {
314+
refreshOptions: {
315+
enabled: true,
316+
refreshIntervalInMs: 1000,
317+
watchedSettings: [{ key: "sentinel" }]
318+
},
319+
keyVaultOptions: {
320+
secretClients: [client]
321+
}
322+
});
323+
324+
expect(settings.get("TestKey")).eq("SecretValue-1");
325+
expect(getSecretCallCount).eq(1);
326+
327+
// Wait for min secret refresh interval (60 seconds) to pass
328+
await sleepInMs(60_000 + 100);
329+
330+
// Now change sentinel to trigger key-value refresh
331+
currentSentinelValue = "changedValue";
332+
sentinelEtag = "changed-etag";
333+
restoreMocks();
334+
mockAppConfigurationClientListConfigurationSettings([getKvs()]);
335+
mockAppConfigurationClientGetConfigurationSetting(getKvs());
336+
sinon.stub(client, "getSecret").callsFake(async () => {
337+
getSecretCallCount++;
338+
return { value: `SecretValue-${getSecretCallCount}` } as KeyVaultSecret;
339+
});
340+
341+
await sleepInMs(1000 + 100); // Wait for kv refresh interval
342+
await settings.refresh();
343+
344+
// Now getSecret SHOULD be called again because min interval has passed
345+
expect(getSecretCallCount).eq(2);
346+
expect(settings.get("TestKey")).eq("SecretValue-2");
347+
});
348+
});
202349
/* eslint-enable @typescript-eslint/no-unused-expressions */

0 commit comments

Comments
 (0)