Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
21 changes: 15 additions & 6 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
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 { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";

export class AzureAppConfigurationImpl extends Map<string, unknown> implements AzureAppConfiguration {
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.
Expand All @@ -22,6 +25,9 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> 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 AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
}

public async load() {
Expand All @@ -35,8 +41,8 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> 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]);
}
}
Expand All @@ -46,10 +52,13 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
}
}

private async processKeyValue(setting: ConfigurationSetting<string>) {
// TODO: should process different type of values
// keyvault reference, feature flag, json, others
return setting.value;
private async processAdapters(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
for(const adapter of this.adapters) {
if (adapter.canProcess(setting)) {
return adapter.processKeyValue(setting);
}
}
return [setting.key, setting.value];
}

private keyWithPrefixesTrimmed(key: string): string {
Expand Down
2 changes: 2 additions & 0 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// Licensed under the MIT license.

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

export interface AzureAppConfigurationOptions {
selectors?: { keyFilter: string, labelFilter: string }[];
trimKeyPrefixes?: string[];
clientOptions?: AppConfigurationClientOptions;
keyVaultOptions?: AzureAppConfigurationKeyVaultOptions;
}
16 changes: 16 additions & 0 deletions src/IKeyValueAdapter.ts
Original file line number Diff line number Diff line change
@@ -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]>;
}
11 changes: 11 additions & 0 deletions src/keyvault/AzureAppConfigurationKeyVaultOptions.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}
74 changes: 74 additions & 0 deletions src/keyvault/AzureKeyVaultKeyValueAdapter.ts
Original file line number Diff line number Diff line change
@@ -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 AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
/**
* Map vault hostname to corresponding secret client.
*/
private secretClients: Map<string, SecretClient>;

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;
}
113 changes: 113 additions & 0 deletions test/keyvault.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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"],
["TestKeyFixedVersion", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459", "OldSecretValue"],
["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");
expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue");
});

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");
});
})
Loading