Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs
T
Joseph Doherty e39a125069 fix(acl): close the #808 review findings — reference variables, owner folder chain, NUL keys, unknown ids (#808)
Review of af5c9744:
1. UNS reference variables (s=Area/Line/Equip/Eff) of a moved or renamed
   equipment stayed readable/writable under the OLD names' grants: they now
   resolve as ReferenceVariable and are authorized at their split path only
   while it equals their live equipment folder's placement (live chain ==
   generation chain) + one name; otherwise denied (Read and Write).
2. TryPlaceScriptedCondition now also requires the owner folder's live chain
   to match the generation (AuthorizeScriptedCondition gains ownerFolderChain),
   so a moved equipment's alarm/children/commands follow its folder.
3. Identifiers containing NUL are denied before the decision cache, keeping
   client ids disjoint from the NUL-prefixed resolved-scope keys (a client
   could otherwise poison a real folder's cached verdict for its key shard).
4. An id no live node holds is Unknown: placed at [\0unknown] so only
   Cluster / Namespace-uns grants decide it (a removed opaque id can no longer
   split onto a same-named narrow grant; narrow grantees get no NodeDeleted for
   removed UNS folders/vtags/conditions — documented); 4-segment
   reference-shaped ids keep their split.
5. A wire test drives placements from DeploymentArtifact.ParseDataPlaneAuthority.

Baseline updates: #797 owner tests (unit + wire) and Sdk2RepeatedAlarmAuthority
now install folder scopes; Sdk2HistoryAuthority's UNS alias used the RAW path
as its UNS id (never minted in production) and now carries EqA/HistoryAlias.
Each new guard was falsified (reverting it fails its test).
2026-11-10 23:44:11 -05:00

606 lines
37 KiB
C#

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