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.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #808 over the wire — a real activated apikey session with zero server-wide roles and only a /// NARROW UNS grant, against a production-shaped UNS tree: every Area / Line / Equipment folder is minted under its /// opaque entity id (UA-…, UL-…, EQ-…), virtual tags under EQ-…/Name, scripted conditions /// under their opaque ScriptedAlarmId, while every grant is planted at a NAME path resolved by /// . Before #808 such a session could not browse, read or subscribe any of those /// nodes, and never received a scripted-alarm event through the Server object — so it never held the EventId that /// Acknowledge / Confirm / AddComment need. 's own folders (EqA ⇒ /// [uns, EqA]) are deliberately NOT used: their id coincides with their path, which is what hid this. /// public sealed class UnsFolderScopeWireTests { private const string AreaId = "UA-0000000001"; private const string Line1 = "UL-0000000001"; private const string Line2 = "UL-0000000002"; private const string Pump = "EQ-00000000a1b2"; private const string Fan = "EQ-00000000c3d4"; private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi", deployed under Pump private const string PumpLo = "scripted-pump-lo"; private const string FanAlarm = "scripted-fan-hi"; private const string PumpSpeed = Pump + "/Speed"; // a virtual tag: s=/ private const string FanSpeed = Fan + "/Speed"; private const string PumpFlow = "Area1/Line1/Pump/Flow"; // a UNS reference variable: s=/// private const string FlowTagId = "TAG-flow"; private const NodePermissions Operator = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm; /// /// The ScadaBridge path end to end on an Equipment grant alone: navigate from the shared root down to the /// equipment, read its folder / alarm children / virtual tag / reference variable, receive its scripted alarm /// through the Server object, and Acknowledge with the EventId the session obtained ITSELF. Every sibling /// (another equipment's folder, alarm, alarm child, virtual tag, folder event item) is denied on the same /// session, and the sibling's alarm event — reported FIRST — never arrives. /// [Fact] public async Task A_narrow_equipment_grant_browses_reads_receives_and_acknowledges_end_to_end() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808EndToEnd", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator)); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); AssertNoRoles(rig); // Navigation: shared root → area → line → equipment, each step by Browse transit / the grant itself. (await BrowseAsync(session, RootNodeId(session), ct)).Ids.ShouldContain(AreaId); (await BrowseAsync(session, Uns(session, AreaId), ct)).Ids.ShouldBe([Line1], "Line2 holds nothing granted"); (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump], "the sibling equipment is withheld"); var inPump = (await BrowseAsync(session, Uns(session, Pump), ct)).Ids; inPump.ShouldContain(Alarm); inPump.ShouldContain(PumpLo); inPump.ShouldContain(PumpSpeed); inPump.ShouldContain(PumpFlow); (await BrowseAsync(session, Uns(session, Fan), ct)).Status.ShouldBe(StatusCodes.BadUserAccessDenied); // Reads: the folder, a scripted condition child (#629 instance-scoped id), the virtual tag, the reference. var pumpEnabled = ChildId(rig, Alarm, c => c.EnabledState!.Id!); var fanEnabled = ChildId(rig, FanAlarm, c => c.EnabledState!.Id!); pumpEnabled.ShouldStartWith(Alarm + "/"); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, pumpEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, FanAlarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, fanEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, FanSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, AreaId), Attributes.DisplayName, ct)) .ShouldBe(StatusCodes.BadUserAccessDenied, "transit confers Browse on the ancestors, never Read"); // Events: the session's OWN Server-object item, plus direct items on its equipment folder and the sibling's. var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); var pumpFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Pump)); var fanFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Fan)); await subscription.ApplyChangesAsync(ct); server.Status.Error.ShouldBeNull(); pumpFolder.Status.Error.ShouldBeNull(); fanFolder.Status.Error.ShouldNotBeNull().StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, FanAlarm, pre with { Message = "fan-base" }); // reported first: a late copy would precede "base" Project(rig, Alarm, pre); await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base") && events.Has(pumpFolder, "base")); events.Has(server, "fan-base").ShouldBeFalse("the sibling's alarm is not delivered through the Server object"); // Acknowledge with the EventId this narrow session received itself — the ScadaBridge flow #797 alone could // not complete. await ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(server, "base").Count == 2); events.ConditionEvents(server, "base")[1][RecordedEvents.AckedField].TryGetValue(out bool acked).ShouldBeTrue(); acked.ShouldBeTrue(); var siblingEventId = rig.Nm.TryGetAlarmCondition(FanAlarm)!.EventId!.Value.ToArray(); var denied = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, FanAlarm), "Acknowledge", siblingEventId, ct)); denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); Project(rig, FanAlarm, pre with { Message = "fan-sentinel" }); Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(server, "sentinel")); events.Of(server).Count(f => RecordedEvents.Message(f)?.StartsWith("fan-", StringComparison.Ordinal) == true) .ShouldBe(0); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]); await session.CloseAsync(ct); } /// /// A principal holding only a TAG grant (one reference variable beneath the equipment) can navigate to it and /// read it, but is NOT handed the equipment's other contents: its folder is browsable by transit and nothing /// more, and the equipment's scripted alarms and virtual tags stay hidden from Browse and Read and its events /// do not reach the Server object — a Tag grant is about that signal, not the equipment. /// [Fact] public async Task A_tag_grant_reaches_its_reference_without_exposing_the_equipments_alarms_or_virtual_tags() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808TagOnly", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line1, Deployed(), TagGrant()); using var session = await rig.ConnectAsync(anonymous: false, ct); (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]); (await BrowseAsync(session, Uns(session, Pump), ct)).Ids.ShouldBe([PumpFlow]); (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); await session.CloseAsync(ct); } /// /// A scripted alarm the generation has MOVED (deployed under Fan) while the address space still binds it to Pump — /// the window between installing a generation and rebuilding its address space, or indefinitely after a failed /// apply. Under BOTH owners' grants it is invisible (no Server-object event, no Read of the condition or its /// children) and not commandable; the owner's other, correctly placed alarm keeps working (positive control). /// Once the address space catches up the new owner's grant sees it and the old owner's does not. /// [Theory] [InlineData(Pump)] [InlineData(Fan)] public async Task A_moved_alarm_is_denied_under_both_owners_until_the_address_space_catches_up(string grantedEquipment) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Uns808Moved{grantedEquipment[^4..]}", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), EquipmentGrant(grantedEquipment, Operator)); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); var control = grantedEquipment == Pump ? PumpLo : FanAlarm; var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, Alarm, pre with { Message = "moved" }); Project(rig, control, pre with { Message = "control" }); await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control")); events.Has(server, "moved").ShouldBeFalse("a moved alarm is unplaceable for every grant during the window"); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct)) .ShouldBe(StatusCodes.BadUserAccessDenied); var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(); (await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", staleEventId, ct))) .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.ShouldBeEmpty(); // The address space catches up: the alarm is re-materialised under its deployed owner. ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); Project(rig, Alarm, pre with { Message = "placed" }); Project(rig, control, pre with { Message = "control-2" }); await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control-2")); var visible = grantedEquipment == Fan; events.Has(server, "placed").ShouldBe(visible); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)) .ShouldBe(visible ? StatusCodes.Good : StatusCodes.BadUserAccessDenied); await session.CloseAsync(ct); } /// /// Gitea #826 — the same stale window seen by a DIRECT event item on the old owner's equipment folder, which the /// SDK feeds through its per-folder fan-out rather than the Server object. The old owner's folder subscription /// receives neither the moved alarm's live events nor its ConditionRefresh replay (EventId included), while its /// correctly placed alarm keeps arriving on the same item (positive control). After the rebuild re-materialises /// the alarm under its deployed owner, the new owner's folder subscription receives it. /// [Fact] public async Task A_moved_alarm_stops_reaching_its_old_folders_subscribers_until_the_rebuild_places_it() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns826FolderMoved", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); // The key holds the OLD owner (Pump); anonymous holds the NEW one (Fan). The generation already moved Alarm. Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), EquipmentGrant(Pump, Operator), AnonymousGrant(NodeAclScopeKind.Equipment, Fan)); using var oldOwner = await rig.ConnectAsync(anonymous: false, ct); using var newOwner = await rig.ConnectAsync(anonymous: true, ct); var oldEvents = new RecordedEvents(); var oldSubscription = await RecordedEvents.SubscribeAsync(oldOwner, oldEvents, ct); var pumpFolder = RecordedEvents.AddEventItem(oldSubscription, Uns(oldOwner, Pump)); await oldSubscription.ApplyChangesAsync(ct); pumpFolder.Status.Error.ShouldBeNull(); var newEvents = new RecordedEvents(); var newSubscription = await RecordedEvents.SubscribeAsync(newOwner, newEvents, ct); var fanFolder = RecordedEvents.AddEventItem(newSubscription, Uns(newOwner, Fan)); await newSubscription.ApplyChangesAsync(ct); fanFolder.Status.Error.ShouldBeNull(); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, Alarm, pre with { Message = "moved" }); // reported first: the control arrives after its verdict Project(rig, PumpLo, pre with { Message = "control" }); await RecordedEvents.WaitUntilAsync(() => oldEvents.Has(pumpFolder, "control")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("the moved alarm must not reach its old owner's folder item"); // ConditionRefresh on the folder item replays the placed alarm and withholds the moved one. await RefreshAsync(oldOwner, oldSubscription, ct); await RecordedEvents.WaitUntilAsync(() => oldEvents.HasType(pumpFolder, ObjectTypeIds.RefreshEndEventType) && oldEvents.ConditionEvents(pumpFolder, "control").Count == 2); oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("a refresh must not replay the moved alarm to the old folder"); // The rebuild places it under its deployed owner. ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); rig.Nm.CompleteAddressSpaceApply(); Project(rig, Alarm, pre with { Message = "placed" }); Project(rig, PumpLo, pre with { Message = "control-2" }); await RecordedEvents.WaitUntilAsync(() => newEvents.Has(fanFolder, "placed") && oldEvents.Has(pumpFolder, "control-2")); oldEvents.Has(pumpFolder, "placed").ShouldBeFalse(); await oldOwner.CloseAsync(ct); await newOwner.CloseAsync(ct); } /// /// An EQUIPMENT moved to another line by the generation while its folder still hangs under the old line: the live /// folder ancestry disagrees with the generation's, so the folder, everything it owns, its scripted alarm (read /// AND Acknowledge) and its UNS reference variable (still on its OLD-name id — read AND write) are denied under /// the new line's grant AND the old one's. After the rebuild re-parents it (and re-ids the reference variable), /// the new line's grant reaches all of it and the old line's reaches none of it. /// [Theory] [InlineData(Line1)] [InlineData(Line2)] public async Task A_moved_equipment_and_everything_under_it_is_denied_until_the_rebuild_reparents_it(string grantedLine) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Uns808Folder{grantedLine[^1..]}", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line2, Deployed(), LineGrant(grantedLine)); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); var oldFlow = FlowId(Line1); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, oldFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await WriteAsync(session, Uns(session, oldFlow), 1.5, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); // The condition must carry a real EventId first, or the SDK refuses the call before any gate runs. Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base")); (await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct))) .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.ShouldBeEmpty(); // Positive control: the sibling, placed identically by both generations, follows the grant. (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)) .ShouldBe(grantedLine == Line1 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied); rig.Sink.RebuildAddressSpace(); Topology(rig, pumpLine: Line2); var allowed = grantedLine == Line2; var expected = allowed ? StatusCodes.Good : StatusCodes.BadUserAccessDenied; var newFlow = FlowId(Line2); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(expected); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(expected); (await ReadAsync(session, Uns(session, newFlow), Attributes.DisplayName, ct)).ShouldBe(expected); (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(expected); var write = await WriteAsync(session, Uns(session, newFlow), 1.5, ct); if (allowed) write.ShouldNotBe(StatusCodes.BadUserAccessDenied, "the new line's grant reaches the re-ided variable"); else write.ShouldBe(StatusCodes.BadUserAccessDenied); Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "rebuilt")); var ack = ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct); if (allowed) await ack; else (await Should.ThrowAsync(ack)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.Count.ShouldBe(allowed ? 1 : 0); await session.CloseAsync(ct); } /// /// An Area RENAMED by the generation before the address space re-ids its reference variables: the folders keep /// their opaque ids (still placed — the Equipment grant reads the folder and its virtual tag), but the reference /// variable's id still spells the OLD name, so it is denied until it is re-pointed. /// [Fact] public async Task A_reference_variable_on_a_stale_name_path_is_denied_while_its_folder_is_placed() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808Rename", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); // The Tag grant's path still spells the OLD Area name (Install plants it at Area1/Line1/Pump/Flow) — standing in // for any grant that names the old path, e.g. another Area that has since been given the name "Area1". Install(rig, pumpLine: Line1, Deployed(), areaName: "AreaRenamed", EquipmentGrant(Pump, Operator), TagGrant()); using var session = await rig.ConnectAsync(anonymous: false, ct); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); await session.CloseAsync(ct); } /// /// Gitea #826 (secondary edge of #808): an Area NAMED exactly like one of its equipment's opaque ids. That /// equipment's reference variable (s=<AreaName>/Line/Equipment/Name) then starts with the /// equipment's own id, which the id rule alone read as folder-owned — losing its Tag-grant match. The variable /// is now known to be a reference by the Organizes edge the reference pass wires to its raw node, so the Tag /// grant reaches it; the equipment folder itself stays denied to that grant (control). /// [Fact] public async Task A_reference_variable_under_an_area_named_like_its_equipment_id_keeps_its_tag_grant() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns826AreaNamedLikeId", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); var raw = rig.EnsureRawVariable("flow", "Double", writable: false); var reference = $"{Pump}/Line1/Pump/Flow"; // the Area's NAME is Pump's id rig.Sink.EnsureVariable(reference, Pump, "Flow", "Double", writable: false, AddressSpaceRealm.Uns); rig.Sink.AddReference(reference, AddressSpaceRealm.Uns, raw, AddressSpaceRealm.Raw); Install(rig, pumpLine: Line1, Deployed(), areaName: Pump, tagArea: Pump, TagGrant()); using var session = await rig.ConnectAsync(anonymous: false, ct); (await ReadAsync(session, Uns(session, reference), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); await session.CloseAsync(ct); } /// /// Review of #808 (cache poisoning): the resolved-scope cache keys are NUL-prefixed, so a client that first /// Reads the nonexistent s="\0Area1\0Line1\0Pump" — the exact cache key of the real Pump folder — must /// not memoize a deny the real folder then inherits. A NUL-bearing id is refused before the cache. /// [Fact] public async Task A_nul_bearing_identifier_cannot_poison_a_resolved_scopes_cached_verdict() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808Nul", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator)); using var session = await rig.ConnectAsync(anonymous: false, ct); (await ReadAsync(session, Uns(session, "\u0000Area1\u0000Line1\u0000Pump"), Attributes.DisplayName, ct)) .ShouldBe(StatusCodes.BadUserAccessDenied); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); await session.CloseAsync(ct); } /// /// Review of #808: an identifier NO live node holds (a removed folder, virtual tag or scripted alarm — what a /// NodeDeleted model change names — or a probe) is decided only by a realm-wide grant. It no longer splits, so a /// removed opaque id never matches a narrow grant on an unrelated entity of the same NAME (an Area named like a /// removed alarm id). A reference-variable-shaped id (four segments) keeps its name-path split. /// [Fact] public async Task An_unknown_uns_identifier_is_decided_only_by_a_realm_wide_grant() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808Unknown", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); Install(rig, pumpLine: Line1, Deployed(), KeyGrant("area", NodeAclScopeKind.UnsArea, AreaId, NodePermissions.ReadOnly), new NodeAcl { NodeAclId = "anon-cluster", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.ReadOnly, }); using var narrow = await rig.ConnectAsync(anonymous: false, ct); using var wide = await rig.ConnectAsync(anonymous: true, ct); // "Area1" is the granted Area's NAME — a removed scripted alarm with that id used to split onto it. foreach (var gone in new[] { "Area1", "EQ-00000000dead", "Area1/Line1", "scripted-removed/EnabledState" }) { (await ReadAsync(narrow, Uns(narrow, gone), Attributes.DisplayName, ct)) .ShouldBe(StatusCodes.BadUserAccessDenied, gone); (await ReadAsync(wide, Uns(wide, gone), Attributes.DisplayName, ct)) .ShouldBe(StatusCodes.BadNodeIdUnknown, $"{gone}: a realm-wide grant still decides it"); } // A four-segment (reference-variable-shaped) id keeps its name-path split under the Area grant. (await ReadAsync(narrow, Uns(narrow, "Area1/Line1/Pump/Gone"), Attributes.DisplayName, ct)) .ShouldBe(StatusCodes.BadNodeIdUnknown); // Positive control: the live Area folder itself. (await ReadAsync(narrow, Uns(narrow, AreaId), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); await narrow.CloseAsync(ct); await wide.CloseAsync(ct); } /// /// The placements through the PRODUCTION producer: a deployment artifact's UNS rows, scripted alarms and grant → /// DeploymentArtifact.ParseDataPlaneAuthority (its folder scopes, owner paths, deployed owners and grant /// paths) → install → wire decisions, with no hand-built scope anywhere. /// [Fact] public async Task Placements_decoded_from_a_deployment_artifact_authorize_a_narrow_grant_over_the_wire() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Uns808Artifact", encrypted: false, ct); rig.KeyRoles = []; Topology(rig, pumpLine: Line1); var routed = Route(rig); var artifact = $$""" { "UnsAreas": [{"UnsAreaId":"{{AreaId}}","ClusterId":"{{ServerEventRig.Cluster}}","Name":"Area1"}], "UnsLines": [ {"UnsLineId":"{{Line1}}","UnsAreaId":"{{AreaId}}","Name":"Line1"}, {"UnsLineId":"{{Line2}}","UnsAreaId":"{{AreaId}}","Name":"Line2"} ], "Equipment": [ {"EquipmentId":"{{Pump}}","UnsLineId":"{{Line1}}","Name":"Pump"}, {"EquipmentId":"{{Fan}}","UnsLineId":"{{Line1}}","Name":"Fan"} ], "ScriptedAlarms": [ {"ScriptedAlarmId":"{{Alarm}}","EquipmentId":"{{Pump}}","Name":"hi"}, {"ScriptedAlarmId":"{{PumpLo}}","EquipmentId":"{{Pump}}","Name":"lo"}, {"ScriptedAlarmId":"{{FanAlarm}}","EquipmentId":"{{Fan}}","Name":"fan hi"} ], "NodeAcls": [ {"NodeAclId":"acl-pump","ClusterId":"{{ServerEventRig.Cluster}}","Principal":"{{ServerEventRig.KeyPrincipal}}", "ScopeKind":{{(int)NodeAclScopeKind.Equipment}},"ScopeId":"{{Pump}}","PermissionFlags":{{(int)Operator}}} ] } """; var projection = ZB.MOM.WW.OtOpcUa.Runtime.Drivers.DeploymentArtifact.ParseDataPlaneAuthority( System.Text.Encoding.UTF8.GetBytes(artifact), "node-a:4840"); projection.UnsFolderScopes.Keys.Order(StringComparer.Ordinal).ShouldBe([Pump, Fan, AreaId, Line1, Line2]); var state = new DataPlaneAuthorityState(); state.Apply(ServerEventRig.Cluster, "rev-artifact", [ServerEventRig.Key()], projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); rig.Nm.DataPlaneAuthority = state; rig.Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System); using var session = await rig.ConnectAsync(anonymous: false, ct); (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]); (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct)) .ShouldBe(StatusCodes.Good); (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, FanAlarm, pre with { Message = "fan-base" }); Project(rig, Alarm, pre); await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base")); events.Has(server, "fan-base").ShouldBeFalse(); await ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]); await session.CloseAsync(ct); } // ───────────────────────────── helpers ───────────────────────────── /// /// Area1 / {Line1, Line2}; Pump under and Fan under Line1 — every folder an opaque /// entity id, as AddressSpaceApplier.MaterialiseHierarchy mints them. Each equipment carries scripted /// alarms and a virtual tag; Pump also carries a UNS reference variable (a NAME-path id). /// private static void Topology(ServerEventRig rig, string pumpLine) { rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Line1, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Line2, parentNodeId: AreaId, displayName: "Line2", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Pump, parentNodeId: pumpLine, displayName: "Pump", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Fan, parentNodeId: Line1, displayName: "Fan", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureVariable(PumpSpeed, Pump, "Speed", "Double", writable: false, AddressSpaceRealm.Uns); rig.Sink.EnsureVariable(FanSpeed, Fan, "Speed", "Double", writable: false, AddressSpaceRealm.Uns); rig.Sink.EnsureVariable(FlowId(pumpLine), Pump, "Flow", "Double", writable: true, AddressSpaceRealm.Uns); rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); rig.Sink.MaterialiseAlarmCondition(PumpLo, Pump, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns); rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); } /// The live reference variable's id when Pump hangs under (its NAME path). private static string FlowId(string pumpLine) => $"Area1/{(pumpLine == Line1 ? "Line1" : "Line2")}/Pump/Flow"; private static AclScopePathResolver Resolver(string pumpLine, string areaName) => new( new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary { [AreaId] = areaName }, new Dictionary { [Line1] = (AreaId, "Line1"), [Line2] = (AreaId, "Line2") }, new Dictionary { [Pump] = (pumpLine, "Pump"), [Fan] = (Line1, "Fan") }, []); /// /// Installs a generation whose topology puts Pump under — the owner paths, folder /// scopes and grant paths all from ONE resolver, as DeploymentArtifact.ParseDataPlaneAuthority does. /// private static void Install( ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, params NodeAcl[] grants) => Install(rig, pumpLine, deployedOwners, "Area1", grants); /// As above, with the generation naming the Area (a rename the address space /// has not caught up with when it differs from the live Area1). private static void Install( ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, params NodeAcl[] grants) => Install(rig, pumpLine, deployedOwners, areaName, tagArea: "Area1", grants); /// As above, with the Tag grant's UNS projection under the Area NAMED . private static void Install( ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, string tagArea, params NodeAcl[] grants) { var resolver = Resolver(pumpLine, areaName); var scopePaths = new Dictionary>(resolver.Resolve(grants)) { // A Tag grant's UNS projection (the resolver would derive it from raw rows this rig does not model). [new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, tagArea, "Line1", "Pump", "Flow"])], }; NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); var owners = new Dictionary { [Pump] = One(NodeAclScopeKind.Equipment, Pump), [Fan] = One(NodeAclScopeKind.Equipment, Fan), }; var folders = new Dictionary { [AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]), [Line1] = new(One(NodeAclScopeKind.UnsLine, Line1), [AreaId, Line1]), [Line2] = new(One(NodeAclScopeKind.UnsLine, Line2), [AreaId, Line2]), [Pump] = new(owners[Pump], [AreaId, pumpLine, Pump]), [Fan] = new(owners[Fan], [AreaId, Line1, Fan]), }; var state = new DataPlaneAuthorityState(); state.Apply( ServerEventRig.Cluster, "rev-1", [ServerEventRig.Key()], grants, scopePaths, owners, deployedOwners, folders); rig.Nm.DataPlaneAuthority = state; rig.Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System); } /// The generation's deployed scripted-alarm owners; moves . private static Dictionary Deployed(string alarmOwner = Pump) => new() { [Alarm] = alarmOwner, [PumpLo] = Pump, [FanAlarm] = Fan }; private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) => KeyGrant($"eq-{equipmentId}", NodeAclScopeKind.Equipment, equipmentId, flags); private static NodeAcl LineGrant(string lineId) => KeyGrant($"line-{lineId}", NodeAclScopeKind.UnsLine, lineId, Operator | NodePermissions.WriteOperate); private static NodeAcl TagGrant() => KeyGrant("tag-flow", NodeAclScopeKind.Tag, FlowTagId, Operator); private static NodeAcl KeyGrant(string id, NodeAclScopeKind kind, string scopeId, NodePermissions flags) => new() { NodeAclId = id, ClusterId = ServerEventRig.Cluster, Principal = ServerEventRig.KeyPrincipal, ScopeKind = kind, ScopeId = scopeId, PermissionFlags = flags, }; private static NodeAcl AnonymousGrant(NodeAclScopeKind kind, string scopeId) => new() { NodeAclId = $"anon-{scopeId}", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous, ScopeKind = kind, ScopeId = scopeId, PermissionFlags = NodePermissions.ReadOnly, }; private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => await session.CallAsync( ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); private static List Route(ServerEventRig rig) { var routed = new List(); rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); }; return routed; } private static void AssertNoRoles(ServerEventRig rig) => rig.Server.CurrentInstance.SessionManager.GetSessions() .Select(s => s.EffectiveIdentity).OfType() .Single(i => i.ApiKeyId == ServerEventRig.KeyId) .Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role"); /// The s= id of one of a live condition's instance-scoped children. private static string ChildId(ServerEventRig rig, string alarmId, Func child) => child(rig.Nm.TryGetAlarmCondition(alarmId).ShouldNotBeNull()).NodeId.IdentifierAsString; private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) => rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns); private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id); private static NodeId RootNodeId(ISession session) => new(V3NodeIds.SharedRootFolderId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); private static async Task ReadAsync(ISession session, NodeId node, uint attribute, CancellationToken ct) { var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[] { new() { NodeId = node, AttributeId = attribute }, }, ct); return response.Results.ToList().ShouldHaveSingleItem().StatusCode; } private static async Task WriteAsync(ISession session, NodeId node, double value, CancellationToken ct) { var response = await session.WriteAsync(null, new WriteValue[] { new() { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) }, }, ct); return response.Results.ToList().ShouldHaveSingleItem(); } private static async Task<(StatusCode Status, List Ids)> BrowseAsync( ISession session, NodeId parent, CancellationToken ct) { var response = await session.BrowseAsync( requestHeader: null, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse: [ new BrowseDescription { NodeId = parent, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = 0, ResultMask = (uint)BrowseResultMask.All, }, ], ct: ct); var result = response.Results[0]; return (result.StatusCode, result.References.ToList().Select(r => r.NodeId.IdentifierAsString).ToList()); } }