diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index 4808ac18433d..4a18a0522ef7 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -70,6 +70,7 @@ known_presence_issues: - ['sdk/keyvault','#5499'] - ['sdk/eventhub','#5499'] - ['sdk/attestation/Microsoft.Azure.Attestation','#5499'] + - ['sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration', '#9939'] - ['sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection','#9955'] # List for changelogs begins here diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index eb8e12dbb39d..1f9173bb6d2e 100755 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -119,6 +119,8 @@ + + diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/api/Azure.Security.KeyVault.Secrets.Extensions.Configuration.netstandard2.0.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/api/Azure.Security.KeyVault.Secrets.Extensions.Configuration.netstandard2.0.cs new file mode 100644 index 000000000000..288e5471b3c2 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/api/Azure.Security.KeyVault.Secrets.Extensions.Configuration.netstandard2.0.cs @@ -0,0 +1,34 @@ +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + public partial class AzureKeyVaultConfigurationOptions + { + public AzureKeyVaultConfigurationOptions() { } + public AzureKeyVaultConfigurationOptions(System.Uri vaultUri, Azure.Core.TokenCredential credential) { } + public Azure.Security.KeyVault.Secrets.SecretClient Client { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public Azure.Security.KeyVault.Secrets.Extensions.Configuration.IKeyVaultSecretManager Manager { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public System.TimeSpan? ReloadInterval { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public partial class DefaultKeyVaultSecretManager : Azure.Security.KeyVault.Secrets.Extensions.Configuration.IKeyVaultSecretManager + { + public DefaultKeyVaultSecretManager() { } + public virtual string GetKey(Azure.Security.KeyVault.Secrets.KeyVaultSecret secret) { throw null; } + public virtual bool Load(Azure.Security.KeyVault.Secrets.SecretProperties secret) { throw null; } + } + public partial interface IKeyVaultSecretManager + { + string GetKey(Azure.Security.KeyVault.Secrets.KeyVaultSecret secret); + bool Load(Azure.Security.KeyVault.Secrets.SecretProperties secret); + } +} +namespace Microsoft.Extensions.Configuration +{ + public static partial class AzureKeyVaultConfigurationExtensions + { + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, Azure.Security.KeyVault.Secrets.Extensions.Configuration.AzureKeyVaultConfigurationOptions options) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, Azure.Security.KeyVault.Secrets.SecretClient client, Azure.Security.KeyVault.Secrets.Extensions.Configuration.IKeyVaultSecretManager manager) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Uri vaultUri) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Uri vaultUri, Azure.Core.TokenCredential credential) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Uri vaultUri, Azure.Core.TokenCredential credential, Azure.Security.KeyVault.Secrets.Extensions.Configuration.IKeyVaultSecretManager manager) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddAzureKeyVault(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Uri vaultUri, Azure.Security.KeyVault.Secrets.Extensions.Configuration.IKeyVaultSecretManager manager) { throw null; } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj new file mode 100644 index 000000000000..0f741f5a3862 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj @@ -0,0 +1,20 @@ + + + + Azure Key Vault configuration provider implementation for Microsoft.Extensions.Configuration. + $(RequiredTargetFrameworks) + $(PackageTags);azure;keyvault + 1.0.0-preview.1 + false + + + + + + + + + + + + diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationExtensions.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationExtensions.cs new file mode 100644 index 000000000000..b25c444f20d5 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationExtensions.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Core; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Azure.Security.KeyVault.Secrets.Extensions.Configuration; + +#pragma warning disable AZC0001 // Extension methods have to be in the correct namespace to appear in intellisense. +namespace Microsoft.Extensions.Configuration +#pragma warning restore +{ + /// + /// Extension methods for registering with . + /// + public static class AzureKeyVaultConfigurationExtensions + { + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// The Azure Key Vault uri. + /// The credential to to use for authentication. + /// The . + public static IConfigurationBuilder AddAzureKeyVault( + this IConfigurationBuilder configurationBuilder, + Uri vaultUri, + TokenCredential credential) + { + return AddAzureKeyVault(configurationBuilder, vaultUri, credential, new DefaultKeyVaultSecretManager()); + } + + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// Azure Key Vault uri. + /// The . + public static IConfigurationBuilder AddAzureKeyVault( + this IConfigurationBuilder configurationBuilder, + Uri vaultUri) + { + return AddAzureKeyVault(configurationBuilder, vaultUri, new DefaultAzureCredential()); + } + + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// Azure Key Vault uri. + /// The instance used to control secret loading. + /// The . + public static IConfigurationBuilder AddAzureKeyVault( + this IConfigurationBuilder configurationBuilder, + Uri vaultUri, + IKeyVaultSecretManager manager) + { + return AddAzureKeyVault(configurationBuilder, vaultUri, new DefaultAzureCredential(), manager); + } + + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// Azure Key Vault uri. + /// The credential to to use for authentication. + /// The instance used to control secret loading. + /// The . + public static IConfigurationBuilder AddAzureKeyVault( + this IConfigurationBuilder configurationBuilder, + Uri vaultUri, + TokenCredential credential, + IKeyVaultSecretManager manager) + { + return AddAzureKeyVault(configurationBuilder, new AzureKeyVaultConfigurationOptions(vaultUri, credential) + { + Manager = manager + }); + } + + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// The to use for retrieving values. + /// The instance used to control secret loading. + /// The . + public static IConfigurationBuilder AddAzureKeyVault( + this IConfigurationBuilder configurationBuilder, + SecretClient client, + IKeyVaultSecretManager manager) + { + return configurationBuilder.Add(new AzureKeyVaultConfigurationSource(new AzureKeyVaultConfigurationOptions() + { + Client = client, + Manager = manager + })); + } + + /// + /// Adds an that reads configuration values from the Azure KeyVault. + /// + /// The to add to. + /// The to use. + /// The . + public static IConfigurationBuilder AddAzureKeyVault(this IConfigurationBuilder configurationBuilder, AzureKeyVaultConfigurationOptions options) + { + Argument.AssertNotNull(configurationBuilder, nameof(configurationBuilder)); + Argument.AssertNotNull(options, nameof(configurationBuilder)); + Argument.AssertNotNull(options.Client, $"{nameof(options)}.{nameof(options.Client)}"); + Argument.AssertNotNull(options.Manager, $"{nameof(options)}.{nameof(options.Manager)}"); + + configurationBuilder.Add(new AzureKeyVaultConfigurationSource(options)); + + return configurationBuilder; + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationOptions.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationOptions.cs new file mode 100644 index 000000000000..60c09b47d16b --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationOptions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Core; +using Microsoft.Extensions.Configuration; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + /// + /// Options class used by the . + /// + public class AzureKeyVaultConfigurationOptions + { + /// + /// Creates a new instance of . + /// + public AzureKeyVaultConfigurationOptions() + { + Manager = DefaultKeyVaultSecretManager.Instance; + } + + /// + /// Creates a new instance of . + /// + /// Azure Key Vault uri. + /// The to use for authentication. + public AzureKeyVaultConfigurationOptions( + Uri vaultUri, + TokenCredential credential) : this() + { + Client = new SecretClient(vaultUri, credential); + } + + /// + /// Gets or sets the to use for retrieving values. + /// + public SecretClient Client { get; set; } + + /// + /// Gets or sets the instance used to control secret loading. + /// + public IKeyVaultSecretManager Manager { get; set; } + + /// + /// Gets or sets the timespan to wait between attempts at polling the Azure Key Vault for changes. null to disable reloading. + /// + public TimeSpan? ReloadInterval { get; set; } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationProvider.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationProvider.cs new file mode 100644 index 000000000000..3dd0b3d7731e --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationProvider.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.Configuration; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + /// + /// An AzureKeyVault based . + /// + internal class AzureKeyVaultConfigurationProvider : ConfigurationProvider, IDisposable + { + private readonly TimeSpan? _reloadInterval; + private readonly SecretClient _client; + private readonly IKeyVaultSecretManager _manager; + private Dictionary _loadedSecrets; + private Task _pollingTask; + private readonly CancellationTokenSource _cancellationToken; + + /// + /// Creates a new instance of . + /// + /// The to use for retrieving values. + /// The to use in managing values. + /// The timespan to wait in between each attempt at polling the Azure Key Vault for changes. Default is null which indicates no reloading. + public AzureKeyVaultConfigurationProvider(SecretClient client, IKeyVaultSecretManager manager, TimeSpan? reloadInterval = null) + { + Argument.AssertNotNull(client, nameof(client)); + Argument.AssertNotNull(manager, nameof(manager)); + + _client = client; + _manager = manager; + if (reloadInterval != null && reloadInterval.Value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(reloadInterval), reloadInterval, nameof(reloadInterval) + " must be positive."); + } + + _pollingTask = null; + _cancellationToken = new CancellationTokenSource(); + _reloadInterval = reloadInterval; + } + + /// + /// Load secrets into this provider. + /// + public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + + private async Task PollForSecretChangesAsync() + { + while (!_cancellationToken.IsCancellationRequested) + { + await WaitForReload().ConfigureAwait(false); + try + { + await LoadAsync().ConfigureAwait(false); + } + catch (Exception) + { + // Ignore + } + } + } + + protected virtual Task WaitForReload() + { + // WaitForReload is only called when the _reloadInterval has a value. + return Task.Delay(_reloadInterval.Value, _cancellationToken.Token); + } + + private async Task LoadAsync() + { + var secretPages = _client.GetPropertiesOfSecretsAsync(); + + var tasks = new List>>(); + var newLoadedSecrets = new Dictionary(); + var oldLoadedSecrets = Interlocked.Exchange(ref _loadedSecrets, null); + + await foreach (var secret in secretPages.ConfigureAwait(false)) + { + if (!_manager.Load(secret) || secret.Enabled != true) + { + continue; + } + + var secretId = secret.Name; + if (oldLoadedSecrets != null && + oldLoadedSecrets.TryGetValue(secretId, out var existingSecret) && + existingSecret.IsUpToDate(secret.UpdatedOn)) + { + oldLoadedSecrets.Remove(secretId); + newLoadedSecrets.Add(secretId, existingSecret); + } + else + { + tasks.Add(_client.GetSecretAsync(secret.Name)); + } + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (var task in tasks) + { + var secretBundle = task.Result; + newLoadedSecrets.Add(secretBundle.Value.Name, new LoadedSecret(_manager.GetKey(secretBundle), secretBundle.Value.Value, secretBundle.Value.Properties.UpdatedOn)); + } + + _loadedSecrets = newLoadedSecrets; + + // Reload is needed if we are loading secrets that were not loaded before or + // secret that was loaded previously is not available anymore + if (tasks.Any() || oldLoadedSecrets?.Any() == true) + { + SetData(_loadedSecrets, fireToken: oldLoadedSecrets != null); + } + + // schedule a polling task only if none exists and a valid delay is specified + if (_pollingTask == null && _reloadInterval != null) + { + _pollingTask = PollForSecretChangesAsync(); + } + } + + private void SetData(Dictionary loadedSecrets, bool fireToken) + { + var data = new Dictionary(loadedSecrets.Count, StringComparer.OrdinalIgnoreCase); + foreach (var secretItem in loadedSecrets) + { + data.Add(secretItem.Value.Key, secretItem.Value.Value); + } + + Data = data; + if (fireToken) + { + OnReload(); + } + } + + /// + public void Dispose() + { + _cancellationToken.Cancel(); + } + + private readonly struct LoadedSecret + { + public LoadedSecret(string key, string value, DateTimeOffset? updated) + { + Key = key; + Value = value; + Updated = updated; + } + + public string Key { get; } + public string Value { get; } + public DateTimeOffset? Updated { get; } + + public bool IsUpToDate(DateTimeOffset? updated) + { + if (updated.HasValue != Updated.HasValue) + { + return false; + } + + return updated.GetValueOrDefault() == Updated.GetValueOrDefault(); + } + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationSource.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationSource.cs new file mode 100644 index 000000000000..c28f1a43b26c --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/AzureKeyVaultConfigurationSource.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + /// + /// Represents Azure Key Vault secrets as an . + /// + internal class AzureKeyVaultConfigurationSource : IConfigurationSource + { + private readonly AzureKeyVaultConfigurationOptions _options; + + public AzureKeyVaultConfigurationSource(AzureKeyVaultConfigurationOptions options) + { + _options = options; + } + + /// + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new AzureKeyVaultConfigurationProvider(_options.Client, _options.Manager, _options.ReloadInterval); + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/DefaultKeyVaultSecretManager.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/DefaultKeyVaultSecretManager.cs new file mode 100644 index 000000000000..e617de01c489 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/DefaultKeyVaultSecretManager.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + /// + /// Default implementation of that loads all secrets + /// and replaces '--' with ':" in key names. + /// + public class DefaultKeyVaultSecretManager : IKeyVaultSecretManager + { + internal static IKeyVaultSecretManager Instance { get; } = new DefaultKeyVaultSecretManager(); + + /// + public virtual string GetKey(KeyVaultSecret secret) + { + return secret.Name.Replace("--", ConfigurationPath.KeyDelimiter); + } + + /// + public virtual bool Load(SecretProperties secret) + { + return true; + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/IKeyVaultSecretManager.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/IKeyVaultSecretManager.cs new file mode 100644 index 000000000000..9b716723375d --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/IKeyVaultSecretManager.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration +{ + /// + /// The instance used to control secret loading. + /// + public interface IKeyVaultSecretManager + { + /// + /// Checks if value should be retrieved. + /// + /// The instance. + /// true if secrets value should be loaded, otherwise false. + bool Load(SecretProperties secret); + + /// + /// Maps secret to a configuration key. + /// + /// The instance. + /// Configuration key name to store secret value. + string GetKey(KeyVaultSecret secret); + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Properties/AssemblyInfo.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..e17e21839715 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] +[assembly: InternalsVisibleTo("Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100d15ddcb29688295338af4b7686603fe614abd555e09efba8fb88ee09e1f7b1ccaeed2e8f823fa9eef3fdd60217fc012ea67d2479751a0b8c087a4185541b851bd8b16f8d91b840e51b1cb0ba6fe647997e57429265e85ef62d565db50a69ae1647d54d7bd855e4db3d8a91510e5bcbd0edfbbecaa20a7bd9ae74593daa7b11b4")] \ No newline at end of file diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests.csproj b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests.csproj new file mode 100644 index 000000000000..4d3eb067eca6 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests.csproj @@ -0,0 +1,22 @@ + + + + $(RequiredTargetFrameworks) + true + + + + + + + + + + + + + + + + + diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/AzureKeyVaultConfigurationTest.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/AzureKeyVaultConfigurationTest.cs new file mode 100644 index 000000000000..daf151caad8d --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/AzureKeyVaultConfigurationTest.cs @@ -0,0 +1,529 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.Testing; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests +{ + public class AzureKeyVaultConfigurationTest + { + private static readonly TimeSpan NoReloadDelay = TimeSpan.FromMilliseconds(1); + + private void SetPages(Mock mock, params KeyVaultSecret[][] pages) + { + SetPages(mock, null, pages); + } + + private void SetPages(Mock mock, Func getSecretCallback, params KeyVaultSecret[][] pages) + { + getSecretCallback ??= (_ => Task.CompletedTask); + + var pagesOfProperties = pages.Select( + page => page.Select(secret => secret.Properties).ToArray()).ToArray(); + + mock.Setup(m => m.GetPropertiesOfSecretsAsync(default)).Returns(new MockAsyncPageable(pagesOfProperties)); + + foreach (var page in pages) + { + foreach (var secret in page) + { + mock.Setup(client => client.GetSecretAsync(secret.Name, null, default)) + .Callback((string name, string label, CancellationToken token) => getSecretCallback(name)) + .ReturnsAsync(Response.FromValue(secret, Mock.Of())); + } + } + } + + private class MockAsyncPageable : AsyncPageable + { + private readonly SecretProperties[][] _pages; + + public MockAsyncPageable(SecretProperties[][] pages) + { + _pages = pages; + } + + public override async IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) + { + foreach (var page in _pages) + { + yield return Page.FromValues(page, null, Mock.Of()); + } + + await Task.CompletedTask; + } + } + + [Test] + public void LoadsAllSecretsFromVault() + { + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1") + }, + new[] + { + CreateSecret("Secret2", "Value2") + } + ); + + // Act + using (var provider = new AzureKeyVaultConfigurationProvider(client.Object, new DefaultKeyVaultSecretManager())) + { + provider.Load(); + + var childKeys = provider.GetChildKeys(Enumerable.Empty(), null).ToArray(); + Assert.AreEqual(new[] { "Secret1", "Secret2" }, childKeys); + Assert.AreEqual("Value1", provider.Get("Secret1")); + Assert.AreEqual("Value2", provider.Get("Secret2")); + } + } + + private KeyVaultSecret CreateSecret(string name, string value, bool? enabled = true, DateTimeOffset? updated = null) + { + var id = new Uri("http://azure.keyvault/" + name); + + var secretProperties = SecretModelFactory.SecretProperties(id, name: name, updatedOn: updated); + secretProperties.Enabled = enabled; + + return SecretModelFactory.KeyVaultSecret(secretProperties, value); + } + + [Test] + public void DoesNotLoadFilteredItems() + { + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1") + }, + new[] + { + CreateSecret("Secret2", "Value2") + } + ); + + // Act + using (var provider = new AzureKeyVaultConfigurationProvider(client.Object, new EndsWithOneKeyVaultSecretManager())) + { + provider.Load(); + + // Assert + var childKeys = provider.GetChildKeys(Enumerable.Empty(), null).ToArray(); + Assert.AreEqual(new[] { "Secret1" }, childKeys); + Assert.AreEqual("Value1", provider.Get("Secret1")); + } + } + + [Test] + public void DoesNotLoadDisabledItems() + { + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1") + }, + new[] + { + CreateSecret("Secret2", "Value2", enabled: false), + CreateSecret("Secret3", "Value3", enabled: null), + } + ); + + // Act + using (var provider = new AzureKeyVaultConfigurationProvider(client.Object, new DefaultKeyVaultSecretManager())) + { + provider.Load(); + + // Assert + var childKeys = provider.GetChildKeys(Enumerable.Empty(), null).ToArray(); + Assert.AreEqual(new[] { "Secret1" }, childKeys); + Assert.AreEqual("Value1", provider.Get("Secret1")); + Assert.Throws(() => provider.Get("Secret2")); + Assert.Throws(() => provider.Get("Secret3")); + } + } + + [Test] + public void SupportsReload() + { + var updated = DateTime.Now; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1", enabled: true, updated: updated) + } + ); + + // Act & Assert + using (var provider = new AzureKeyVaultConfigurationProvider(client.Object, new DefaultKeyVaultSecretManager())) + { + provider.Load(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value2", enabled: true, updated: updated.AddSeconds(1)) + } + ); + + provider.Load(); + Assert.AreEqual("Value2", provider.Get("Secret1")); + } + } + + [Test] + public async Task SupportsAutoReload() + { + var updated = DateTime.Now; + int numOfTokensFired = 0; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1", enabled: true, updated: updated) + } + ); + + // Act & Assert + using (var provider = new ReloadControlKeyVaultProvider(client.Object, new DefaultKeyVaultSecretManager(), reloadPollDelay: NoReloadDelay)) + { + ChangeToken.OnChange( + () => provider.GetReloadToken(), + () => { + numOfTokensFired++; + }); + + provider.Load(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + + await provider.Wait(); + + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value2", enabled: true, updated: updated.AddSeconds(1)) + } + ); + + provider.Release(); + + await provider.Wait(); + + Assert.AreEqual("Value2", provider.Get("Secret1")); + Assert.AreEqual(1, numOfTokensFired); + } + } + + [Test] + public async Task DoesntReloadUnchanged() + { + var updated = DateTime.Now; + int numOfTokensFired = 0; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1", enabled: true, updated: updated) + } + ); + + // Act & Assert + using (var provider = new ReloadControlKeyVaultProvider(client.Object, new DefaultKeyVaultSecretManager(), reloadPollDelay: NoReloadDelay)) + { + ChangeToken.OnChange( + () => provider.GetReloadToken(), + () => { + numOfTokensFired++; + }); + + provider.Load(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + + await provider.Wait(); + + provider.Release(); + + await provider.Wait(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + Assert.AreEqual(0, numOfTokensFired); + } + } + + [Test] + public async Task SupportsReloadOnRemove() + { + int numOfTokensFired = 0; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1"), + CreateSecret("Secret2", "Value2") + } + ); + + // Act & Assert + using (var provider = new ReloadControlKeyVaultProvider(client.Object, new DefaultKeyVaultSecretManager(), reloadPollDelay: NoReloadDelay)) + { + ChangeToken.OnChange( + () => provider.GetReloadToken(), + () => { + numOfTokensFired++; + }); + + provider.Load(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + + await provider.Wait(); + + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value2") + } + ); + + provider.Release(); + + await provider.Wait(); + + Assert.Throws(() => provider.Get("Secret2")); + Assert.AreEqual(1, numOfTokensFired); + } + } + + [Test] + public async Task SupportsReloadOnEnabledChange() + { + int numOfTokensFired = 0; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1"), + CreateSecret("Secret2", "Value2") + } + ); + + // Act & Assert + using (var provider = new ReloadControlKeyVaultProvider(client.Object, new DefaultKeyVaultSecretManager(), reloadPollDelay: NoReloadDelay)) + { + ChangeToken.OnChange( + () => provider.GetReloadToken(), + () => { + numOfTokensFired++; + }); + + provider.Load(); + + Assert.AreEqual("Value2", provider.Get("Secret2")); + + await provider.Wait(); + + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value2"), + CreateSecret("Secret2", "Value2", enabled: false) + } + ); + + provider.Release(); + + await provider.Wait(); + + Assert.Throws(() => provider.Get("Secret2")); + Assert.AreEqual(1, numOfTokensFired); + } + } + + [Test] + public async Task SupportsReloadOnAdd() + { + int numOfTokensFired = 0; + + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1") + } + ); + + // Act & Assert + using (var provider = new ReloadControlKeyVaultProvider(client.Object, new DefaultKeyVaultSecretManager(), reloadPollDelay: NoReloadDelay)) + { + ChangeToken.OnChange( + () => provider.GetReloadToken(), + () => { + numOfTokensFired++; + }); + + provider.Load(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + + await provider.Wait(); + + SetPages(client, + new[] + { + CreateSecret("Secret1", "Value1"), + }, + new[] + { + CreateSecret("Secret2", "Value2") + } + ); + + provider.Release(); + + await provider.Wait(); + + Assert.AreEqual("Value1", provider.Get("Secret1")); + Assert.AreEqual("Value2", provider.Get("Secret2")); + Assert.AreEqual(1, numOfTokensFired); + } + } + + [Test] + public void ReplaceDoubleMinusInKeyName() + { + var client = new Mock(); + SetPages(client, + new[] + { + CreateSecret("Section--Secret1", "Value1") + } + ); + + // Act + using (var provider = new AzureKeyVaultConfigurationProvider(client.Object, new DefaultKeyVaultSecretManager())) + { + provider.Load(); + + // Assert + Assert.AreEqual("Value1", provider.Get("Section:Secret1")); + } + } + + [Test] + public async Task LoadsSecretsInParallel() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expectedCount = 2; + var client = new Mock(); + + SetPages(client, + async (string id) => + { + if (Interlocked.Decrement(ref expectedCount) == 0) + { + tcs.SetResult(null); + } + + await tcs.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); + }, + new[] + { + CreateSecret("Secret1", "Value1"), + CreateSecret("Secret2", "Value2") + } + ); + + // Act + var provider = new AzureKeyVaultConfigurationProvider(client.Object, new DefaultKeyVaultSecretManager()); + provider.Load(); + await tcs.Task; + + // Assert + Assert.AreEqual("Value1", provider.Get("Secret1")); + Assert.AreEqual("Value2", provider.Get("Secret2")); + } + + [Test] + public void ConstructorThrowsForNullManager() + { + Assert.Throws(() => new AzureKeyVaultConfigurationProvider(Mock.Of(), null)); + } + + [Test] + public void ConstructorThrowsForZeroRefreshPeriodValue() + { + Assert.Throws(() => new AzureKeyVaultConfigurationProvider(Mock.Of(), new DefaultKeyVaultSecretManager(), TimeSpan.Zero)); + } + + [Test] + public void ConstructorThrowsForNegativeRefreshPeriodValue() + { + Assert.Throws(() => new AzureKeyVaultConfigurationProvider(Mock.Of(), new DefaultKeyVaultSecretManager(), TimeSpan.FromMilliseconds(-1))); + } + + private class EndsWithOneKeyVaultSecretManager : DefaultKeyVaultSecretManager + { + public override bool Load(SecretProperties secret) + { + return secret.Name.EndsWith("1"); + } + } + + private class ReloadControlKeyVaultProvider : AzureKeyVaultConfigurationProvider + { + private TaskCompletionSource _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public ReloadControlKeyVaultProvider(SecretClient client, IKeyVaultSecretManager manager, TimeSpan? reloadPollDelay = null) : base(client, manager, reloadPollDelay) + { + } + + protected override async Task WaitForReload() + { + _signalTaskCompletionSource.SetResult(null); + await _releaseTaskCompletionSource.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); + } + + public async Task Wait() + { + await _signalTaskCompletionSource.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); + } + + public void Release() + { + if (!_signalTaskCompletionSource.Task.IsCompleted) + { + throw new InvalidOperationException("Provider is not waiting for reload"); + } + + var releaseTaskCompletionSource = _releaseTaskCompletionSource; + _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + releaseTaskCompletionSource.SetResult(null); + } + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/ConfigurationProviderExtensions.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/ConfigurationProviderExtensions.cs new file mode 100644 index 000000000000..01aae499a1b4 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/test/ConfigurationProviderExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.Configuration; + +namespace Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests +{ + public static class ConfigurationProviderExtensions + { + public static string Get(this IConfigurationProvider provider, string key) + { + string value; + + if (!provider.TryGet(key, out value)) + { + throw new InvalidOperationException("Key not found"); + } + + return value; + } + } +} \ No newline at end of file diff --git a/sdk/keyvault/Azure.Security.KeyVault.sln b/sdk/keyvault/Azure.Security.KeyVault.sln index 34d01de12833..ba225d5a9ff2 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.sln +++ b/sdk/keyvault/Azure.Security.KeyVault.sln @@ -27,6 +27,9 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Azure.Security.KeyVault.Sha EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiCompat", "..\..\eng\ApiCompat\ApiCompat.csproj", "{A0C00A76-5F21-4664-A7B1-BE2DA201BF6E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Security.KeyVault.Secrets.Extensions.Configuration", "Azure.Security.KeyVault.Secrets.Extensions.Configuration\src\Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj", "{9D38074E-D5D3-4036-AD85-8885E139F821}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests", "Azure.Security.KeyVault.Secrets.Extensions.Configuration\test\Azure.Security.KeyVault.Secrets.Extensions.Configuration.Tests.csproj", "{F245603B-24FC-4F8C-A667-5069CD351BFA}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection", "Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection\src\Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.csproj", "{71B00440-2553-4274-A98D-3B5925D1E15D}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.Tests", "Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection\test\Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.Tests.csproj", "{BBF91EB9-2049-47E4-87D7-35DEF26B8F40}" @@ -81,6 +84,14 @@ Global {A0C00A76-5F21-4664-A7B1-BE2DA201BF6E}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0C00A76-5F21-4664-A7B1-BE2DA201BF6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {A0C00A76-5F21-4664-A7B1-BE2DA201BF6E}.Release|Any CPU.Build.0 = Release|Any CPU + {9D38074E-D5D3-4036-AD85-8885E139F821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D38074E-D5D3-4036-AD85-8885E139F821}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D38074E-D5D3-4036-AD85-8885E139F821}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D38074E-D5D3-4036-AD85-8885E139F821}.Release|Any CPU.Build.0 = Release|Any CPU + {F245603B-24FC-4F8C-A667-5069CD351BFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F245603B-24FC-4F8C-A667-5069CD351BFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F245603B-24FC-4F8C-A667-5069CD351BFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F245603B-24FC-4F8C-A667-5069CD351BFA}.Release|Any CPU.Build.0 = Release|Any CPU {71B00440-2553-4274-A98D-3B5925D1E15D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {71B00440-2553-4274-A98D-3B5925D1E15D}.Debug|Any CPU.Build.0 = Debug|Any CPU {71B00440-2553-4274-A98D-3B5925D1E15D}.Release|Any CPU.ActiveCfg = Release|Any CPU