110 lines
4.8 KiB
C#
110 lines
4.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|