Skip to content

Commit b5f96cc

Browse files
Azure app config add key vault (#89)
* Add support for key vault secrets through AppConfig. * Add doc blurb for keyvault integration in AppConfig.
1 parent 3f0789e commit b5f96cc

5 files changed

Lines changed: 133 additions & 14 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ and currently exposes the format of the file which, as mentioned above, should b
180180
[@keyFilter="string"]
181181
[@labelFilter="label"]
182182
[@acceptDateTime="DateTimeOffset"]
183+
[@useAzureKeyVault="bool"]
183184
type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfig" />
184185
```
185186
[AppConfiguration](https://docs.microsoft.com/en-us/azure/azure-app-configuration/overview) is a new offering from Azure, currently in preview. If you
@@ -192,6 +193,9 @@ It is however, __strongly__ encouraged to use `endpoint` with a managed service
192193
* `labelFilter` - Only retrieve configuration values that match a certain label.
193194
* `acceptDateTime` - Instead of versioning ala Azure Key Vault, AppConfiguration uses timestamps. Use this attribute to go back in time
194195
to retrieve configuration values from a past state.
196+
* `useAzureKeyVault` - Enable this feature to allow AzureAppConfigurationBuilder to connect to and retrieve secrets from Azure Key Vault for
197+
config values that are stored in Key Vault. The same managed service identity that is used for connecting to the AppConfiguration service will
198+
be used to connect to Key Vault. Default is `false`.
195199

196200
### AzureKeyVaultConfigBuilder
197201
```xml

samples/SampleWebApp/Web.config

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,20 @@
2222
2323
For Azure AppConfiguration, imagine this is the config store:
2424
25-
Key Label Value LastModified
25+
Key Label Value LastModified
2626
acTest (none) test1
2727
acTest2 (none) test2
2828
acTest2 beta test2b
2929
acTest2 beta2 test2b2
3030
acTest3 beta test3b
3131
acTest4 beta3 test4b3
32-
acLMTest (none) oldest T1 (T1,2 before 12/1/19. T3,4,5 after.)
33-
acLMTest (none) newer T3
34-
acLMTest2 ga older T2
35-
acLMTest2 ga newest T4
36-
acLMTest3 (none) toonew T5
32+
acLMTest (none) oldest T1 (T1,2 before 12/1/19. T3,4,5 after.)
33+
acLMTest (none) newer T3
34+
acLMTest2 ga older T2
35+
acLMTest2 ga newest T4
36+
acLMTest3 (none) toonew T5
37+
acKVTest1 beta V1 from KeyVault
38+
acKVTest2 beta V2 from KeyVault
3739
3840
-->
3941

@@ -54,7 +56,7 @@
5456
<add name="ExpTest" mode="Expand" escapeExpandedValues="true" jsonFile="~/App_Data/expandTest.json" jsonMode="Flat" type="Microsoft.Configuration.ConfigurationBuilders.SimpleJsonConfigBuilder, Microsoft.Configuration.ConfigurationBuilders.Json, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
5557

5658
<add name="appconfig1" endpoint="${AppConfigTestEndpoint}" keyFilter="acTes*" type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfiguration, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
57-
<add name="appconfig2" endpoint="${AppConfigTestEndpoint}" mode="Greedy" labelFilter="beta" type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfiguration, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
59+
<add name="appconfig2" optional="false" endpoint="${AppConfigTestEndpoint}" mode="Greedy" labelFilter="beta" useAzureKeyVault="true" type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfiguration, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
5860
<add name="appconfig3" endpoint="${AppConfigTestEndpoint}" mode="Greedy" keyFilter="acLM*" acceptDateTime="December 1, 2019" type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfiguration, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
5961
</builders>
6062
</configBuilders>
@@ -98,6 +100,8 @@
98100
<add key="acLMTest" value="Will be replaced by appconfig3. Should be 'oldest'"/>
99101
<!-- <add key="acLMTest2" value="Will be added by appconfig3. Should be 'older'" /> -->
100102
<add key="acLMTest3" value="Should be this. This will be left alone, as the config store entry is too new for appconfig3."/>
103+
<add key="acKVTest1" value="Should be replaced with 'V1 from KeyVault' by appconfig2." />
104+
<!-- <add key="acKVTest2" value="Will be added by appconfig2. Should be 'V2 from KeyVault'" /> -->
101105
</appConfigTest>
102106

103107
<connectionStrings configBuilders="Json,ExpTest,AS_Sub_Test2">

src/Azure/AzureKeyVaultConfigBuilder.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,15 @@ private async Task<KeyVaultSecret> GetValueAsync(string key)
201201
if (version != null)
202202
{
203203
KeyVaultSecret versionedSecret = await _kvClient.GetSecretAsync(vKey.Key, version);
204-
return versionedSecret;
204+
if (versionedSecret != null && versionedSecret.Properties.Enabled.GetValueOrDefault())
205+
return versionedSecret;
206+
return null;
205207
}
206208

207209
KeyVaultSecret secret = await _kvClient.GetSecretAsync(vKey.Key);
208-
return secret;
210+
if (secret != null && secret.Properties.Enabled.GetValueOrDefault())
211+
return secret;
212+
return null;
209213
}
210214
catch (RequestFailedException rfex)
211215
{

src/AzureAppConfig/AzureAppConfig.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@
5858
<PackageReference Include="Azure.Identity">
5959
<Version>1.0.0</Version>
6060
</PackageReference>
61+
<PackageReference Include="Azure.Security.KeyVault.Secrets">
62+
<Version>4.0.0</Version>
63+
</PackageReference>
64+
<PackageReference Include="Newtonsoft.Json">
65+
<Version>11.0.1</Version>
66+
</PackageReference>
6167
</ItemGroup>
6268
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
6369
<Import Project="..\..\packages\Microsoft.Azure.Services.AppAuthentication.1.0.3\build\Microsoft.Azure.Services.AppAuthentication.targets" Condition="Exists('..\..\packages\Microsoft.Azure.Services.AppAuthentication.1.0.3\build\Microsoft.Azure.Services.AppAuthentication.targets')" />

src/AzureAppConfig/AzureAppConfigurationBuilder.cs

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
// Licensed under the MIT license. See the License.txt file in the project root for full license information.
33

44
using System;
5+
using System.Collections.Concurrent;
56
using System.Collections.Generic;
67
using System.Collections.Specialized;
8+
using System.Configuration;
79
using System.Linq;
10+
using System.Linq.Expressions;
811
using System.Text.RegularExpressions;
912
using System.Threading.Tasks;
13+
1014
using Azure;
1115
using Azure.Data.AppConfiguration;
1216
using Azure.Identity;
17+
using Azure.Security.KeyVault.Secrets;
18+
using Newtonsoft.Json;
1319

1420
namespace Microsoft.Configuration.ConfigurationBuilders
1521
{
@@ -18,20 +24,24 @@ namespace Microsoft.Configuration.ConfigurationBuilders
1824
/// </summary>
1925
public class AzureAppConfigurationBuilder : KeyValueConfigBuilder
2026
{
27+
private const string KeyVaultContentType = "application/vnd.microsoft.appconfig.keyvaultref+json";
28+
2129
#pragma warning disable CS1591 // No xml comments for tag literals.
2230
public const string endpointTag = "endpoint";
2331
public const string connectionStringTag = "connectionString";
2432
public const string keyFilterTag = "keyFilter";
2533
public const string labelFilterTag = "labelFilter";
2634
public const string dateTimeFilterTag = "acceptDateTime";
35+
public const string useKeyVaultTag = "useAzureKeyVault";
2736
#pragma warning restore CS1591 // No xml comments for tag literals.
2837

2938
private Uri _endpoint;
3039
private string _connectionString;
3140
private string _keyFilter;
3241
private string _labelFilter;
3342
private DateTimeOffset _dateTimeFilter;
34-
43+
private bool _useKeyVault = false;
44+
private ConcurrentDictionary<Uri, SecretClient> _kvClientCache;
3545
private ConfigurationClient _client;
3646

3747
/// <summary>
@@ -68,6 +78,10 @@ protected override void LazyInitialize(string name, NameValueCollection config)
6878
// acceptDateTime
6979
_dateTimeFilter = DateTimeOffset.TryParse(UpdateConfigSettingWithAppSettings(dateTimeFilterTag), out _dateTimeFilter) ? _dateTimeFilter : DateTimeOffset.MinValue;
7080

81+
// Azure Key Vault Integration
82+
_useKeyVault = (UpdateConfigSettingWithAppSettings(useKeyVaultTag) != null) ? Boolean.Parse(config[useKeyVaultTag]) : _useKeyVault;
83+
if (_useKeyVault)
84+
_kvClientCache = new ConcurrentDictionary<Uri, SecretClient>(EqualityComparer<Uri>.Default);
7185

7286
// Always allow 'connectionString' to override black magic. But we expect this to be null most of the time.
7387
_connectionString = UpdateConfigSettingWithAppSettings(connectionStringTag);
@@ -186,7 +200,8 @@ private async Task<string> GetValueAsync(string key)
186200
return null;
187201

188202
SettingSelector selector = new SettingSelector(key, _labelFilter);
189-
selector.Fields = SettingFields.Key | SettingFields.Value;
203+
// TODO: Reduce bandwidth by limiting the fields we retrieve.
204+
//selector.Fields = SettingFields.Key | SettingFields.Value | SettingFields.ContentType;
190205
if (_dateTimeFilter > DateTimeOffset.MinValue)
191206
{
192207
selector.AcceptDateTime = _dateTimeFilter;
@@ -201,7 +216,27 @@ private async Task<string> GetValueAsync(string key)
201216
{
202217
// There should only be one result. If there's more, we're only returning the fisrt.
203218
await enumerator.MoveNextAsync();
204-
return enumerator.Current?.Value;
219+
ConfigurationSetting current = enumerator.Current;
220+
if (current == null)
221+
return null;
222+
223+
if (_useKeyVault && IsKeyVaultReference(current))
224+
{
225+
try
226+
{
227+
return await GetKeyVaultValue(current);
228+
}
229+
catch (Exception)
230+
{
231+
// 'Optional' plays a double role with this provider. Being optional means it is
232+
// ok for us to fail to resolve a keyvault reference. If we are not optional though,
233+
// we want to make some noise when a reference fails to resolve.
234+
if (!Optional)
235+
throw;
236+
}
237+
}
238+
239+
return current.Value;
205240
}
206241
finally
207242
{
@@ -221,7 +256,8 @@ private async Task<ICollection<KeyValuePair<string, string>>> GetAllValuesAsync(
221256
return data;
222257

223258
SettingSelector selector = new SettingSelector(_keyFilter, _labelFilter);
224-
selector.Fields = SettingFields.Key | SettingFields.Value;
259+
// TODO: Reduce bandwidth by limiting the fields we retrieve.
260+
//selector.Fields = SettingFields.Key | SettingFields.Value | SettingFields.ContentType;
225261
if (_dateTimeFilter > DateTimeOffset.MinValue)
226262
{
227263
selector.AcceptDateTime = _dateTimeFilter;
@@ -239,8 +275,27 @@ private async Task<ICollection<KeyValuePair<string, string>>> GetAllValuesAsync(
239275
while (await enumerator.MoveNextAsync())
240276
{
241277
ConfigurationSetting setting = enumerator.Current;
278+
string configValue = setting.Value;
279+
280+
// If it's a key vault reference, go fetch the value from key vault
281+
if (_useKeyVault && IsKeyVaultReference(setting))
282+
{
283+
try
284+
{
285+
configValue = await GetKeyVaultValue(setting);
286+
}
287+
catch (Exception)
288+
{
289+
// 'Optional' plays a double role with this provider. Being optional means it is
290+
// ok for us to fail to resolve a keyvault reference. If we are not optional though,
291+
// we want to make some noise when a reference fails to resolve.
292+
if (!Optional)
293+
throw;
294+
}
295+
}
296+
242297
if (!data.ContainsKey(setting.Key))
243-
data[setting.Key] = setting.Value;
298+
data[setting.Key] = configValue;
244299
}
245300
}
246301
finally
@@ -252,5 +307,51 @@ private async Task<ICollection<KeyValuePair<string, string>>> GetAllValuesAsync(
252307

253308
return data;
254309
}
310+
311+
private bool IsKeyVaultReference(ConfigurationSetting setting)
312+
{
313+
string contentType = setting.ContentType?.Split(';')[0].Trim();
314+
315+
return String.Equals(contentType, KeyVaultContentType);
316+
}
317+
318+
private async Task<string> GetKeyVaultValue(ConfigurationSetting setting)
319+
{
320+
// The key vault reference will be in the form of a Uri wrapped in JSON, like so:
321+
// {"uri":"https://vaultName.vault.azure.net/secrets/secretName"}
322+
323+
// Content validation - will throw JsonReaderException on failure
324+
KeyVaultSecretReference secretRef = JsonConvert.DeserializeObject<KeyVaultSecretReference>(setting.Value, KeyVaultSecretReference.s_SerializationSettings);
325+
326+
// Uri validation - will throw UriFormatException upon failure
327+
Uri secretUri = new Uri(secretRef.Uri);
328+
Uri vaultUri = new Uri(secretUri.GetLeftPart(UriPartial.Authority));
329+
330+
// TODO: Check to see if SecretClient can take the full uri instead of requiring us to parse out the secretID.
331+
SecretClient kvClient = GetSecretClient(vaultUri);
332+
if (kvClient == null && !Optional)
333+
throw new ConfigurationErrorsException("Could not connect to Azure Key Vault while retrieving secret. Connection is not optional.");
334+
335+
// Retrieve Value
336+
KeyVaultSecret kvSecret = await kvClient.GetSecretAsync(secretUri.Segments[2].TrimEnd(new char[] { '/' })); // ['/', 'secrets/', '{secretID}/']
337+
if (kvSecret != null && kvSecret.Properties.Enabled.GetValueOrDefault())
338+
return kvSecret.Value;
339+
340+
return null;
341+
}
342+
343+
private SecretClient GetSecretClient(Uri vaultUri)
344+
{
345+
return _kvClientCache.GetOrAdd(vaultUri, uri => new SecretClient(uri, new DefaultAzureCredential()));
346+
}
347+
348+
[JsonObject(MemberSerialization.OptIn)]
349+
private class KeyVaultSecretReference
350+
{
351+
public static JsonSerializerSettings s_SerializationSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None };
352+
353+
[JsonProperty("uri")]
354+
public string Uri { get; set; }
355+
}
255356
}
256357
}

0 commit comments

Comments
 (0)