271b96a644
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges the stored DriverConfig with the typed device config). The server (DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and BrowserSessionService) honours the named owner only when every endpoint value (new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver stores; otherwise the owner is null and its instance-named secrets are refused. Every Test Connect / browse whose config carries an in-scope secret:/env: reference is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a structured Information line: user, driver, endpoint hosts, outcome). L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets AuthType to non-Certificate (string or ordinal); the deploy gate also checks the runtime-merged driver+device config so the switch cannot be split from the path; the form no longer persists the hidden field. L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open failures into its error message. L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory. Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
8.6 KiB
C#
167 lines
8.6 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any
|
|
/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value
|
|
/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read".
|
|
/// </summary>
|
|
public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase
|
|
{
|
|
[Theory]
|
|
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
|
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")]
|
|
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
|
|
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
|
|
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
|
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")]
|
|
public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config)
|
|
{
|
|
var probe = new RecordingProbe(driverType);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid()));
|
|
|
|
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
|
reply.Ok.ShouldBeFalse();
|
|
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
|
|
reply.Message.ShouldContain("lmxopcua#815");
|
|
probe.Calls.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Positive control: a namespaced reference does reach the probe (the refusal is not blanket).</summary>
|
|
[Fact]
|
|
public void A_namespaced_reference_reaches_the_probe()
|
|
{
|
|
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(
|
|
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid()));
|
|
|
|
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
|
probe.Calls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#835: a reference naming ANOTHER instance's secret never reaches the probe, whether the caller names
|
|
/// its own driver or not (a null owner — a not-yet-created driver — is the stricter case).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("DRV-evil")]
|
|
[InlineData(null)]
|
|
public void A_reference_naming_another_instances_secret_never_reaches_the_probe(string? ownerId)
|
|
{
|
|
var dbName = Guid.NewGuid().ToString("N");
|
|
var factory = NewInMemoryDbFactory(dbName);
|
|
SeedGalaxyDrivers(factory);
|
|
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(
|
|
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/Victim/apikey"}}""",
|
|
5, Guid.NewGuid(), ownerId));
|
|
|
|
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
|
reply.Ok.ShouldBeFalse();
|
|
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
|
|
reply.Message.ShouldContain("lmxopcua#835");
|
|
probe.Calls.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Positive control: the owner's own secret (and a shared one) does reach the probe.</summary>
|
|
[Theory]
|
|
[InlineData("secret:galaxy/DRV-victim/apikey")]
|
|
[InlineData("secret:galaxy/shared-gw/apikey")]
|
|
public void The_owners_own_or_a_shared_secret_reaches_the_probe(string reference)
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGalaxyDrivers(factory);
|
|
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(
|
|
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference),
|
|
5, Guid.NewGuid(), "DRV-victim"));
|
|
|
|
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
|
probe.Calls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #835 review I-1: Test Connect from the victim's OWN device modal with an unsaved endpoint (the modal merges the
|
|
/// stored DriverConfig with the typed device config) must not send the victim's own secret to that endpoint.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_unsaved_endpoint_cannot_use_the_named_owners_own_secret()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGalaxyDrivers(factory);
|
|
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(
|
|
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""",
|
|
5, Guid.NewGuid(), "DRV-victim"));
|
|
|
|
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
|
reply.Ok.ShouldBeFalse();
|
|
reply.Message.ShouldNotBeNull().ShouldContain("save the change first");
|
|
probe.Calls.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>lmxopcua#836: an absolute OpcUaClient user-certificate path never reaches the probe.</summary>
|
|
[Fact]
|
|
public void An_absolute_user_certificate_path_never_reaches_the_probe()
|
|
{
|
|
var probe = new RecordingProbe(DriverTypeNames.OpcUaClient);
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
|
|
|
actor.Tell(new TestDriverConnect(
|
|
DriverTypeNames.OpcUaClient,
|
|
"""{"EndpointUrl":"opc.tcp://attacker:4840","AuthType":"Certificate","UserCertificatePath":"/etc/ssl/private/host.pfx"}""",
|
|
5, Guid.NewGuid()));
|
|
|
|
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
|
reply.Ok.ShouldBeFalse();
|
|
reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#836");
|
|
probe.Calls.ShouldBe(0);
|
|
}
|
|
|
|
private static void SeedGalaxyDrivers(Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> factory)
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance
|
|
{
|
|
DriverInstanceId = "DRV-victim", ClusterId = "c", Name = "Victim", DriverType = DriverTypeNames.Galaxy, DriverConfig = """{"Gateway":{"Endpoint":"http://gw:5001"}}""",
|
|
});
|
|
db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance
|
|
{
|
|
DriverInstanceId = "DRV-evil", ClusterId = "c", Name = "Evil", DriverType = DriverTypeNames.Galaxy, DriverConfig = "{}",
|
|
});
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private sealed class RecordingProbe(string driverType) : IDriverProbe
|
|
{
|
|
private int _calls;
|
|
|
|
public int Calls => Volatile.Read(ref _calls);
|
|
|
|
public string DriverType => driverType;
|
|
|
|
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _calls);
|
|
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
|
}
|
|
}
|
|
}
|