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;
}
}
}
}
}