Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialReferencesTests.cs
T
Joseph Doherty a224fd6391 fix(security): scope operator-authored credential references per driver (#815)
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control,
set a credential field to any reference the node can resolve
(env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>,
Galaxy file:<any path>) and click Test Connect / live browse; the node resolved
the value and sent it to that host.

One shared rule in Core.Abstractions (CredentialReferences +
DriverCredentialPolicy) generalises the #803 Heidenhain namespace:
- Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... |
  legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged.
- OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged.
- Heidenhain adopts the shared parser/resolver (namespace unchanged).
Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate
(CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test
Connect (AdminOperationsActor, before any probe), live browse
(BrowserSessionService, before any browser) and again at resolve time in every
driver/browser (fail closed, before any store/env read).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:58 -05:00

135 lines
8.4 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>lmxopcua#815 — the shared credential-reference parser, resolver and per-driver policy table.</summary>
public sealed class CredentialReferencesTests
{
private static readonly CredentialScope Galaxy = DriverCredentialPolicy.GalaxyScope;
[Theory]
[InlineData(null, CredentialReferenceKind.Blank)]
[InlineData("", CredentialReferenceKind.Blank)]
[InlineData("secret:galaxy/inst/apikey", CredentialReferenceKind.Secret)]
[InlineData("${secret:galaxy/inst/apikey}", CredentialReferenceKind.Secret)]
[InlineData("secret:otopcua/galaxy/ack-fallback", CredentialReferenceKind.Secret)]
[InlineData("env:OTOPCUA_GALAXY_API_KEY", CredentialReferenceKind.Environment)]
[InlineData("env:GALAXY_MXGW_API_KEY", CredentialReferenceKind.Environment)]
[InlineData("plain-key", CredentialReferenceKind.Literal)]
[InlineData("dev:k", CredentialReferenceKind.Literal)]
[InlineData("secret:otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:otopcua/apikeys/ab/galaxy-ack-password", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:OT-MESH-PSK-MAIN", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy/", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy/../otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy//x", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxyx/y", CredentialReferenceKind.OutOfScope)]
[InlineData("${secret:galaxy/x", CredentialReferenceKind.OutOfScope)]
[InlineData("SECRET:galaxy/x", CredentialReferenceKind.OutOfScope)]
[InlineData("Env:OTOPCUA_GALAXY_X", CredentialReferenceKind.OutOfScope)]
[InlineData("env:ConnectionStrings__ConfigDb", CredentialReferenceKind.OutOfScope)]
[InlineData("env:ServerHistorian__ApiKey", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_GALAXY_", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_GALAXY_lower", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_HEIDENHAIN_X", CredentialReferenceKind.OutOfScope)]
[InlineData("file:appsettings.json", CredentialReferenceKind.OutOfScope)]
[InlineData("FILE:x", CredentialReferenceKind.OutOfScope)]
public void Parse_classifies_against_the_galaxy_scope(string? value, CredentialReferenceKind expected) =>
CredentialReferences.Parse(value, Galaxy, out _).ShouldBe(expected);
[Fact]
public void Scopes_do_not_overlap_across_drivers()
{
CredentialReferences.Parse("secret:heidenhain/x", Galaxy, out _).ShouldBe(CredentialReferenceKind.OutOfScope);
CredentialReferences.Parse("secret:galaxy/x", DriverCredentialPolicy.OpcUaClientScope, out _)
.ShouldBe(CredentialReferenceKind.OutOfScope);
CredentialReferences.Parse("env:OTOPCUA_GALAXY_X", DriverCredentialPolicy.HeidenhainScope, out _)
.ShouldBe(CredentialReferenceKind.OutOfScope);
}
[Theory]
[InlineData(CredentialLiteralPolicy.Allowed, "plain", true)]
[InlineData(CredentialLiteralPolicy.Refused, "plain", false)]
[InlineData(CredentialLiteralPolicy.Refused, "dev:x", false)]
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "dev:x", true)]
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "plain", false)]
[InlineData(CredentialLiteralPolicy.Allowed, "env:ConnectionStrings__ConfigDb", false)]
public void IsAcceptable_applies_the_literal_policy(CredentialLiteralPolicy policy, string value, bool expected) =>
CredentialReferences.IsAcceptable(value, Galaxy, policy).ShouldBe(expected);
/// <summary>The fail-closed core: an out-of-scope reference throws before either source is consulted.</summary>
[Theory]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("secret:otopcua/historian/api-key")]
[InlineData("file:appsettings.json")]
public async Task Resolve_never_reads_for_an_out_of_scope_reference(string value)
{
var reads = 0;
var ex = await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
value, Galaxy, "F", CredentialLiteralPolicy.Allowed,
(_, _) => { reads++; return ValueTask.FromResult<string?>("LEAKED"); },
_ => { reads++; return "LEAKED"; },
CancellationToken.None));
reads.ShouldBe(0);
ex.Message.ShouldContain("'secret:galaxy/<name>'");
ex.Message.ShouldContain("'env:OTOPCUA_GALAXY_<NAME>'");
ex.Message.ShouldNotContain("LEAKED");
}
[Fact]
public async Task Resolve_reads_scoped_references_and_fails_closed_when_absent()
{
(await CredentialReferences.ResolveAsync("secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
(n, _) => ValueTask.FromResult<string?>(n == "galaxy/a" ? "v" : null), _ => null, CancellationToken.None))
.ShouldBe("v");
(await CredentialReferences.ResolveAsync("env:OTOPCUA_GALAXY_A", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, n => n == "OTOPCUA_GALAXY_A" ? "e" : null, CancellationToken.None)).ShouldBe("e");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"secret:galaxy/missing", Galaxy, "F", CredentialLiteralPolicy.Refused,
(_, _) => ValueTask.FromResult<string?>(null), null, CancellationToken.None))).Message.ShouldContain("absent");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"env:OTOPCUA_GALAXY_UNSET", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, _ => null, CancellationToken.None))).Message.ShouldContain("not set");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, null, CancellationToken.None))).Message.ShouldContain("no secret store");
}
[Fact]
public void DescribeProblems_scans_every_depth_case_insensitively_and_never_echoes_the_value()
{
const string json = """
{
"gateway": { "endpoint": "http://evil:5001", "apiKeySecretRef": "env:ConnectionStrings__ConfigDb" },
"Devices": [ { "AlarmAck": { "FallbackPasswordSecretRef": "hunter2" } } ]
}
""";
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, json);
problems.Count.ShouldBe(2);
problems.ShouldContain(p => p.StartsWith("gateway.apiKeySecretRef", StringComparison.Ordinal));
problems.ShouldContain(p => p.StartsWith("Devices[0].AlarmAck.FallbackPasswordSecretRef", StringComparison.Ordinal));
problems.ShouldAllBe(p => !p.Contains("hunter2") && !p.Contains("ConnectionStrings__ConfigDb"));
}
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"dev:x"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"literal-key"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"plain","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"env:OTOPCUA_HEIDENHAIN_INSPECT"}}""")]
[InlineData(DriverTypeNames.Modbus, """{"Password":"env:ConnectionStrings__ConfigDb"}""")]
[InlineData(DriverTypeNames.Galaxy, "")]
public void DescribeProblems_accepts_namespaced_and_unrelated_configs(string driverType, string json) =>
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldBeEmpty();
[Theory]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"secret:galaxy/main/apikey"}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:C:\\ProgramData\\x.txt"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":42}}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"InspectPassword":"plain"}""")]
[InlineData(DriverTypeNames.Galaxy, "{not json")]
public void DescribeProblems_refuses_out_of_namespace_configs(string driverType, string json) =>
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldNotBeEmpty();
}