Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs
T
Joseph Doherty 4620e542e8 fix(acl): require the live scripted-alarm owner to match the generation's deployed owner (#797)
Review of 3d1b86b4: OpcUaPublishActor installs a new authority generation
before applying its address space, and an apply that throws keeps the old one.
A scripted alarm moved from equipment A to B therefore stayed commandable with
A's grant under B's generation (briefly before the rebuild, or indefinitely
after a failed apply).

The generation now carries ScriptedAlarmOwners (ScriptedAlarmId -> EquipmentId,
decoded by ParseDataPlaneAuthority from the artifact's ScriptedAlarms rows, the
same rows the composition materialises from). AuthorizeScriptedCondition denies
unless the node manager's live binding names the same owner as the generation:
a moved alarm is denied under the old owner's, the new owner's and a Cluster
grant until the address space re-binds it; an alarm the generation no longer
deploys is denied. Install/apply order unchanged.

Tests: window (a) generation moved / binding stale => both owners and Cluster
denied, then rebuild under the new owner => new owner allowed, old denied;
window (b) apply failed after install => denied across clock and further
generations, then a successful rebuild restores the new owner only; undeployed
alarm denied; artifact decode of the owner map.
2026-11-10 21:42:03 -05:00

822 lines
42 KiB
C#

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 #797 — a scripted alarm condition's NodeId is its opaque <c>ScriptedAlarmId</c>, 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.
/// </summary>
/// <remarks>
/// <para>
/// The topology is production-shaped: every UNS folder id is an opaque entity id (<c>UA-…</c>,
/// <c>UL-…</c>, <c>EQ-…</c>) exactly as <c>AddressSpaceApplier.MaterialiseHierarchy</c> mints them, and
/// the grant paths and owner paths both come from <see cref="AclScopePathResolver"/> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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 ─────────────────────────────
/// <summary>
/// THE #797 allow: a session holding NO roles and only an <c>Equipment</c> 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 <c>BadUserAccessDenied</c>.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// The old split scope <c>[uns, scripted-eqa-hi]</c> 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.
/// </summary>
[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<string, string> { [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 ─────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<string, NodeAclPath>(), 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 ─────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>A full rebuild and an equipment-subtree removal drop every binding they tear down.</summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// The production population path end to end: a composition applied by <see cref="AddressSpaceApplier"/>
/// through the <see cref="DeferredAddressSpaceSink"/> wrapper the actors inject (no new sink method — the
/// binding rides <c>MaterialiseAlarmCondition</c>'s existing <c>EquipmentId</c>), then an Equipment grant
/// resolved from the SAME names authorizes the alarm while the sibling equipment's grant does not.
/// </summary>
[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<AddressSpaceApplier>.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(); }
}
/// <summary>
/// #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.
/// </summary>
[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(); }
}
/// <summary>
/// #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.
/// </summary>
/// <remarks>
/// 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 <c>OpcUaPublishActor</c>'s outer catch leaves.
/// </remarks>
[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(); }
}
/// <summary>An alarm id the generation does not deploy at all is denied even though the address space binds it.</summary>
[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<string, string> { [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(); }
}
/// <summary>What a successful ChangedAlarms rebuild does to the node manager: wipe, re-ensure, re-materialise.</summary>
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 ─────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<NativeAlarmAck>();
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<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[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(); }
}
/// <summary>
/// With <c>Security:Acl:Enabled</c> OFF nothing changes: the activation-time <c>AlarmAck</c> role decides,
/// bound or unbound, and a session without it is denied — the binding is consulted only by the ACL path.
/// </summary>
[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 ─────────────────────────────
/// <summary>
/// Area1 / Line1 / {Pump, Fan}, every folder under its opaque entity id, with one scripted alarm under each
/// equipment — the shape <c>AddressSpaceApplier</c> materialises in production.
/// </summary>
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<string, string> Areas() => new() { [AreaId] = "Area1" };
/// <summary>The production resolver over the topology's NAMES, as the deployment artifact builds it.</summary>
private static AclScopePathResolver Resolver(IReadOnlyDictionary<string, string>? areas = null) => new(
new Dictionary<string, (string? ParentId, string Name)>(),
new Dictionary<string, (string? RawFolderId, string Name)>(),
new Dictionary<string, (string DriverInstanceId, string Name)>(),
new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>
{
// 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<string, (string UnsAreaId, string Name)> { [LineId] = (AreaId, "Line1") },
new Dictionary<string, (string UnsLineId, string Name)>
{
[Pump] = (LineId, "Pump"),
[Fan] = (LineId, "Fan"),
},
[(Pump, "TAG-1", "Speed")]);
/// <summary>Every equipment's owner path, through the same resolver call the artifact makes.</summary>
private static Dictionary<string, NodeAclPath> OwnerPaths()
{
var resolver = Resolver();
return new Dictionary<string, NodeAclPath>
{
[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<string, string> 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<string, string> 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);
}
/// <summary>A new deployment of the same topology carrying <paramref name="keys"/> and <paramref name="grants"/>.</summary>
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<string, string> deployedOwners,
ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
/// <summary>
/// The generation's deployed scripted-alarm owners (the artifact's <c>ScriptedAlarms</c> rows);
/// <paramref name="alarmOwner"/> moves the Pump alarm.
/// </summary>
private static Dictionary<string, string> 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,
};
/// <summary>A key-carrying session; no roles unless given — under enforcement the grant alone must decide.</summary>
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<AlarmCommand> Route(OtOpcUaNodeManager nm)
{
var routed = new List<AlarmCommand>();
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);
/// <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);
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<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
return (started.Host, started.Server);
}
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}