a224fd6391
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>
151 lines
6.3 KiB
C#
151 lines
6.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
|
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
|
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
|
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
|
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
|
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
|
/// Non-secret literals and null/empty pass through unchanged.
|
|
/// </summary>
|
|
public sealed class OpcUaClientSecretResolutionTests
|
|
{
|
|
private const string KnownPwName = "opcuaclient/inst/password";
|
|
private const string KnownPwValue = "resolved-password-plaintext";
|
|
private const string KnownCertPwName = "opcuaclient/inst/cert-pw";
|
|
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
|
|
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
|
|
|
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
|
[Fact]
|
|
public async Task Secret_password_resolves_to_store_plaintext()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.Password.ShouldBe(KnownPwValue);
|
|
}
|
|
|
|
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
|
[Fact]
|
|
public async Task Secret_cert_password_resolves_to_store_plaintext()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
|
|
}
|
|
|
|
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
|
[Fact]
|
|
public async Task Literal_and_null_pass_through_unchanged()
|
|
{
|
|
var options = new OpcUaClientDriverOptions
|
|
{
|
|
Password = "plainpw",
|
|
UserCertificatePassword = null,
|
|
};
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.Password.ShouldBe("plainpw");
|
|
resolved.UserCertificatePassword.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
|
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
|
[Fact]
|
|
public async Task Unknown_secret_ref_is_fail_closed()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" };
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None));
|
|
|
|
ex.Message.ShouldContain("Password");
|
|
ex.Message.ShouldContain("opcuaclient/inst/absent");
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the
|
|
/// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("secret:otopcua/historian/api-key")]
|
|
[InlineData("secret:galaxy/inst1/apikey")]
|
|
[InlineData("secret:OT-MESH-PSK-MAIN")]
|
|
[InlineData("env:ConnectionStrings__ConfigDb")]
|
|
[InlineData("env:ServerHistorian__ApiKey")]
|
|
[InlineData("Secret:opcuaclient/inst/password")]
|
|
[InlineData("file:appsettings.json")]
|
|
public async Task Out_of_namespace_reference_is_refused_without_reading(string reference)
|
|
{
|
|
var store = new CountingResolver();
|
|
var envReads = 0;
|
|
foreach (var options in new[]
|
|
{
|
|
new OpcUaClientDriverOptions { Password = reference },
|
|
new OpcUaClientDriverOptions { UserCertificatePassword = reference },
|
|
})
|
|
{
|
|
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
|
|
OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
|
options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; }));
|
|
ex.Message.ShouldContain("secret:opcuaclient/<name>");
|
|
ex.Message.ShouldNotContain("LEAKED");
|
|
}
|
|
|
|
store.Calls.ShouldBe(0);
|
|
envReads.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>A namespaced environment reference resolves.</summary>
|
|
[Fact]
|
|
public async Task Namespaced_environment_reference_resolves()
|
|
{
|
|
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
|
new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(),
|
|
CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null);
|
|
resolved.Password.ShouldBe("pw");
|
|
}
|
|
|
|
private sealed class CountingResolver : ISecretResolver
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
Calls++;
|
|
return Task.FromResult<string?>("LEAKED");
|
|
}
|
|
}
|
|
|
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
|
private sealed class StubSecretResolver : ISecretResolver
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
Task.FromResult<string?>(name.Value switch
|
|
{
|
|
KnownPwName => KnownPwValue,
|
|
KnownCertPwName => KnownCertPwValue,
|
|
_ => null,
|
|
});
|
|
}
|
|
}
|