feat(secrets): SecretReferenceScanner derives a site's secret refs from its data-connection configs
This commit is contained in:
@@ -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:<name></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:<name></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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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:<name></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)]));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user