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; /// /// Gitea #797 — a scripted alarm condition's NodeId is its opaque ScriptedAlarmId, so with ACLs /// enforced its Part 9 commands are authorized at the scope of the equipment its exact live instance is /// bound to, resolved through the CURRENT authority generation — never at a scope split out of the id. /// /// /// /// The topology is production-shaped: every UNS folder id is an opaque entity id (UA-…, /// UL-…, EQ-…) exactly as AddressSpaceApplier.MaterialiseHierarchy mints them, and /// the grant paths and owner paths both come from over the area / /// line / equipment NAMES, as the deployment artifact resolves them. Nothing in the address space is a /// slash-qualified synthetic id, so a pass here cannot be a coincidence of the id spelling a path. /// /// /// The handlers are driven directly (the SDK delegates on the condition), which reaches the gate /// exactly as a client call does and additionally lets a test hold on to a REPLACED instance — the /// one case a real client can no longer address once the NodeId resolves to its successor. /// /// public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable { private const string Cluster = "SITE-A"; private const string Principal = "scadabridge"; private const string KeyId = "k-797"; private const string AreaId = "UA-0000000001"; private const string LineId = "UL-0000000001"; private const string Pump = "EQ-00000000a1b2"; private const string Fan = "EQ-00000000c3d4"; private const string Alarm = "scripted-eqa-hi"; private const string FanAlarm = "scripted-fan-hi"; private const NodePermissions AlarmOps = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; private static readonly DateTime Start = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-797-{Guid.NewGuid():N}"); // ───────────────────────────── the defect, and its positive ───────────────────────────── /// /// THE #797 allow: a session holding NO roles and only an Equipment grant on the owning equipment /// acknowledges a scripted condition with a production-valid opaque id, and the command routes once with /// the id unchanged. Before the binding this was BadUserAccessDenied. /// [Fact] public async Task An_equipment_grant_authorizes_every_command_on_its_own_scripted_condition() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); var condition = nm.TryGetAlarmCondition(Alarm)!; var ctx = Context(server, KeyIdentity()); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("re-ack")).ShouldBe(ServiceResult.Good); condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm")).ShouldBe(ServiceResult.Good); condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("re-confirm")).ShouldBe(ServiceResult.Good); condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("note")).ShouldBe(ServiceResult.Good); condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0).ShouldBe(ServiceResult.Good); condition.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: 60_000).ShouldBe(ServiceResult.Good); condition.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0).ShouldBe(ServiceResult.Good); condition.OnEnableDisable!(ctx, condition, false).ShouldBe(ServiceResult.Good); condition.OnEnableDisable!(ctx, condition, true).ShouldBe(ServiceResult.Good); routed.Select(c => c.Operation).ShouldBe( ["Acknowledge", "Acknowledge", "Confirm", "Confirm", "AddComment", "OneShotShelve", "TimedShelve", "Unshelve", "Disable", "Enable"]); routed.ShouldAllBe(c => c.AlarmId == Alarm && c.User == Principal); } finally { await host.DisposeAsync(); } } /// /// The narrow grant confers exactly its own flags at the owner: Acknowledge alone does not confer Confirm /// or Shelve, so the operation split survives the move to owner scope. /// [Fact] public async Task The_owner_scope_honours_the_grants_operation_flags() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge)); var condition = nm.TryGetAlarmCondition(Alarm)!; var ctx = Context(server, KeyIdentity()); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good); Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm"))); Denied(condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0)); routed.Select(c => c.Operation).ShouldBe(["Acknowledge"]); } finally { await host.DisposeAsync(); } } /// /// An ancestor grant (the line) covers its equipment's alarms; a grant on the SIBLING equipment, or on a /// single tag beneath the owner, does not. Each deny is preceded on the same session by an allow on a /// condition the grant does cover, so the deny cannot be a gate that denies everything. /// [Fact] public async Task Sibling_equipment_and_tag_grants_are_denied_and_an_ancestor_grant_is_allowed() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var clock = new FakeClock(Start); var (state, _) = Wire(nm, clock, [Key()], EquipmentGrant(Fan, AlarmOps)); var pump = nm.TryGetAlarmCondition(Alarm)!; var fan = nm.TryGetAlarmCondition(FanAlarm)!; var ctx = Context(server, KeyIdentity()); fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("fan ok")).ShouldBe(ServiceResult.Good); Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("sibling"))); Denied(pump.OnAddComment!(ctx, pump, EventId(), new LocalizedText("sibling"))); routed.Count.ShouldBe(1); // A tag grant beneath the owner is about that signal, not the equipment's alarms. Reapply(state, "rev-tag", [Key()], new NodeAcl { NodeAclId = "tag", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Tag, ScopeId = "TAG-1", PermissionFlags = AlarmOps, }, EquipmentGrant(Fan, AlarmOps)); Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("tag"))); routed.Count.ShouldBe(1); // The line is an ancestor of both equipments: it covers both. Reapply(state, "rev-line", [Key()], new NodeAcl { NodeAclId = "line", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsLine, ScopeId = LineId, PermissionFlags = AlarmOps, }); pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("line")).ShouldBe(ServiceResult.Good); routed.Select(c => c.AlarmId).ShouldBe([FanAlarm, Alarm]); } finally { await host.DisposeAsync(); } } /// /// The old split scope [uns, scripted-eqa-hi] would have been satisfied by an unrelated AREA whose /// name happened to equal the alarm's id. The owner binding ignores the id's spelling entirely. /// [Fact] public async Task An_area_whose_name_equals_the_alarm_id_confers_nothing() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var areas = new Dictionary { [AreaId] = "Area1", ["UA-decoy"] = Alarm }; Wire(nm, new FakeClock(Start), [Key()], areas, new NodeAcl { NodeAclId = "decoy", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsArea, ScopeId = "UA-decoy", PermissionFlags = AlarmOps, }); var condition = nm.TryGetAlarmCondition(Alarm)!; Denied(condition.OnAcknowledge!(Context(server, KeyIdentity()), condition, EventId(), new LocalizedText("decoy"))); routed.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } // ───────────────────────────── re-checked at every call ───────────────────────────── /// /// Key, grant and generation are re-read at each call: a deployment that drops the grant or disables the /// key, or the clock passing the key's expiry, denies the very next command on the same open session; /// restoring the authority restores the allow. /// [Theory] [InlineData("grant")] [InlineData("key")] [InlineData("expiry")] [InlineData("owner-path")] public async Task Revocation_reaches_the_next_command_and_restoration_restores_it(string change) { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var clock = new FakeClock(Start); var key = Key(expiresUtc: Start.AddMinutes(10)); var grant = EquipmentGrant(Pump, AlarmOps); var (state, _) = Wire(nm, clock, [key], grant); var condition = nm.TryGetAlarmCondition(Alarm)!; var ctx = Context(server, KeyIdentity()); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("before")).ShouldBe(ServiceResult.Good); switch (change) { case "grant": Reapply(state, "rev-2", [key]); break; case "key": var disabled = Key(expiresUtc: Start.AddMinutes(10)); disabled.Enabled = false; Reapply(state, "rev-2", [disabled], grant); break; case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break; case "owner-path": // The generation no longer places the owner (equipment deleted from the artifact while the // node is still served): the grant's own scope stays resolvable, the owner does not. state.Apply(Cluster, "rev-2", [key], [grant], Resolver().Resolve([grant]), new Dictionary(), Deployed()); break; } Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("after"))); Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("after"))); Denied(condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("after"))); routed.Count.ShouldBe(1); clock.Reset(Start); Reapply(state, "rev-3", [key], grant); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("restored")).ShouldBe(ServiceResult.Good); routed.Count.ShouldBe(2); } finally { await host.DisposeAsync(); } } // ───────────────────────────── binding lifecycle ───────────────────────────── /// /// Delete + recreate of the same id under ANOTHER equipment: the new instance is bound to the new owner, /// the old equipment's grant is denied on it, the new equipment's grant is allowed — and the REPLACED /// instance, still held by a caller, is denied under every grant, even a Cluster one. /// [Fact] public async Task A_recreated_alarm_binds_to_its_new_equipment_and_the_replaced_instance_is_denied() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); var ctx = Context(server, KeyIdentity()); var original = nm.TryGetAlarmCondition(Alarm)!; original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("original")).ShouldBe(ServiceResult.Good); nm.RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull("a removed condition has no owner"); Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("removed"))); nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); var recreated = nm.TryGetAlarmCondition(Alarm)!; recreated.ShouldNotBeSameAs(original); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); Denied(recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("old grant"))); routed.Count.ShouldBe(1); Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [Key()], EquipmentGrant(Fan, AlarmOps)); recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("new grant")).ShouldBe(ServiceResult.Good); routed.Count.ShouldBe(2); // The replaced instance stays dead under every grant, the broadest included. Reapply(state, "rev-cluster", Deployed(alarmOwner: Fan), [Key()], ClusterGrant(AlarmOps)); Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("stale"))); Denied(original.OnAddComment!(ctx, original, EventId(), new LocalizedText("stale"))); routed.Count.ShouldBe(2); routed.ShouldAllBe(c => c.AlarmId == Alarm); } finally { await host.DisposeAsync(); } } /// /// A re-materialise of a live scripted condition naming a DIFFERENT owner (no rebuild in between) is not /// the idempotent no-op it is for an unchanged one: the condition is re-parented under — and re-bound to — /// the new equipment. An unchanged re-materialise keeps the instance (and its subscriptions) and its binding. /// [Fact] public async Task A_rematerialise_under_another_equipment_rebinds_and_an_unchanged_one_is_a_no_op() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; Topology(nm); var first = nm.TryGetAlarmCondition(Alarm)!; nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); nm.TryGetAlarmCondition(Alarm).ShouldBeSameAs(first); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); var moved = nm.TryGetAlarmCondition(Alarm)!; moved.ShouldNotBeSameAs(first); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); moved.Parent!.NodeId.IdentifierAsString.ShouldBe(Fan); } finally { await host.DisposeAsync(); } } /// A full rebuild and an equipment-subtree removal drop every binding they tear down. [Fact] public async Task Rebuild_and_equipment_subtree_removal_drop_the_binding() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps)); var ctx = Context(server, KeyIdentity()); var fan = nm.TryGetAlarmCondition(FanAlarm)!; fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("live")).ShouldBe(ServiceResult.Good); nm.RemoveEquipmentSubtree(Fan, AddressSpaceRealm.Uns).ShouldBeTrue(); nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBeNull(); Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("subtree gone"))); var pump = nm.TryGetAlarmCondition(Alarm)!; nm.RebuildAddressSpace(); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull(); Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("rebuilt away"))); routed.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } } /// /// A scripted condition whose owner folder did not exist when it was materialised hangs under the root and /// is UNBOUND: with enforcement on, nothing authorizes it — not even a Cluster grant. There is no fallback /// to the realm root, the cluster, or the id. An unknown id has no condition at all. /// [Fact] public async Task An_unbound_scripted_condition_is_denied_even_under_a_cluster_grant() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns); Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps)); var ctx = Context(server, KeyIdentity()); var orphan = nm.TryGetAlarmCondition("scripted-orphan")!; nm.ScriptedAlarmOwnerOf("scripted-orphan").ShouldBeNull(); nm.ScriptedAlarmOwnerOf("scripted-never-existed").ShouldBeNull(); var bound = nm.TryGetAlarmCondition(Alarm)!; bound.OnAcknowledge!(ctx, bound, EventId(), new LocalizedText("positive control")).ShouldBe(ServiceResult.Good); Denied(orphan.OnAcknowledge!(ctx, orphan, EventId(), new LocalizedText("orphan"))); routed.Select(c => c.AlarmId).ShouldBe([Alarm]); } finally { await host.DisposeAsync(); } } /// /// The production population path end to end: a composition applied by /// through the wrapper the actors inject (no new sink method — the /// binding rides MaterialiseAlarmCondition's existing EquipmentId), then an Equipment grant /// resolved from the SAME names authorizes the alarm while the sibling equipment's grant does not. /// [Fact] public async Task The_applier_binds_each_scripted_alarm_to_its_composition_equipment() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); var deferred = new DeferredAddressSpaceSink(); deferred.SetSink(new SdkAddressSpaceSink(nm)); var applier = new AddressSpaceApplier(deferred, NullLogger.Instance); var composition = new AddressSpaceComposition( [new UnsAreaProjection(AreaId, "Area1")], [new UnsLineProjection(LineId, AreaId, "Line1")], [new EquipmentNode(Pump, "Pump", LineId), new EquipmentNode(Fan, "Fan", LineId)], [], []) { EquipmentScriptedAlarms = [ new EquipmentScriptedAlarmPlan(Alarm, Pump, "hi", "AlarmCondition", 500, "m", "s1", "return false;", [], false, true, true), new EquipmentScriptedAlarmPlan(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, "m", "s2", "return false;", [], false, true, true), ], }; applier.MaterialiseHierarchy(composition).ShouldBe(0); applier.MaterialiseScriptedAlarms(composition).ShouldBe(0); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBe(Fan); nm.TryGetAlarmCondition(Alarm)!.NodeId.IdentifierAsString.ShouldBe(Alarm, "the opaque NodeId is unchanged"); var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); var ctx = Context(server, KeyIdentity()); var pump = nm.TryGetAlarmCondition(Alarm)!; var fan = nm.TryGetAlarmCondition(FanAlarm)!; pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("owner")).ShouldBe(ServiceResult.Good); Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("sibling"))); routed.Select(c => c.AlarmId).ShouldBe([Alarm]); state.Current.GenerationId.ShouldBe(1); } finally { await host.DisposeAsync(); } } /// /// #797 review, window (a): the publish actor installs a generation BEFORE applying its address space. A /// generation that moves the alarm from Pump to Fan, while the address space still binds it to Pump, must /// deny BOTH owners' grants (and a Cluster grant) — never leave it commandable with the old owner's grant — /// until the rebuild re-materialises it under Fan, after which only Fan's grant allows. /// [Fact] public async Task A_generation_that_moves_the_alarm_denies_both_owners_until_the_address_space_rebinds() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); var ctx = Context(server, KeyIdentity()); var condition = nm.TryGetAlarmCondition(Alarm)!; condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("before move")).ShouldBe(ServiceResult.Good); // The moving generation lands; the address space has not been applied yet (binding still Pump). var moved = Deployed(alarmOwner: Fan); Reapply(state, "rev-move-pump", moved, [Key()], EquipmentGrant(Pump, AlarmOps)); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("old owner grant"))); Reapply(state, "rev-move-fan", moved, [Key()], EquipmentGrant(Fan, AlarmOps)); Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("new owner grant"))); Reapply(state, "rev-move-cluster", moved, [Key()], ClusterGrant(AlarmOps)); Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("cluster grant"))); routed.Count.ShouldBe(1); // The apply catches up: a ChangedAlarms rebuild re-materialises the alarm under Fan. RebuildUnder(nm, alarmOwner: Fan); var rebound = nm.TryGetAlarmCondition(Alarm)!; nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); Reapply(state, "rev-applied-fan", moved, [Key()], EquipmentGrant(Fan, AlarmOps)); rebound.OnAcknowledge!(ctx, rebound, EventId(), new LocalizedText("new owner")).ShouldBe(ServiceResult.Good); Reapply(state, "rev-applied-pump", moved, [Key()], EquipmentGrant(Pump, AlarmOps)); Denied(rebound.OnAcknowledge!(ctx, rebound, EventId(), new LocalizedText("old owner after rebind"))); routed.Select(c => c.AlarmId).ShouldBe([Alarm, Alarm]); } finally { await host.DisposeAsync(); } } /// /// #797 review, window (b): the apply THROWS after the moving generation was installed, so the old address /// space (and its Pump binding) stays in force indefinitely. The alarm stays denied under every grant for as /// long as that lasts — the clock moving and further generations that still move it change nothing — and a /// later successful rebuild under Fan restores exactly Fan's authority. /// /// /// The failed apply is represented by its end state — a new generation installed over the previous address /// space with nothing re-materialised — which is what OpcUaPublishActor's outer catch leaves. /// [Fact] public async Task A_failed_apply_after_the_moving_generation_keeps_the_alarm_denied_until_a_successful_rebuild() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var clock = new FakeClock(Start); var (state, _) = Wire(nm, clock, [Key()], EquipmentGrant(Pump, AlarmOps), EquipmentGrant(Fan, AlarmOps)); var ctx = Context(server, KeyIdentity()); var stale = nm.TryGetAlarmCondition(Alarm)!; Reapply(state, "rev-move", Deployed(alarmOwner: Fan), [Key()], EquipmentGrant(Pump, AlarmOps), EquipmentGrant(Fan, AlarmOps)); // (apply threw here — nothing below touches the address space) Denied(stale.OnAcknowledge!(ctx, stale, EventId(), new LocalizedText("1"))); clock.Advance(TimeSpan.FromHours(6)); Denied(stale.OnAddComment!(ctx, stale, EventId(), new LocalizedText("2"))); Reapply(state, "rev-move-again", Deployed(alarmOwner: Fan), [Key()], ClusterGrant(AlarmOps)); Denied(stale.OnAcknowledge!(ctx, stale, EventId(), new LocalizedText("3"))); nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump, "the stale binding is still there; the generation check is what denies"); routed.ShouldBeEmpty(); RebuildUnder(nm, alarmOwner: Fan); var rebound = nm.TryGetAlarmCondition(Alarm)!; Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [Key()], EquipmentGrant(Fan, AlarmOps)); rebound.OnAcknowledge!(ctx, rebound, EventId(), new LocalizedText("after rebuild")).ShouldBe(ServiceResult.Good); Denied(stale.OnAcknowledge!(ctx, stale, EventId(), new LocalizedText("stale instance"))); routed.Select(c => c.AlarmId).ShouldBe([Alarm]); } finally { await host.DisposeAsync(); } } /// An alarm id the generation does not deploy at all is denied even though the address space binds it. [Fact] public async Task A_bound_alarm_the_generation_does_not_deploy_is_denied() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); var (state, _) = Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps)); var ctx = Context(server, KeyIdentity()); var fan = nm.TryGetAlarmCondition(FanAlarm)!; fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("deployed")).ShouldBe(ServiceResult.Good); Reapply(state, "rev-without-fan-alarm", new Dictionary { [Alarm] = Pump }, [Key()], ClusterGrant(AlarmOps)); Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("deleted by this generation"))); routed.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } } /// What a successful ChangedAlarms rebuild does to the node manager: wipe, re-ensure, re-materialise. private static void RebuildUnder(OtOpcUaNodeManager nm, string alarmOwner) { nm.RebuildAddressSpace(); nm.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition(Alarm, alarmOwner, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); nm.CompleteAddressSpaceApply(); } // ───────────────────────────── what must not change ───────────────────────────── /// /// Native conditions (ConditionId = RawPath) keep their NodeId-scoped decision: a RawDevice grant /// authorizes Acknowledge and AddComment, and an Equipment grant on a referencing equipment does not — /// exactly as before #797. No owner binding is ever made for them. /// [Fact] public async Task Native_conditions_keep_their_raw_path_scoped_decision() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var native = new List(); nm.NativeAlarmAckRouter = native.Add; var scripted = Route(nm); Topology(nm); nm.EnsureFolder("Plant/Modbus/dev1", parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp_hi", "Plant/Modbus/dev1", "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); nm.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw, [Pump], AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw)!; nm.ScriptedAlarmOwnerOf("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw).ShouldBeNull(); var ctx = Context(server, KeyIdentity()); var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("equipment grant"))); var rawDevice = new NodeAcl { NodeAclId = "dev", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "dev1", PermissionFlags = AlarmOps, }; var scopes = new Dictionary> { [new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])], }; state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed()); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good); condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good); native.Count.ShouldBe(1); native[0].ConditionNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); scripted.Select(c => (c.AlarmId, c.Operation)).ShouldBe([("Plant/Modbus/dev1/temp_hi", "AddComment")]); } finally { await host.DisposeAsync(); } } /// /// With Security:Acl:Enabled OFF nothing changes: the activation-time AlarmAck role decides, /// bound or unbound, and a session without it is denied — the binding is consulted only by the ACL path. /// [Fact] public async Task With_acl_off_the_alarm_ack_role_decides_bound_or_unbound() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var routed = Route(nm); Topology(nm); nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns); Wire(nm, new FakeClock(Start), [Key()], enabled: false, EquipmentGrant(Fan, AlarmOps)); var bound = nm.TryGetAlarmCondition(Alarm)!; var orphan = nm.TryGetAlarmCondition("scripted-orphan")!; var withRole = Context(server, KeyIdentity(OpcUaDataPlaneRoles.AlarmAck)); bound.OnAcknowledge!(withRole, bound, EventId(), new LocalizedText("1")).ShouldBe(ServiceResult.Good); orphan.OnAcknowledge!(withRole, orphan, EventId(), new LocalizedText("2")).ShouldBe(ServiceResult.Good); var noRole = Context(server, KeyIdentity()); Denied(bound.OnAcknowledge!(noRole, bound, EventId(), new LocalizedText("3"))); routed.Select(c => c.AlarmId).ShouldBe([Alarm, "scripted-orphan"]); } finally { await host.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── /// /// Area1 / Line1 / {Pump, Fan}, every folder under its opaque entity id, with one scripted alarm under each /// equipment — the shape AddressSpaceApplier materialises in production. /// private static void Topology(OtOpcUaNodeManager nm) { nm.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); } private static Dictionary Areas() => new() { [AreaId] = "Area1" }; /// The production resolver over the topology's NAMES, as the deployment artifact builds it. private static AclScopePathResolver Resolver(IReadOnlyDictionary? areas = null) => new( new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary { // A tag referenced by Pump, so a Tag grant resolves to a path BENEATH the owner. ["TAG-1"] = ("dev-x", null, "Speed"), }, areas ?? Areas(), new Dictionary { [LineId] = (AreaId, "Line1") }, new Dictionary { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan"), }, [(Pump, "TAG-1", "Speed")]); /// Every equipment's owner path, through the same resolver call the artifact makes. private static Dictionary OwnerPaths() { var resolver = Resolver(); return new Dictionary { [Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(), [Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(), }; } private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) => Wire(nm, clock, keys, enabled: true, Areas(), grants); private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, IReadOnlyDictionary areas, params NodeAcl[] grants) => Wire(nm, clock, keys, enabled: true, areas, grants); private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled, params NodeAcl[] grants) => Wire(nm, clock, keys, enabled, Areas(), grants); private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled, IReadOnlyDictionary areas, NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed()); var authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock); nm.DataPlaneAuthority = state; nm.Authorizer = authorizer; return (state, authorizer); } /// A new deployment of the same topology carrying and . private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) => Reapply(state, revision, Deployed(), keys, grants); private static void Reapply( DataPlaneAuthorityState state, string revision, IReadOnlyDictionary deployedOwners, ApiKey[] keys, params NodeAcl[] grants) => state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners); /// /// The generation's deployed scripted-alarm owners (the artifact's ScriptedAlarms rows); /// moves the Pump alarm. /// private static Dictionary Deployed(string alarmOwner = Pump) => new() { [Alarm] = alarmOwner, [FanAlarm] = Fan, ["scripted-orphan"] = "EQ-not-materialised", }; private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) => new() { NodeAclId = $"acl-{equipmentId}", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Equipment, ScopeId = equipmentId, PermissionFlags = flags, }; private static NodeAcl ClusterGrant(NodePermissions flags) => new() { NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = flags, }; private static ApiKey Key(DateTime? expiresUtc = null) => new() { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = Principal, Enabled = true, CreatedUtc = Start.AddDays(-1), ExpiresUtc = expiresUtc, }; /// A key-carrying session; no roles unless given — under enforcement the grant alone must decide. private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) => new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId); private static ServerSystemContext Context(OtOpcUaSdkServer server, RoleCarryingUserIdentity identity) => new(server.CurrentInstance) { UserIdentity = identity }; private static List Route(OtOpcUaNodeManager nm) { var routed = new List(); nm.AlarmCommandRouter = routed.Add; return routed; } private static ByteString EventId() => ByteString.From(Guid.NewGuid().ToByteArray()); private static void Denied(ServiceResult result) => result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); /// A hand-advanced clock; the authorizer reads it on every decision. private sealed class FakeClock(DateTime utcNow) : TimeProvider { private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); /// public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now = _now.Add(by); public void Reset(DateTime utcNow) => _now = new DateTimeOffset(utcNow, TimeSpan.Zero); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.ScriptedOwnerScopeTest", ApplicationUri = $"urn:OtOpcUa.ScriptedOwnerScopeTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); return (started.Host, started.Server); } /// public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } }