Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public static SecretKeyGeneratorContext Create(
SecretArn = secretArn,
RawKey = rawKey,
DefaultKey = defaultKey,
JsonPath = GetJsonPath(resolvedRootKey, defaultKey)
JsonPath = GetJsonPath(resolvedRootKey, rawKey)
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;

using Microsoft.Extensions.Configuration;

namespace Kralizek.Extensions.Configuration.Internal
{
internal static class SecretKeyMapper
{
/// <summary>
/// Validates the given <see cref="SecretKeyMappingOptions"/> and throws if any option is invalid.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when <see cref="SecretKeyMappingOptions.SecretNamePathSeparator"/> is an empty string.
/// </exception>
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.");
}

/// <summary>
/// Produces the mapped configuration key for a JSON-derived secret entry.
/// </summary>
/// <param name="rawKey">
/// The raw key as produced by <c>ExtractValues</c>, in the form <c>secretName:jsonPath</c>.
/// </param>
/// <param name="secretName">The secret name used as the prefix in <paramref name="rawKey"/>.</param>
/// <param name="options">The key mapping options to apply.</param>
/// <returns>The mapped configuration key.</returns>
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;
}

/// <summary>
/// Produces the mapped configuration key for a scalar (non-JSON) secret.
/// </summary>
/// <param name="secretName">The secret name.</param>
/// <param name="options">The key mapping options to apply.</param>
/// <returns>The mapped configuration key.</returns>
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]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ public SecretsManagerDiscoveryConfigurationProvider(IAmazonSecretsManager client

/// <inheritdoc/>
protected override Task<Dictionary<string, string?>> FetchConfigurationCoreAsync(CancellationToken cancellationToken)
=> _options.UseBatchFetch
{
SecretKeyMapper.ValidateOptions(_options.KeyMapping);
return _options.UseBatchFetch
? FetchConfigurationBatchAsync(cancellationToken)
: FetchConfigurationAsync(cancellationToken);
}

private async Task<IReadOnlyList<SecretListEntry>> FetchAllSecretsAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -224,25 +227,27 @@ private void ProcessSecretString(Dictionary<string, string?> 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);
}
}
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ public SecretsManagerKnownSecretConfigurationProvider(IAmazonSecretsManager clie

/// <inheritdoc/>
protected override Task<Dictionary<string, string?>> FetchConfigurationCoreAsync(CancellationToken cancellationToken)
=> FetchConfigurationAsync(cancellationToken);
{
SecretKeyMapper.ValidateOptions(_options.KeyMapping);
return FetchConfigurationAsync(cancellationToken);
}

private async Task<Dictionary<string, string?>> FetchConfigurationAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ public SecretsManagerKnownSecretsConfigurationProvider(IAmazonSecretsManager cli

/// <inheritdoc/>
protected override Task<Dictionary<string, string?>> FetchConfigurationCoreAsync(CancellationToken cancellationToken)
=> _options.UseBatchFetch
{
SecretKeyMapper.ValidateOptions(_options.KeyMapping);
return _options.UseBatchFetch
? FetchConfigurationBatchAsync(cancellationToken)
: FetchConfigurationAsync(cancellationToken);
}

private async Task<Dictionary<string, string?>> FetchConfigurationAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -216,24 +219,26 @@ private void ProcessSecretString(Dictionary<string, string?> 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Kralizek.Extensions.Configuration
{
/// <summary>
/// Controls how AWS secret names and values are projected into .NET configuration keys.
/// </summary>
/// <remarks>
/// These options are shared by all provider modes: Discovery, KnownSecret, and KnownSecrets.
/// The resulting mapped key is passed as <see cref="SecretKeyGeneratorContext.DefaultKey"/> to
/// the <c>KeyGenerator</c> delegate, where it can be further customized.
/// </remarks>
public sealed class SecretKeyMappingOptions
{
/// <summary>
/// Gets or sets the separator used to split the secret-name portion of generated keys
/// into .NET configuration path segments.
/// </summary>
/// <remarks>
/// <para>
/// When set, occurrences of this value in the secret-name portion are replaced with
/// <see cref="Microsoft.Extensions.Configuration.ConfigurationPath.KeyDelimiter"/> (<c>:</c>).
/// Leading and trailing delimiters produced by path-style secret names are trimmed.
/// </para>
/// <para>
/// Set to <see langword="null"/> to disable secret-name separator normalization.
/// An empty string is invalid and will cause an <see cref="System.InvalidOperationException"/>
/// to be thrown when secrets are loaded.
/// </para>
/// <para>Examples:</para>
/// <list type="bullet">
/// <item>
/// <description>
/// <c>"/"</c> (the default) maps <c>/my-app/production/database</c>
/// to <c>my-app:production:database</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>"--"</c> maps <c>my-app--production--database</c>
/// to <c>my-app:production:database</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see langword="null"/> disables normalization; secret names are used as-is.
/// </description>
/// </item>
/// </list>
/// </remarks>
public string? SecretNamePathSeparator { get; set; } = "/";

/// <summary>
/// Gets or sets a value indicating whether JSON-derived configuration keys include
/// the mapped secret name as a prefix.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="true"/> (the default), JSON-derived keys are namespaced under the
/// mapped secret name, reducing the risk of key collisions across secrets.
/// </para>
/// <para>
/// When <see langword="false"/>, 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
/// <see cref="TargetSection"/>.
/// </para>
/// <para>
/// This option has no effect on scalar/simple secrets; their key is always derived from
/// the mapped secret name.
/// </para>
/// </remarks>
public bool PrefixJsonKeysWithSecretName { get; set; } = true;

/// <summary>
/// Gets or sets an optional configuration section prepended to all generated keys.
/// </summary>
/// <remarks>
/// <para>
/// When set, all generated keys — both JSON-derived and scalar — are placed under this section.
/// </para>
/// <para>
/// Example: with <c>TargetSection = "Email"</c>, a JSON key <c>Smtp:Host</c>
/// becomes <c>Email:Smtp:Host</c>.
/// </para>
/// </remarks>
public string? TargetSection { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ public sealed class SecretsManagerDiscoveryOptions
/// </summary>
public bool UseBatchFetch { get; set; } = true;

/// <summary>
/// Gets the built-in key mapping options that control how AWS secret names and values
/// are projected into .NET configuration keys.
/// </summary>
/// <remarks>
/// The mapped key is passed as <see cref="SecretKeyGeneratorContext.DefaultKey"/> to
/// <see cref="KeyGenerator"/>. Use <see cref="KeyGenerator"/> as an advanced escape hatch
/// when the built-in options are not sufficient.
/// </remarks>
public SecretKeyMappingOptions KeyMapping { get; } = new();

/// <summary>
/// Gets or sets a function that maps a <see cref="SecretKeyGeneratorContext"/>
/// to the final configuration key stored in the provider.
Expand Down
Loading