Merge #562/#563: event/alarm items revalidated by both ACL triggers (reversible stand-down); key usability on WriteOperate/AlarmAck gates regardless of Security:Acl:Enabled
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+549
@@ -0,0 +1,549 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
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.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #562 over the wire — an <b>alarm</b> (event) subscription is re-authorized by both revalidation
|
||||
/// triggers, exactly as a data-change subscription is: a deployment that removes its grant, and the host's
|
||||
/// clock-driven <see cref="AclRevalidationTimer"/> once the session's key expires.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #562 an event monitored item was gated at creation and never again, so an alarm feed
|
||||
/// survived the revocation of the authority it was admitted under until the client disconnected.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Over the wire for the reason <c>AclSubscriptionEnforcementTests</c> gives: whether an event item
|
||||
/// can recover the identity of the session that created it, and whether detaching it from the SDK's
|
||||
/// event fan-out really stops delivery (and re-attaching really resumes it), are assumptions about SDK
|
||||
/// behaviour that fail silently if wrong.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every absence assertion is paired with a <b>positive control on the same event</b>: one condition is
|
||||
/// wired to two equipment notifiers (or watched by two sessions), so a single transition must reach the
|
||||
/// still-authorized item before the denied one's silence counts for anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||
private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
|
||||
private const string EqA = "EqA";
|
||||
private const string EqB = "EqB";
|
||||
private const string KeyPrincipal = "expiry-probe";
|
||||
private const string KeyId = "k-event-expiry";
|
||||
|
||||
// [0]=EventId, [1]=Message — the message is each transition's discriminator.
|
||||
private const int MessageFieldIndex = 1;
|
||||
|
||||
private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// The deployment axis, end to end: revoke → the alarm feed stops (while the still-granted control
|
||||
/// item receives the same event) → restore → the feed resumes with no client action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The restore leg is the reason the stand-down is a detach rather than
|
||||
/// <c>MonitoringMode.Disabled</c>: a disabled item can only be re-enabled by the client, and a machine
|
||||
/// consumer never will.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_live_alarm_subscription()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclEventRevoke", ct);
|
||||
try
|
||||
{
|
||||
var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB));
|
||||
rig.Materialise();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var collector = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, collector, ct);
|
||||
var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
||||
var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
onB.Status.Error.ShouldBeNull();
|
||||
|
||||
// Baseline: both items receive the transition.
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
||||
|
||||
// The next deployment carries only EqB's grant.
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
// Positive control: the same event reached the still-granted item, so it was fired and published…
|
||||
await WaitUntilAsync(() => collector.Has(onB, "t2"));
|
||||
// …and settle past a publishing interval so a late copy for EqA would have arrived too.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment");
|
||||
|
||||
// A deployment that restores the grant brings the feed back on its own.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
await WaitUntilAsync(() => collector.Has(onA, "t3") && collector.Has(onB, "t3"));
|
||||
collector.Has(onA, "t2").ShouldBeFalse("nothing withheld while stood down is replayed on restore");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A client may delete an item while it is stood down: the delete succeeds, and a later deployment
|
||||
/// that would have restored it does not resurrect it onto the notifier's fan-out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK's own unsubscribe would try to remove an item that is no longer attached, so the node
|
||||
/// manager short-circuits it. Asserted over the wire because the failure — an exception inside the
|
||||
/// SDK's delete path, or a ghost item still receiving events — is only observable there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_stood_down_alarm_item_can_be_deleted_and_is_not_resurrected()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclEventDelete", ct);
|
||||
try
|
||||
{
|
||||
var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB));
|
||||
rig.Materialise();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var collector = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, collector, ct);
|
||||
var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
||||
var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
||||
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
subscription.RemoveItem(onA);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
// A restoring deployment must not re-attach the deleted item. The client could never see that
|
||||
// happen — it has deleted the item — so the server-side fan-out is what is asserted.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
||||
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => collector.Has(onB, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
collector.Has(onA, "t2").ShouldBeFalse();
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clock axis: the key behind a session's alarm subscription expires, one revalidation tick runs
|
||||
/// through the host's own <see cref="AclRevalidationTimer"/>, and that session stops receiving alarms
|
||||
/// — with no deployment anywhere in the run — while an independent session on the same notifier keeps
|
||||
/// receiving the same event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_expired_key_stops_a_live_alarm_subscription_with_no_deploy()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclEventExpiry", ct);
|
||||
try
|
||||
{
|
||||
var key = new ApiKey
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = KeyId,
|
||||
SecretHash = "hash",
|
||||
Principal = KeyPrincipal,
|
||||
Enabled = true,
|
||||
CreatedUtc = Start.AddDays(-1),
|
||||
ExpiresUtc = Start.AddMinutes(10),
|
||||
};
|
||||
var state = rig.Enforce(
|
||||
[key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon"));
|
||||
rig.Materialise();
|
||||
|
||||
using var keySession = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var controlSession = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var keyEvents = new EventCollector();
|
||||
var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct);
|
||||
var keyItem = AddEventItem(keySubscription, rig.UnsNodeId(keySession, EqA));
|
||||
await keySubscription.ApplyChangesAsync(ct);
|
||||
keyItem.Status.Error.ShouldBeNull();
|
||||
|
||||
var controlEvents = new EventCollector();
|
||||
var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct);
|
||||
var controlItem = AddEventItem(controlSubscription, rig.UnsNodeId(controlSession, EqA));
|
||||
await controlSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
// Only the clock moves.
|
||||
rig.Advance(TimeSpan.FromMinutes(20));
|
||||
rig.TickRevalidation();
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's alarm feed must stop on the tick");
|
||||
state.Current.GenerationId.ShouldBe(1);
|
||||
|
||||
await controlSubscription.DeleteAsync(true, ct);
|
||||
await keySubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The negative control for the clock axis: the same run with the clock advanced but not past
|
||||
/// <c>ExpiresUtc</c> keeps the alarm feed live across a tick.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without it the test above also passes on a build whose tick stands down every event item it sees,
|
||||
/// which would silence every alarm consumer in the fleet once a minute.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task An_unexpired_key_keeps_its_alarm_subscription_across_a_tick()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclEventLive", ct);
|
||||
try
|
||||
{
|
||||
var key = new ApiKey
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = KeyId,
|
||||
SecretHash = "hash",
|
||||
Principal = KeyPrincipal,
|
||||
Enabled = true,
|
||||
CreatedUtc = Start.AddDays(-1),
|
||||
ExpiresUtc = Start.AddHours(4),
|
||||
};
|
||||
rig.Enforce([key], ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, rig.UnsNodeId(session, EqA));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t1"));
|
||||
|
||||
rig.Advance(TimeSpan.FromMinutes(20));
|
||||
rig.TickRevalidation();
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => events.Has(item, "t2"));
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static NodeAcl EquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-{equipment}",
|
||||
ClusterId = Cluster,
|
||||
Principal = OpcUaPrincipals.Anonymous,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
||||
};
|
||||
|
||||
private static NodeAcl ClusterGrant(string principal, string id) => new()
|
||||
{
|
||||
NodeAclId = id,
|
||||
ClusterId = Cluster,
|
||||
Principal = principal,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])],
|
||||
};
|
||||
|
||||
private static async Task<Subscription> CreateSubscriptionAsync(
|
||||
ISession session, EventCollector collector, CancellationToken ct)
|
||||
{
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
subscription.FastEventCallback = collector.OnEvents;
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = source,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
Filter = filter,
|
||||
QueueSize = 100,
|
||||
SamplingInterval = 0,
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
condition().ShouldBeTrue("condition not met within timeout");
|
||||
}
|
||||
|
||||
/// <summary>Records which transition messages reached which monitored item.</summary>
|
||||
private sealed class EventCollector
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly HashSet<(uint Handle, string Message)> _seen = [];
|
||||
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var e in notification.Events)
|
||||
{
|
||||
if (e.EventFields.Count > MessageFieldIndex
|
||||
&& e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text })
|
||||
{
|
||||
_seen.Add((e.ClientHandle, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Has(MonitoredItem item, string message)
|
||||
{
|
||||
lock (_gate) return _seen.Contains((item.ClientHandle, message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A booted server with ACL enforcement, one native alarm wired to two equipment notifiers.</summary>
|
||||
private sealed class Rig : IAsyncDisposable
|
||||
{
|
||||
private readonly string _pkiRoot;
|
||||
private readonly FakeClock _clock = new(Start);
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private SdkAddressSpaceSink _sink = null!;
|
||||
private AclRevalidationTimer? _timer;
|
||||
private int _port;
|
||||
|
||||
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
private OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||
|
||||
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
|
||||
{
|
||||
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
||||
rig._port = AllocateFreePort();
|
||||
rig._server = new OtOpcUaSdkServer();
|
||||
rig._host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = $"OtOpcUa.{name}",
|
||||
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
||||
OpcUaPort = rig._port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance,
|
||||
new KeyPresentingAuthenticator());
|
||||
|
||||
await rig._host.StartAsync(rig._server, ct);
|
||||
rig._sink = new SdkAddressSpaceSink(rig.Nm);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/// <summary>Switches enforcement on with the given grants and no keys.</summary>
|
||||
public DataPlaneAuthorityState Enforce(params NodeAcl[] grants) => Enforce([], grants);
|
||||
|
||||
/// <summary>
|
||||
/// Switches enforcement on, wiring the authorizer, the authority source (the deployment trigger)
|
||||
/// and the host's revalidation timer (the clock trigger) over one hand-advanced clock.
|
||||
/// </summary>
|
||||
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
|
||||
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
|
||||
|
||||
// A long interval: the tick is driven by hand through the same component the host arms.
|
||||
_timer = new AclRevalidationTimer(
|
||||
() => _server.NodeManager?.RevalidateMonitoredItems(),
|
||||
TimeSpan.FromHours(1),
|
||||
TimeProvider.System,
|
||||
NullLogger.Instance);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>One native condition on a raw device, wired as an event source of two equipment folders.</summary>
|
||||
public void Materialise()
|
||||
{
|
||||
_sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
_sink.MaterialiseAlarmCondition(
|
||||
RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||||
_sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
|
||||
_sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns);
|
||||
_sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
/// <summary>Fires one genuine condition transition, carrying <paramref name="message"/>.</summary>
|
||||
public void Transition(string message, bool active) =>
|
||||
_sink.WriteAlarmCondition(
|
||||
RawAlarmPath,
|
||||
new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: !active, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message),
|
||||
DateTime.UtcNow,
|
||||
AddressSpaceRealm.Raw);
|
||||
|
||||
public NodeId UnsNodeId(ISession session, string id)
|
||||
{
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
ns.ShouldBeGreaterThan((ushort)0);
|
||||
return new NodeId(id, ns);
|
||||
}
|
||||
|
||||
/// <summary>Event items attached to the fan-out of the manager's own notifiers.</summary>
|
||||
public int AttachedEventItems => Nm.AttachedEventItemCount;
|
||||
|
||||
public void Advance(TimeSpan by) => _clock.Advance(by);
|
||||
|
||||
public void TickRevalidation() => _timer!.Tick();
|
||||
|
||||
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AclEventClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.AclEventClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
|
||||
var identity = anonymous
|
||||
? new UserIdentity(new AnonymousIdentityToken())
|
||||
: new UserIdentity(new UserNameIdentityToken
|
||||
{
|
||||
UserName = "apikey",
|
||||
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
||||
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
||||
});
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "AclEventSubscriptionEnforcementTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: identity,
|
||||
preferredLocales: null,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
await _host.DisposeAsync();
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits the fixed <c>apikey</c> username as <see cref="KeyPrincipal"/> carrying
|
||||
/// <see cref="KeyId"/>; anonymous falls through to the interface default (the
|
||||
/// <c>anonymous</c> principal). Credential verification itself is covered in the Host suite.
|
||||
/// </summary>
|
||||
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
||||
string username, string password, CancellationToken ct) =>
|
||||
Task.FromResult(
|
||||
string.Equals(username, "apikey", StringComparison.Ordinal)
|
||||
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
||||
? OpcUaUserAuthResult.AllowKey(KeyPrincipal, [], KeyId)
|
||||
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||
}
|
||||
|
||||
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
||||
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #563 (arch-review 03/S19 residual R2) — with <c>Security:Acl:Enabled</c> <b>off</b>, the two
|
||||
/// server-wide role gates (<c>WriteOperate</c> on Value writes, <c>AlarmAck</c> on the Part 9 methods)
|
||||
/// re-check that the session's API key is still usable on every call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A session's roles are resolved once, at activation, from the key's grants. Before #563 nothing
|
||||
/// with the flag off ever looked at the key again, so a key whose <c>ExpiresUtc</c> passed — or that
|
||||
/// a deployment disabled — kept <c>WriteOperate</c> / <c>AlarmAck</c> until the client chose to
|
||||
/// disconnect, which on a machine-to-machine data plane can be never.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every test here is an <b>allow-then-deny</b> on the same session: the first call proves the
|
||||
/// session genuinely holds the role, so the later denial can only have come from the key check. A
|
||||
/// deny-only test would pass against a gate that denied everything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string Principal = "scadabridge";
|
||||
private const string KeyId = "k-563";
|
||||
|
||||
private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(), $"otopcua-keygate-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>
|
||||
/// THE #563 test for the write gate: ACL off, a key-carrying session holding <c>WriteOperate</c>
|
||||
/// writes; only the clock moves past <c>ExpiresUtc</c>; the next write is refused and never reaches
|
||||
/// the device.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_expired_key_loses_its_write_role_on_the_clock_with_acl_off()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
var clock = new FakeClock(Start);
|
||||
var (state, authorizer) = Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10)));
|
||||
authorizer.Enabled.ShouldBeFalse();
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
gateway.Writes.Count.ShouldBe(1);
|
||||
state.Current.GenerationId.ShouldBe(1); // no deployment moved anything — only the clock did
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deployment axis of the same gate: a deployment that disables the key strips the write role
|
||||
/// from the session that is already open.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_key_disabled_by_a_deployment_loses_its_write_role_with_acl_off()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
var (state, _) = Wire(nm, new FakeClock(Start), Key(expiresUtc: null));
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
var disabled = Key(expiresUtc: null);
|
||||
disabled.Enabled = false;
|
||||
state.Apply(Cluster, "rev-2", [disabled], [], NoScopes);
|
||||
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
gateway.Writes.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous carries no key, so nothing can expire: an anonymous session granted
|
||||
/// <c>WriteOperate</c> keeps writing however far the clock moves, against a snapshot holding no keys
|
||||
/// at all. Pins that the new check keys on the presence of a key id, not on "is this session in the
|
||||
/// key table".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_anonymous_session_is_unaffected_by_the_key_check()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
var clock = new FakeClock(Start);
|
||||
Wire(nm, clock);
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
var anonymous = new RoleCarryingUserIdentity(
|
||||
new AnonymousIdentityToken(), [OpcUaDataPlaneRoles.WriteOperate],
|
||||
OpcUaPrincipals.Anonymous, apiKeyId: null);
|
||||
|
||||
Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
clock.Advance(TimeSpan.FromDays(400));
|
||||
Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
gateway.Writes.Count.ShouldBe(2);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The alarm half: ACL off, a key-carrying session holding <c>AlarmAck</c> acknowledges; the key
|
||||
/// expires by the clock; the next acknowledge is refused and routes nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_expired_key_loses_its_alarm_ack_role_on_the_clock_with_acl_off()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var routed = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = routed.Add;
|
||||
var clock = new FakeClock(Start);
|
||||
Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10)));
|
||||
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition(
|
||||
"alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
var condition = nm.TryGetAlarmCondition("alm-1")!;
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance)
|
||||
{
|
||||
UserIdentity = KeyIdentity(OpcUaDataPlaneRoles.AlarmAck),
|
||||
};
|
||||
|
||||
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("1"))
|
||||
.ShouldBe(ServiceResult.Good);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("2"))
|
||||
.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||
|
||||
private static ApiKey Key(DateTime? expiresUtc) => new()
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = KeyId,
|
||||
SecretHash = "hash",
|
||||
Principal = Principal,
|
||||
Enabled = true,
|
||||
CreatedUtc = Start.AddDays(-1),
|
||||
ExpiresUtc = expiresUtc,
|
||||
};
|
||||
|
||||
/// <summary>A key-carrying session whose activation-time roles are <paramref name="roles"/>.</summary>
|
||||
private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) =>
|
||||
new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId);
|
||||
|
||||
/// <summary>
|
||||
/// Wires a production-shaped, flag-OFF authorizer over one applied snapshot. The node manager's
|
||||
/// authority source and the authorizer's are the same instance, as the host wires them.
|
||||
/// </summary>
|
||||
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
|
||||
OtOpcUaNodeManager nm, TimeProvider clock, params ApiKey[] keys)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, [], NoScopes);
|
||||
var authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: false, NullLogger.Instance, clock);
|
||||
nm.DataPlaneAuthority = state;
|
||||
nm.Authorizer = authorizer;
|
||||
return (state, authorizer);
|
||||
}
|
||||
|
||||
private static NodeId Writable(OtOpcUaNodeManager nm, string rawPath)
|
||||
{
|
||||
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
|
||||
writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw);
|
||||
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
||||
}
|
||||
|
||||
private static List<ServiceResult> Write(
|
||||
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
|
||||
var writes = new List<WriteValue>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
|
||||
},
|
||||
};
|
||||
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
|
||||
nm.Write(context, writes, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>Records what actually reached the device.</summary>
|
||||
private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public List<string> Writes { get; } = [];
|
||||
|
||||
public Task<NodeWriteOutcome> WriteAsync(
|
||||
string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{
|
||||
lock (Writes) Writes.Add(nodeId);
|
||||
return Task.FromResult(new NodeWriteOutcome(true, null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
||||
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
/// <summary>Moves the clock forward.</summary>
|
||||
/// <param name="by">How far forward.</param>
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.KeyGateTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.KeyGateTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user