diff --git a/Directory.Packages.props b/Directory.Packages.props index b3825726499..6434cf91117 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,8 @@ + + diff --git a/src/Components/Aspire.Azure.Security.KeyVault/AbstractAzureKeyVaultComponent.cs b/src/Components/Aspire.Azure.Security.KeyVault/AbstractAzureKeyVaultComponent.cs new file mode 100644 index 00000000000..ffc8662641b --- /dev/null +++ b/src/Components/Aspire.Azure.Security.KeyVault/AbstractAzureKeyVaultComponent.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Azure.Common; +using Azure.Core; +using Azure.Core.Extensions; +using Microsoft.Extensions.Azure; + +namespace Aspire.Azure.Security.KeyVault; + +/// +/// Abstracts the common configuration binding required by +/// Deriving type implements KeyVaultClient specific item: +/// +/// +/// The KeyVaultClient type for this component. +/// The associated configuration for the +internal abstract class AbstractAzureKeyVaultComponent + : AzureComponent + where TClient : class + where TOptions : class +{ + internal abstract TClient CreateComponentClient(Uri vaultUri, TOptions options, TokenCredential cred); + + protected override IAzureClientBuilder AddClient(AzureClientFactoryBuilder azureFactoryBuilder, AzureSecurityKeyVaultSettings settings, string connectionName, string configurationSectionName) + { + return azureFactoryBuilder.AddClient((options, cred, _) => + { + if (settings.VaultUri is null) + { + throw new InvalidOperationException($"VaultUri is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'VaultUri' key in the '{configurationSectionName}' configuration section."); + } + + return CreateComponentClient(settings.VaultUri, options, cred); + }); + } + + protected override bool GetHealthCheckEnabled(AzureSecurityKeyVaultSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureSecurityKeyVaultSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureSecurityKeyVaultSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureSecurityKeyVaultSettings settings) + => !settings.DisableTracing; +} diff --git a/src/Components/Aspire.Azure.Security.KeyVault/Aspire.Azure.Security.KeyVault.csproj b/src/Components/Aspire.Azure.Security.KeyVault/Aspire.Azure.Security.KeyVault.csproj index 2a4dbb03da4..5f9ec339ce2 100644 --- a/src/Components/Aspire.Azure.Security.KeyVault/Aspire.Azure.Security.KeyVault.csproj +++ b/src/Components/Aspire.Azure.Security.KeyVault/Aspire.Azure.Security.KeyVault.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/Components/Aspire.Azure.Security.KeyVault/AspireKeyVaultExtensions.cs b/src/Components/Aspire.Azure.Security.KeyVault/AspireKeyVaultExtensions.cs index 1b2bffb2b80..7e87c0a2147 100644 --- a/src/Components/Aspire.Azure.Security.KeyVault/AspireKeyVaultExtensions.cs +++ b/src/Components/Aspire.Azure.Security.KeyVault/AspireKeyVaultExtensions.cs @@ -3,16 +3,14 @@ using Aspire.Azure.Common; using Aspire.Azure.Security.KeyVault; -using Azure.Core; using Azure.Core.Extensions; using Azure.Extensions.AspNetCore.Configuration.Secrets; using Azure.Identity; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Keys; using Azure.Security.KeyVault.Secrets; -using HealthChecks.Azure.KeyVault.Secrets; -using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Microsoft.Extensions.Hosting; @@ -42,7 +40,8 @@ public static void AddAzureKeyVaultClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(connectionName); - new KeyVaultComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + new AzureKeyVaultSecretsComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); } /// @@ -64,7 +63,96 @@ public static void AddKeyedAzureKeyVaultClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - new KeyVaultComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + new AzureKeyVaultSecretsComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + } + + /// + /// Registers as a singleton in the services provided by the . + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Security:KeyVault:{name}" section. + /// Thrown when mandatory is not provided. + public static void AddAzureKeyVaultCertificateClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + new AzureKeyVaultCertificatesComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + } + + /// + /// Registers as a singleton for given in the services provided by the . + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection information from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Security:KeyVault:{name}" section. + /// Thrown when mandatory is not provided. + public static void AddKeyedAzureKeyVaultCertificateClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + new AzureKeyVaultCertificatesComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + } + + /// + /// Registers as a singleton in the services provided by the . + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Security:KeyVault:{name}" section. + /// Thrown when mandatory is not provided. + public static void AddAzureKeyVaultKeyClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + new AzureKeyVaultKeysComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + } + + /// + /// Registers as a singleton for given in the services provided by the . + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection information from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Security:KeyVault:{name}" section. + /// Thrown when mandatory is not provided. + public static void AddKeyedAzureKeyVaultKeyClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + new AzureKeyVaultKeysComponent() + .AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); } /// @@ -118,49 +206,4 @@ private static SecretClient GetSecretClient( return new SecretClient(settings.VaultUri, settings.Credential ?? new DefaultAzureCredential(), clientOptions); } - - private sealed class KeyVaultComponent : AzureComponent - { - protected override IAzureClientBuilder AddClient( - AzureClientFactoryBuilder azureFactoryBuilder, AzureSecurityKeyVaultSettings settings, - string connectionName, string configurationSectionName) - { - return azureFactoryBuilder.AddClient((options, cred, _) => - { - if (settings.VaultUri is null) - { - throw new InvalidOperationException($"VaultUri is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'VaultUri' key in the '{configurationSectionName}' configuration section."); - } - - return new SecretClient(settings.VaultUri, cred, options); - }); - } - - protected override IHealthCheck CreateHealthCheck(SecretClient client, AzureSecurityKeyVaultSettings settings) - => new AzureKeyVaultSecretsHealthCheck(client, new AzureKeyVaultSecretsHealthCheckOptions()); - - protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) - { -#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works - clientBuilder.ConfigureOptions(options => configuration.Bind(options)); -#pragma warning restore IDE0200 - } - - protected override void BindSettingsToConfiguration(AzureSecurityKeyVaultSettings settings, IConfiguration configuration) - { - configuration.Bind(settings); - } - - protected override bool GetHealthCheckEnabled(AzureSecurityKeyVaultSettings settings) - => !settings.DisableHealthChecks; - - protected override TokenCredential? GetTokenCredential(AzureSecurityKeyVaultSettings settings) - => settings.Credential; - - protected override bool GetMetricsEnabled(AzureSecurityKeyVaultSettings settings) - => false; - - protected override bool GetTracingEnabled(AzureSecurityKeyVaultSettings settings) - => !settings.DisableTracing; - } } diff --git a/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultCertificatesComponent.cs b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultCertificatesComponent.cs new file mode 100644 index 00000000000..33e85dc84c8 --- /dev/null +++ b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultCertificatesComponent.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Aspire.Azure.Security.KeyVault; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Security.KeyVault.Certificates; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Representation of an configured as a +/// +internal sealed class AzureKeyVaultCertificatesComponent : AbstractAzureKeyVaultComponent +{ + internal override CertificateClient CreateComponentClient(Uri vaultUri, CertificateClientOptions options, TokenCredential cred) + => new(vaultUri, cred, options); + + protected override bool GetHealthCheckEnabled(AzureSecurityKeyVaultSettings settings) + => false; + + protected override IHealthCheck CreateHealthCheck(CertificateClient client, AzureSecurityKeyVaultSettings settings) + => throw new NotImplementedException(); + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + => clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 // Remove unnecessary lambda expression + + protected override void BindSettingsToConfiguration(AzureSecurityKeyVaultSettings settings, IConfiguration configuration) + => configuration.Bind(settings); +} diff --git a/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultKeysComponent.cs b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultKeysComponent.cs new file mode 100644 index 00000000000..2688f890b11 --- /dev/null +++ b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultKeysComponent.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Security.KeyVault; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Security.KeyVault.Keys; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +internal sealed class AzureKeyVaultKeysComponent : AbstractAzureKeyVaultComponent +{ + protected override bool GetHealthCheckEnabled(AzureSecurityKeyVaultSettings settings) + => false; + + protected override IHealthCheck CreateHealthCheck(KeyClient client, AzureSecurityKeyVaultSettings settings) + => throw new NotImplementedException(); + + internal override KeyClient CreateComponentClient(Uri vaultUri, KeyClientOptions options, TokenCredential cred) + => new(vaultUri, cred, options); + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + => clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 // Remove unnecessary lambda expression + + protected override void BindSettingsToConfiguration(AzureSecurityKeyVaultSettings settings, IConfiguration configuration) + => configuration.Bind(settings); +} diff --git a/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultSecretsComponent.cs b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultSecretsComponent.cs new file mode 100644 index 00000000000..9e56132a53c --- /dev/null +++ b/src/Components/Aspire.Azure.Security.KeyVault/AzureKeyVaultSecretsComponent.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Security.KeyVault; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Security.KeyVault.Secrets; +using HealthChecks.Azure.KeyVault.Secrets; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +internal sealed class AzureKeyVaultSecretsComponent : AbstractAzureKeyVaultComponent +{ + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + => clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 // Remove unnecessary lambda expression + + protected override void BindSettingsToConfiguration(AzureSecurityKeyVaultSettings settings, IConfiguration configuration) + => configuration.Bind(settings); + + protected override IHealthCheck CreateHealthCheck(SecretClient client, AzureSecurityKeyVaultSettings settings) + => new AzureKeyVaultSecretsHealthCheck(client, new AzureKeyVaultSecretsHealthCheckOptions()); + + internal override SecretClient CreateComponentClient(Uri vaultUri, SecretClientOptions options, TokenCredential cred) + => new(vaultUri, cred, options); +} diff --git a/src/Components/Aspire.Azure.Security.KeyVault/README.md b/src/Components/Aspire.Azure.Security.KeyVault/README.md index b657c927941..5cafd647cdb 100644 --- a/src/Components/Aspire.Azure.Security.KeyVault/README.md +++ b/src/Components/Aspire.Azure.Security.KeyVault/README.md @@ -57,6 +57,41 @@ public ProductsController(SecretClient client) See the [Azure.Security.KeyVault.Secrets documentation](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Secrets/README.md) for examples on using the `SecretClient`. +### Optionally include KeyClient and CertificateClient + +You can also dependency inject a `KeyClient` and/or `CertificateClient` too: + +```csharp +builder.AddAzureKeyVaultKeyClient("keys"); +builder.AddAzureKeyVaultCertificateClient("certificates"); +``` + +Which can then be retrieved in the same way the `SecretClient` is. For example , to retrieve a `KeyClient` from a Web API controller: + +```csharp +private readonly KeyClient _client; + +public ProductsController(KeyClient client) +{ + _client = client; +} +``` + +Or to retrieve a `CertificateClient` from a Web API controller: + +```csharp +private readonly CertificateClient _client; + +public ProductsController(CertificateClient client) +{ + _client = client; +} +``` + +See the [Azure.Security.KeyVault.Keys documentation](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Keys/README.md) for examples on using the `KeyClient`. + +See the [Azure.Security.KeyVault.Certificates documentation](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Certificates/README.md) for examples on using the `CertificateClient`. + ## Configuration The .NET Aspire Azure Key Vault library provides multiple options to configure the Azure Key Vault connection based on the requirements and conventions of your project. Note that the `VaultUri` is required to be supplied. diff --git a/tests/Aspire.Azure.Security.KeyVault.Tests/AspireKeyVaultExtensionsTests.cs b/tests/Aspire.Azure.Security.KeyVault.Tests/AspireKeyVaultExtensionsTests.cs index e9cf7d23eb2..682386d3fc9 100644 --- a/tests/Aspire.Azure.Security.KeyVault.Tests/AspireKeyVaultExtensionsTests.cs +++ b/tests/Aspire.Azure.Security.KeyVault.Tests/AspireKeyVaultExtensionsTests.cs @@ -4,6 +4,8 @@ using System.Globalization; using System.Text; using Azure.Core; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Keys; using Azure.Security.KeyVault.Secrets; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +21,7 @@ public class AspireKeyVaultExtensionsTests [InlineData(false)] public void VaultUriCanBeSetInCode(bool useKeyed) { - var vaultUri = new Uri(ConformanceTests.VaultUri); + var vaultUri = new Uri(ConformanceConstants.VaultUri); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Configuration.AddInMemoryCollection([ @@ -52,8 +54,8 @@ public void ConnectionNameWinsOverConfigSection(bool useKeyed) var key = useKeyed ? "secrets" : null; builder.Configuration.AddInMemoryCollection([ - new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Azure:Security:KeyVault", key, "VaultUri"), "unused"), - new KeyValuePair("ConnectionStrings:secrets", ConformanceTests.VaultUri) + new KeyValuePair("Aspire:Azure:Security:KeyVault:{key}:VaultUri", "unused"), + new KeyValuePair("ConnectionStrings:secrets", ConformanceConstants.VaultUri) ]); if (useKeyed) @@ -70,7 +72,7 @@ public void ConnectionNameWinsOverConfigSection(bool useKeyed) host.Services.GetRequiredKeyedService("secrets") : host.Services.GetRequiredService(); - Assert.Equal(new Uri(ConformanceTests.VaultUri), client.VaultUri); + Assert.Equal(new Uri(ConformanceConstants.VaultUri), client.VaultUri); } [Fact] @@ -78,7 +80,7 @@ public void AddsKeyVaultSecretsToConfig() { var builder = Host.CreateEmptyApplicationBuilder(null); builder.Configuration.AddInMemoryCollection([ - new KeyValuePair("ConnectionStrings:secrets", ConformanceTests.VaultUri) + new KeyValuePair("ConnectionStrings:secrets", ConformanceConstants.VaultUri) ]); builder.Configuration.AddAzureKeyVaultSecrets("secrets", configureClientOptions: o => @@ -177,7 +179,7 @@ public void CanAddMultipleKeyedServices() { var builder = Host.CreateEmptyApplicationBuilder(null); builder.Configuration.AddInMemoryCollection([ - new KeyValuePair("ConnectionStrings:secrets1", ConformanceTests.VaultUri), + new KeyValuePair("ConnectionStrings:secrets1", ConformanceConstants.VaultUri), new KeyValuePair("ConnectionStrings:secrets2", "https://aspiretests2.vault.azure.net/"), new KeyValuePair("ConnectionStrings:secrets3", "https://aspiretests3.vault.azure.net/") ]); @@ -201,4 +203,124 @@ public void CanAddMultipleKeyedServices() Assert.Equal(new Uri("https://aspiretests2.vault.azure.net/"), client2.VaultUri); Assert.Equal(new Uri("https://aspiretests3.vault.azure.net/"), client3.VaultUri); } + + [Fact] + public void CanAddMultipleClientTypes() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var connectionName = "keyVaultMultipleClients"; + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{connectionName}", ConformanceConstants.VaultUri) + ]); + + builder.AddAzureKeyVaultClient(connectionName); + builder.AddAzureKeyVaultKeyClient(connectionName); + builder.AddAzureKeyVaultCertificateClient(connectionName); + + using var host = builder.Build(); + + var secretClient = host.Services.GetRequiredService(); + var keyClient = host.Services.GetRequiredService(); + var certClient = host.Services.GetRequiredService(); + + var vaultUri = new Uri(ConformanceConstants.VaultUri); + + Assert.Equal(vaultUri, secretClient.VaultUri); + Assert.Equal(vaultUri, keyClient.VaultUri); + Assert.Equal(vaultUri, certClient.VaultUri); + } + + [Fact] + public void CanAddMultipleKeyedClients() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var secretClientName = "secret-client"; + var secretClientUri = "https://aspiretests1.vault.azure.net/"; + + var keyClientName = "key-client"; + var keyClientUri = "https://aspiretests2.vault.azure.net/"; + + var certClientName = "cert-client"; + var certClientUri = "https://aspiretests3.vault.azure.net/"; + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{secretClientName}", secretClientUri), + new KeyValuePair($"ConnectionStrings:{keyClientName}", keyClientUri), + new KeyValuePair($"ConnectionStrings:{certClientName}", certClientUri) + ]); + + builder.AddKeyedAzureKeyVaultClient(secretClientName); + builder.AddKeyedAzureKeyVaultKeyClient(keyClientName); + builder.AddKeyedAzureKeyVaultCertificateClient(certClientName); + + using var host = builder.Build(); + + var secretClient = host.Services.GetRequiredKeyedService(secretClientName); + var keyClient = host.Services.GetRequiredKeyedService(keyClientName); + var certClient = host.Services.GetRequiredKeyedService(certClientName); + + Assert.NotEqual(secretClient.VaultUri, keyClient.VaultUri); + Assert.NotEqual(keyClient.VaultUri, certClient.VaultUri); + + Assert.Equal(secretClient.VaultUri, new Uri(secretClientUri)); + Assert.Equal(keyClient.VaultUri, new Uri(keyClientUri)); + Assert.Equal(certClient.VaultUri, new Uri(certClientUri)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddingUnnamedKeyedSecretClientShouldThrow(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedAzureKeyVaultClient(name); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + + Assert.Equal(nameof(name), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddingUnnamedKeyedKeyClientShouldThrow(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedAzureKeyVaultKeyClient(name); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + + Assert.Equal(nameof(name), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddingUnnamedKeyedCertificateClientShouldThrow(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedAzureKeyVaultCertificateClient(name); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + + Assert.Equal(nameof(name), exception.ParamName); + } } diff --git a/tests/Aspire.Azure.Security.KeyVault.Tests/CertificateClientConformanceTests.cs b/tests/Aspire.Azure.Security.KeyVault.Tests/CertificateClientConformanceTests.cs new file mode 100644 index 00000000000..eb9029e8255 --- /dev/null +++ b/tests/Aspire.Azure.Security.KeyVault.Tests/CertificateClientConformanceTests.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Azure.Identity; +using Azure.Security.KeyVault.Certificates; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Security.KeyVault.Tests; +public class CertificateClientConformanceTests : ConformanceTests +{ + // Roles: Key Vault Certificate User (pending) + private const string VaultUri = ConformanceConstants.VaultUri; + + private static readonly Lazy s_canConnectToServer = new(GetCanConnect); + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.Security.KeyVault.Certificates.CertificateClient"; + + protected override string[] RequiredLogCategories => new string[] { "Azure.Core" }; + + protected override bool SupportsKeyedRegistrations => true; + + protected override bool CanConnectToServer => s_canConnectToServer.Value; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Azure": { + "Security": { + "KeyVault": { + "VaultUri": "http://YOUR_URI", + "DisableHealthChecks": true, + "DisableTracing": false, + "ClientOptions": { + "DisableChallengeResourceVerification": true, + "Retry": { + "Mode": "Exponential", + "Delay": "00:03" + } + } + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "YOUR_URI"}}}}}""", "Value does not match format \"uri\""), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "DisableHealthChecks": "true"}}}}}""", "Value is \"string\" but should be \"boolean\""), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "ClientOptions": {"Retry": {"Mode": "Fast"}}}}}}}""", "Value should match one of the values specified by the enum"), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "ClientOptions": {"Retry": {"NetworkTimeout": "3S"}}}}}}}""", "The string value is not a match for the indicated regular expression") + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[] + { + new(CreateConfigKey("Aspire:Azure:Security:KeyVault", key, "VaultUri"), VaultUri), + new(CreateConfigKey("Aspire:Azure:Security:KeyVault", key, "ClientOptions:Retry:MaxRetries"), "0") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddAzureKeyVaultCertificateClient("certificates", ConfigureCredentials); + } + else + { + builder.AddKeyedAzureKeyVaultCertificateClient(key, ConfigureCredentials); + } + + void ConfigureCredentials(AzureSecurityKeyVaultSettings settings) + { + if (CanConnectToServer) + { + settings.Credential = new DefaultAzureCredential(); + } + configure?.Invoke(settings); + } + } + + protected override void SetHealthCheck(AzureSecurityKeyVaultSettings options, bool enabled) + // Disable Key Vault health check tests until https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/issues/2279 is fixed + // => options.DisableHealthChecks = !enabled; + => throw new NotImplementedException(); + + protected override void SetMetrics(AzureSecurityKeyVaultSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetTracing(AzureSecurityKeyVaultSettings options, bool enabled) + => options.DisableTracing = !enabled; + + protected override void TriggerActivity(CertificateClient service) + => service.GetCertificate("IsAlive"); + + [Fact] + public void TracingEnablesTheRightActivitySource() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + + [Fact] + public void TracingEnablesTheRightActivitySource_Keyed() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: "key")).Dispose(); + + private static bool GetCanConnect() + { + CertificateClientOptions clientOptions = new(); + clientOptions.Retry.MaxRetries = 0; // don't enable retries (test runs few times faster) + CertificateClient certClient = new(new Uri(VaultUri), new DefaultAzureCredential(), clientOptions); + + try + { + return certClient.GetCertificate("IsAlive").Value.Name.Equals("IsAlive", StringComparison.CurrentCultureIgnoreCase); + } + catch (Exception) + { + return false; + } + } +} diff --git a/tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceConstants.cs b/tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceConstants.cs new file mode 100644 index 00000000000..8720fa5efdf --- /dev/null +++ b/tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceConstants.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Azure.Security.KeyVault.Tests; + +public sealed class ConformanceConstants +{ + public const string VaultUri = "https://aspiretests.vault.azure.net/"; +} diff --git a/tests/Aspire.Azure.Security.KeyVault.Tests/KeyClientConformanceTests.cs b/tests/Aspire.Azure.Security.KeyVault.Tests/KeyClientConformanceTests.cs new file mode 100644 index 00000000000..141604c562b --- /dev/null +++ b/tests/Aspire.Azure.Security.KeyVault.Tests/KeyClientConformanceTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Azure.Identity; +using Azure.Security.KeyVault.Keys; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Security.KeyVault.Tests; + +public class KeyClientConformanceTests : ConformanceTests +{ + + // Roles: Key Vault Certificate User (pending) + private const string VaultUri = ConformanceConstants.VaultUri; + + private static readonly Lazy s_canConnectToServer = new(GetCanConnect); + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.Security.KeyVault.Keys.KeyClient"; + + protected override string[] RequiredLogCategories => new string[] { "Azure.Core" }; + + protected override bool SupportsKeyedRegistrations => true; + + protected override bool CanConnectToServer => s_canConnectToServer.Value; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Azure": { + "Security": { + "KeyVault": { + "VaultUri": "http://YOUR_URI", + "DisableHealthChecks": true, + "DisableTracing": false, + "ClientOptions": { + "DisableChallengeResourceVerification": true, + "Retry": { + "Mode": "Exponential", + "Delay": "00:03" + } + } + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "YOUR_URI"}}}}}""", "Value does not match format \"uri\""), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "DisableHealthChecks": "true"}}}}}""", "Value is \"string\" but should be \"boolean\""), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "ClientOptions": {"Retry": {"Mode": "Fast"}}}}}}}""", "Value should match one of the values specified by the enum"), + ("""{"Aspire": { "Azure": { "Security":{ "KeyVault": { "VaultUri": "http://YOUR_URI", "ClientOptions": {"Retry": {"NetworkTimeout": "3S"}}}}}}}""", "The string value is not a match for the indicated regular expression") + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[] + { + new(CreateConfigKey("Aspire:Azure:Security:KeyVault", key, "VaultUri"), VaultUri), + new(CreateConfigKey("Aspire:Azure:Security:KeyVault", key, "ClientOptions:Retry:MaxRetries"), "0") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddAzureKeyVaultKeyClient("keys", ConfigureCredentials); + } + else + { + builder.AddKeyedAzureKeyVaultKeyClient(key, ConfigureCredentials); + } + + void ConfigureCredentials(AzureSecurityKeyVaultSettings settings) + { + if (CanConnectToServer) + { + settings.Credential = new DefaultAzureCredential(); + } + configure?.Invoke(settings); + } + } + + protected override void SetHealthCheck(AzureSecurityKeyVaultSettings options, bool enabled) + // Disable Key Vault health check tests until https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/issues/2279 is fixed + // => options.DisableHealthChecks = !enabled; + => throw new NotImplementedException(); + + protected override void SetMetrics(AzureSecurityKeyVaultSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetTracing(AzureSecurityKeyVaultSettings options, bool enabled) + => options.DisableTracing = !enabled; + + protected override void TriggerActivity(KeyClient service) + => service.GetKey("IsAlive"); + + [Fact] + public void TracingEnablesTheRightActivitySource() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + + [Fact] + public void TracingEnablesTheRightActivitySource_Keyed() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: "key")).Dispose(); + + private static bool GetCanConnect() + { + KeyClientOptions clientOptions = new(); + clientOptions.Retry.MaxRetries = 0; // don't enable retries (test runs few times faster) + KeyClient keyClient = new(new Uri(VaultUri), new DefaultAzureCredential(), clientOptions); + + try + { + return keyClient.GetKey("IsAlive").Value.Name.Equals("IsAlive", StringComparison.CurrentCultureIgnoreCase); + } + catch (Exception) + { + return false; + } + } +} diff --git a/tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Security.KeyVault.Tests/SecretClientConformanceTests.cs similarity index 96% rename from tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceTests.cs rename to tests/Aspire.Azure.Security.KeyVault.Tests/SecretClientConformanceTests.cs index 24cf646b333..0fb9c51150e 100644 --- a/tests/Aspire.Azure.Security.KeyVault.Tests/ConformanceTests.cs +++ b/tests/Aspire.Azure.Security.KeyVault.Tests/SecretClientConformanceTests.cs @@ -12,10 +12,10 @@ namespace Aspire.Azure.Security.KeyVault.Tests; -public class ConformanceTests : ConformanceTests +public class SecretClientConformanceTests : ConformanceTests { // Roles: Key Vault Secrets User - public const string VaultUri = "https://aspiretests.vault.azure.net/"; + private const string VaultUri = ConformanceConstants.VaultUri; private static readonly Lazy s_canConnectToServer = new(GetCanConnect);