3d1b86b451
Scripted condition NodeIds are opaque ScriptedAlarmIds, so splitting them as a topology path could never match an Equipment grant: a narrow equipment grant was denied while a Cluster grant passed. - OtOpcUaNodeManager binds each LIVE scripted condition instance to the EquipmentId it was materialised under (only when it hangs under that folder); dropped on remove / drop-recreate / equipment-subtree removal / rebuild; a same-id re-materialise under another equipment re-parents and re-binds. - DataPlaneAuthoritySnapshot carries EquipmentScopePaths (EquipmentId -> [uns, Area, Line, Equipment]) decoded from the artifact by the same AclScopePathResolver call that plants Equipment grants. - DataPlaneAuthorizer.AuthorizeScriptedCondition re-reads key, grant and generation per call (uncached) and evaluates at the owner path; unbound, replaced or unplaceable owners deny under every grant. No id-split or realm-root fallback. - Native conditions (RawPath) and the ACL-off role gate are unchanged. NodeIds and AlarmCommand.AlarmId are unchanged. Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808 (folder ids + Server-object event visibility for narrow UNS grants).
682 lines
34 KiB
C#
682 lines
34 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>());
|
|
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", [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", [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(); }
|
|
}
|
|
|
|
// ───────────────────────────── 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());
|
|
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());
|
|
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) =>
|
|
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths());
|
|
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|