From 76df9830f7a688c14070f11601273c7a3ff28504 Mon Sep 17 00:00:00 2001 From: Steve Molloy Date: Fri, 20 Dec 2019 17:40:21 -0800 Subject: [PATCH 1/2] Add support for key vault secrets through AppConfig. --- samples/SampleWebApp/Web.config | 18 +-- src/Azure/AzureKeyVaultConfigBuilder.cs | 8 +- src/AzureAppConfig/AzureAppConfig.csproj | 6 + .../AzureAppConfigurationBuilder.cs | 111 +++++++++++++++++- 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/samples/SampleWebApp/Web.config b/samples/SampleWebApp/Web.config index 9be1e15..2d06b8b 100644 --- a/samples/SampleWebApp/Web.config +++ b/samples/SampleWebApp/Web.config @@ -22,18 +22,20 @@ For Azure AppConfiguration, imagine this is the config store: - Key Label Value LastModified + Key Label Value LastModified acTest (none) test1 acTest2 (none) test2 acTest2 beta test2b acTest2 beta2 test2b2 acTest3 beta test3b acTest4 beta3 test4b3 - acLMTest (none) oldest T1 (T1,2 before 12/1/19. T3,4,5 after.) - acLMTest (none) newer T3 - acLMTest2 ga older T2 - acLMTest2 ga newest T4 - acLMTest3 (none) toonew T5 + acLMTest (none) oldest T1 (T1,2 before 12/1/19. T3,4,5 after.) + acLMTest (none) newer T3 + acLMTest2 ga older T2 + acLMTest2 ga newest T4 + acLMTest3 (none) toonew T5 + acKVTest1 beta V1 from KeyVault + acKVTest2 beta V2 from KeyVault --> @@ -54,7 +56,7 @@ - + @@ -98,6 +100,8 @@ + + diff --git a/src/Azure/AzureKeyVaultConfigBuilder.cs b/src/Azure/AzureKeyVaultConfigBuilder.cs index e3b61ec..532bf5f 100644 --- a/src/Azure/AzureKeyVaultConfigBuilder.cs +++ b/src/Azure/AzureKeyVaultConfigBuilder.cs @@ -201,11 +201,15 @@ private async Task GetValueAsync(string key) if (version != null) { KeyVaultSecret versionedSecret = await _kvClient.GetSecretAsync(vKey.Key, version); - return versionedSecret; + if (versionedSecret != null && versionedSecret.Properties.Enabled.GetValueOrDefault()) + return versionedSecret; + return null; } KeyVaultSecret secret = await _kvClient.GetSecretAsync(vKey.Key); - return secret; + if (secret != null && secret.Properties.Enabled.GetValueOrDefault()) + return secret; + return null; } catch (RequestFailedException rfex) { diff --git a/src/AzureAppConfig/AzureAppConfig.csproj b/src/AzureAppConfig/AzureAppConfig.csproj index 4778c19..373948d 100644 --- a/src/AzureAppConfig/AzureAppConfig.csproj +++ b/src/AzureAppConfig/AzureAppConfig.csproj @@ -58,6 +58,12 @@ 1.0.0 + + 4.0.0 + + + 11.0.1 + diff --git a/src/AzureAppConfig/AzureAppConfigurationBuilder.cs b/src/AzureAppConfig/AzureAppConfigurationBuilder.cs index 7dbff15..d64bf3f 100644 --- a/src/AzureAppConfig/AzureAppConfigurationBuilder.cs +++ b/src/AzureAppConfig/AzureAppConfigurationBuilder.cs @@ -2,14 +2,20 @@ // Licensed under the MIT license. See the License.txt file in the project root for full license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Specialized; +using System.Configuration; using System.Linq; +using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; + using Azure; using Azure.Data.AppConfiguration; using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Newtonsoft.Json; namespace Microsoft.Configuration.ConfigurationBuilders { @@ -18,12 +24,15 @@ namespace Microsoft.Configuration.ConfigurationBuilders /// public class AzureAppConfigurationBuilder : KeyValueConfigBuilder { + private const string KeyVaultContentType = "application/vnd.microsoft.appconfig.keyvaultref+json"; + #pragma warning disable CS1591 // No xml comments for tag literals. public const string endpointTag = "endpoint"; public const string connectionStringTag = "connectionString"; public const string keyFilterTag = "keyFilter"; public const string labelFilterTag = "labelFilter"; public const string dateTimeFilterTag = "acceptDateTime"; + public const string useKeyVaultTag = "useAzureKeyVault"; #pragma warning restore CS1591 // No xml comments for tag literals. private Uri _endpoint; @@ -31,7 +40,8 @@ public class AzureAppConfigurationBuilder : KeyValueConfigBuilder private string _keyFilter; private string _labelFilter; private DateTimeOffset _dateTimeFilter; - + private bool _useKeyVault = false; + private ConcurrentDictionary _kvClientCache; private ConfigurationClient _client; /// @@ -68,6 +78,10 @@ protected override void LazyInitialize(string name, NameValueCollection config) // acceptDateTime _dateTimeFilter = DateTimeOffset.TryParse(UpdateConfigSettingWithAppSettings(dateTimeFilterTag), out _dateTimeFilter) ? _dateTimeFilter : DateTimeOffset.MinValue; + // Azure Key Vault Integration + _useKeyVault = (UpdateConfigSettingWithAppSettings(useKeyVaultTag) != null) ? Boolean.Parse(config[useKeyVaultTag]) : _useKeyVault; + if (_useKeyVault) + _kvClientCache = new ConcurrentDictionary(EqualityComparer.Default); // Always allow 'connectionString' to override black magic. But we expect this to be null most of the time. _connectionString = UpdateConfigSettingWithAppSettings(connectionStringTag); @@ -186,7 +200,8 @@ private async Task GetValueAsync(string key) return null; SettingSelector selector = new SettingSelector(key, _labelFilter); - selector.Fields = SettingFields.Key | SettingFields.Value; + // TODO: Reduce bandwidth by limiting the fields we retrieve. + //selector.Fields = SettingFields.Key | SettingFields.Value | SettingFields.ContentType; if (_dateTimeFilter > DateTimeOffset.MinValue) { selector.AcceptDateTime = _dateTimeFilter; @@ -201,7 +216,27 @@ private async Task GetValueAsync(string key) { // There should only be one result. If there's more, we're only returning the fisrt. await enumerator.MoveNextAsync(); - return enumerator.Current?.Value; + ConfigurationSetting current = enumerator.Current; + if (current == null) + return null; + + if (_useKeyVault && IsKeyVaultReference(current)) + { + try + { + return await GetKeyVaultValue(current); + } + catch (Exception) + { + // 'Optional' plays a double role with this provider. Being optional means it is + // ok for us to fail to resolve a keyvault reference. If we are not optional though, + // we want to make some noise when a reference fails to resolve. + if (!Optional) + throw; + } + } + + return current.Value; } finally { @@ -221,7 +256,8 @@ private async Task>> GetAllValuesAsync( return data; SettingSelector selector = new SettingSelector(_keyFilter, _labelFilter); - selector.Fields = SettingFields.Key | SettingFields.Value; + // TODO: Reduce bandwidth by limiting the fields we retrieve. + //selector.Fields = SettingFields.Key | SettingFields.Value | SettingFields.ContentType; if (_dateTimeFilter > DateTimeOffset.MinValue) { selector.AcceptDateTime = _dateTimeFilter; @@ -239,8 +275,27 @@ private async Task>> GetAllValuesAsync( while (await enumerator.MoveNextAsync()) { ConfigurationSetting setting = enumerator.Current; + string configValue = setting.Value; + + // If it's a key vault reference, go fetch the value from key vault + if (_useKeyVault && IsKeyVaultReference(setting)) + { + try + { + configValue = await GetKeyVaultValue(setting); + } + catch (Exception) + { + // 'Optional' plays a double role with this provider. Being optional means it is + // ok for us to fail to resolve a keyvault reference. If we are not optional though, + // we want to make some noise when a reference fails to resolve. + if (!Optional) + throw; + } + } + if (!data.ContainsKey(setting.Key)) - data[setting.Key] = setting.Value; + data[setting.Key] = configValue; } } finally @@ -252,5 +307,51 @@ private async Task>> GetAllValuesAsync( return data; } + + private bool IsKeyVaultReference(ConfigurationSetting setting) + { + string contentType = setting.ContentType?.Split(';')[0].Trim(); + + return String.Equals(contentType, KeyVaultContentType); + } + + private async Task GetKeyVaultValue(ConfigurationSetting setting) + { + // The key vault reference will be in the form of a Uri wrapped in JSON, like so: + // {"uri":"https://vaultName.vault.azure.net/secrets/secretName"} + + // Content validation - will throw JsonReaderException on failure + KeyVaultSecretReference secretRef = JsonConvert.DeserializeObject(setting.Value, KeyVaultSecretReference.s_SerializationSettings); + + // Uri validation - will throw UriFormatException upon failure + Uri secretUri = new Uri(secretRef.Uri); + Uri vaultUri = new Uri(secretUri.GetLeftPart(UriPartial.Authority)); + + // TODO: Check to see if SecretClient can take the full uri instead of requiring us to parse out the secretID. + SecretClient kvClient = GetSecretClient(vaultUri); + if (kvClient == null && !Optional) + throw new ConfigurationErrorsException("Could not connect to Azure Key Vault while retrieving secret. Connection is not optional."); + + // Retrieve Value + KeyVaultSecret kvSecret = await kvClient.GetSecretAsync(secretUri.Segments[2].TrimEnd(new char[] { '/' })); // ['/', 'secrets/', '{secretID}/'] + if (kvSecret != null && kvSecret.Properties.Enabled.GetValueOrDefault()) + return kvSecret.Value; + + return null; + } + + private SecretClient GetSecretClient(Uri vaultUri) + { + return _kvClientCache.GetOrAdd(vaultUri, uri => new SecretClient(uri, new DefaultAzureCredential())); + } + + [JsonObject(MemberSerialization.OptIn)] + private class KeyVaultSecretReference + { + public static JsonSerializerSettings s_SerializationSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None }; + + [JsonProperty("uri")] + public string Uri { get; set; } + } } } From f98e7d0e0cb56cf4eb78befc3bb42bbc3ad6e9fc Mon Sep 17 00:00:00 2001 From: Steve Molloy Date: Fri, 20 Dec 2019 17:46:26 -0800 Subject: [PATCH 2/2] Add doc blurb for keyvault integration in AppConfig. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 373725d..0be7b61 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ and currently exposes the format of the file which, as mentioned above, should b [@keyFilter="string"] [@labelFilter="label"] [@acceptDateTime="DateTimeOffset"] + [@useAzureKeyVault="bool"] type="Microsoft.Configuration.ConfigurationBuilders.AzureAppConfigurationBuilder, Microsoft.Configuration.ConfigurationBuilders.AzureAppConfig" /> ``` [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 * `labelFilter` - Only retrieve configuration values that match a certain label. * `acceptDateTime` - Instead of versioning ala Azure Key Vault, AppConfiguration uses timestamps. Use this attribute to go back in time to retrieve configuration values from a past state. + * `useAzureKeyVault` - Enable this feature to allow AzureAppConfigurationBuilder to connect to and retrieve secrets from Azure Key Vault for + config values that are stored in Key Vault. The same managed service identity that is used for connecting to the AppConfiguration service will + be used to connect to Key Vault. Default is `false`. ### AzureKeyVaultConfigBuilder ```xml