feat(secrets): SecretReferenceScanner derives a site's secret refs from its data-connection configs

This commit is contained in:
Joseph Doherty
2026-09-28 17:27:28 -04:00
parent bf7d2db0b1
commit 01cb1f5652
2 changed files with 99 additions and 0 deletions
@@ -0,0 +1,64 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
/// <summary>One secret a site needs, and which of its data connections reference it.</summary>
public sealed record SecretReference(string Name, IReadOnlyList<string> ReferencedBy);
/// <summary>
/// Derives the secrets a site needs from its data-connection configs: every JSON string VALUE of the
/// exact form <c>secret:&lt;name&gt;</c>, at any depth, in primary or backup configuration. Protocol-agnostic
/// so a new adapter adopting <c>secret:</c> refs needs no change here. Bootstrap secrets are excluded
/// (design §3.1). Unparseable JSON yields nothing rather than throwing — the deploy gate owns config validity.
/// </summary>
public static class SecretReferenceScanner
{
public const string Prefix = "secret:";
private static readonly string[] BootstrapPrefixes = ["SB-GRPC-PSK-"];
/// <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,
/// de-duplicated list of connection names that referenced it.
/// </summary>
public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections)
{
var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var dc in connections)
foreach (var json in new[] { dc.PrimaryConfigurationJson, dc.BackupConfigurationJson })
foreach (var name in NamesIn(json))
{
if (!found.TryGetValue(name, out var by)) found[name] = by = new SortedSet<string>(StringComparer.Ordinal);
by.Add(dc.Name);
}
return found.Select(kv => new SecretReference(kv.Key, kv.Value.ToList())).ToList();
}
private static IEnumerable<string> NamesIn(string? json)
{
if (string.IsNullOrWhiteSpace(json)) yield break;
JsonDocument doc;
try { doc = JsonDocument.Parse(json); } catch (JsonException) { yield break; }
using (doc)
{
var stack = new Stack<JsonElement>(); stack.Push(doc.RootElement);
while (stack.Count > 0)
{
var e = stack.Pop();
switch (e.ValueKind)
{
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;
yield return name;
break;
}
}
}
}
}
@@ -0,0 +1,35 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Secrets;
/// <summary>
/// <see cref="SecretReferenceScanner"/> derives, per site, the distinct <c>secret:&lt;name&gt;</c>
/// references found anywhere in its data connections' primary/backup JSON (design §3.1).
/// </summary>
public class SecretReferenceScannerTests
{
private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null)
=> new(name, "MxGateway", primary, backup, 3);
[Fact]
public void FindsNestedRefs_CaseInsensitive_AcrossPrimaryAndBackup()
{
var refs = SecretReferenceScanner.Scan([
Dc("GW1", """{"Endpoint":"http://gw:5000","ApiKey":"SECRET: mxgw-key "}"""),
Dc("GW2", """{"a":{"b":["x","secret:mxgw-key"]}}""", """{"ApiKey":"secret:backup-key"}"""),
]);
Assert.Equal(["backup-key", "mxgw-key"], refs.Select(r => r.Name));
Assert.Equal(["GW1", "GW2"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy);
}
[Theory]
[InlineData("""{"ApiKey":"${secret:node-local}"}""")]
[InlineData("""{"Note":"see secret:x in docs"}""")]
[InlineData("""{"ApiKey":"secret:"}""")]
[InlineData("""{"Port":5000}""")]
[InlineData("not json")]
[InlineData("""{"Psk":"secret:SB-GRPC-PSK-site-a"}""")]
public void IgnoresNonReferences(string json)
=> Assert.Empty(SecretReferenceScanner.Scan([Dc("C", json)]));
}