Skip to content

Commit cc0d337

Browse files
authored
Resolve key vault references (#2)
1 parent 667c3a2 commit cc0d337

9 files changed

+334
-9
lines changed

package-lock.json

Lines changed: 66 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
},
5050
"dependencies": {
5151
"@azure/app-configuration": "^1.4.1",
52-
"@azure/identity": "^3.3.0"
52+
"@azure/identity": "^3.3.0",
53+
"@azure/keyvault-secrets": "^4.7.0"
5354
}
5455
}

src/AzureAppConfigurationImpl.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration";
55
import { AzureAppConfiguration } from "./AzureAppConfiguration";
66
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
7+
import { IKeyValueAdapter } from "./IKeyValueAdapter";
78
import { KeyFilter } from "./KeyFilter";
89
import { LabelFilter } from "./LabelFilter";
10+
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";
911

1012
export class AzureAppConfigurationImpl extends Map<string, unknown> implements AzureAppConfiguration {
13+
private adapters: IKeyValueAdapter[] = [];
1114
/**
1215
* Trim key prefixes sorted in descending order.
1316
* Since multiple prefixes could start with the same characters, we need to trim the longest prefix first.
@@ -22,6 +25,9 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
2225
if (options?.trimKeyPrefixes) {
2326
this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a));
2427
}
28+
// TODO: should add more adapters to process different type of values
29+
// feature flag, json, others
30+
this.adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
2531
}
2632

2733
public async load() {
@@ -35,8 +41,8 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
3541

3642
for await (const setting of settings) {
3743
if (setting.key && setting.value) {
38-
const trimmedKey = this.keyWithPrefixesTrimmed(setting.key);
39-
const value = await this.processKeyValue(setting);
44+
const [key, value] = await this.processAdapters(setting);
45+
const trimmedKey = this.keyWithPrefixesTrimmed(key);
4046
keyValues.push([trimmedKey, value]);
4147
}
4248
}
@@ -46,10 +52,13 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
4652
}
4753
}
4854

49-
private async processKeyValue(setting: ConfigurationSetting<string>) {
50-
// TODO: should process different type of values
51-
// keyvault reference, feature flag, json, others
52-
return setting.value;
55+
private async processAdapters(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
56+
for(const adapter of this.adapters) {
57+
if (adapter.canProcess(setting)) {
58+
return adapter.processKeyValue(setting);
59+
}
60+
}
61+
return [setting.key, setting.value];
5362
}
5463

5564
private keyWithPrefixesTrimmed(key: string): string {

src/AzureAppConfigurationOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
// Licensed under the MIT license.
33

44
import { AppConfigurationClientOptions } from "@azure/app-configuration";
5+
import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions";
56

67
export interface AzureAppConfigurationOptions {
78
selectors?: { keyFilter: string, labelFilter: string }[];
89
trimKeyPrefixes?: string[];
910
clientOptions?: AppConfigurationClientOptions;
11+
keyVaultOptions?: AzureAppConfigurationKeyVaultOptions;
1012
}

src/IKeyValueAdapter.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
import { ConfigurationSetting } from "@azure/app-configuration";
4+
5+
export interface IKeyValueAdapter {
6+
/**
7+
* Determine whether the adapter applies to a configuration setting.
8+
* Note: A setting is expected to be processed by at most one adapter.
9+
*/
10+
canProcess(setting: ConfigurationSetting): boolean;
11+
12+
/**
13+
* This method process the original configuration setting, and returns processed key and value in an array.
14+
*/
15+
processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>;
16+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { TokenCredential } from "@azure/identity";
5+
import { SecretClient } from "@azure/keyvault-secrets";
6+
7+
export interface AzureAppConfigurationKeyVaultOptions {
8+
secretClients?: SecretClient[];
9+
credential?: TokenCredential;
10+
secretResolver?: (keyVaultReference: URL) => string | Promise<string>;
11+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration";
5+
import { IKeyValueAdapter } from "../IKeyValueAdapter";
6+
import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions";
7+
import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
8+
9+
export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
10+
/**
11+
* Map vault hostname to corresponding secret client.
12+
*/
13+
private secretClients: Map<string, SecretClient>;
14+
15+
constructor(
16+
private keyVaultOptions: AzureAppConfigurationKeyVaultOptions | undefined
17+
) { }
18+
19+
public canProcess(setting: ConfigurationSetting): boolean {
20+
return isSecretReference(setting);
21+
}
22+
23+
public async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> {
24+
// TODO: cache results to save requests.
25+
if (!this.keyVaultOptions) {
26+
throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s).");
27+
}
28+
29+
// precedence: secret clients > credential > secret resolver
30+
const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier(
31+
parseSecretReference(setting).value.secretId
32+
);
33+
34+
const client = this.getSecretClient(new URL(vaultUrl));
35+
if (client) {
36+
// TODO: what if error occurs when reading a key vault value? Now it breaks the whole load.
37+
const secret = await client.getSecret(secretName, { version });
38+
return [setting.key, secret.value];
39+
}
40+
41+
if (this.keyVaultOptions.secretResolver) {
42+
return [setting.key, await this.keyVaultOptions.secretResolver(new URL(sourceId))];
43+
}
44+
45+
throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found.");
46+
}
47+
48+
private getSecretClient(vaultUrl: URL): SecretClient | undefined {
49+
if (this.secretClients === undefined) {
50+
this.secretClients = new Map();
51+
for (const c of this.keyVaultOptions?.secretClients ?? []) {
52+
this.secretClients.set(getHost(c.vaultUrl), c);
53+
}
54+
}
55+
56+
let client: SecretClient | undefined;
57+
client = this.secretClients.get(vaultUrl.host);
58+
if (client !== undefined) {
59+
return client;
60+
}
61+
62+
if (this.keyVaultOptions?.credential) {
63+
client = new SecretClient(vaultUrl.toString(), this.keyVaultOptions.credential);
64+
this.secretClients.set(vaultUrl.host, client);
65+
return client;
66+
}
67+
68+
return undefined;
69+
}
70+
}
71+
72+
function getHost(url: string) {
73+
return new URL(url).host;
74+
}

