diff --git a/MIGRATION.md b/MIGRATION.md index 688a365..7ee7998 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,64 @@ # Migration Guide +## 2.0.0 — Built-in key mapping (SecretNamePathSeparator default) + +### Default key generation now normalizes `/` in secret names + +The introduction of `SecretKeyMappingOptions` in 2.0.0 changed the default configuration key generation for secrets whose names contain `/`. + +**Before** (without `SecretKeyMappingOptions`): + +A secret named `/my-app/production/database` produced configuration keys like: + +``` +/my-app/production/database (scalar) +/my-app/production/database:Key (JSON) +``` + +**Current 2.0 beta / 2.0.0 final behavior:** + +The default `SecretNamePathSeparator = "/"` replaces each `/` in the secret-name portion with the +.NET configuration path delimiter (`:`), trimming any leading/trailing delimiters: + +``` +my-app:production:database (scalar) +my-app:production:database:Key (JSON) +``` + +#### To restore the previous (verbatim) behavior + +Set `SecretNamePathSeparator = null` on the options: + +```csharp +builder.Configuration.AddSecretsManagerKnownSecret( + "my-app/production", + options => + { + options.KeyMapping.SecretNamePathSeparator = null; + }); + +builder.Configuration.AddSecretsManagerKnownSecrets( + new[] { "/my-app/production", "/my-app/shared" }, + options => + { + options.KeyMapping.SecretNamePathSeparator = null; + }); + +builder.Configuration.AddSecretsManagerDiscovery( + options => + { + options.KeyMapping.SecretNamePathSeparator = null; + }); +``` + +#### Custom `KeyGenerator` that relied on `/` in `DefaultKey` + +If you used a `KeyGenerator` that parsed or stripped `/` from `context.DefaultKey`, update it to +work with the already-normalized (colon-separated) `DefaultKey`, or disable normalization with +`SecretNamePathSeparator = null` and keep the existing logic unchanged. + +--- + ## 1.x → 2.0 Version 2.0 replaces the single `AddSecretsManager` API with three explicit, purpose-built methods. This is a **breaking change**: all call sites must be updated. diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyGeneratorContextFactory.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyGeneratorContextFactory.cs index b16a87a..06e8bcb 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyGeneratorContextFactory.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyGeneratorContextFactory.cs @@ -24,7 +24,7 @@ public static SecretKeyGeneratorContext Create( SecretArn = secretArn, RawKey = rawKey, DefaultKey = defaultKey, - JsonPath = GetJsonPath(resolvedRootKey, defaultKey) + JsonPath = GetJsonPath(resolvedRootKey, rawKey) }; } diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMapper.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMapper.cs new file mode 100644 index 0000000..04feff2 --- /dev/null +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMapper.cs @@ -0,0 +1,86 @@ +using System; + +using Microsoft.Extensions.Configuration; + +namespace Kralizek.Extensions.Configuration.Internal +{ + internal static class SecretKeyMapper + { + /// + /// Validates the given and throws if any option is invalid. + /// + /// + /// Thrown when is an empty string. + /// + public static void ValidateOptions(SecretKeyMappingOptions options) + { + if (options.SecretNamePathSeparator is not null && options.SecretNamePathSeparator.Length == 0) + throw new InvalidOperationException( + $"{nameof(SecretKeyMappingOptions)}.{nameof(SecretKeyMappingOptions.SecretNamePathSeparator)} cannot be an empty string. " + + "Set it to null to disable secret-name separator normalization."); + } + + /// + /// Produces the mapped configuration key for a JSON-derived secret entry. + /// + /// + /// The raw key as produced by ExtractValues, in the form secretName:jsonPath. + /// + /// The secret name used as the prefix in . + /// The key mapping options to apply. + /// The mapped configuration key. + public static string MapJsonKey(string rawKey, string secretName, SecretKeyMappingOptions options) + { + // Extract the JSON-path portion from the raw key (everything after "secretName:"). + var prefix = $"{secretName}{ConfigurationPath.KeyDelimiter}"; + var jsonPath = rawKey.StartsWith(prefix, StringComparison.Ordinal) + ? rawKey[prefix.Length..] + : rawKey; + + string key; + if (options.PrefixJsonKeysWithSecretName) + { + var mappedSecretName = ApplyPathSeparator(secretName, options.SecretNamePathSeparator); + key = string.IsNullOrEmpty(mappedSecretName) + ? jsonPath + : ConfigurationPath.Combine(mappedSecretName, jsonPath); + } + else + { + key = jsonPath; + } + + var targetSection = options.TargetSection; + if (!string.IsNullOrEmpty(targetSection)) + key = ConfigurationPath.Combine(targetSection!, key); + + return key; + } + + /// + /// Produces the mapped configuration key for a scalar (non-JSON) secret. + /// + /// The secret name. + /// The key mapping options to apply. + /// The mapped configuration key. + public static string MapScalarKey(string secretName, SecretKeyMappingOptions options) + { + var mappedSecretName = ApplyPathSeparator(secretName, options.SecretNamePathSeparator); + var key = mappedSecretName; + + var targetSection = options.TargetSection; + if (!string.IsNullOrEmpty(targetSection)) + key = ConfigurationPath.Combine(targetSection!, key); + + return key; + } + + private static string ApplyPathSeparator(string secretName, string? separator) + { + if (separator is null) return secretName; + return secretName + .Replace(separator, ConfigurationPath.KeyDelimiter) + .Trim(ConfigurationPath.KeyDelimiter[0]); + } + } +} \ No newline at end of file diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerDiscoveryConfigurationProvider.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerDiscoveryConfigurationProvider.cs index fe6923c..3cbf44d 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerDiscoveryConfigurationProvider.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerDiscoveryConfigurationProvider.cs @@ -41,9 +41,12 @@ public SecretsManagerDiscoveryConfigurationProvider(IAmazonSecretsManager client /// protected override Task> FetchConfigurationCoreAsync(CancellationToken cancellationToken) - => _options.UseBatchFetch + { + SecretKeyMapper.ValidateOptions(_options.KeyMapping); + return _options.UseBatchFetch ? FetchConfigurationBatchAsync(cancellationToken) : FetchConfigurationAsync(cancellationToken); + } private async Task> FetchAllSecretsAsync(CancellationToken cancellationToken) { @@ -224,12 +227,13 @@ private void ProcessSecretString(Dictionary dict, SecretListEnt { foreach (var (key, value) in SecretsManagerHelpers.ExtractValues(jElement!, resolvedKey)) { + var defaultKey = SecretKeyMapper.MapJsonKey(key, resolvedKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.Create( secretId, secret.Name, secret.ARN, key, - key, + defaultKey, resolvedKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, value); @@ -237,12 +241,13 @@ private void ProcessSecretString(Dictionary dict, SecretListEnt } else { + var defaultKey = SecretKeyMapper.MapScalarKey(resolvedKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.CreateScalar( secretId, secret.Name, secret.ARN, resolvedKey, - resolvedKey); + defaultKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, secretString); } diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProvider.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProvider.cs index 775bfcf..e2a15b2 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProvider.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProvider.cs @@ -43,7 +43,10 @@ public SecretsManagerKnownSecretConfigurationProvider(IAmazonSecretsManager clie /// protected override Task> FetchConfigurationCoreAsync(CancellationToken cancellationToken) - => FetchConfigurationAsync(cancellationToken); + { + SecretKeyMapper.ValidateOptions(_options.KeyMapping); + return FetchConfigurationAsync(cancellationToken); + } private async Task> FetchConfigurationAsync(CancellationToken cancellationToken) { @@ -85,24 +88,26 @@ public SecretsManagerKnownSecretConfigurationProvider(IAmazonSecretsManager clie { foreach (var (key, value) in SecretsManagerHelpers.ExtractValues(jElement!, rootKey)) { + var defaultKey = SecretKeyMapper.MapJsonKey(key, rootKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.Create( _secretId, secretEntry.Name ?? _secretId, secretEntry.ARN, key, - key); + defaultKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, value); } } else { + var defaultKey = SecretKeyMapper.MapScalarKey(rootKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.CreateScalar( _secretId, secretEntry.Name ?? _secretId, secretEntry.ARN, rootKey, - rootKey); + defaultKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, secretString); } diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretsConfigurationProvider.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretsConfigurationProvider.cs index 61d9251..e301cb3 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretsConfigurationProvider.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretsConfigurationProvider.cs @@ -44,9 +44,12 @@ public SecretsManagerKnownSecretsConfigurationProvider(IAmazonSecretsManager cli /// protected override Task> FetchConfigurationCoreAsync(CancellationToken cancellationToken) - => _options.UseBatchFetch + { + SecretKeyMapper.ValidateOptions(_options.KeyMapping); + return _options.UseBatchFetch ? FetchConfigurationBatchAsync(cancellationToken) : FetchConfigurationAsync(cancellationToken); + } private async Task> FetchConfigurationAsync(CancellationToken cancellationToken) { @@ -216,24 +219,26 @@ private void ProcessSecretString(Dictionary dict, SecretListEnt { foreach (var (key, value) in SecretsManagerHelpers.ExtractValues(jElement!, rootKey)) { + var defaultKey = SecretKeyMapper.MapJsonKey(key, rootKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.Create( secretId, secretEntry.Name ?? secretId, secretEntry.ARN, key, - key); + defaultKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, value); } } else { + var defaultKey = SecretKeyMapper.MapScalarKey(rootKey, _options.KeyMapping); var context = SecretKeyGeneratorContextFactory.CreateScalar( secretId, secretEntry.Name ?? secretId, secretEntry.ARN, rootKey, - rootKey); + defaultKey); var configKey = _options.KeyGenerator(context); ApplyEntry(dict, configKey, secretString); } diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretKeyMappingOptions.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretKeyMappingOptions.cs new file mode 100644 index 0000000..624dafc --- /dev/null +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretKeyMappingOptions.cs @@ -0,0 +1,87 @@ +namespace Kralizek.Extensions.Configuration +{ + /// + /// Controls how AWS secret names and values are projected into .NET configuration keys. + /// + /// + /// These options are shared by all provider modes: Discovery, KnownSecret, and KnownSecrets. + /// The resulting mapped key is passed as to + /// the KeyGenerator delegate, where it can be further customized. + /// + public sealed class SecretKeyMappingOptions + { + /// + /// Gets or sets the separator used to split the secret-name portion of generated keys + /// into .NET configuration path segments. + /// + /// + /// + /// When set, occurrences of this value in the secret-name portion are replaced with + /// (:). + /// Leading and trailing delimiters produced by path-style secret names are trimmed. + /// + /// + /// Set to to disable secret-name separator normalization. + /// An empty string is invalid and will cause an + /// to be thrown when secrets are loaded. + /// + /// Examples: + /// + /// + /// + /// "/" (the default) maps /my-app/production/database + /// to my-app:production:database. + /// + /// + /// + /// + /// "--" maps my-app--production--database + /// to my-app:production:database. + /// + /// + /// + /// + /// disables normalization; secret names are used as-is. + /// + /// + /// + /// + public string? SecretNamePathSeparator { get; set; } = "/"; + + /// + /// Gets or sets a value indicating whether JSON-derived configuration keys include + /// the mapped secret name as a prefix. + /// + /// + /// + /// When (the default), JSON-derived keys are namespaced under the + /// mapped secret name, reducing the risk of key collisions across secrets. + /// + /// + /// When , the secret name is stripped and only the JSON property path + /// is used as the configuration key. This is useful when loading a JSON secret directly as + /// normal application configuration, or when projecting it into a specific + /// . + /// + /// + /// This option has no effect on scalar/simple secrets; their key is always derived from + /// the mapped secret name. + /// + /// + public bool PrefixJsonKeysWithSecretName { get; set; } = true; + + /// + /// Gets or sets an optional configuration section prepended to all generated keys. + /// + /// + /// + /// When set, all generated keys — both JSON-derived and scalar — are placed under this section. + /// + /// + /// Example: with TargetSection = "Email", a JSON key Smtp:Host + /// becomes Email:Smtp:Host. + /// + /// + public string? TargetSection { get; set; } + } +} \ No newline at end of file diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerDiscoveryOptions.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerDiscoveryOptions.cs index 9415609..d139e8f 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerDiscoveryOptions.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerDiscoveryOptions.cs @@ -34,6 +34,17 @@ public sealed class SecretsManagerDiscoveryOptions /// public bool UseBatchFetch { get; set; } = true; + /// + /// Gets the built-in key mapping options that control how AWS secret names and values + /// are projected into .NET configuration keys. + /// + /// + /// The mapped key is passed as to + /// . Use as an advanced escape hatch + /// when the built-in options are not sufficient. + /// + public SecretKeyMappingOptions KeyMapping { get; } = new(); + /// /// Gets or sets a function that maps a /// to the final configuration key stored in the provider. diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretOptions.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretOptions.cs index 5919664..f70b8e5 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretOptions.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretOptions.cs @@ -17,6 +17,17 @@ namespace Kralizek.Extensions.Configuration /// public sealed class SecretsManagerKnownSecretOptions { + /// + /// Gets the built-in key mapping options that control how AWS secret names and values + /// are projected into .NET configuration keys. + /// + /// + /// The mapped key is passed as to + /// . Use as an advanced escape hatch + /// when the built-in options are not sufficient. + /// + public SecretKeyMappingOptions KeyMapping { get; } = new(); + /// /// Gets or sets a function that maps a /// to the final configuration key stored in the provider. diff --git a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretsOptions.cs b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretsOptions.cs index f3409f1..3c436c1 100644 --- a/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretsOptions.cs +++ b/src/Kralizek.Extensions.Configuration.AWSSecretsManager/SecretsManagerKnownSecretsOptions.cs @@ -27,6 +27,17 @@ public sealed class SecretsManagerKnownSecretsOptions /// public bool UseBatchFetch { get; set; } = true; + /// + /// Gets the built-in key mapping options that control how AWS secret names and values + /// are projected into .NET configuration keys. + /// + /// + /// The mapped key is passed as to + /// . Use as an advanced escape hatch + /// when the built-in options are not sufficient. + /// + public SecretKeyMappingOptions KeyMapping { get; } = new(); + /// /// Gets or sets a function that maps a /// to the final configuration key stored in the provider. diff --git a/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMappingTests.cs b/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMappingTests.cs new file mode 100644 index 0000000..58f91e5 --- /dev/null +++ b/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretKeyMappingTests.cs @@ -0,0 +1,579 @@ +using System.Collections.Generic; +using System.Threading; + +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; + +using Kralizek.Extensions.Configuration; +using Kralizek.Extensions.Configuration.Internal; + +using Moq; + +using NUnit.Framework; + +namespace Tests.Internal +{ + [TestFixture] + [TestOf(typeof(SecretKeyMapper))] + public class SecretKeyMapperTests + { + // --------------------------------------------------------------------------- + // ValidateOptions + // --------------------------------------------------------------------------- + + [Test] + public void ValidateOptions_does_not_throw_when_separator_is_slash() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = "/" }; + Assert.DoesNotThrow(() => SecretKeyMapper.ValidateOptions(options)); + } + + [Test] + public void ValidateOptions_does_not_throw_when_separator_is_null() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = null }; + Assert.DoesNotThrow(() => SecretKeyMapper.ValidateOptions(options)); + } + + [Test] + public void ValidateOptions_throws_when_separator_is_empty_string() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = string.Empty }; + Assert.Throws(() => SecretKeyMapper.ValidateOptions(options)); + } + + // --------------------------------------------------------------------------- + // MapScalarKey — SecretNamePathSeparator + // --------------------------------------------------------------------------- + + [Test] + public void MapScalarKey_replaces_slash_separator_with_colon() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = "/" }; + var result = SecretKeyMapper.MapScalarKey("/my-app/production/database", options); + Assert.That(result, Is.EqualTo("my-app:production:database")); + } + + [Test] + public void MapScalarKey_replaces_double_dash_separator_with_colon() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = "--" }; + var result = SecretKeyMapper.MapScalarKey("my-app--production--database", options); + Assert.That(result, Is.EqualTo("my-app:production:database")); + } + + [Test] + public void MapScalarKey_trims_leading_delimiter_from_path_style_name() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = "/" }; + var result = SecretKeyMapper.MapScalarKey("/leading-slash", options); + Assert.That(result, Is.EqualTo("leading-slash")); + } + + [Test] + public void MapScalarKey_leaves_name_unchanged_when_separator_is_null() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = null }; + var result = SecretKeyMapper.MapScalarKey("/my-app/production/database", options); + Assert.That(result, Is.EqualTo("/my-app/production/database")); + } + + [Test] + public void MapScalarKey_leaves_simple_name_unchanged() + { + var options = new SecretKeyMappingOptions { SecretNamePathSeparator = "/" }; + var result = SecretKeyMapper.MapScalarKey("my-secret", options); + Assert.That(result, Is.EqualTo("my-secret")); + } + + // --------------------------------------------------------------------------- + // MapScalarKey — TargetSection + // --------------------------------------------------------------------------- + + [Test] + public void MapScalarKey_prepends_target_section() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + TargetSection = "Secrets" + }; + var result = SecretKeyMapper.MapScalarKey("/my-app/production/database", options); + Assert.That(result, Is.EqualTo("Secrets:my-app:production:database")); + } + + [Test] + public void MapScalarKey_does_not_prepend_null_target_section() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + TargetSection = null + }; + var result = SecretKeyMapper.MapScalarKey("my-secret", options); + Assert.That(result, Is.EqualTo("my-secret")); + } + + // --------------------------------------------------------------------------- + // MapJsonKey — PrefixJsonKeysWithSecretName + // --------------------------------------------------------------------------- + + [Test] + public void MapJsonKey_includes_mapped_secret_name_prefix_when_prefix_is_true() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + PrefixJsonKeysWithSecretName = true + }; + // raw key = secretName:jsonPath produced by ExtractValues + var result = SecretKeyMapper.MapJsonKey( + "/my-app/production/database:ConnectionStrings:Database", + "/my-app/production/database", + options); + Assert.That(result, Is.EqualTo("my-app:production:database:ConnectionStrings:Database")); + } + + [Test] + public void MapJsonKey_omits_secret_name_prefix_when_prefix_is_false() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + PrefixJsonKeysWithSecretName = false + }; + var result = SecretKeyMapper.MapJsonKey( + "/my-app/production/database:ConnectionStrings:Database", + "/my-app/production/database", + options); + Assert.That(result, Is.EqualTo("ConnectionStrings:Database")); + } + + [Test] + public void MapJsonKey_leaves_raw_key_unchanged_when_separator_is_null_and_prefix_is_true() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = null, + PrefixJsonKeysWithSecretName = true + }; + var result = SecretKeyMapper.MapJsonKey( + "/my-app/production/database:ConnectionStrings:Database", + "/my-app/production/database", + options); + Assert.That(result, Is.EqualTo("/my-app/production/database:ConnectionStrings:Database")); + } + + // --------------------------------------------------------------------------- + // MapJsonKey — TargetSection + // --------------------------------------------------------------------------- + + [Test] + public void MapJsonKey_prepends_target_section_with_prefix_true() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + PrefixJsonKeysWithSecretName = true, + TargetSection = "Secrets" + }; + var result = SecretKeyMapper.MapJsonKey( + "/my-app/production/database:ConnectionStrings:Database", + "/my-app/production/database", + options); + Assert.That(result, Is.EqualTo("Secrets:my-app:production:database:ConnectionStrings:Database")); + } + + [Test] + public void MapJsonKey_prepends_target_section_with_prefix_false() + { + var options = new SecretKeyMappingOptions + { + SecretNamePathSeparator = "/", + PrefixJsonKeysWithSecretName = false, + TargetSection = "Secrets" + }; + var result = SecretKeyMapper.MapJsonKey( + "/my-app/production/database:ConnectionStrings:Database", + "/my-app/production/database", + options); + Assert.That(result, Is.EqualTo("Secrets:ConnectionStrings:Database")); + } + } + + // --------------------------------------------------------------------------- + // Provider integration tests for key mapping + // --------------------------------------------------------------------------- + + [TestFixture] + public class KeyMappingKnownSecretProviderTests + { + private static GetSecretValueResponse BuildJsonResponse(string secretName, string secretJson) => + new GetSecretValueResponse + { + ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + Name = secretName, + SecretString = secretJson + }; + + private static GetSecretValueResponse BuildScalarResponse(string secretName, string secretValue) => + new GetSecretValueResponse + { + ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + Name = secretName, + SecretString = secretValue + }; + + [Test] + public void JSON_secret_with_path_name_uses_colon_separator_by_default() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildJsonResponse("/my-app/production/database", "{\"Smtp\":{\"Host\":\"localhost\"}}")); + + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", new SecretsManagerKnownSecretOptions()); + sut.Load(); + + Assert.That(sut.Get("my-app:production:database:Smtp:Host"), Is.EqualTo("localhost")); + } + + [Test] + public void JSON_secret_without_name_prefix_uses_only_json_path() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildJsonResponse("/my-app/production/database", "{\"Smtp\":{\"Host\":\"localhost\"}}")); + + var options = new SecretsManagerKnownSecretOptions + { + KeyMapping = { PrefixJsonKeysWithSecretName = false } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", options); + sut.Load(); + + Assert.That(sut.Get("Smtp:Host"), Is.EqualTo("localhost")); + } + + [Test] + public void JSON_secret_with_target_section_and_no_prefix() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildJsonResponse("my-app/email", "{\"Smtp\":{\"Host\":\"smtp.example.com\",\"Port\":587}}")); + + var options = new SecretsManagerKnownSecretOptions + { + KeyMapping = + { + PrefixJsonKeysWithSecretName = false, + TargetSection = "Email" + } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "my-app/email", options); + sut.Load(); + + Assert.That(sut.Get("Email:Smtp:Host"), Is.EqualTo("smtp.example.com")); + Assert.That(sut.Get("Email:Smtp:Port"), Is.EqualTo("587")); + } + + [Test] + public void JSON_secret_with_target_section_and_prefix() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildJsonResponse("/my-app/production/database", "{\"ConnectionStrings\":{\"Database\":\"server=.;\"}}")); + + var options = new SecretsManagerKnownSecretOptions + { + KeyMapping = + { + PrefixJsonKeysWithSecretName = true, + TargetSection = "Secrets" + } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", options); + sut.Load(); + + Assert.That(sut.Get("Secrets:my-app:production:database:ConnectionStrings:Database"), Is.EqualTo("server=.;")); + } + + [Test] + public void Scalar_secret_with_path_name_uses_colon_separator() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildScalarResponse("/my-app/production/database", "secret-value")); + + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", new SecretsManagerKnownSecretOptions()); + sut.Load(); + + Assert.That(sut.Get("my-app:production:database"), Is.EqualTo("secret-value")); + } + + [Test] + public void Scalar_secret_with_target_section() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildScalarResponse("/my-app/production/database", "secret-value")); + + var options = new SecretsManagerKnownSecretOptions + { + KeyMapping = { TargetSection = "Secrets" } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", options); + sut.Load(); + + Assert.That(sut.Get("Secrets:my-app:production:database"), Is.EqualTo("secret-value")); + } + + [Test] + public void Scalar_PrefixJsonKeysWithSecretName_does_not_affect_scalar_key() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildScalarResponse("/my-app/production/database", "secret-value")); + + // PrefixJsonKeysWithSecretName does not affect scalar secrets; the key is always the mapped secret name. + var optionsWithPrefix = new SecretsManagerKnownSecretOptions + { + KeyMapping = { PrefixJsonKeysWithSecretName = false } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/production/database", optionsWithPrefix); + sut.Load(); + + Assert.That(sut.Get("my-app:production:database"), Is.EqualTo("secret-value")); + } + + [Test] + public void Key_mapping_produces_raw_key_as_context_raw_key_and_mapped_key_as_default_key() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildJsonResponse("/my-app/database", "{\"Property\":\"value\"}")); + + SecretKeyGeneratorContext? capturedContext = null; + var options = new SecretsManagerKnownSecretOptions + { + KeyGenerator = ctx => { capturedContext = ctx; return ctx.DefaultKey; } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider( + secretsManager, "/my-app/database", options); + sut.Load(); + + Assert.That(capturedContext, Is.Not.Null); + Assert.That(capturedContext!.RawKey, Is.EqualTo("/my-app/database:Property")); + Assert.That(capturedContext.DefaultKey, Is.EqualTo("my-app:database:Property")); + Assert.That(capturedContext.JsonPath, Is.EqualTo("Property")); + } + + [Test] + public void Empty_SecretNamePathSeparator_throws_on_load() + { + var secretsManager = Mock.Of(); + Mock.Get(secretsManager) + .Setup(s => s.GetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildScalarResponse("my-secret", "value")); + + var options = new SecretsManagerKnownSecretOptions + { + KeyMapping = { SecretNamePathSeparator = string.Empty } + }; + var sut = new SecretsManagerKnownSecretConfigurationProvider(secretsManager, "my-secret", options); + + Assert.Throws(() => sut.Load()); + } + } + + [TestFixture] + public class KeyMappingKnownSecretsProviderTests + { + private static void SetupBatchResponse(IAmazonSecretsManager secretsManager, string secretName, string secretValue) + { + Mock.Get(secretsManager) + .Setup(s => s.BatchGetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new BatchGetSecretValueResponse + { + SecretValues = new List + { + new SecretValueEntry + { + ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + Name = secretName, + SecretString = secretValue + } + }, + Errors = new List() + }); + } + + [Test] + public void JSON_secret_with_path_name_uses_colon_separator() + { + var secretsManager = Mock.Of(); + SetupBatchResponse(secretsManager, "/my-app/production/database", "{\"Key\":\"value\"}"); + + var sut = new SecretsManagerKnownSecretsConfigurationProvider( + secretsManager, + new[] { "/my-app/production/database" }, + new SecretsManagerKnownSecretsOptions()); + sut.Load(); + + Assert.That(sut.Get("my-app:production:database:Key"), Is.EqualTo("value")); + } + + [Test] + public void JSON_secret_without_name_prefix_uses_only_json_path() + { + var secretsManager = Mock.Of(); + SetupBatchResponse(secretsManager, "/my-app/shared", "{\"ConnectionStrings\":{\"Default\":\"server=.;\"}}"); + + var options = new SecretsManagerKnownSecretsOptions + { + KeyMapping = { PrefixJsonKeysWithSecretName = false } + }; + var sut = new SecretsManagerKnownSecretsConfigurationProvider( + secretsManager, new[] { "/my-app/shared" }, options); + sut.Load(); + + Assert.That(sut.Get("ConnectionStrings:Default"), Is.EqualTo("server=.;")); + } + + [Test] + public void Scalar_secret_with_path_name_uses_colon_separator() + { + var secretsManager = Mock.Of(); + SetupBatchResponse(secretsManager, "/my-app/production/api-key", "my-api-key"); + + var sut = new SecretsManagerKnownSecretsConfigurationProvider( + secretsManager, + new[] { "/my-app/production/api-key" }, + new SecretsManagerKnownSecretsOptions()); + sut.Load(); + + Assert.That(sut.Get("my-app:production:api-key"), Is.EqualTo("my-api-key")); + } + } + + [TestFixture] + public class KeyMappingDiscoveryProviderTests + { + private static void SetupDiscovery(IAmazonSecretsManager secretsManager, string secretName, string secretArn) + { + Mock.Get(secretsManager) + .Setup(s => s.ListSecretsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ListSecretsResponse + { + SecretList = new List + { + new SecretListEntry { ARN = secretArn, Name = secretName } + } + }); + } + + private static void SetupBatchResponse(IAmazonSecretsManager secretsManager, string secretName, string secretArn, string secretValue) + { + Mock.Get(secretsManager) + .Setup(s => s.BatchGetSecretValueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new BatchGetSecretValueResponse + { + SecretValues = new List + { + new SecretValueEntry { ARN = secretArn, Name = secretName, SecretString = secretValue } + }, + Errors = new List() + }); + } + + [Test] + public void Discovery_JSON_secret_with_path_name_uses_colon_separator() + { + const string secretArn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"; + const string secretName = "/my-app/production/database"; + var secretsManager = Mock.Of(); + SetupDiscovery(secretsManager, secretName, secretArn); + SetupBatchResponse(secretsManager, secretName, secretArn, "{\"Key\":\"value\"}"); + + var sut = new SecretsManagerDiscoveryConfigurationProvider( + secretsManager, new SecretsManagerDiscoveryOptions { UseBatchFetch = true }); + sut.Load(); + + Assert.That(sut.Get("my-app:production:database:Key"), Is.EqualTo("value")); + } + + [Test] + public void Discovery_scalar_secret_with_path_name_uses_colon_separator() + { + const string secretArn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"; + const string secretName = "/my-app/production/api-key"; + var secretsManager = Mock.Of(); + SetupDiscovery(secretsManager, secretName, secretArn); + SetupBatchResponse(secretsManager, secretName, secretArn, "my-api-key"); + + var sut = new SecretsManagerDiscoveryConfigurationProvider( + secretsManager, new SecretsManagerDiscoveryOptions { UseBatchFetch = true }); + sut.Load(); + + Assert.That(sut.Get("my-app:production:api-key"), Is.EqualTo("my-api-key")); + } + + [Test] + public void Discovery_JSON_secret_without_name_prefix() + { + const string secretArn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"; + const string secretName = "/my-app/shared"; + var secretsManager = Mock.Of(); + SetupDiscovery(secretsManager, secretName, secretArn); + SetupBatchResponse(secretsManager, secretName, secretArn, "{\"Feature\":\"enabled\"}"); + + var options = new SecretsManagerDiscoveryOptions + { + UseBatchFetch = true, + KeyMapping = { PrefixJsonKeysWithSecretName = false } + }; + var sut = new SecretsManagerDiscoveryConfigurationProvider(secretsManager, options); + sut.Load(); + + Assert.That(sut.Get("Feature"), Is.EqualTo("enabled")); + } + + [Test] + public void Discovery_JSON_secret_with_target_section() + { + const string secretArn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"; + const string secretName = "/my-app/production/database"; + var secretsManager = Mock.Of(); + SetupDiscovery(secretsManager, secretName, secretArn); + SetupBatchResponse(secretsManager, secretName, secretArn, "{\"ConnectionStrings\":{\"Database\":\"server=.;\"}}"); + + var options = new SecretsManagerDiscoveryOptions + { + UseBatchFetch = true, + KeyMapping = + { + PrefixJsonKeysWithSecretName = false, + TargetSection = "App" + } + }; + var sut = new SecretsManagerDiscoveryConfigurationProvider(secretsManager, options); + sut.Load(); + + Assert.That(sut.Get("App:ConnectionStrings:Database"), Is.EqualTo("server=.;")); + } + } +} \ No newline at end of file diff --git a/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProviderTests.cs b/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProviderTests.cs index d19a7be..91ee275 100644 --- a/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProviderTests.cs +++ b/tests/Tests.Extensions.Configuration.AWSSecretsManager/Internal/SecretsManagerKnownSecretConfigurationProviderTests.cs @@ -140,7 +140,10 @@ public void Key_is_rooted_at_response_Name_not_request_secretId([Frozen] IAmazon var sut = new SecretsManagerKnownSecretConfigurationProvider(secretsManager, secretArn, new SecretsManagerKnownSecretOptions()); sut.Load(); - Assert.That(sut.Get(secretName, "Key"), Is.EqualTo("value")); + // With default SecretNamePathSeparator = "/" the secret name is mapped from + // "/App/Production/Config" to "App:Production:Config", so the JSON key "Key" + // is stored under "App:Production:Config:Key". + Assert.That(sut.Get("App:Production:Config", "Key"), Is.EqualTo("value")); Assert.That(sut.HasKey(secretArn, "Key"), Is.False); } @@ -151,7 +154,6 @@ public void KnownSecret_with_json_flattening_roots_keys_at_secret_name_not_arn([ const string secretArn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:App-Production-Service-Settings-Nested-Section-AbCdEf"; const string secretName = "/App/Production/Service/Settings/Nested/Section"; const string secretJson = "{\"Property\":\"value\",\"Nested\":{\"Enabled\":true}}"; - const string pathPrefix = "/App/Production/Service/Settings/"; var response = new GetSecretValueResponse { @@ -164,21 +166,17 @@ public void KnownSecret_with_json_flattening_roots_keys_at_secret_name_not_arn([ .Setup(p => p.GetSecretValueAsync(It.Is(r => r.SecretId == secretArn), It.IsAny())) .ReturnsAsync(response); - var options = new SecretsManagerKnownSecretOptions - { - KeyGenerator = context => - { - var key = context.DefaultKey; - var stripped = key.StartsWith(pathPrefix) ? key.Substring(pathPrefix.Length) : key; - return stripped.Replace("/", ":"); - } - }; - - var sut = new SecretsManagerKnownSecretConfigurationProvider(secretsManager, secretArn, options); + var sut = new SecretsManagerKnownSecretConfigurationProvider(secretsManager, secretArn, new SecretsManagerKnownSecretOptions()); sut.Load(); - Assert.That(sut.Get("Nested:Section:Property"), Is.EqualTo("value")); - Assert.That(sut.Get("Nested:Section:Nested:Enabled"), Is.EqualTo("True")); + // With default SecretNamePathSeparator = "/" the secret name is mapped from + // "/App/Production/Service/Settings/Nested/Section" to + // "App:Production:Service:Settings:Nested:Section". + Assert.That(sut.Get("App:Production:Service:Settings:Nested:Section:Property"), Is.EqualTo("value")); + Assert.That(sut.Get("App:Production:Service:Settings:Nested:Section:Nested:Enabled"), Is.EqualTo("True")); + + // Verify the key is NOT rooted at the ARN used to request the secret. + Assert.That(sut.HasKey(secretArn, "Property"), Is.False); } [Test, CustomAutoData]