diff --git a/.gitignore b/.gitignore index 7bdb680f..5816d5d3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.user *.userosscache *.sln.docstates +*.local.json # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/build/ci-build.yml b/build/ci-build.yml index 76514507..d4bae5a7 100644 --- a/build/ci-build.yml +++ b/build/ci-build.yml @@ -32,6 +32,7 @@ variables: - group: 'Arcus - GitHub Package Registry' - group: 'Build Configuration' - template: ./variables/build.yml + - template: ./variables/test.yml stages: - stage: Build @@ -108,6 +109,11 @@ stages: inputs: packageType: 'sdk' version: '$(DotNet.Sdk.VersionBC)' + - template: 'templates/download-hashicorp-vault.yml' + parameters: + targetFolder: '$(Build.SourcesDirectory)' + version: $(HashiCorp.Vault.Version) + vaultBinVariableName: 'Arcus.HashiCorp.VaultBin' - template: test/run-integration-tests.yml@templates parameters: dotnetSdkVersion: '$(DotNet.Sdk.Version)' diff --git a/build/nuget-release.yml b/build/nuget-release.yml index 19543e4e..44a1c5f5 100644 --- a/build/nuget-release.yml +++ b/build/nuget-release.yml @@ -19,6 +19,7 @@ variables: - group: 'Arcus - GitHub Package Registry' - group: 'Build Configuration' - template: ./variables/build.yml + - template: ./variables/test.yml - name: 'Package.Version' value: ${{ parameters['Package.Version'] }} @@ -94,6 +95,11 @@ stages: inputs: packageType: 'sdk' version: '$(DotNet.Sdk.VersionBC)' + - template: 'templates/download-hashicorp-vault.yml' + parameters: + targetFolder: '$(Build.SourcesDirectory)' + version: $(HashiCorp.Vault.Version) + vaultBinVariableName: 'Arcus.HashiCorp.VaultBin' - template: test/run-integration-tests.yml@templates parameters: dotnetSdkVersion: '$(DotNet.Sdk.Version)' diff --git a/build/templates/download-hashicorp-vault.yml b/build/templates/download-hashicorp-vault.yml new file mode 100644 index 00000000..4ffe0d96 --- /dev/null +++ b/build/templates/download-hashicorp-vault.yml @@ -0,0 +1,42 @@ +parameters: + - name: targetFolder + type: string + default: '$(Build.SourcesDirectory)' + - name: version + type: string + - name: vaultBinVariableName + type: string + default: 'Arcus.HashiCorp.VaultBin' + +steps: + - powershell: | + $vault_zip = "vault_${{ parameters.version }}_linux_amd64.zip" + $vault_url = "https://releases.hashicorp.com/vault/${{ parameters.version }}/$vault_zip" + $destination = "${{ parameters.targetFolder }}/$vault_zip" + if (!(Test-Path $destination)) { + Write-Output "Downloading $vault_url to $destination" + [Net.ServicePointManager]::SecurityProtocol = 'Tls12' + Invoke-WebRequest -Uri $vault_url -OutFile $vault_zip + ls + if (Test-Path $vault_zip) { + Write-Output "Downloaded .zip file to $vault_zip" + } else { + Write-Error "Could not find downloaded .zip file $vault_zip" + } + } + Expand-Archive -LiteralPath $vault_zip -DestinationPath ${{ parameters.targetFolder }} + ls + $vault_bin = "${{ parameters.targetFolder }}/vault" + if (Test-Path $vault_bin) { + Write-Output "Extracted HashiCorp Vault to executable file" + } else { + Write-Error "Could not find extracted HashiCorp Vault executable file" + } + Write-Host "##vso[task.setvariable variable=${{ parameters.vaultBinVariableName }}]$vault_bin" + workingDirectory: ${{ parameters.targetFolder }} + displayName: 'Download HashiCorp Vault' + - bash: | + chmod +x $VAULT_BIN + env: + VAULT_BIN: '${{ parameters.targetFolder }}/vault' + displayName: 'Make HashiCorp Vault executable runnable' \ No newline at end of file diff --git a/build/variables/test.yml b/build/variables/test.yml new file mode 100644 index 00000000..96c7aa16 --- /dev/null +++ b/build/variables/test.yml @@ -0,0 +1,2 @@ +variables: + HashiCorp.Vault.Version: 1.5.0 diff --git a/docs/preview/features/secret-store/index.md b/docs/preview/features/secret-store/index.md index fc94dde5..e8f496f2 100644 --- a/docs/preview/features/secret-store/index.md +++ b/docs/preview/features/secret-store/index.md @@ -15,9 +15,13 @@ Once register, you can fetch all secrets by using `ISecretProvider` which will g ## Built-in secret providers Several built in secret providers available in the package. -* [Environment variables](./../../features/secret-store/provider/environment-variables) * [Configuration](./../../features/secret-store/provider/configuration) -* [Azure key vault](./../../features/secret-store/provider/key-vault) +* [Environment variables](./../../features/secret-store/provider/environment-variables) + +And several additional providers in seperate packages. + +* [Azure Key Vault](./../../features/secret-store/provider/key-vault) +* [HashiCorp](./../../features/secret-store/hashicorp-vault) * [User Secrets](./../../features/secret-store/provider/user-secrets) If you require an additional secret providers that aren't available here, please [this document](./../../features/secret-store/create-new-secret-provider) that describes how you can create your own secret provider. diff --git a/docs/preview/features/secret-store/provider/hashicorp-vault.md b/docs/preview/features/secret-store/provider/hashicorp-vault.md new file mode 100644 index 00000000..58232440 --- /dev/null +++ b/docs/preview/features/secret-store/provider/hashicorp-vault.md @@ -0,0 +1,100 @@ +--- +title: "HashiCorp Vault secret provider" +layout: default +--- + +# HashiCorp Vault secret provider +HashiCorp Vault secret provider brings secrets from the KeyValue secret engine to your application. + +## Installation +Adding secrets from HashiCorp Vault into the secret store requires following package: + +```shell +PM > Install-Package Arcus.Security.Providers.HashiCorp +``` + +## Configuration +After installing the package, the addtional extensions becomes available when building the secret store. + +```csharp +public class Program +{ + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .ConfigureSecretStore((context, config, builder) => + { + // Adding the HashiCorp Vault secret provider with the built-in overloads. + // ======================================================================= + + // UserPass authentication built-in overload: + // ------------------------------------------ + builder.AddHashiCorpVaultWithUserPass( + // URI where the HashiCorp Vault is running. + vaultServerUriWithPort: "https://uri.to.your.running.vault:5200", + // Username/Password combination to authenticate with the vault. + username: "admin", + password: "s3cr3t", + // Path where the secrets are stored in the KeyValue secret engine. + secretPath: "my-secrets" + ); + + // Following defaults can be overridden: + + // Mount point of UserPass athentication (default: userpass). + builder.AddHashiCorpVaultWithUserPass(..., userPassMountPoint: "myuserpass"); + + // Version of the KeyValue secret engine (default: V2). + builder.AddHashiCorpVaultWithUserPass(..., keyValueVersion: VaultKeyValueSecretEngineVersion.V1); + + // Mount point of KeyValue secret engine (default: kv-v2). + builder.AddHashiCorpVaultWithUserPass(..., keyValueMountPoint: "secret"); + + // Kubernetes authentication built-in overload: + // -------------------------------------------- + builder.AddHashiCorpVaultWithKubernetes( + // URI where the HashiCorp Vault is running. + vaultServerUriWithPort: "https://uri.to.your.running.vault:5200", + // Role name of the Kubernetes service account. + roleName: "admin", + // JSON web token (JWT) of the Kubernetes service account, + jwt: "ey.xxx.xxx", + // Path where the secrets are stored in the KeyValue secret engine. + secretPath: "my-secrets" + ); + + // Mount point of Kubernetes authentication (default: kubernetes). + builder.AddHashiCorpVaultWithKubernetes(..., kubernetesMountPoint: "mykubernetes"); + + // Version of the KeyValue secret engine (default: V2). + builder.AddHashiCorpVaultWithKubernetes(..., keyValueVersion: VaultKeyValueSecretEngineVersion.V1); + + // Mount point of KeyValue secret engine (default: kv-v2). + builder.AddHashiCorpVaultWithKubernetes(..., keyValueMountPoint: "secret"); + + // Custom settings overload for when using the [VaultSharp](https://github.com/rajanadar/VaultSharp) settings directly: + // -------------------------------------------------------------------------------------------------------------------- + var tokenAuthentication = new TokenAuthMethodInfo("token"); + var settings = VaultClientSettings("http://uri.to.your.running.vault.5200", tokenAuthentication); + builder.AddHashiCorpVault( + settings, + // Path where the secrets are stored in the KeyValue secret engine. + secretPath: "my-secrets"); + + // Version of the KeyValue secret engine (default: V2). + builder.AddHashiCorpVault(..., keyValueVersion: VaultKeyValueSecretEngineVersion.V1); + + // Mount point of KeyValue secret engine (default: kv-v2). + builder.AddHashiCorpVault(..., keyValueMountPoint: "secret"); + }) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + } +} +``` + +[← back](/) diff --git a/src/Arcus.Security.Providers.HashiCorp/Arcus.Security.Providers.HashiCorp.csproj b/src/Arcus.Security.Providers.HashiCorp/Arcus.Security.Providers.HashiCorp.csproj new file mode 100644 index 00000000..b6bba67a --- /dev/null +++ b/src/Arcus.Security.Providers.HashiCorp/Arcus.Security.Providers.HashiCorp.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + Arcus + Provides support for HashiCorp + Copyright (c) Arcus + https://github.com/arcus-azure/arcus.security/blob/master/LICENSE + https://github.com/arcus-azure/arcus.security + https://raw.githubusercontent.com/arcus-azure/arcus/master/media/arcus.png + https://github.com/arcus-azure/arcus.security + Git + HashiCorp;OSS;Security + Arcus.Security.Providers.HashiCorp + Arcus.Security.Providers.HashiCorp + Arcus.Security.Providers.HashiCorp + true + true + + + + + + + + + + + diff --git a/src/Arcus.Security.Providers.HashiCorp/Extensions/SecretStoreBuilderExtensions.cs b/src/Arcus.Security.Providers.HashiCorp/Extensions/SecretStoreBuilderExtensions.cs new file mode 100644 index 00000000..91a3136d --- /dev/null +++ b/src/Arcus.Security.Providers.HashiCorp/Extensions/SecretStoreBuilderExtensions.cs @@ -0,0 +1,163 @@ +using System; +using Arcus.Security.Core; +using GuardNet; +using Microsoft.Extensions.Hosting; +using VaultSharp; +using VaultSharp.V1.AuthMethods; +using VaultSharp.V1.AuthMethods.Kubernetes; +using VaultSharp.V1.AuthMethods.UserPass; +using VaultSharp.V1.SecretsEngines; + +namespace Arcus.Security.Providers.HashiCorp.Extensions +{ + /// + /// Extensions on the to add the HashiCorp Vault as . + /// + public static class SecretStoreBuilderExtensions + { + /// + /// + /// Adds the secrets of a HashiCorp Vault KeyValue engine to the secret store. + /// + /// + /// See more information on HashiCorp: https://www.vaultproject.io/docs. + /// + /// + /// The builder to add the HashiCorp secrets to. + /// The URI that points to the running HashiCorp Vault. + /// The username of the UserPass authentication method. + /// The password of the UserPass authentication method. + /// The secret path where the secret provider should look for secrets. + /// The client API version to use when interacting with the KeyValue secret engine. + /// The point where HashiCorp Vault KeyValue secret engine is mounted (default: kv-v2). + /// The point where the HashiCorp Vault UserPass authentication is mounted (default: userpass). + /// Thrown when the or is null. + /// + /// Thrown when the is blank or doesn't represent a valid URI, + /// or the or is blank, + /// or the is blank, + /// or the is blank, + /// or the is blank, + /// or the isn't within the bounds of the enumeration. + /// + public static SecretStoreBuilder AddHashiCorpVaultWithUserPass( + this SecretStoreBuilder builder, + string vaultServerUriWithPort, + string username, + string password, + string secretPath, + VaultKeyValueSecretEngineVersion keyValueVersion = VaultKeyValueSecretEngineVersion.V2, + string keyValueMountPoint = SecretsEngineDefaultPaths.KeyValueV2, + string userPassMountPoint = AuthMethodDefaultPaths.UserPass) + { + Guard.NotNull(builder, nameof(builder), "Requires a secret store builder to add the HashiCorp Vault secret provider"); + Guard.NotNullOrWhitespace(vaultServerUriWithPort, nameof(vaultServerUriWithPort)); + Guard.NotNullOrWhitespace(username, nameof(username), "Requires a username for the UserPass authentication during connecting with the HashiCorp Vault"); + Guard.NotNullOrWhitespace(password, nameof(password), "Requires a password for the UserPass authentication during connecting with the HashiCorp Vault"); + Guard.NotNullOrWhitespace(secretPath, nameof(secretPath), "Requires a path where the HashiCorp Vault secrets are stored"); + Guard.For(() => !Uri.IsWellFormedUriString(vaultServerUriWithPort, UriKind.RelativeOrAbsolute), "Requires a HashiCorp Vault server URI with HTTP port"); + Guard.For(() => !Enum.IsDefined(typeof(VaultKeyValueSecretEngineVersion), keyValueVersion), "Requires the client API version to be either V1 or V2"); + + IAuthMethodInfo authenticationMethod = new UserPassAuthMethodInfo(userPassMountPoint, username, password); + var settings = new VaultClientSettings(vaultServerUriWithPort, authenticationMethod); + + return AddHashiCorpVault(builder, settings, secretPath, keyValueVersion, keyValueMountPoint); + } + + /// + /// + /// Adds the secrets of a HashiCorp Vault KeyValue engine to the secret store. + /// + /// + /// See more information on HashiCorp: https://www.vaultproject.io/docs. + /// + /// + /// The builder to add the HashiCorp secrets to. + /// The URI that points to the running HashiCorp Vault. + /// + /// The name of the role in the Kubernetes authentication. + /// Role types have specific entities that can perform login operations against this endpoint. + /// Constraints specific to the role type must be set on the role. These are applied to the authenticated entities attempting to login. + /// + /// The service account JWT used to access the TokenReview API to validate other JWTs during login. + /// The secret path where the secret provider should look for secrets. + /// The client API version to use when interacting with the KeyValue secret engine. + /// The point where HashiCorp Vault KeyVault secret engine is mounted. + /// The point where the HashiCorp Vault Kubernetes authentication is mounted. + /// Thrown when the . + /// + /// Thrown when the is blank or doesn't represent a valid URI, + /// or the is blank, + /// or the is blank, + /// or the is blank, + /// or the is blank, + /// or the isn't within the bounds of the enumeration. + /// + public static SecretStoreBuilder AddHashiCorpVaultWithKubernetes( + this SecretStoreBuilder builder, + string vaultServerUriWithPort, + string roleName, + string jsonWebToken, + string secretPath, + VaultKeyValueSecretEngineVersion keyValueVersion = VaultKeyValueSecretEngineVersion.V2, + string keyValueMountPoint = SecretsEngineDefaultPaths.KeyValueV2, + string kubernetesMountPoint = AuthMethodDefaultPaths.Kubernetes) + { + Guard.NotNull(builder, nameof(builder), "Requires a secret store builder to add the HashiCorp Vault secret provider"); + Guard.NotNullOrWhitespace(vaultServerUriWithPort, nameof(vaultServerUriWithPort), "Requires a valid HashiCorp Vault URI with HTTP port to connect to the running HashiCorp Vault"); + Guard.NotNullOrWhitespace(jsonWebToken, nameof(jsonWebToken), "Requires a valid Json Web Token (JWT) during the Kubernetes authentication procedure"); + Guard.NotNullOrWhitespace(secretPath, nameof(secretPath), "Requires a path where the HashiCorp Vault secrets are stored"); + Guard.NotNullOrWhitespace(keyValueMountPoint, nameof(keyValueMountPoint), "Requires a mount point for the KeyValue secret engine"); + Guard.NotNullOrWhitespace(kubernetesMountPoint, nameof(kubernetesMountPoint), "Requires a mount point for the Kubernetes authentication"); + Guard.For(() => !Uri.IsWellFormedUriString(vaultServerUriWithPort, UriKind.RelativeOrAbsolute), "Requires a HashiCorp Vault server URI with HTTP port"); + Guard.For(() => !Enum.IsDefined(typeof(VaultKeyValueSecretEngineVersion), keyValueVersion), "Requires the client API version to be either V1 or V2"); + + IAuthMethodInfo authenticationMethod = new KubernetesAuthMethodInfo(kubernetesMountPoint, roleName, jsonWebToken); + var settings = new VaultClientSettings(vaultServerUriWithPort, authenticationMethod); + + return AddHashiCorpVault(builder, settings, secretPath, keyValueVersion, keyValueMountPoint); + } + + /// + /// + /// Adds the secrets of a HashiCorp Vault KeyValue engine to the secret store. + /// + /// + /// See more information on HashiCorp: https://www.vaultproject.io/docs. + /// + /// + /// The builder to add the HashiCorp secrets to. + /// + /// The secret path where the secret provider should look for secrets. + /// The client API version to use when interacting with the KeyValue secret engine. + /// The point where HashiCorp Vault KeyVault secret engine is mounted. + /// + /// Thrown when the , or is null. + /// + /// + /// Thrown when the doesn't have a valid Vault server URI or a missing authentication method, + /// or the is blank, + /// or the isn't within the bounds of the enumeration + /// or the is blank. + /// + public static SecretStoreBuilder AddHashiCorpVault( + this SecretStoreBuilder builder, + VaultClientSettings settings, + string secretPath, + VaultKeyValueSecretEngineVersion keyValueVersion = VaultKeyValueSecretEngineVersion.V2, + string keyValueMountPoint = SecretsEngineDefaultPaths.KeyValueV2) + { + Guard.NotNull(builder, nameof(builder), "Requires a secret store builder to add the HashiCorp Vault secret provider"); + Guard.NotNull(settings, nameof(settings), "Requires HashiCorp Vault settings to correctly connect to the running HashiCorp Vault"); + Guard.NotNull(settings.VaultServerUriWithPort, nameof(settings.VaultServerUriWithPort), "Requires the HashiCorp Vault settings to have a valid URI with HTTP port"); + Guard.NotNull(settings.AuthMethodInfo, nameof(settings.AuthMethodInfo), "Requires the HashiCorp Vault settings to have an authentication method configured"); + Guard.NotNullOrWhitespace(secretPath, nameof(secretPath), "Requires a secret path to look for secret values"); + Guard.For(() => !Uri.IsWellFormedUriString(settings.VaultServerUriWithPort, UriKind.RelativeOrAbsolute), "Requires a HashiCorp Vault server URI with HTTP port"); + Guard.For(() => !Enum.IsDefined(typeof(VaultKeyValueSecretEngineVersion), keyValueVersion), "Requires the client API version to be either V1 or V2"); + Guard.NotNullOrWhitespace(keyValueMountPoint, nameof(keyValueMountPoint), "Requires a point where the KeyVault secret engine is mounted"); + + var provider = new HashiCorpSecretProvider(settings, keyValueVersion, keyValueMountPoint, secretPath); + return builder.AddProvider(provider); + } + } +} diff --git a/src/Arcus.Security.Providers.HashiCorp/HashiCorpSecretProvider.cs b/src/Arcus.Security.Providers.HashiCorp/HashiCorpSecretProvider.cs new file mode 100644 index 00000000..219db630 --- /dev/null +++ b/src/Arcus.Security.Providers.HashiCorp/HashiCorpSecretProvider.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Arcus.Security.Core; +using GuardNet; +using VaultSharp; +using VaultSharp.V1.Commons; + +namespace Arcus.Security.Providers.HashiCorp +{ + /// + /// + /// Represents an that interacts with a HashiCorp Vault KeyVault engine to retrieve secrets. + /// + /// + /// See more information on HashiCorp Vault: https://www.vaultproject.io/docs. + /// + /// + public class HashiCorpSecretProvider : ISecretProvider + { + private readonly VaultKeyValueSecretEngineVersion _secretEngineVersion; + private readonly string _mountPoint; + private readonly string _secretPath; + private readonly IVaultClient _vaultClient; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration and authentication settings to successfully connect to the HashiCorp Vault instance. + /// The client API version of the KeyValue secret engine to use when retrieving HashiCorp secrets. + /// The point where HashiCorp Vault KeyValue secret engine is mounted. + /// The HashiCorp secret path available in the KeyValue engine where this secret provider should look for secrets. + /// + /// Thrown when the , + /// or is null + /// or the doesn't contain a authentication method. + /// + /// Thrown when the is not within the bounds of the enumeration, + /// or the doesn't contain a valid Vault URI. + /// + public HashiCorpSecretProvider(VaultClientSettings settings, VaultKeyValueSecretEngineVersion secretEngineVersion, string mountPoint, string secretPath) + { + Guard.NotNull(settings, nameof(settings), "Requires HashiCorp settings to successfully connect to the Vault"); + Guard.NotNull(settings.AuthMethodInfo, nameof(settings.AuthMethodInfo), "Requires a authentication method to connect to the HashiCorp Vault"); + Guard.NotNullOrWhitespace(mountPoint, nameof(mountPoint), "Requires a point where the HashiCorp Vault KeyValue secret engine is mounted"); + Guard.NotNullOrWhitespace(secretPath, nameof(secretPath), "Requires a path where the HashiCorp Vault KeyValue secret engine should look for secrets"); + Guard.For(() => !Uri.IsWellFormedUriString(settings.VaultServerUriWithPort, UriKind.RelativeOrAbsolute), "Requires a HashiCorp Vault server URI with HTTP port"); + Guard.For(() => !Enum.IsDefined(typeof(VaultKeyValueSecretEngineVersion), secretEngineVersion), "Requires the client API version to be either V1 or V2"); + + _secretEngineVersion = secretEngineVersion; + _mountPoint = mountPoint; + _secretPath = secretPath; + _vaultClient = new VaultClient(settings); + } + + /// + /// Retrieves the secret value, based on the given name + /// + /// The name of the secret key + /// Returns the secret key. + /// The must not be empty + /// The must not be null + /// The secret was not found, using the given name + public async Task GetRawSecretAsync(string secretName) + { + Guard.NotNullOrWhitespace(secretName, nameof(secretName), + $"Requires a non-blank secret name to look up the secret in the HashiCorp Vault {_secretEngineVersion} KeyValue secret engine"); + + Secret secret = await GetSecretAsync(secretName); + return secret?.Value; + } + + /// + /// Retrieves the secret value, based on the given name + /// + /// The name of the secret key + /// Returns a that contains the secret key + /// The must not be empty + /// The must not be null + /// The secret was not found, using the given name + public async Task GetSecretAsync(string secretName) + { + Guard.NotNullOrWhitespace(secretName, nameof(secretName), + $"Requires a non-blank secret name to look up the secret in the HashiCorp Vault {_secretEngineVersion} KeyValue secret engine"); + + SecretData result = await ReadSecretDataAsync(_secretPath); + + if (result.Data.TryGetValue(secretName, out object value) && value != null) + { + var version = result.Metadata?.Version.ToString(); + return new Secret(value.ToString(), version); + } + + return null; + } + + private async Task ReadSecretDataAsync(string secretPath) + { + switch (_secretEngineVersion) + { + case VaultKeyValueSecretEngineVersion.V1: + Secret> secretV1 = + await _vaultClient.V1.Secrets.KeyValue.V1.ReadSecretAsync(secretPath, mountPoint: _mountPoint); + return new SecretData { Data = secretV1.Data }; + + case VaultKeyValueSecretEngineVersion.V2: + Secret secretV2 = + await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(secretPath, mountPoint: _mountPoint); + return secretV2.Data; + + default: + throw new ArgumentOutOfRangeException(nameof(_secretEngineVersion), _secretEngineVersion, "Unknown client API version"); + } + } + } +} diff --git a/src/Arcus.Security.Providers.HashiCorp/VaultKeyValueSecretEngineVersion.cs b/src/Arcus.Security.Providers.HashiCorp/VaultKeyValueSecretEngineVersion.cs new file mode 100644 index 00000000..7d7bed27 --- /dev/null +++ b/src/Arcus.Security.Providers.HashiCorp/VaultKeyValueSecretEngineVersion.cs @@ -0,0 +1,21 @@ +namespace Arcus.Security.Providers.HashiCorp +{ + /// + /// Represents the version of the KeyValue secret engines when interacting with the HashiCorp Vault in the . + /// See the official HashiCorp Vault docs: https://www.vaultproject.io/docs/secrets/kv for more information on this subject. + /// + public enum VaultKeyValueSecretEngineVersion + { + /// + /// Uses the KeyValue V1 secret engine when reading secrets in the . + /// See the HashiCorp Vault docs: https://www.vaultproject.io/docs/secrets/kv/kv-v1 for more information on this version of the secret engine. + /// + V1 = 1, + + /// + /// Uses the KeyValue V! secret engine when reading secrets in the . + /// See the HashiCorp Vault docs: https://www.vaultproject.io/docs/secrets/kv/kv-v2 for more information on this version of the secret engine. + /// + V2 = 2 + } +} \ No newline at end of file diff --git a/src/Arcus.Security.Tests.Integration/Arcus.Security.Tests.Integration.csproj b/src/Arcus.Security.Tests.Integration/Arcus.Security.Tests.Integration.csproj index 3fde039f..7d19916f 100644 --- a/src/Arcus.Security.Tests.Integration/Arcus.Security.Tests.Integration.csproj +++ b/src/Arcus.Security.Tests.Integration/Arcus.Security.Tests.Integration.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;netcoreapp3.1 + netcoreapp3.1 false @@ -17,11 +17,13 @@ + + @@ -33,4 +35,4 @@ Always - \ No newline at end of file + diff --git a/src/Arcus.Security.Tests.Integration/Fixture/TestConfig.cs b/src/Arcus.Security.Tests.Integration/Fixture/TestConfig.cs new file mode 100644 index 00000000..a1c11993 --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/Fixture/TestConfig.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using GuardNet; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Arcus.Security.Tests.Integration.Fixture +{ + /// + /// Represents the configuration used in the integration test suite. + /// + public class TestConfig : IConfiguration + { + private readonly IConfiguration _configuration; + + /// + /// Prevents a new instance of the class from being created. + /// + private TestConfig(IConfiguration configuration) + { + Guard.NotNull(configuration, nameof(configuration), $"Requires an {nameof(IConfiguration)} instance to initialize the test config"); + + _configuration = configuration; + } + + /// + /// Creates a new instance of the class. + /// + public static TestConfig Create() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.local.json", optional: true) + .Build(); + + return new TestConfig(configuration); + } + + /// + /// Gets the configured HashiCorp Vault execution file. + /// + public FileInfo GetHashiCorpVaultBin() + { + const string key = "Arcus:HashiCorp:VaultBin"; + string vaultBin = _configuration[key]; + + if (String.IsNullOrWhiteSpace(vaultBin)) + { + throw new KeyNotFoundException( + $"Could not find HashiCorp Vault execution file for key: '{key}', was blank"); + } + + FileInfo vaultFile; + try + { + vaultFile = new FileInfo(vaultBin); + } + catch (Exception exception) + { + throw new FileNotFoundException( + $"File path returned for key '{key}' doesn't point to valid HashiCorp vault execution file", exception); + } + + if (!vaultFile.Exists || !vaultFile.Name.StartsWith("vault")) + { + throw new FileNotFoundException( + $"File path returned for key '{key}' doesn't point to valid HashiCorp vault execution file"); + } + + return vaultFile; + } + + /// + /// Gets a configuration sub-section with the specified key. + /// + /// The key of the configuration section. + /// The . + /// + /// This method will never return null. If no matching sub-section is found with the specified key, + /// an empty will be returned. + /// + public IConfigurationSection GetSection(string key) + { + return _configuration.GetSection(key); + } + + /// + /// Gets the immediate descendant configuration sub-sections. + /// + /// The configuration sub-sections. + public IEnumerable GetChildren() + { + return _configuration.GetChildren(); + } + + /// + /// Returns a that can be used to observe when this configuration is reloaded. + /// + /// A . + public IChangeToken GetReloadToken() + { + return _configuration.GetReloadToken(); + } + + /// Gets or sets a configuration value. + /// The configuration key. + /// The configuration value. + public string this[string key] + { + get => _configuration[key]; + set => _configuration[key] = value; + } + } +} diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/HashiCorpSecretProviderTests.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/HashiCorpSecretProviderTests.cs new file mode 100644 index 00000000..4924d0f4 --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/HashiCorpSecretProviderTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Arcus.Security.Providers.HashiCorp; +using Arcus.Security.Tests.Integration.Fixture; +using Arcus.Security.Tests.Integration.HashiCorp.Hosting; +using Arcus.Testing.Logging; +using Microsoft.Extensions.Logging; +using Vault.Endpoints.Sys; +using VaultSharp; +using VaultSharp.V1.AuthMethods; +using VaultSharp.V1.AuthMethods.UserPass; +using Xunit; +using Xunit.Abstractions; + +namespace Arcus.Security.Tests.Integration.HashiCorp +{ + public class HashiCorpSecretProviderTests + { + private const string DefaultDevMountPoint = "secret"; + + private readonly TestConfig _config; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public HashiCorpSecretProviderTests(ITestOutputHelper outputWriter) + { + _config = TestConfig.Create(); + _logger = new XunitTestLogger(outputWriter); + } + + [Fact] + public async Task AuthenticateWithUserPassKeyValueV2_GetSecret_Succeeds() + { + // Arrange + string secretPath = "mysecret"; + string secretName = "my-value"; + string expected = "s3cr3t"; + + string userName = "arcus"; + string password = "123"; + + const string policyName = "my-policy"; + + using (var server = await HashiCorpVaultTestServer.StartServerAsync(_config, _logger)) + { + await server.AddPolicyAsync(policyName, DefaultDevMountPoint, new[] { "read" }); + await server.EnableAuthenticationTypeAsync(AuthMethodDefaultPaths.UserPass, "Authenticating with username and password"); + await server.AddUserPassUserAsync(userName, password, policyName); + await server.KeyValueV2.WriteSecretAsync( + mountPoint: DefaultDevMountPoint, + path: secretPath, + data: new Dictionary { [secretName] = expected }); + + var authentication = new UserPassAuthMethodInfo(userName, password); + var settings = new VaultClientSettings(server.ListenAddress.ToString(), authentication); + var provider = new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V2, DefaultDevMountPoint, secretPath); + + // Act + string actual = await provider.GetRawSecretAsync(secretName); + + // Assert + Assert.Equal(expected, actual); + } + } + + [Fact] + public async Task AuthenticateWithUserPassKeyValueV1_GetSecret_Succeeds() + { + // Arrange + string secretPath = "mysecret"; + string secretName = "my-value"; + string expected = "s3cr3t"; + + string userName = "arcus"; + string password = "123"; + + const string policyName = "my-policy"; + const string mountPoint = "secret-v1"; + const VaultKeyValueSecretEngineVersion keyValueVersion = VaultKeyValueSecretEngineVersion.V1; + + using (var server = await HashiCorpVaultTestServer.StartServerAsync(_config, _logger)) + { + await server.MountKeyValueAsync(mountPoint, keyValueVersion); + await server.AddPolicyAsync(policyName, mountPoint, new[] { "read" }); + await server.EnableAuthenticationTypeAsync(AuthMethodDefaultPaths.UserPass, "Authenticating with username and password"); + await server.AddUserPassUserAsync(userName, password, policyName); + await server.KeyValueV1.WriteSecretAsync( + mountPoint: mountPoint, + path: secretPath, + values: new Dictionary { [secretName] = expected }); + + var authentication = new UserPassAuthMethodInfo(userName, password); + var settings = new VaultClientSettings(server.ListenAddress.ToString(), authentication); + var provider = new HashiCorpSecretProvider(settings, keyValueVersion, mountPoint, secretPath); + + // Act + string actual = await provider.GetRawSecretAsync(secretName); + + // Assert + Assert.Equal(expected, actual); + } + } + } +} diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/CouldNotStartHashiCorpVaultException.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/CouldNotStartHashiCorpVaultException.cs new file mode 100644 index 00000000..56ec3068 --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/CouldNotStartHashiCorpVaultException.cs @@ -0,0 +1,35 @@ +using System; + +namespace Arcus.Security.Tests.Integration.HashiCorp.Hosting +{ + /// + /// Exception thrown when the cannot be started correctly. + /// + [Serializable] + public class CouldNotStartHashiCorpVaultException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public CouldNotStartHashiCorpVaultException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the exception. + public CouldNotStartHashiCorpVaultException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the exception. + /// The exception that is the cause of the current exception. + public CouldNotStartHashiCorpVaultException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/HashiCorpVaultTestServer.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/HashiCorpVaultTestServer.cs new file mode 100644 index 00000000..c49c007c --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/Hosting/HashiCorpVaultTestServer.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Arcus.Security.Providers.HashiCorp; +using Arcus.Security.Tests.Integration.Fixture; +using Arcus.Security.Tests.Integration.HashiCorp.Mounting; +using GuardNet; +using Microsoft.Extensions.Logging; +using Polly; +using Vault; +using Vault.Endpoints; +using Vault.Endpoints.Sys; +using Vault.Models.Auth.UserPass; +using VaultSharp; +using VaultSharp.V1.AuthMethods.Token; +using VaultSharp.V1.SecretsEngines.KeyValue.V1; +using VaultSharp.V1.SecretsEngines.KeyValue.V2; +using VaultSharp.V1.SystemBackend; +using IVaultClient = VaultSharp.IVaultClient; +using MountInfo = Arcus.Security.Tests.Integration.HashiCorp.Mounting.MountInfo; +using Policy = Polly.Policy; +using VaultClient = Vault.VaultClient; + +namespace Arcus.Security.Tests.Integration.HashiCorp.Hosting +{ + /// + /// Represents a HashiCorp Vault instance running in 'dev server' mode. + /// + public class HashiCorpVaultTestServer : IDisposable + { + private readonly Process _process; + private readonly string _rootToken; + private readonly VaultSharp.VaultClient _apiClient; + private readonly ISysEndpoint _systemEndpoint; + private readonly IEndpoint _authenticationEndpoint; + private readonly ILogger _logger; + + private bool _disposed; + + private HashiCorpVaultTestServer(Process process, string rootToken, string listenAddress, ILogger logger) + { + Guard.NotNull(process, nameof(process)); + Guard.NotNullOrWhitespace(rootToken, nameof(rootToken)); + Guard.NotNullOrWhitespace(listenAddress, nameof(listenAddress)); + Guard.NotNull(logger, nameof(logger)); + + _process = process; + _rootToken = rootToken; + _logger = logger; + + ListenAddress = new UriBuilder(listenAddress).Uri; + var client = new VaultClient(ListenAddress, rootToken); + _systemEndpoint = client.Sys; + _authenticationEndpoint = client.Auth; + + var settings = new VaultClientSettings(ListenAddress.ToString(), new TokenAuthMethodInfo(rootToken)); + _apiClient = new VaultSharp.VaultClient(settings); + } + + /// + /// Gets the URI where the HashiCorp Vault test server is listening on. + /// + public Uri ListenAddress { get; } + + /// + /// Gets the KeyValue V2 secret engine to control the secret store in the HashiCorp Vault. + /// + public IKeyValueSecretsEngineV1 KeyValueV1 => _apiClient.V1.Secrets.KeyValue.V1; + + /// + /// Gets the KeyValue V2 secret engine to control the secret store in the HashiCorp Vault. + /// + public IKeyValueSecretsEngineV2 KeyValueV2 => _apiClient.V1.Secrets.KeyValue.V2; + + /// + /// Starts a new instance of the using the 'dev server' settings, meaning the Vault will run fully in-memory. + /// + /// The configuration instance to retrieve the HashiCorp installation folder ('Arcus.HashiCorp.VaultBin'). + /// The instance to log diagnostic trace messages during the lifetime of the test server. + /// Thrown when the or is null. + public static async Task StartServerAsync(TestConfig configuration, ILogger logger) + { + Guard.NotNull(logger, nameof(logger), + "Requires a logger for logging diagnostic trace messages during the lifetime of the test server"); + Guard.NotNull(configuration, nameof(configuration), + "Requires a configuration instance to retrieve the HashiCorp Vault installation folder"); + + var rootToken = Guid.NewGuid().ToString(); + int port = GetRandomUnusedPort(); + string listenAddress = $"127.0.0.1:{port}"; + string vaultArgs = String.Join(" ", new List + { + "server", + "-dev", + $"-dev-root-token-id={rootToken}", + $"-dev-listen-address={listenAddress}" + }); + + FileInfo vaultFile = configuration.GetHashiCorpVaultBin(); + var startInfo = new ProcessStartInfo(vaultFile.FullName, vaultArgs) + { + WorkingDirectory = Directory.GetCurrentDirectory(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + }; + + startInfo.EnvironmentVariables["HOME"] = Directory.GetCurrentDirectory(); + var process = new Process { StartInfo = startInfo }; + var server = new HashiCorpVaultTestServer(process, rootToken, listenAddress, logger); + + try + { + await server.StartHashiCorpVaultAsync(); + } + catch (Exception exception) + { + var message = "An unexpected problem occured while trying to start the HashiCorp Vault"; + logger.LogError(exception, message); + + throw new CouldNotStartHashiCorpVaultException(message, exception); + } + finally + { + process?.Dispose(); + } + + return server; + } + + private static int GetRandomUnusedPort() + { + var listener = new TcpListener(IPAddress.Any, 0); + listener.Start(); + int port = ((IPEndPoint) listener.LocalEndpoint).Port; + listener.Stop(); + + return port; + } + + private async Task StartHashiCorpVaultAsync() + { + _logger.LogTrace("Starting HashiCorp Vault at '{listenAddress}'...", ListenAddress); + + if (!_process.Start()) + { + throw new CouldNotStartHashiCorpVaultException( + $"Vault process did not start correctly, exit code: {_process.ExitCode}"); + } + + var isStarted = false; + + string line = await _process.StandardOutput.ReadLineAsync(); + while (line != null) + { + _logger.LogTrace(line); + if (line?.StartsWith("==> Vault server started!") == true) + { + isStarted = true; + break; + } + + line = await _process.StandardOutput.ReadLineAsync(); + } + + if (!isStarted) + { + throw new CouldNotStartHashiCorpVaultException( + "Vault process wasn't configured correctly and could therefore not be started successfully"); + } + + _logger.LogInformation("HashiCorp Vault started at '{ListenAddress}'", ListenAddress); + } + + /// + /// Mounts the KeyValue secret engine with a specific to a specific . + /// + /// The path to mount the secret engine to. + /// The version of the KeyValue secret engine to mount. + /// + /// Thrown when the is blank or the is outside the bounds of the enumeration. + /// + public async Task MountKeyValueAsync(string path, VaultKeyValueSecretEngineVersion version) + { + Guard.NotNullOrWhitespace(path, nameof(path), "Requires a path to mount the KeyValue secret engine to"); + Guard.For(() => !Enum.IsDefined(typeof(VaultKeyValueSecretEngineVersion), version), "Requires a KeyValue secret engine version that is either V1 or V2"); + + var content = new MountInfo + { + Type = "kv", + Description = "KeyValue v1 secret engine", + Options = new MountOptions { Version = ((int) version).ToString() } + }; + + var http = new VaultHttpClient(); + var uri = new Uri(ListenAddress, "/v1/sys/mounts/" + path); + await http.PostVoid(uri, content, _rootToken, default(CancellationToken)); + } + + /// + /// Adds a new authorization policy to the running HashiCorp Vault. + /// + /// The name to identify the policy. + /// The path where this policy will be applicable. + /// The capabilities that should be available in the policy. + /// Thrown when the , , or any of the is blank. + /// Thrown when the is null. + public async Task AddPolicyAsync(string name, string path, string[] capabilities) + { + Guard.NotNullOrWhitespace(name, nameof(name), "Requires a name to identify the policy"); + Guard.NotNullOrWhitespace(path, nameof(path), "Requires a path where the policy will be applicable"); + Guard.NotNull(capabilities, nameof(capabilities), "Requires a set of capabilities that should be available in this policy"); + Guard.NotAny(capabilities, nameof(capabilities), "Requires a set of capabilities that should be available in this policy"); + Guard.For(() => capabilities.Any(String.IsNullOrWhiteSpace), "Requires all the capabilities of the policy to be filled out (not blank)"); + + string joinedCapabilities = String.Join(", ", capabilities.Select(c => $"\"{c}\"")); + string rules = $"path \"{path}/*\" {{ capabilities = [ {joinedCapabilities} ]}}"; + + await _systemEndpoint.PutPolicy(name, rules); + } + + /// + /// Enables an authentication type on the HashiCorp Vault. + /// + /// The type of the authentication to enable. + /// The optional message that describes the authentication type (for user friendliness). + /// Thrown when the is blank. + public async Task EnableAuthenticationTypeAsync(string type, string description) + { + Guard.NotNullOrWhitespace(type, nameof(type), "Requires an authentication type to enable the authentication"); + + await _systemEndpoint.EnableAuth(path: type, authType: type, description: description); + } + + /// + /// Adds a user to the UserPass authentication in HashiCorp Vault, related to a specific path + /// and only be able to do the capabilities defined in the policy with the . + /// + /// The username of the user. + /// The password of the user. + /// The name of the policy the user will be capable to do. + /// Thrown when the , , or is blank. + public async Task AddUserPassUserAsync(string username, string password, string policyName) + { + Guard.NotNullOrWhitespace(username, nameof(username)); + Guard.NotNullOrWhitespace(password, nameof(password)); + Guard.NotNullOrWhitespace(policyName, nameof(policyName)); + + await _authenticationEndpoint.Write($"/userpass/users/{username}", new UsersRequest + { + Password = password, + Policies = new List { policyName } + }); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + RetryAction(StopHashiCorpVault); + } + + _disposed = true; + } + + private void StopHashiCorpVault() + { + if (!_process.HasExited) + { +#if NETCOREAPP3_1 + _process.Kill(entireProcessTree: true); +#else + _process.Kill(); +#endif + } + + _process.Dispose(); + } + + protected PolicyResult RetryAction(Action action, int timeoutInSeconds = 30, int retryIntervalInSeconds = 1) + { + Guard.NotNull(action, nameof(action), "Requires disposing function to be retried"); + Guard.NotLessThan(timeoutInSeconds, 0, nameof(timeoutInSeconds), "Requires a timeout (in sec) greater than zero"); + Guard.NotLessThan(retryIntervalInSeconds, 0, nameof(retryIntervalInSeconds), "Requires a retry interval (in sec) greater than zero"); + + return Policy.Timeout(TimeSpan.FromSeconds(timeoutInSeconds)) + .Wrap(Policy.Handle() + .WaitAndRetryForever(index => + { + _logger.LogTrace("Retry after {Seconds} seconds the disposing action", retryIntervalInSeconds); + return TimeSpan.FromSeconds(retryIntervalInSeconds); + })) + .ExecuteAndCapture(action); + } + } +} diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountConfig.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountConfig.cs new file mode 100644 index 00000000..33dc7d26 --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountConfig.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Arcus.Security.Tests.Integration.HashiCorp.Mounting +{ + /// + /// JSON data object as request data as part of the for enabling secret engines in the HashiCorp Vault. + /// + public class MountConfig + { + /// + /// Gets or sets the default lease duration, specified as a string duration like "5s" or "30m". + /// + [JsonProperty("default_lease_ttl")] + public string DefaultLeaseTtl { get; set; } + + /// + /// Gets or sets the maximum lease duration, specified as a string duration like "5s" or "30m". + /// + [JsonProperty("max_lease_ttl")] + public string MaxLeaseTtl { get; set; } + } +} \ No newline at end of file diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountInfo.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountInfo.cs new file mode 100644 index 00000000..85301c5a --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountInfo.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; + +namespace Arcus.Security.Tests.Integration.HashiCorp.Mounting +{ + /// + /// JSON data object as request data for enabling secret engines in the HashiCorp Vault. + /// + public class MountInfo + { + /// + /// Gets or sets the type of the backend, such as "aws". + /// + [JsonProperty("type")] + public string Type { get; set; } + + /// + /// Gets or sets the human-friendly description of the mount. + /// + [JsonProperty("description")] + public string Description { get; set; } + + /// + /// Gets or sets the configuration options for this mount. + /// + [JsonProperty("config")] + public MountConfig Config { get; set; } + + /// + /// Gets or sets the mount type specific options that are passed to the backend. + /// + [JsonProperty("options")] + public MountOptions Options { get; set; } + } +} diff --git a/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountOptions.cs b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountOptions.cs new file mode 100644 index 00000000..06fc0760 --- /dev/null +++ b/src/Arcus.Security.Tests.Integration/HashiCorp/Mounting/MountOptions.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Arcus.Security.Tests.Integration.HashiCorp.Mounting +{ + /// + /// JSON data object as request data as part of the for enabling secret engines in the HashiCorp Vault. + /// + public class MountOptions + { + /// + /// Gets or sets the version of the KV to mount. Set to "2" for mount KV v2. + /// + [JsonProperty("version")] + public string Version { get; set; } + } +} \ No newline at end of file diff --git a/src/Arcus.Security.Tests.Integration/appsettings.json b/src/Arcus.Security.Tests.Integration/appsettings.json index 4bff8da3..00c50710 100644 --- a/src/Arcus.Security.Tests.Integration/appsettings.json +++ b/src/Arcus.Security.Tests.Integration/appsettings.json @@ -12,6 +12,9 @@ "AzureServicesAuth": { "ConnectionString": "#{Arcus_MSI_AzureServicesAuth_ConnectionString}#" } - } + }, + "HashiCorp": { + "VaultBin": "#{Arcus.HashiCorp.VaultBin}#" + } } } \ No newline at end of file diff --git a/src/Arcus.Security.Tests.Unit/Arcus.Security.Tests.Unit.csproj b/src/Arcus.Security.Tests.Unit/Arcus.Security.Tests.Unit.csproj index f10aa87a..36ca43bd 100644 --- a/src/Arcus.Security.Tests.Unit/Arcus.Security.Tests.Unit.csproj +++ b/src/Arcus.Security.Tests.Unit/Arcus.Security.Tests.Unit.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Arcus.Security.Tests.Unit/HashiCorp/HashiCorpSecretProviderTests.cs b/src/Arcus.Security.Tests.Unit/HashiCorp/HashiCorpSecretProviderTests.cs new file mode 100644 index 00000000..4f002495 --- /dev/null +++ b/src/Arcus.Security.Tests.Unit/HashiCorp/HashiCorpSecretProviderTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using Arcus.Security.Providers.HashiCorp; +using VaultSharp; +using VaultSharp.V1.AuthMethods.Token; +using VaultSharp.V1.AuthMethods.UserPass; +using Xunit; + +namespace Arcus.Security.Tests.Unit.HashiCorp +{ + public class HashiCorpSecretProviderTests + { + [Fact] + public void CreateProvider_WithoutSettings_Throws() + { + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings: null, secretEngineVersion: VaultKeyValueSecretEngineVersion.V2, mountPoint: "kv-v2", secretPath: "secrets/path")); + } + + [Fact] + public void CreateProvider_WithOutOfBoundsClientApiVersion_Throws() + { + var settings = new VaultClientSettings("https://vault.server:245", new UserPassAuthMethodInfo("username", "password")); + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V1 | VaultKeyValueSecretEngineVersion.V2, "kv-v2", "secret/path")); + } + + [Fact] + public void CreateProvider_WithoutSecretPaths_Throws() + { + var settings = new VaultClientSettings("https://vault.server:245", new UserPassAuthMethodInfo("username", "password")); + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V1, mountPoint:"kv-v2", secretPath: null)); + } + + [Fact] + public void CreateProvider_WithoutMountPoint_Throws() + { + var settings = new VaultClientSettings("https://vault.server:245", new UserPassAuthMethodInfo("username", "password")); + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V2, mountPoint: null, secretPath: "secret/path")); + } + + [Fact] + public void CreateProvider_WithInvalidVaultUri_Throws() + { + var settings = new VaultClientSettings("not a valid vault URI", new UserPassAuthMethodInfo("username", "password")); + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V1, mountPoint: "kv-v2", secretPath: "secret/path")); + } + + [Fact] + public void CreateProvider_WithoutAuthenticationMethod_Throws() + { + var settings = new VaultClientSettings("https://vault.server:245", authMethodInfo: null); + Assert.ThrowsAny( + () => new HashiCorpSecretProvider(settings, VaultKeyValueSecretEngineVersion.V1, mountPoint: "kv-v2", secretPath: "secret/path")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetSecret_WithoutSecretName_Throws(string secretName) + { + // Arrange + var provider = new HashiCorpSecretProvider( + new VaultClientSettings("https://vault.server:246", new TokenAuthMethodInfo("vault.token")), + mountPoint: "secret", + secretPath: "secret/path", + secretEngineVersion: VaultKeyValueSecretEngineVersion.V1); + + // Act / Assert + await Assert.ThrowsAnyAsync(() => provider.GetSecretAsync(secretName)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetRawSecret_WithoutSecretName_Throws(string secretName) + { + // Arrange + var provider = new HashiCorpSecretProvider( + new VaultClientSettings("https://vault.server:246", new TokenAuthMethodInfo("vault.token")), + mountPoint: "secret", + secretPath: "secret/path", + secretEngineVersion: VaultKeyValueSecretEngineVersion.V2); + + // Act / Assert + await Assert.ThrowsAnyAsync(() => provider.GetRawSecretAsync(secretName)); + } + } +} diff --git a/src/Arcus.Security.Tests.Unit/HashiCorp/SecretStoreBuilderExtensionsTests.cs b/src/Arcus.Security.Tests.Unit/HashiCorp/SecretStoreBuilderExtensionsTests.cs new file mode 100644 index 00000000..ea3ff07b --- /dev/null +++ b/src/Arcus.Security.Tests.Unit/HashiCorp/SecretStoreBuilderExtensionsTests.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using Arcus.Security.Providers.HashiCorp; +using Arcus.Security.Providers.HashiCorp.Extensions; +using Microsoft.Extensions.Hosting; +using VaultSharp; +using VaultSharp.V1.AuthMethods.UserPass; +using Xunit; + +namespace Arcus.Security.Tests.Unit.HashiCorp +{ + public class SecretStoreBuilderExtensionsTests + { + public static IEnumerable Blanks => new[] + { + new object[] { null }, + new object[] { "" }, + new object[] { " " } + }; + + public static IEnumerable OutOfBoundsClientApiVersion => new[] + { + new object[] { (VaultKeyValueSecretEngineVersion) 5 }, + new object[] { VaultKeyValueSecretEngineVersion.V1 | VaultKeyValueSecretEngineVersion.V2 }, + }; + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpVault_WithoutUsername_Throws(string userName) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass("https://vault.uri:456", userName, password: "P@$$w0rd", secretPath: "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpVault_WithoutPassword_Throws(string password) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass("https://vault.uri:456", "username", password, secretPath: "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpVault_WithoutJwt_Throws(string jwt) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes("https://vault.uri:456", "role name", jwt, "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpVaultWithKubernetes_WithoutVaultUri_Throws(string vaultUri) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes(vaultUri, "role name", "jwt", "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpVaultWithUserPass_WithoutVaultUri_Throws(string vaultUri) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass(vaultUri, "username", "password", "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithKubernetes_WithoutSecretPath_Throws(string secretPath) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes("https://vault.uri:456", "role name", "jwt", secretPath); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithUserPass_WithoutSecretPath_Throws(string secretPath) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass("https://vault.uri:456", "username", "password", secretPath); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(OutOfBoundsClientApiVersion))] + public void AddHashiCorpWithKubernetes_WithOutOfBoundsKeyValueVersion_Throws(VaultKeyValueSecretEngineVersion secretEngineVersion) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes("https://vault.uri:456", "role name", "jwt", "secret/path", secretEngineVersion); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(OutOfBoundsClientApiVersion))] + public void AddHashiCorpWithUserPass_WithOutOfBoundsKeyValueVerion_Throws(VaultKeyValueSecretEngineVersion secretEngineVersion) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass("https://vault.uri:456", "username", "password", "secret/path", secretEngineVersion); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(OutOfBoundsClientApiVersion))] + public void AddHashiCorp_WithOutOfBoundsKeyValueVersion_Throws(VaultKeyValueSecretEngineVersion secretEngineVersion) + { + // Arrange + var builder = new HostBuilder(); + var settings = new VaultClientSettings("https://vault.uri:456", new UserPassAuthMethodInfo("username", "password")); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVault(settings, secretPath: "secret/path", keyValueVersion: secretEngineVersion); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Fact] + public void AddHashiCorp_WithoutSettings_Throws() + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVault(settings: null, secretPath: "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorp_WithoutVaultUri_Throws(string vaultUri) + { + // Arrange + var builder = new HostBuilder(); + var settings = new VaultClientSettings(vaultUri, new UserPassAuthMethodInfo("username", "password")); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVault(settings, secretPath: "secret/path"); + }); + + // Assert + var exception = Assert.ThrowsAny(() => builder.Build()); + Assert.True(exception is ArgumentException || exception is UriFormatException); + } + + [Fact] + public void AddHashiCorp_WithoutAuthenticationMethod_Throws() + { + // Arrange + var builder = new HostBuilder(); + var settings = new VaultClientSettings("https://vault.uri:456", authMethodInfo: null); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVault(settings, secretPath: "secret/path"); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithUserPass_WithoutUserPassAuthenticationMountPoint_Throws(string userPassMountPoint) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass( + "https://vault.uri:456", + "username", + "password", + "secret/path", + VaultKeyValueSecretEngineVersion.V2, + userPassMountPoint: userPassMountPoint); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithKubernetes_WithoutKubernetesAuthenticationMountPoint_Throws(string kubernetesMountPoint) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes( + "https://vault.uri:456", + "rolename", + "jwt", + "secret/path", + VaultKeyValueSecretEngineVersion.V2, + kubernetesMountPoint: kubernetesMountPoint); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithUserPass_WithoutKeyValueMountPoint_Throws(string keyValueMountPoint) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithUserPass( + "https://vault.uri:456", + "username", + "password", + "secret/path", + VaultKeyValueSecretEngineVersion.V2, + keyValueMountPoint: keyValueMountPoint); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorpWithKubernetes_WithoutKeyValueMountPoint_Throws(string keyValueMountPoint) + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVaultWithKubernetes( + "https://vault.uri:456", + "rolename", + "jwt", + "secret/path", + VaultKeyValueSecretEngineVersion.V2, + keyValueMountPoint: keyValueMountPoint); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + + [Theory] + [MemberData(nameof(Blanks))] + public void AddHashiCorp_WithoutKeyValueMountPoint_Throws(string keyValueMountPoint) + { + // Arrange + var builder = new HostBuilder(); + var settings = new VaultClientSettings("https://vault.uri:456", new UserPassAuthMethodInfo("username", "password")); + + // Act + builder.ConfigureSecretStore((config, stores) => + { + stores.AddHashiCorpVault( + settings, + "secret/path", + VaultKeyValueSecretEngineVersion.V2, + keyValueMountPoint: keyValueMountPoint); + }); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } + } +} diff --git a/src/Arcus.Security.sln b/src/Arcus.Security.sln index 1c07149a..5b5ec648 100644 --- a/src/Arcus.Security.sln +++ b/src/Arcus.Security.sln @@ -19,9 +19,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.Security.All", "Arcus EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.Security.Tests.Core", "Arcus.Security.Tests.Core\Arcus.Security.Tests.Core.csproj", "{D2C8C470-EA9E-4237-84ED-14665187E19F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Security.Providers.UserSecrets", "Arcus.Security.Providers.UserSecrets\Arcus.Security.Providers.UserSecrets.csproj", "{BF7F5713-6566-452A-B1F9-C8DA3AB1EB85}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.Security.Providers.UserSecrets", "Arcus.Security.Providers.UserSecrets\Arcus.Security.Providers.UserSecrets.csproj", "{BF7F5713-6566-452A-B1F9-C8DA3AB1EB85}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Security.AzureFunctions", "Arcus.Security.AzureFunctions\Arcus.Security.AzureFunctions.csproj", "{77104975-D6EB-4A4C-827A-5EC7E01B7A36}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.Security.AzureFunctions", "Arcus.Security.AzureFunctions\Arcus.Security.AzureFunctions.csproj", "{77104975-D6EB-4A4C-827A-5EC7E01B7A36}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Security.Providers.HashiCorp", "Arcus.Security.Providers.HashiCorp\Arcus.Security.Providers.HashiCorp.csproj", "{2C4BCF60-7D87-404A-827A-A52CAC431D63}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -61,6 +63,10 @@ Global {77104975-D6EB-4A4C-827A-5EC7E01B7A36}.Debug|Any CPU.Build.0 = Debug|Any CPU {77104975-D6EB-4A4C-827A-5EC7E01B7A36}.Release|Any CPU.ActiveCfg = Release|Any CPU {77104975-D6EB-4A4C-827A-5EC7E01B7A36}.Release|Any CPU.Build.0 = Release|Any CPU + {2C4BCF60-7D87-404A-827A-A52CAC431D63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C4BCF60-7D87-404A-827A-A52CAC431D63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C4BCF60-7D87-404A-827A-A52CAC431D63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C4BCF60-7D87-404A-827A-A52CAC431D63}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -71,6 +77,7 @@ Global {DD03DFBB-7D61-4FC9-B2D0-1BB800EA1ED9} = {DD66B1E8-3676-4C13-8C37-AEA80E1C21B7} {D2C8C470-EA9E-4237-84ED-14665187E19F} = {C42FDAC3-5A31-4F39-BD92-C57C2CDAB2D2} {BF7F5713-6566-452A-B1F9-C8DA3AB1EB85} = {DD66B1E8-3676-4C13-8C37-AEA80E1C21B7} + {2C4BCF60-7D87-404A-827A-A52CAC431D63} = {DD66B1E8-3676-4C13-8C37-AEA80E1C21B7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D3310D31-C37D-47F4-A6D3-325EE3806698}