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]