fix(site-secrets): one shared secret: reference parser for scanner and MxGateway adapter

Final review M3: SecretReferenceScanner.TryParseSecretReference (trimmed, case-insensitive
prefix, trimmed name, empty = not a reference, ${secret:} never matches) is used by the scanner
and by MxGatewayDataConnection's ApiKey resolution, so a padded or case-variant reference
resolves the name central ships. A bare 'secret:' ApiKey now fails closed. M7: unit test pins
stamped hash == ComputeCurrentArtifactHashesAsync with a non-empty Secrets list.
This commit is contained in:
Joseph Doherty
2026-09-28 21:08:34 -04:00
parent b1b49bb36c
commit 67a3950f74
5 changed files with 149 additions and 7 deletions
@@ -17,6 +17,29 @@ public static class SecretReferenceScanner
public const string Prefix = "secret:";
private static readonly string[] BootstrapPrefixes = ["SB-GRPC-PSK-"];
/// <summary>
/// The single parser for a <c>secret:&lt;name&gt;</c> reference value, shared by this scanner
/// (what central ships) and every adapter that resolves the reference at connect time (what
/// the site looks up), so both sides always agree on the name (final review M3). The value is
/// trimmed, the <c>secret:</c> prefix matches ordinal-ignore-case, and the remainder is
/// trimmed; an empty remainder is not a reference. The config-expander form
/// <c>${secret:…}</c> is a different syntax and never matches.
/// </summary>
/// <param name="value">A configuration string value.</param>
/// <param name="name">The referenced secret name when this returns <see langword="true"/>; otherwise empty.</param>
/// <returns><see langword="true"/> when <paramref name="value"/> is a <c>secret:&lt;name&gt;</c> reference.</returns>
public static bool TryParseSecretReference(string? value, out string name)
{
name = string.Empty;
if (value is null) return false;
var trimmed = value.Trim();
if (!trimmed.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) return false;
var candidate = trimmed[Prefix.Length..].Trim();
if (candidate.Length == 0) return false;
name = candidate;
return true;
}
/// <summary>
/// Scans every connection's primary and backup configuration JSON for <c>secret:&lt;name&gt;</c>
/// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted,
@@ -51,10 +74,8 @@ public static class SecretReferenceScanner
case JsonValueKind.Object: foreach (var p in e.EnumerateObject()) stack.Push(p.Value); break;
case JsonValueKind.Array: foreach (var i in e.EnumerateArray()) stack.Push(i); break;
case JsonValueKind.String:
var s = e.GetString()!.Trim();
if (!s.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) break;
var name = s[Prefix.Length..].Trim();
if (name.Length == 0 || BootstrapPrefixes.Any(b => name.StartsWith(b, StringComparison.OrdinalIgnoreCase))) break;
if (!TryParseSecretReference(e.GetString(), out var name)) break;
if (BootstrapPrefixes.Any(b => name.StartsWith(b, StringComparison.OrdinalIgnoreCase))) break;
yield return name;
break;
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -98,20 +99,27 @@ public class MxGatewayDataConnection
// so a fail-closed throw here leaves adapter state untouched (no half-open client,
// previous connection — if any — still intact for a later retry). A literal key
// (no `secret:` prefix) passes through unchanged for back-compat.
// The reference is parsed by the SAME parser central's SecretReferenceScanner uses to
// decide which secret to ship (trimmed, case-insensitive prefix), so a padded or
// case-variant reference resolves exactly the name that was deployed (final review M3).
var apiKey = cfg.ApiKey;
if (!string.IsNullOrEmpty(apiKey) &&
apiKey.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
if (SecretReferenceScanner.TryParseSecretReference(apiKey, out var secretName))
{
if (_secretResolver is null)
{
throw new InvalidOperationException(
"MxGateway ApiKey uses a 'secret:' reference but no ISecretResolver is available to resolve it.");
}
var secretName = apiKey["secret:".Length..];
apiKey = await _secretResolver.GetAsync(new SecretName(secretName), cancellationToken)
?? throw new InvalidOperationException(
$"MxGateway ApiKey secret '{secretName}' could not be resolved (not found or tombstoned).");
}
else if (!string.IsNullOrEmpty(apiKey) &&
apiKey.TrimStart().StartsWith(SecretReferenceScanner.Prefix, StringComparison.OrdinalIgnoreCase))
{
// `secret:` with no name: fail closed rather than sending the literal text as a key.
throw new InvalidOperationException("MxGateway ApiKey is a 'secret:' reference with no secret name.");
}
// Reconnect reuses this adapter: cancel the previous event loop and dispose
// the previous client so a flapping gateway cannot accumulate orphaned