test: prove peer provenance and repeated alarm authority with baseline scope oracle

This commit is contained in:
Joseph Doherty
2026-11-10 12:50:19 -05:00
parent b9a03b4274
commit 47a29dee2b
4 changed files with 273 additions and 9 deletions
@@ -0,0 +1,44 @@
// Deliberately compiled only in a temporary SDK1.5 baseline integration checkout; never in the candidate solution.
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class BaselineScriptedScopeProbe
{
[Fact]
public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct);
rig.KeyRoles = [];
const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
rig.Enforce(true, [Key()], new NodeAcl
{
NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions,
});
rig.MaterialiseScripted();
var routed = 0;
rig.Nm.AlarmCommandRouter = _ => routed++;
using var session = await rig.ConnectAsync(anonymous: false, ct);
rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope"));
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull();
var eventId = condition.EventId.ShouldNotBeNull().Value;
var denied = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId,
MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new object[] { eventId, new LocalizedText("denied") }));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBe(0);
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new object[] { eventId, new LocalizedText("allowed") });
routed.ShouldBe(1);
await session.CloseAsync(ct);
}
}
@@ -0,0 +1,44 @@
// The same application-authored opaque ID and two grant outcomes are independently exercised on SDK1.5.
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class Sdk2OpaqueScriptedScopeBaselineTests
{
[Fact]
public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct);
rig.KeyRoles = [];
const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
rig.Enforce(true, [Key()], new NodeAcl
{
NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions,
});
rig.MaterialiseScripted();
var routed = 0;
rig.Nm.AlarmCommandRouter = _ => routed++;
using var session = await rig.ConnectAsync(anonymous: false, ct);
rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope"));
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull();
var eventId = condition.EventId.ShouldNotBeNull().Value;
var denied = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId,
MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("denied")) }));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBe(0);
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("allowed")) });
routed.ShouldBe(1);
await session.CloseAsync(ct);
}
}
@@ -0,0 +1,126 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>The SDK2 already-acknowledged/confirmed compatibility path must still consult the current application authority.</summary>
public sealed class Sdk2RepeatedAlarmAuthorityWireTests
{
public static TheoryData<bool, string, string> Cases => new(
from operation in new[] { (Native: true, Method: "Acknowledge"), (Native: false, Method: "Acknowledge"), (Native: false, Method: "Confirm") }
from change in new[] { "grant", "key", "expiry" }
select (operation.Native, operation.Method, change));
[Theory]
[MemberData(nameof(Cases))]
public async Task A_current_trunk_retry_uses_its_application_grant_and_current_key(bool native, string method, string change)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("RepeatAlarmAuthority", encrypted: true, ct);
rig.KeyRoles = [];
var clock = new Clock();
var key = Key(); key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10);
var permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.MethodCall;
var grant = new NodeAcl
{
NodeAclId = native ? "narrow-native-alarm" : "cluster-scripted-alarm", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = native ? NodeAclScopeKind.RawDevice : NodeAclScopeKind.Cluster,
ScopeId = native ? "alarm-owner" : null, PermissionFlags = permissions,
};
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new(grant.ScopeKind, "alarm-owner")] = native
? [new([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])]
: [],
};
var state = rig.Enforce(true, [key], scopes, grant);
rig.Nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock);
if (native) rig.MaterialiseNative(); else rig.MaterialiseScripted();
var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
var path = native ? RawAlarmPath : ScriptedAlarmId;
var routed = 0;
rig.Nm.NativeAlarmAckRouter = _ => Interlocked.Increment(ref routed);
rig.Nm.AlarmCommandRouter = _ => Interlocked.Increment(ref routed);
using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true);
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
.ShouldBeOfType<RoleCarryingUserIdentity>();
identity.Roles.ShouldBeEmpty("the positive retry must be authorized by the application grant alone");
// Native: narrow raw-device scope. Scripted: preserve the existing cluster-scope contract;
// opaque scripted IDs are not under their equipment ACL path (a separately tracked baseline defect).
identity.ApiKeyId.ShouldBe(KeyId);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
item.Status.Error.ShouldBeNull();
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "retry"));
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == 1);
var condition = rig.Nm.TryGetAlarmCondition(path, realm).ShouldNotBeNull();
var methodId = method == "Acknowledge" ? MethodIds.AcknowledgeableConditionType_Acknowledge : MethodIds.AcknowledgeableConditionType_Confirm;
var eventIdNode = condition.EventId.ShouldNotBeNull();
var commentNode = condition.Comment.ShouldNotBeNull();
var ackNode = condition.AckedState.ShouldNotBeNull().Id.ShouldNotBeNull();
var confirmNode = condition.ConfirmedState.ShouldNotBeNull().Id.ShouldNotBeNull();
var initialId = eventIdNode.Value;
await session.CallAsync(condition.NodeId, methodId, ct,
Variant.From(initialId), Variant.From(new LocalizedText("allowed retry")));
// A commented re-ack reports its comment. An already-confirmed retry has no state delta
// and remains silent under the existing Confirm reporting contract.
var expectedEvents = method == "Confirm" ? 1 : 2;
if (expectedEvents == 2)
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == expectedEvents);
routed.ShouldBe(1, "each authorized call reaches its owner once; the scripted owner decides whether a retry changes state");
var currentId = eventIdNode.Value;
currentId.IsEmpty.ShouldBeFalse();
var beforeEvents = events.Of(item).Count;
var beforeRoutes = Volatile.Read(ref routed);
var beforeComment = commentNode.Value;
var beforeAck = ackNode.Value;
var beforeConfirm = confirmNode.Value;
var keys = new[] { key };
var grants = new[] { grant };
if (change == "grant") grants = [];
if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; }
if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20));
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes);
var error = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId, methodId, ct,
Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded"))));
error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Volatile.Read(ref routed).ShouldBe(beforeRoutes);
eventIdNode.Value.ShouldBe(currentId);
commentNode.Value.ShouldBe(beforeComment);
ackNode.Value.ShouldBe(beforeAck);
confirmNode.Value.ShouldBe(beforeConfirm);
// A subsequent authorized same-item sentinel drains any queued method event before the assertion.
clock.Reset();
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes);
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel"));
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel");
events.ConditionEvents(item, "retry").Count.ShouldBe(expectedEvents);
await subscription.DeleteAsync(true, ct);
await session.CloseAsync(ct);
}
private sealed class Clock : TimeProvider
{
private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow;
private TimeSpan _offset;
public override DateTimeOffset GetUtcNow() => _initial + _offset;
public void Advance(TimeSpan time) => _offset += time;
public void Reset() => _offset = TimeSpan.Zero;
}
}
@@ -52,6 +52,44 @@ public sealed class Sdk2TrustedChannelTests
rig.Writes.ShouldBe(1, "revocation must be checked before a cached narrow grant or dispatch");
}
[Theory]
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
public async Task Concurrent_IPv4_and_IPv6_activations_keep_their_own_transport_peer(OpcUaSecurityProfile profile)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await TrustedRig.StartAsync(profile, ct);
rig.HoldPeerActivations = true;
// Distinct test attempt markers share one key identity and trusted certificate. Both auth flows await the barrier.
var firstConnection = rig.ConnectAsync("contract-key-ipv4", ct, transportHost: "127.0.0.1");
var secondConnection = rig.ConnectAsync("contract-key-ipv6", ct, transportHost: "[::1]");
ISession[] connections;
try { connections = await Task.WhenAll(firstConnection, secondConnection); }
catch
{
if (firstConnection.IsCompletedSuccessfully) (await firstConnection).Dispose();
if (secondConnection.IsCompletedSuccessfully) (await secondConnection).Dispose();
throw;
}
using var ipv4 = connections[0];
using var ipv6 = connections[1];
rig.Peers.Count.ShouldBe(2);
var first4 = rig.PeerObservations.Single(x => x.Attempt == "IPv4");
var first6 = rig.PeerObservations.Single(x => x.Attempt == "IPv6");
first4.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback);
first6.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.IPv6Loopback);
first4.AfterAwaitPeer.ShouldBe(first4.Peer.Address);
first6.AfterAwaitPeer.ShouldBe(first6.Peer.Address);
foreach (var session in connections)
(await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good);
await ipv4.ReconnectAsync(null, null, ct);
rig.Peers.Count.ShouldBe(3);
rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback,
"reactivation cannot inherit the concurrent IPv6 activation's peer");
await ipv6.CloseAsync(ct);
await ipv4.CloseAsync(ct);
}
[Theory]
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
@@ -85,14 +123,19 @@ public sealed class Sdk2TrustedChannelTests
private const string Cluster = "SDK2";
private const string PathId = "Plant/Device/Value";
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-trusted-{Guid.NewGuid():N}");
private readonly Dictionary<ApplicationConfiguration, ApplicationInstance> _applications = [];
private readonly ConcurrentDictionary<ApplicationConfiguration, ApplicationInstance> _applications = new();
private readonly DataPlaneAuthorityState _authority = new();
private readonly OpcUaApplicationHostOptions _options;
private OpcUaApplicationHost _host = null!;
private int _port;
private int _writes;
private int _authCalls;
internal ConcurrentQueue<IPEndPoint?> Peers { get; } = new();
internal sealed record PeerObservation(string Attempt, IPEndPoint? Peer, IPAddress? AfterAwaitPeer);
internal ConcurrentQueue<PeerObservation> PeerObservations { get; } = new();
internal IReadOnlyList<IPEndPoint?> Peers => PeerObservations.Select(x => x.Peer).ToArray();
internal bool HoldPeerActivations { get; set; }
private readonly TaskCompletionSource _peerBarrier = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _peerBarrierArrivals;
internal OtOpcUaSdkServer Server { get; private set; } = null!;
internal int Writes => Volatile.Read(ref _writes);
internal int AuthCalls => Volatile.Read(ref _authCalls);
@@ -173,7 +216,7 @@ public sealed class Sdk2TrustedChannelTests
{
await config.ValidateAsync(ApplicationType.Client, ct);
await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
_applications.Add(config, app);
_applications[config] = app;
return config;
}
catch { await app.DisposeAsync(); throw; }
@@ -181,10 +224,10 @@ public sealed class Sdk2TrustedChannelTests
private async Task ReleaseConfigurationAsync(ApplicationConfiguration config)
{
if (_applications.Remove(config, out var app)) await app.DisposeAsync();
if (_applications.TryRemove(config, out var app)) await app.DisposeAsync();
}
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false)
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null)
{
var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki;
var config = await ConfigurationAsync(pki, ct);
@@ -198,6 +241,7 @@ public sealed class Sdk2TrustedChannelTests
var telemetry = DefaultTelemetry.Create(_ => { });
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{_port}/OtOpcUa", true, telemetry, ct)
?? throw new InvalidOperationException("No secure endpoint.");
if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa";
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
"Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
@@ -217,16 +261,22 @@ public sealed class Sdk2TrustedChannelTests
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{ [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] });
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct)
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct)
{
Peers.Enqueue(peer is null ? null : new IPEndPoint(peer.Address, peer.Port));
return AuthenticateUserNameAsync(username, password, ct);
var attempt = password switch { "contract-key-ipv4" => "IPv4", "contract-key-ipv6" => "IPv6", _ => "Other" };
if (HoldPeerActivations && attempt != "Other")
{
if (Interlocked.Increment(ref _peerBarrierArrivals) == 2) _peerBarrier.TrySetResult();
await _peerBarrier.Task.WaitAsync(TimeSpan.FromSeconds(15), ct);
}
PeerObservations.Enqueue(new(attempt, peer is null ? null : new IPEndPoint(peer.Address, peer.Port), Server.ActivationPeerAddress));
return await AuthenticateUserNameAsync(username, password, ct);
}
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
Interlocked.Increment(ref _authCalls);
return Task.FromResult(username == "apikey" && password == "contract-key"
return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6")
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential."));
}
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)