feat(secrets): ${secret:} config expander (fail-closed)

This commit is contained in:
Joseph Doherty
2026-07-15 16:58:34 -04:00
parent 79ebd14baa
commit 0bb553212a
3 changed files with 216 additions and 0 deletions
@@ -0,0 +1,109 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Configuration;
/// <summary>
/// Expands <c>${secret:name}</c> reference tokens in configuration values by resolving each
/// referenced secret through an <see cref="ISecretResolver"/>. Expansion is <em>fail-closed</em>:
/// a token that names an absent secret throws <see cref="SecretNotFoundException"/> rather than
/// leaving the placeholder (or a blank) in place, so a misconfigured deployment fails loudly at
/// startup instead of silently running with an empty credential.
/// </summary>
public sealed class SecretReferenceExpander
{
private const string TokenMarker = "${secret:";
private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
private readonly ISecretResolver _resolver;
/// <summary>Creates an expander that resolves references via <paramref name="resolver"/>.</summary>
/// <param name="resolver">The resolver used to fetch each referenced secret's plaintext.</param>
public SecretReferenceExpander(ISecretResolver resolver) =>
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
/// <summary>
/// Replaces every <c>${secret:name}</c> token in <paramref name="raw"/> with the resolved
/// plaintext of the named secret. Multiple tokens (including repeats of the same name) are all
/// expanded; each distinct name is resolved once.
/// </summary>
/// <param name="raw">The raw configuration value, possibly containing reference tokens.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// The expanded value, or <paramref name="raw"/> unchanged when it is <c>null</c> or contains
/// no tokens.
/// </returns>
/// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
public async Task<string> ExpandAsync(string raw, CancellationToken ct)
{
if (raw is null)
{
return raw!;
}
MatchCollection matches = TokenPattern.Matches(raw);
if (matches.Count == 0)
{
return raw;
}
// Resolve each distinct referenced name exactly once, then substitute synchronously.
var resolved = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Match match in matches)
{
string name = match.Groups[1].Value.Trim();
if (resolved.ContainsKey(name))
{
continue;
}
string? value = await _resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
if (value is null)
{
throw new SecretNotFoundException(
$"Configuration references unknown secret '{name}' via ${{secret:{name}}}.");
}
resolved[name] = value;
}
return TokenPattern.Replace(raw, m => resolved[m.Groups[1].Value.Trim()]);
}
/// <summary>
/// Walks every entry in <paramref name="config"/> and rewrites, in place, any value that
/// contains a <c>${secret:...}</c> token to its expanded plaintext. Writes go through the
/// <see cref="IConfigurationRoot"/> indexer so subsequent reads — and any options binding done
/// afterward — observe the expanded values.
/// </summary>
/// <remarks>
/// This MUST run <em>after</em> configuration is loaded and <em>before</em> options validation
/// or <c>ValidateOnStart</c> executes; otherwise validators may see the unexpanded placeholder.
/// Fail-closed: a missing referenced secret propagates <see cref="SecretNotFoundException"/> out
/// of startup.
/// </remarks>
/// <param name="config">The loaded configuration root to mutate.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
public async Task ExpandConfigurationAsync(IConfigurationRoot config, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(config);
// Materialize first: mutating provider data while enumerating AsEnumerable() is unsafe.
var pending = new List<KeyValuePair<string, string>>();
foreach (KeyValuePair<string, string?> entry in config.AsEnumerable())
{
if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal))
{
pending.Add(new KeyValuePair<string, string>(entry.Key, value));
}
}
foreach (KeyValuePair<string, string> entry in pending)
{
config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false);
}
}
}
@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
@@ -0,0 +1,106 @@
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
namespace ZB.MOM.WW.Secrets.Tests.Configuration;
public sealed class SecretReferenceExpanderTests
{
private sealed class FakeSecretResolver : ISecretResolver
{
private readonly Dictionary<string, string?> _values;
public FakeSecretResolver(Dictionary<string, string?> values) => _values = values;
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(_values.TryGetValue(name.Value, out string? v) ? v : null);
}
private static SecretReferenceExpander Expander(params (string Name, string? Value)[] secrets)
{
var dict = new Dictionary<string, string?>();
foreach ((string name, string? value) in secrets)
{
dict[new SecretName(name).Value] = value;
}
return new SecretReferenceExpander(new FakeSecretResolver(dict));
}
[Fact]
public async Task ExpandAsync_SingleToken()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
string result = await expander.ExpandAsync("Password=${secret:sql/foo}", CancellationToken.None);
Assert.Equal("Password=hunter2", result);
}
[Fact]
public async Task ExpandAsync_MultipleTokens()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"), ("sql/bar", "swordfish"));
string result = await expander.ExpandAsync(
"u=${secret:sql/foo};p=${secret:sql/bar}", CancellationToken.None);
Assert.Equal("u=hunter2;p=swordfish", result);
}
[Fact]
public async Task ExpandAsync_NoToken_Unchanged()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
string result = await expander.ExpandAsync("no tokens here", CancellationToken.None);
Assert.Equal("no tokens here", result);
}
[Fact]
public async Task ExpandAsync_MissingSecret_Throws()
{
SecretReferenceExpander expander = Expander();
SecretNotFoundException ex = await Assert.ThrowsAsync<SecretNotFoundException>(
() => expander.ExpandAsync("Password=${secret:sql/absent}", CancellationToken.None));
Assert.Contains("sql/absent", ex.Message);
Assert.Contains("${secret:sql/absent}", ex.Message);
}
[Fact]
public async Task ExpandConfigurationAsync_RewritesMatchingKeys()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}",
["Plain:Value"] = "unchanged",
})
.Build();
await expander.ExpandConfigurationAsync(config, CancellationToken.None);
Assert.Contains("hunter2", config["ConnectionStrings:Db"]);
Assert.DoesNotContain("${secret:", config["ConnectionStrings:Db"]);
Assert.Equal("unchanged", config["Plain:Value"]);
}
[Fact]
public async Task ExpandConfigurationAsync_MissingSecret_FailsClosed()
{
SecretReferenceExpander expander = Expander();
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}",
})
.Build();
await Assert.ThrowsAsync<SecretNotFoundException>(
() => expander.ExpandConfigurationAsync(config, CancellationToken.None));
}
}