Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs
T
Joseph Doherty 3d1b86b451 fix(acl): authorize scripted alarm commands at their owning equipment's scope (#797)
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).
2026-11-10 21:23:57 -05:00

62 lines
3.5 KiB
C#

// Gitea #797: this test USED to pin the denial (an Equipment grant could not authorize an opaque scripted
// alarm id; only a Cluster grant could). The owner binding fixed that deliberately, so it now pins the fixed
// behaviour: the owning equipment's grant allows, the sibling equipment's grant denies, a cluster grant still
// allows. The SDK 1.5 baseline probe under scripts/validation/opcua-scripted-alarm-baseline records the
// pre-fix denial on the original application commit and is unaffected.
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class Sdk2OpaqueScriptedScopeBaselineTests
{
[Fact]
public async Task Opaque_scripted_ID_is_allowed_by_its_equipment_grant_denied_by_a_sibling_grant_and_allowed_by_a_cluster_grant()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct);
rig.KeyRoles = [];
const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
NodeAcl Narrow(string equipment) => new()
{
NodeAclId = $"narrow-{equipment}", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = permissions,
};
// Sibling first: a grant on EqB must not authorize EqA's alarm.
rig.Enforce(true, [Key()], Narrow(EqB));
rig.MaterialiseScripted();
rig.Nm.ScriptedAlarmOwnerOf(ScriptedAlarmId).ShouldBe(EqA);
var routed = 0;
rig.Nm.AlarmCommandRouter = _ => routed++;
using var session = await rig.ConnectAsync(anonymous: false, ct);
rig.Project(false, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 500, "scope"));
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull();
condition.NodeId.IdentifierAsString.ShouldBe(ScriptedAlarmId, "the opaque NodeId is unchanged");
var eventId = condition.EventId.ShouldNotBeNull().Value;
var denied = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId,
MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("sibling")) }));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBe(0);
// The owning equipment's narrow grant now authorizes the same opaque id (#797 fixed).
rig.Enforce(true, [Key()], Narrow(EqA));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("owner")) });
routed.ShouldBe(1);
// The existing cluster contract is preserved.
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new Variant[] { Variant.From(condition.EventId!.Value), Variant.From(new LocalizedText("cluster")) });
routed.ShouldBe(2);
await session.CloseAsync(ct);
}
}