test/keyvault.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
const chai = require("chai");
5+
const chaiAsPromised = require("chai-as-promised");
6+
chai.use(chaiAsPromised);
7+
const expect = chai.expect;
8+
const { load } = require("../dist/index");
9+
const { sinon,
10+
createMockedConnectionString,
11+
createMockedTokenCredential,
12+
mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } = require("./utils/testHelper");
13+
const { SecretClient } = require("@azure/keyvault-secrets");
14+
15+
const mockedData = [
16+
// key, secretUri, value
17+
["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"],
18+
["TestKeyFixedVersion", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459", "OldSecretValue"],
19+
["TestKey2", "https://fake-vault-name2.vault.azure.net/secrets/fakeSecretName2", "SecretValue2"]
20+
];
21+
22+
function mockAppConfigurationClient() {
23+
const kvs = mockedData.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri));
24+
mockAppConfigurationClientListConfigurationSettings(kvs);
25+
}
26+
27+
function mockNewlyCreatedKeyVaultSecretClients() {
28+
mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value]));
29+
}
30+
describe("key vault reference", function () {
31+
beforeEach(() => {
32+
mockAppConfigurationClient();
33+
mockNewlyCreatedKeyVaultSecretClients();
34+
});
35+
36+
afterEach(() => {
37+
restoreMocks();
38+
});
39+
40+
it("require key vault options to resolve reference", async () => {
41+
expect(load(createMockedConnectionString())).eventually.rejected;
42+
});
43+
44+
it("should resolve key vault reference with credential", async () => {
45+
const settings = await load(createMockedConnectionString(), {
46+
keyVaultOptions: {
47+
credential: createMockedTokenCredential()
48+
}
49+
});
50+
expect(settings).not.undefined;
51+
expect(settings.get("TestKey")).eq("SecretValue");
52+
expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue");
53+
});
54+
55+
it("should resolve key vault reference with secret resolver", async () => {
56+
const settings = await load(createMockedConnectionString(), {
57+
keyVaultOptions: {
58+
secretResolver: (kvrUrl) => {
59+
return "SecretResolver::" + kvrUrl.toString();
60+
}
61+
}
62+
});
63+
expect(settings).not.undefined;
64+
expect(settings.get("TestKey")).eq("SecretResolver::https://fake-vault-name.vault.azure.net/secrets/fakeSecretName");
65+
});
66+
67+
it("should resolve key vault reference with corresponding secret clients", async () => {
68+
sinon.restore();
69+
mockAppConfigurationClient();
70+
71+
// mock specific behavior per secret client
72+
const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
73+
sinon.stub(client1, "getSecret").returns({ value: "SecretValueViaClient1" });
74+
const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential());
75+
sinon.stub(client2, "getSecret").returns({ value: "SecretValueViaClient2" });
76+
const settings = await load(createMockedConnectionString(), {
77+
keyVaultOptions: {
78+
secretClients: [
79+
client1,
80+
client2,
81+
]
82+
}
83+
});
84+
expect(settings).not.undefined;
85+
expect(settings.get("TestKey")).eq("SecretValueViaClient1");
86+
expect(settings.get("TestKey2")).eq("SecretValueViaClient2");
87+
});
88+
89+
it("should throw error when secret clients not provided for all key vault references", async () => {
90+
const loadKeyVaultPromise = load(createMockedConnectionString(), {
91+
keyVaultOptions: {
92+
secretClients: [
93+
new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()),
94+
]
95+
}
96+
});
97+
expect(loadKeyVaultPromise).eventually.rejected;
98+
});
99+
100+
it("should fallback to use default credential when corresponding secret client not provided", async () => {
101+
const settings = await load(createMockedConnectionString(), {
102+
keyVaultOptions: {
103+
secretClients: [
104+
new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()),
105+
],
106+
credential: createMockedTokenCredential()
107+
}
108+
});
109+
expect(settings).not.undefined;
110+
expect(settings.get("TestKey")).eq("SecretValue");
111+
expect(settings.get("TestKey2")).eq("SecretValue2");
112+
});
113+
})

0 commit comments

Comments
 (0)