diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs new file mode 100644 index 0000000..2edf0ac --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Configuration; + +/// +/// Expands ${secret:name} reference tokens in configuration values by resolving each +/// referenced secret through an . Expansion is fail-closed: +/// a token that names an absent secret throws 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. +/// +public sealed class SecretReferenceExpander +{ + private const string TokenMarker = "${secret:"; + + private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled); + + private readonly ISecretResolver _resolver; + + /// Creates an expander that resolves references via . + /// The resolver used to fetch each referenced secret's plaintext. + public SecretReferenceExpander(ISecretResolver resolver) => + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// + /// Replaces every ${secret:name} token in 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. + /// + /// The raw configuration value, possibly containing reference tokens. + /// A token to cancel the operation. + /// + /// The expanded value, or unchanged when it is null or contains + /// no tokens. + /// + /// A referenced secret does not exist (fail-closed). + public async Task 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(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()]); + } + + /// + /// Walks every entry in and rewrites, in place, any value that + /// contains a ${secret:...} token to its expanded plaintext. Writes go through the + /// indexer so subsequent reads — and any options binding done + /// afterward — observe the expanded values. + /// + /// + /// This MUST run after configuration is loaded and before options validation + /// or ValidateOnStart executes; otherwise validators may see the unexpanded placeholder. + /// Fail-closed: a missing referenced secret propagates out + /// of startup. + /// + /// The loaded configuration root to mutate. + /// A token to cancel the operation. + /// A referenced secret does not exist (fail-closed). + 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>(); + foreach (KeyValuePair entry in config.AsEnumerable()) + { + if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal)) + { + pending.Add(new KeyValuePair(entry.Key, value)); + } + } + + foreach (KeyValuePair entry in pending) + { + config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj index 450ec10..759ce67 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj @@ -11,6 +11,7 @@ + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs new file mode 100644 index 0000000..dd868f8 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs @@ -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 _values; + + public FakeSecretResolver(Dictionary values) => _values = values; + + public Task 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(); + 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( + () => 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 + { + ["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 + { + ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}", + }) + .Build(); + + await Assert.ThrowsAsync( + () => expander.ExpandConfigurationAsync(config, CancellationToken.None)); + } +}