From 590ecd7c13e14dfabfa36ed4ef3038389f2681f1 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Thu, 7 Sep 2023 18:43:24 +0800 Subject: [PATCH 1/5] Resolve key vault references --- package-lock.json | 67 +++++++++++- package.json | 3 +- src/AzureAppConfigurationImpl.ts | 65 +++++++++++- src/AzureAppConfigurationKeyVaultOptions.ts | 11 ++ src/AzureAppConfigurationOptions.ts | 2 + test/keyvault.test.js | 111 ++++++++++++++++++++ test/utils/testHelper.js | 33 +++++- 7 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 src/AzureAppConfigurationKeyVaultOptions.ts create mode 100644 test/keyvault.test.js diff --git a/package-lock.json b/package-lock.json index d80694b8..6cfd4666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.4.1", - "@azure/identity": "^3.3.0" + "@azure/identity": "^3.3.0", + "@azure/keyvault-secrets": "^4.7.0" }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", @@ -114,6 +115,20 @@ "node": ">=12.0.0" } }, + "node_modules/@azure/core-lro": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.5.4.tgz", + "integrity": "sha512-3GJiMVH7/10bulzOKGrrLeG/uCBH/9VtxqaMcB9lIqAeamI/xYQSHJL/KcsLDuH+yTjYpro/u6D/MuRe4dN70Q==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/core-paging": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.5.0.tgz", @@ -193,6 +208,27 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/keyvault-secrets": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.7.0.tgz", + "integrity": "sha512-YvlFXRQ+SI5NT4GtSFbb6HGo6prW3yzDab8tr6vga2/SjDQew3wJsCAAr/xwZz6XshFXCYEX26CDKmPf+SJKJg==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-http-compat": "^1.3.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/logger": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.4.tgz", @@ -3652,6 +3688,17 @@ "@azure/core-rest-pipeline": "^1.3.0" } }, + "@azure/core-lro": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.5.4.tgz", + "integrity": "sha512-3GJiMVH7/10bulzOKGrrLeG/uCBH/9VtxqaMcB9lIqAeamI/xYQSHJL/KcsLDuH+yTjYpro/u6D/MuRe4dN70Q==", + "requires": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + } + }, "@azure/core-paging": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.5.0.tgz", @@ -3716,6 +3763,24 @@ "uuid": "^8.3.0" } }, + "@azure/keyvault-secrets": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.7.0.tgz", + "integrity": "sha512-YvlFXRQ+SI5NT4GtSFbb6HGo6prW3yzDab8tr6vga2/SjDQew3wJsCAAr/xwZz6XshFXCYEX26CDKmPf+SJKJg==", + "requires": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-http-compat": "^1.3.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + } + }, "@azure/logger": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.4.tgz", diff --git a/package.json b/package.json index b64fabe8..f19d377a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@azure/app-configuration": "^1.4.1", - "@azure/identity": "^3.3.0" + "@azure/identity": "^3.3.0", + "@azure/keyvault-secrets": "^4.7.0" } } diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 39f77da3..3b79c3e4 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,13 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { AzureAppConfiguration } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { KeyFilter } from "./KeyFilter"; import { LabelFilter } from "./LabelFilter"; +import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { + /** + * Map vault hostname to corresponding secret client. + */ + private secretClients: Map; /** * Trim key prefixes sorted in descending order. * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. @@ -48,10 +53,62 @@ export class AzureAppConfigurationImpl extends Map implements A private async processKeyValue(setting: ConfigurationSetting) { // TODO: should process different type of values - // keyvault reference, feature flag, json, others + // feature flag, json, others + if (isSecretReference(setting)) { + return this.resolveKeyVaultReference(setting); + } return setting.value; } + private async resolveKeyVaultReference(setting: ConfigurationSetting) { + // TODO: cache results to save requests. + if (!this.options?.keyVaultOptions) { + throw new Error("Need key vault options to resolve reference."); + } + + // precedence: secret clients > credential > secret resolver + const parsedSecretReference = parseSecretReference(setting); + const { name: secretName, vaultUrl, sourceId } = parseKeyVaultSecretIdentifier( + parsedSecretReference.value.secretId + ); + + const client = this.getSecretClient(vaultUrl); + if (client) { + // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. + const secret = await client.getSecret(secretName); + return secret.value; + } + + if (this.options.keyVaultOptions.secretResolver) { + return await this.options.keyVaultOptions.secretResolver(new URL(sourceId)) + } + + throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + } + + private getSecretClient(vaultUrl: string): SecretClient | undefined { + if (this.secretClients === undefined) { + this.secretClients = new Map(); + for (const c of this.options?.keyVaultOptions?.secretClients ?? []) { + this.secretClients.set(getHost(c.vaultUrl), c); + } + } + + let client: SecretClient | undefined; + client = this.secretClients.get(getHost(vaultUrl)); + if (client !== undefined) { + return client; + } + + if (this.options?.keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl, this.options.keyVaultOptions.credential); + this.secretClients.set(vaultUrl, client); + return client; + } + + return undefined; + } + private keyWithPrefixesTrimmed(key: string): string { if (this.sortedTrimKeyPrefixes) { for (const prefix of this.sortedTrimKeyPrefixes) { @@ -63,3 +120,7 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } } + +function getHost(url: string) { + return new URL(url).host; +} \ No newline at end of file diff --git a/src/AzureAppConfigurationKeyVaultOptions.ts b/src/AzureAppConfigurationKeyVaultOptions.ts new file mode 100644 index 00000000..9b85beef --- /dev/null +++ b/src/AzureAppConfigurationKeyVaultOptions.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; + +export interface AzureAppConfigurationKeyVaultOptions { + secretClients?: SecretClient[]; + credential?: TokenCredential; + secretResolver?: (keyVaultReference: URL) => string | Promise; +} \ No newline at end of file diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 7395fc5a..e92eef51 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -2,9 +2,11 @@ // Licensed under the MIT license. import { AppConfigurationClientOptions } from "@azure/app-configuration"; +import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions"; export interface AzureAppConfigurationOptions { selectors?: { keyFilter: string, labelFilter: string }[]; trimKeyPrefixes?: string[]; clientOptions?: AppConfigurationClientOptions; + keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; } \ No newline at end of file diff --git a/test/keyvault.test.js b/test/keyvault.test.js new file mode 100644 index 00000000..fe51f731 --- /dev/null +++ b/test/keyvault.test.js @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const chai = require("chai"); +const chaiAsPromised = require("chai-as-promised"); +chai.use(chaiAsPromised); +const expect = chai.expect; +const { load } = require("../dist/index"); +const { sinon, + createMockedConnectionString, + createMockedTokenCredential, + mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } = require("./utils/testHelper"); +const { SecretClient } = require("@azure/keyvault-secrets"); + +const mockedData = [ + // key, secretUri, value + ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"], + ["TestKey2", "https://fake-vault-name2.vault.azure.net/secrets/fakeSecretName2", "SecretValue2"] +]; + +function mockAppConfigurationClient() { + const kvs = mockedData.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri)); + mockAppConfigurationClientListConfigurationSettings(kvs); +} + +function mockNewlyCreatedKeyVaultSecretClients() { + mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); +} +describe("key vault reference", function () { + beforeEach(() => { + mockAppConfigurationClient(); + mockNewlyCreatedKeyVaultSecretClients(); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("require key vault options to resolve reference", async () => { + expect(load(createMockedConnectionString())).eventually.rejected; + }); + + it("should resolve key vault reference with credential", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + credential: createMockedTokenCredential() + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + }); + + it("should resolve key vault reference with secret resolver", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretResolver: (kvrUrl) => { + return "SecretResolver::" + kvrUrl.toString(); + } + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretResolver::https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); + }); + + it("should resolve key vault reference with corresponding secret clients", async () => { + sinon.restore(); + mockAppConfigurationClient(); + + // mock specific behavior per secret client + const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client1, "getSecret").returns({ value: "SecretValueViaClient1" }); + const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client2, "getSecret").returns({ value: "SecretValueViaClient2" }); + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + client1, + client2, + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValueViaClient1"); + expect(settings.get("TestKey2")).eq("SecretValueViaClient2"); + }); + + it("should throw error when secret clients not provided for all key vault references", async () => { + const loadKeyVaultPromise = load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ] + } + }); + expect(loadKeyVaultPromise).eventually.rejected; + }); + + it("should fallback to use default credential when corresponding secret client not provided", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ], + credential: createMockedTokenCredential() + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + expect(settings.get("TestKey2")).eq("SecretValue2"); + }); +}) \ No newline at end of file diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js index 5b0d4d1c..f26b8dda 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.js @@ -4,6 +4,7 @@ const sinon = require("sinon"); const { AppConfigurationClient } = require("@azure/app-configuration"); const { ClientSecretCredential } = require("@azure/identity"); +const { SecretClient } = require("@azure/keyvault-secrets"); const TEST_CLIENT_ID = "62e76eb5-218e-4f90-8261-000000000000"; const TEST_TENANT_ID = "72f988bf-86f1-41af-91ab-000000000000"; @@ -15,13 +16,28 @@ function mockAppConfigurationClientListConfigurationSettings(kvList) { } sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake(() => testKvSetGnerator()); } + +// uriValueList: [["", "value"], ...] +function mockSecretClientGetSecret(uriValueList) { + const dict = new Map(); + for (const [uri, value] of uriValueList) { + dict.set(uri, value); + } + + sinon.stub(SecretClient.prototype, "getSecret").callsFake(function (secretName) { + const url = new URL(this.vaultUrl); + url.pathname = `/secrets/${secretName}`; + return { value: dict.get(url.toString()) }; + }) +} + function restoreMocks() { sinon.restore(); } const createMockedEnpoint = (name = "azure") => `https://${name}.azconfig.io`; -const createMockedConnectionString = (endpoint = createMockedEnpoint(), secret="secret", id="b1d9b31") => { +const createMockedConnectionString = (endpoint = createMockedEnpoint(), secret = "secret", id = "b1d9b31") => { const toEncodeAsBytes = Buffer.from(secret); const returnValue = toEncodeAsBytes.toString("base64"); return `Endpoint=${endpoint};Id=${id};Secret=${returnValue}`; @@ -31,12 +47,27 @@ const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_ return new ClientSecretCredential(tenantId, clientId, clientSecret); } +const createMockedKeyVaultReference = (key, vaultUri) => ({ + // https://${vaultName}.vault.azure.net/secrets/${secretName} + value: `{\"uri\":\"${vaultUri}\"}`, + key, + label: null, + contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + lastModified: "2023-05-09T08:51:11.000Z", + tags: { + }, + etag: "SPJSMnJ2ph4BAjftWfdIctV2VIyQxtcIzRbh1oxTBkM", + isReadOnly: false, +}); + module.exports = { sinon, mockAppConfigurationClientListConfigurationSettings, + mockSecretClientGetSecret, restoreMocks, createMockedEnpoint, createMockedConnectionString, createMockedTokenCredential, + createMockedKeyVaultReference, } \ No newline at end of file From aa9a46484d641d6abaeb8961a8cc934b3900d2a4 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 12 Sep 2023 09:40:41 +0800 Subject: [PATCH 2/5] address part of comments --- src/AzureAppConfigurationImpl.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 3b79c3e4..c8ab8bdd 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -63,16 +63,15 @@ export class AzureAppConfigurationImpl extends Map implements A private async resolveKeyVaultReference(setting: ConfigurationSetting) { // TODO: cache results to save requests. if (!this.options?.keyVaultOptions) { - throw new Error("Need key vault options to resolve reference."); + throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); } // precedence: secret clients > credential > secret resolver - const parsedSecretReference = parseSecretReference(setting); const { name: secretName, vaultUrl, sourceId } = parseKeyVaultSecretIdentifier( - parsedSecretReference.value.secretId + parseSecretReference(setting).value.secretId ); - const client = this.getSecretClient(vaultUrl); + const client = this.getSecretClient(new URL(vaultUrl)); if (client) { // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. const secret = await client.getSecret(secretName); @@ -86,7 +85,7 @@ export class AzureAppConfigurationImpl extends Map implements A throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); } - private getSecretClient(vaultUrl: string): SecretClient | undefined { + private getSecretClient(vaultUrl: URL): SecretClient | undefined { if (this.secretClients === undefined) { this.secretClients = new Map(); for (const c of this.options?.keyVaultOptions?.secretClients ?? []) { @@ -95,14 +94,14 @@ export class AzureAppConfigurationImpl extends Map implements A } let client: SecretClient | undefined; - client = this.secretClients.get(getHost(vaultUrl)); + client = this.secretClients.get(vaultUrl.host); if (client !== undefined) { return client; } if (this.options?.keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl, this.options.keyVaultOptions.credential); - this.secretClients.set(vaultUrl, client); + client = new SecretClient(vaultUrl.toString(), this.options.keyVaultOptions.credential); + this.secretClients.set(vaultUrl.host, client); return client; } From f82e612bbfde604d67a35e466a4f8d25f7570f70 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 12 Sep 2023 15:05:24 +0800 Subject: [PATCH 3/5] support key vault reference with specific version --- src/AzureAppConfigurationImpl.ts | 4 ++-- test/keyvault.test.js | 2 ++ test/utils/testHelper.js | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index c8ab8bdd..cfb1c14a 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -67,14 +67,14 @@ export class AzureAppConfigurationImpl extends Map implements A } // precedence: secret clients > credential > secret resolver - const { name: secretName, vaultUrl, sourceId } = parseKeyVaultSecretIdentifier( + const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier( parseSecretReference(setting).value.secretId ); const client = this.getSecretClient(new URL(vaultUrl)); if (client) { // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. - const secret = await client.getSecret(secretName); + const secret = await client.getSecret(secretName, { version }); return secret.value; } diff --git a/test/keyvault.test.js b/test/keyvault.test.js index fe51f731..3332512a 100644 --- a/test/keyvault.test.js +++ b/test/keyvault.test.js @@ -15,6 +15,7 @@ const { SecretClient } = require("@azure/keyvault-secrets"); const mockedData = [ // key, secretUri, value ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"], + ["TestKeyFixedVersion", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459", "OldSecretValue"], ["TestKey2", "https://fake-vault-name2.vault.azure.net/secrets/fakeSecretName2", "SecretValue2"] ]; @@ -48,6 +49,7 @@ describe("key vault reference", function () { }); expect(settings).not.undefined; expect(settings.get("TestKey")).eq("SecretValue"); + expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); }); it("should resolve key vault reference with secret resolver", async () => { diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js index f26b8dda..8c26ccc1 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.js @@ -24,9 +24,12 @@ function mockSecretClientGetSecret(uriValueList) { dict.set(uri, value); } - sinon.stub(SecretClient.prototype, "getSecret").callsFake(function (secretName) { + sinon.stub(SecretClient.prototype, "getSecret").callsFake(function (secretName, options) { const url = new URL(this.vaultUrl); url.pathname = `/secrets/${secretName}`; + if (options?.version) { + url.pathname += `/${options.version}`; + } return { value: dict.get(url.toString()) }; }) } From 77edc2921e894ce412ffeb6a3d4e863056cd7b9f Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 13 Sep 2023 14:40:01 +0800 Subject: [PATCH 4/5] address comments: impl keyvault support as an adapter --- src/AzureAppConfigurationImpl.ts | 79 ++++--------------- src/AzureAppConfigurationOptions.ts | 2 +- src/IKeyValueAdapter.ts | 16 ++++ .../AzureAppConfigurationKeyVaultOptions.ts | 0 src/keyvault/AzureKeyVaultReferenceAdapter.ts | 74 +++++++++++++++++ 5 files changed, 105 insertions(+), 66 deletions(-) create mode 100644 src/IKeyValueAdapter.ts rename src/{ => keyvault}/AzureAppConfigurationKeyVaultOptions.ts (100%) create mode 100644 src/keyvault/AzureKeyVaultReferenceAdapter.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index cfb1c14a..031caf71 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,18 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration"; import { AzureAppConfiguration } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; +import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { KeyFilter } from "./KeyFilter"; import { LabelFilter } from "./LabelFilter"; -import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { AzureKeyVaultReferenceAdapter } from "./keyvault/AzureKeyVaultReferenceAdapter"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { - /** - * Map vault hostname to corresponding secret client. - */ - private secretClients: Map; + private adapters: IKeyValueAdapter[] = []; /** * Trim key prefixes sorted in descending order. * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. @@ -27,6 +25,9 @@ export class AzureAppConfigurationImpl extends Map implements A if (options?.trimKeyPrefixes) { this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + // TODO: should add more adapters to process different type of values + // feature flag, json, others + this.adapters.push(new AzureKeyVaultReferenceAdapter(options?.keyVaultOptions)); } public async load() { @@ -40,8 +41,8 @@ export class AzureAppConfigurationImpl extends Map implements A for await (const setting of settings) { if (setting.key && setting.value) { - const trimmedKey = this.keyWithPrefixesTrimmed(setting.key); - const value = await this.processKeyValue(setting); + const [key, value] = await this.processAdapters(setting); + const trimmedKey = this.keyWithPrefixesTrimmed(key); keyValues.push([trimmedKey, value]); } } @@ -51,61 +52,13 @@ export class AzureAppConfigurationImpl extends Map implements A } } - private async processKeyValue(setting: ConfigurationSetting) { - // TODO: should process different type of values - // feature flag, json, others - if (isSecretReference(setting)) { - return this.resolveKeyVaultReference(setting); - } - return setting.value; - } - - private async resolveKeyVaultReference(setting: ConfigurationSetting) { - // TODO: cache results to save requests. - if (!this.options?.keyVaultOptions) { - throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); - } - - // precedence: secret clients > credential > secret resolver - const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier( - parseSecretReference(setting).value.secretId - ); - - const client = this.getSecretClient(new URL(vaultUrl)); - if (client) { - // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. - const secret = await client.getSecret(secretName, { version }); - return secret.value; - } - - if (this.options.keyVaultOptions.secretResolver) { - return await this.options.keyVaultOptions.secretResolver(new URL(sourceId)) - } - - throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); - } - - private getSecretClient(vaultUrl: URL): SecretClient | undefined { - if (this.secretClients === undefined) { - this.secretClients = new Map(); - for (const c of this.options?.keyVaultOptions?.secretClients ?? []) { - this.secretClients.set(getHost(c.vaultUrl), c); + private async processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { + for(const adapter of this.adapters) { + if (adapter.canProcess(setting)) { + return adapter.processKeyValue(setting); } } - - let client: SecretClient | undefined; - client = this.secretClients.get(vaultUrl.host); - if (client !== undefined) { - return client; - } - - if (this.options?.keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.options.keyVaultOptions.credential); - this.secretClients.set(vaultUrl.host, client); - return client; - } - - return undefined; + return [setting.key, setting.value]; } private keyWithPrefixesTrimmed(key: string): string { @@ -119,7 +72,3 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } } - -function getHost(url: string) { - return new URL(url).host; -} \ No newline at end of file diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index e92eef51..011bd684 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { AppConfigurationClientOptions } from "@azure/app-configuration"; -import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions"; +import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; export interface AzureAppConfigurationOptions { selectors?: { keyFilter: string, labelFilter: string }[]; diff --git a/src/IKeyValueAdapter.ts b/src/IKeyValueAdapter.ts new file mode 100644 index 00000000..530c870b --- /dev/null +++ b/src/IKeyValueAdapter.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { ConfigurationSetting } from "@azure/app-configuration"; + +export interface IKeyValueAdapter { + /** + * Determine whether the adapter applies to a configuration setting. + * Note: A setting is expected to be processed by at most one adapter. + */ + canProcess(setting: ConfigurationSetting): boolean; + + /** + * This method process the original configuration setting, and returns processed key and value in an array. + */ + processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; +} \ No newline at end of file diff --git a/src/AzureAppConfigurationKeyVaultOptions.ts b/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts similarity index 100% rename from src/AzureAppConfigurationKeyVaultOptions.ts rename to src/keyvault/AzureAppConfigurationKeyVaultOptions.ts diff --git a/src/keyvault/AzureKeyVaultReferenceAdapter.ts b/src/keyvault/AzureKeyVaultReferenceAdapter.ts new file mode 100644 index 00000000..46c6adf3 --- /dev/null +++ b/src/keyvault/AzureKeyVaultReferenceAdapter.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; +import { IKeyValueAdapter } from "../IKeyValueAdapter"; +import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions"; +import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; + +export class AzureKeyVaultReferenceAdapter implements IKeyValueAdapter { + /** + * Map vault hostname to corresponding secret client. + */ + private secretClients: Map; + + constructor( + private keyVaultOptions: AzureAppConfigurationKeyVaultOptions | undefined + ) { } + + public canProcess(setting: ConfigurationSetting): boolean { + return isSecretReference(setting); + } + + public async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + // TODO: cache results to save requests. + if (!this.keyVaultOptions) { + throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); + } + + // precedence: secret clients > credential > secret resolver + const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + + const client = this.getSecretClient(new URL(vaultUrl)); + if (client) { + // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. + const secret = await client.getSecret(secretName, { version }); + return [setting.key, secret.value]; + } + + if (this.keyVaultOptions.secretResolver) { + return [setting.key, await this.keyVaultOptions.secretResolver(new URL(sourceId))]; + } + + throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + } + + private getSecretClient(vaultUrl: URL): SecretClient | undefined { + if (this.secretClients === undefined) { + this.secretClients = new Map(); + for (const c of this.keyVaultOptions?.secretClients ?? []) { + this.secretClients.set(getHost(c.vaultUrl), c); + } + } + + let client: SecretClient | undefined; + client = this.secretClients.get(vaultUrl.host); + if (client !== undefined) { + return client; + } + + if (this.keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl.toString(), this.keyVaultOptions.credential); + this.secretClients.set(vaultUrl.host, client); + return client; + } + + return undefined; + } +} + +function getHost(url: string) { + return new URL(url).host; +} \ No newline at end of file From 6871925fc14164ea8a64b7aa6f2915f77ba593f7 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Fri, 15 Sep 2023 09:17:03 +0800 Subject: [PATCH 5/5] address comments: rename to AzureKeyVaultKeyValueAdapter --- src/AzureAppConfigurationImpl.ts | 4 ++-- ...ultReferenceAdapter.ts => AzureKeyVaultKeyValueAdapter.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/keyvault/{AzureKeyVaultReferenceAdapter.ts => AzureKeyVaultKeyValueAdapter.ts} (94%) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 031caf71..b54c00cb 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -7,7 +7,7 @@ import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { KeyFilter } from "./KeyFilter"; import { LabelFilter } from "./LabelFilter"; -import { AzureKeyVaultReferenceAdapter } from "./keyvault/AzureKeyVaultReferenceAdapter"; +import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { private adapters: IKeyValueAdapter[] = []; @@ -27,7 +27,7 @@ export class AzureAppConfigurationImpl extends Map implements A } // TODO: should add more adapters to process different type of values // feature flag, json, others - this.adapters.push(new AzureKeyVaultReferenceAdapter(options?.keyVaultOptions)); + this.adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); } public async load() { diff --git a/src/keyvault/AzureKeyVaultReferenceAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts similarity index 94% rename from src/keyvault/AzureKeyVaultReferenceAdapter.ts rename to src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 46c6adf3..3f5326fc 100644 --- a/src/keyvault/AzureKeyVaultReferenceAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -6,7 +6,7 @@ import { IKeyValueAdapter } from "../IKeyValueAdapter"; import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions"; import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; -export class AzureKeyVaultReferenceAdapter implements IKeyValueAdapter { +export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { /** * Map vault hostname to corresponding secret client. */