using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #588 over the wire — with Security:Acl:Enabled on, the server-wide events this node manager /// raises (GeneralModelChangeEvents, AuditWriteUpdateEvents) reach a Server-object /// (i=2253) item only when its session may see the node the event names. /// /// /// /// They used to go straight to Server.ReportEvent and the core node manager's Server object, which /// queues to every all-events item with no ACL decision — so a session with no grant learned, from a /// model-change event, the RawPath of every node added, deleted or retyped under it. /// /// /// The withheld session is granted only, so the absence of the raw /// event on its item is paired with a positive control on the same item: the sentinel event about /// is reported after the withheld one, through the same path, and must /// arrive. /// /// public sealed class AclServerObjectModelChangeTests { private const string RawVariable = "Plant/Modbus/dev1/Speed"; private const NodePermissions See = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe; /// /// A NodeAdded / NodeDeleted / DataTypeChanged model-change event naming a raw node reaches the /// cluster-granted session and not the session granted only an equipment folder — which still receives /// the one naming its own folder. /// [Theory] [InlineData("NodeAdded")] [InlineData("NodeDeleted")] [InlineData("DataTypeChanged")] public async Task A_model_change_naming_a_node_the_session_cannot_browse_is_withheld(string verb) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"AclModelChange{verb}", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See), EquipmentGrant(EqA, See)); rig.EnsureEquipment(); rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: false, AddressSpaceRealm.Raw); var (granted, grantedEvents, grantedItem, grantedSubscription) = await ServerObjectAsync(rig, anonymous: false, ct); var (narrow, narrowEvents, narrowItem, narrowSubscription) = await ServerObjectAsync(rig, anonymous: true, ct); switch (verb) { case "NodeAdded": rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw); break; case "NodeDeleted": rig.Sink.RemoveVariableNode(RawVariable, AddressSpaceRealm.Raw).ShouldBeTrue(); break; default: rig.Sink.UpdateTagAttributes(RawVariable, false, null, "Double", false, null, AddressSpaceRealm.Raw).ShouldBeTrue(); break; } rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns); await RecordedEvents.WaitUntilAsync(() => grantedEvents.Of(grantedItem).Count(e => NamesEqA(e)) == 1 && narrowEvents.Of(narrowItem).Any(NamesEqA)); var rawNames = verb == "NodeAdded" ? RawDeviceFolder : RawVariable; grantedEvents.Of(grantedItem).Count(e => Names(e, rawNames)).ShouldBe(1, "the granted control receives the raw change"); narrowEvents.Of(narrowItem).Where(e => Names(e, rawNames)).ShouldBeEmpty( "a session that cannot browse the node must not learn its name from a model-change event"); narrowEvents.Of(narrowItem).Count.ShouldBe(1, "only the change to its own equipment folder"); await narrowSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); narrow.Dispose(); granted.Dispose(); } finally { await rig.DisposeAsync(); } } /// /// With enforcement off nothing changes: a session holding no grant at all still receives the raw /// model-change event through the Server object, exactly once. /// /// Without this the test above also passes on a build that withholds every model change from everyone. [Fact] public async Task With_acl_enforcement_off_every_server_object_item_receives_the_model_change() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("AclModelChangeOff", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See)); rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); var (session, events, item, subscription) = await ServerObjectAsync(rig, anonymous: true, ct); rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw); await RecordedEvents.WaitUntilAsync(() => events.Of(item).Any(e => Names(e, RawDeviceFolder))); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); events.Of(item).Count(e => Names(e, RawDeviceFolder)).ShouldBe(1); await subscription.DeleteAsync(true, ct); session.Dispose(); } finally { await rig.DisposeAsync(); } } /// /// With auditing on over an encrypted channel, a rejected device write's AuditWriteUpdateEvent — /// which carries the written value and the node's prior one — reaches a session granted Read on /// the node and not one granted only an equipment folder. /// /// /// Auditing is off by default (ServerConfiguration.AuditingEnabled, which this repo never sets), /// and audit events only ever leave over SignAndEncrypt — so both sessions are encrypted here, or the /// withheld one would be denied by the channel rather than by the ACL and the test would prove nothing. /// [Fact] public async Task An_audit_write_event_reaches_only_sessions_that_may_read_the_node() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("AclAuditWrite", encrypted: true, ct); try { rig.Enforce( true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See | NodePermissions.WriteOperate), EquipmentGrant(EqA, See)); rig.SetAuditing(true); rig.EnsureEquipment(); rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw); rig.Nm.NodeWriteGateway = new RejectingGateway(); var (granted, grantedEvents, grantedItem, grantedSubscription) = await ServerObjectAsync(rig, anonymous: false, ct, encrypted: true); var (narrow, narrowEvents, narrowItem, narrowSubscription) = await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true); var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var write = await granted.WriteAsync( null, [new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }], ct); StatusCode.IsGood(write.Results[0]).ShouldBeTrue(); await RecordedEvents.WaitUntilAsync(() => grantedEvents.HasType(grantedItem, ObjectTypeIds.AuditWriteUpdateEventType)); // Reported after the audit event through the same delivery, so it queues behind it on the item. rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns); await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA)); narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse( "a session that may not read the node must not receive its written values through an audit event"); await narrowSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); narrow.Dispose(); granted.Dispose(); } finally { await rig.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private static async Task<(ISession Session, RecordedEvents Events, MonitoredItem Item, Subscription Subscription)> ServerObjectAsync(ServerEventRig rig, bool anonymous, CancellationToken ct, bool encrypted = false) { var session = await rig.ConnectAsync(anonymous, ct, encrypted); 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(); return (session, events, item, subscription); } private static bool NamesEqA(VariantCollection e) => Names(e, EqA); /// Whether a model-change event's Changes name the node whose identifier is . private static bool Names(VariantCollection e, string id) => e[RecordedEvents.ChangesField].Value is ExtensionObject[] changes && changes.Any(c => c.Body is ModelChangeStructureDataType { Affected: { } affected } && affected.Identifier as string == id); /// Every device write fails, so the node manager reverts and audits it. private sealed class RejectingGateway : IOpcUaNodeWriteGateway { /// public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(false, "rejected by the test device")); } }