Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs
T
Joseph Doherty f0c6cf9640
v2-ci / unit-tests (push) Has been cancelled
fix(security): running drivers refuse out-of-namespace credential refs at resolve time (#834)
The #815 warn-only compatibility window is over. A running Galaxy driver
(InitializeAsync, every gateway ReopenAsync, the ack fallback password) and a
running OpcUaClient driver (session open) now refuse an out-of-namespace
credential reference with the same CredentialReferenceException that save,
deploy (CredentialReferenceOutOfScope), Test Connect and browse already raise,
before any store / environment / file read and before any endpoint is dialled.
The one existing scope check (CredentialReferences.Parse/ResolveAsync) is now
the only path; nothing new was added.

Removed:
- LegacyCredentialReferenceTracker (Core.Abstractions), its rate-limited WARN
  and the credential-compat:<field> diagnostics entry.
- The per-driver Legacy* resolve methods and every `legacy` parameter
  (GalaxySecretRef, GalaxyAckCredentialResolver, OpcUaUserIdentityFactory,
  OpcUaUserCertificatePaths, OpcUaClientSecretResolution).
- IDriverDiagnostics on GalaxyDriver / OpcUaClientDriver (it only carried the
  tracker's entries).
- The tracker also kept a pre-#836 absolute UserCertificatePath loading on a
  running OpcUaClient driver; that is refused now too.

Preflight / banner decision: KEPT, as a hard error. The Running source
(driver-health credential-compat entries) and CredentialReferenceBannerModel
are gone with the tracker, but the ConfigDb scan still tells an operator
something no other surface does: a Deployed finding is now a driver that is
not connecting, a Draft finding is a deploy the gate will refuse - named
before anyone deploys, e.g. after restoring an old ConfigDb backup or
importing a pre-#815 config. It also carries the #835 cross-instance and
#836/#850 path checks. CredentialReferencePreflightService now logs one Error
per finding (was Warning) with no warn-only wording, and the /raw banner is
alert-danger.

DEV and QA ConfigDbs hold no Galaxy/OpcUaClient driver (checked 2026-11-13);
no production install exists.

Tests: GalaxyDriverLegacyCredentialTests replaced by
GalaxyDriverCredentialScopeTests; new OpcUaClientDriverCredentialScopeTests;
tracker tests in OpcUaClientSecretResolutionTests /
OpcUaClientUserCertificatePathTests deleted; AdminUI preflight tests drop the
Running/banner-model cases and assert Error level. Against the old code the
new refusal tests fail (Galaxy 5/6, OpcUaClient 4/5; the two positive controls
pass on both).

docs/security.md: the migration table now says running drivers refuse; the
warn-only section is replaced by "The compatibility window is over".

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 02:06:43 -05:00

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