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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 11 additions & 7 deletions samples/SampleWebApp/Web.config
Original file line number Diff line number Diff line change
Expand Up @@ -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

-->

Expand All @@ -54,7 +56,7 @@
<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"/>

<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"/>
<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"/>
<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"/>
<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"/>
</builders>
</configBuilders>
Expand Down Expand Up @@ -98,6 +100,8 @@
<add key="acLMTest" value="Will be replaced by appconfig3. Should be 'oldest'"/>
<!-- <add key="acLMTest2" value="Will be added by appconfig3. Should be 'older'" /> -->
<add key="acLMTest3" value="Should be this. This will be left alone, as the config store entry is too new for appconfig3."/>
<add key="acKVTest1" value="Should be replaced with 'V1 from KeyVault' by appconfig2." />
<!-- <add key="acKVTest2" value="Will be added by appconfig2. Should be 'V2 from KeyVault'" /> -->
</appConfigTest>

<connectionStrings configBuilders="Json,ExpTest,AS_Sub_Test2">
Expand Down
8 changes: 6 additions & 2 deletions src/Azure/AzureKeyVaultConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,15 @@ private async Task<KeyVaultSecret> 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)
{
Expand Down
6 changes: 6 additions & 0 deletions src/AzureAppConfig/AzureAppConfig.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@
<PackageReference Include="Azure.Identity">
<Version>1.0.0</Version>
</PackageReference>
<PackageReference Include="Azure.Security.KeyVault.Secrets">
<Version>4.0.0</Version>
</PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>11.0.1</Version>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<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')" />
Expand Down
111 changes: 106 additions & 5 deletions src/AzureAppConfig/AzureAppConfigurationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -18,20 +24,24 @@ namespace Microsoft.Configuration.ConfigurationBuilders
/// </summary>
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;
private string _connectionString;
private string _keyFilter;
private string _labelFilter;
private DateTimeOffset _dateTimeFilter;

private bool _useKeyVault = false;
private ConcurrentDictionary<Uri, SecretClient> _kvClientCache;
private ConfigurationClient _client;

/// <summary>
Expand Down Expand Up @@ -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<Uri, SecretClient>(EqualityComparer<Uri>.Default);

// Always allow 'connectionString' to override black magic. But we expect this to be null most of the time.
_connectionString = UpdateConfigSettingWithAppSettings(connectionStringTag);
Expand Down Expand Up @@ -186,7 +200,8 @@ private async Task<string> 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;
Expand All @@ -201,7 +216,27 @@ private async Task<string> 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
{
Expand All @@ -221,7 +256,8 @@ private async Task<ICollection<KeyValuePair<string, string>>> 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;
Expand All @@ -239,8 +275,27 @@ private async Task<ICollection<KeyValuePair<string, string>>> 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
Expand All @@ -252,5 +307,51 @@ private async Task<ICollection<KeyValuePair<string, string>>> GetAllValuesAsync(

return data;
}

private bool IsKeyVaultReference(ConfigurationSetting setting)
{
string contentType = setting.ContentType?.Split(';')[0].Trim();

return String.Equals(contentType, KeyVaultContentType);
}

private async Task<string> 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<KeyVaultSecretReference>(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; }
}
}
}