fix(acl): per-event visibility for direct folder event items; generation-checked native wiring (#826)
Event items on an equipment / raw device folder were fed by the SDK's MonitoredNode2 fan-out with no ACL decision, so after a deploy moved a scripted alarm (or dropped the reference projecting a native alarm into an equipment) the OLD folder's subscribers kept receiving it - EventId included - until the rebuild landed, indefinitely after a failed apply. - OtOpcUaNodeManager re-implements INodeManager3 so ValidateEventRolePermissions (the SDK's per-event, per-item hook; base not virtual) adds IsNotifierEventVisibleTo: live condition instance, notifier still a legitimate notifier of it, and the #586/#808 Server-object rule (IsConditionVisibleTo over ConditionVisibilityScopes). - The SDK memoizes that verdict per (item, event type, SourceNode): InvalidateFolderEventVerdicts runs on the authority/authorizer setters, RevalidateMonitoredItems (deploy + timer) and CompleteAddressSpaceApply. - ConditionRefresh on direct items goes through RefreshDirectItems (base loop + the same decision). - New generation field TagReferencingEquipment (RawPath -> EquipmentIds, from the artifact's UnsTagReferences via AclScopePathResolver.ResolveTagRawPath): a native alarm's wired equipment folder only counts (folder delivery AND Server-object scopes) while the current generation still references it. - Secondary #808 edge: UNS variables wired UNS->Raw Organizes by AddReference are tracked and always placed as reference variables (an Area named like its equipment's id no longer narrows them). Tests: UnsFolderScopeWireTests (moved alarm on a folder item + ConditionRefresh + rebuild; area named like equipment id), NativeAlarmFolderEventAclWireTests (dropped reference: folder + Server object + refresh + restore), DeploymentArtifactScopePathTests (projection). Rig/event tests now supply tag references.
This commit is contained in:
+7
-7
@@ -88,7 +88,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
// The next deployment carries only EqB's grant.
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -100,7 +100,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
// A deployment that restores the grant brings the feed back on its own.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
@@ -143,7 +143,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
subscription.RemoveItem(onA);
|
||||
@@ -153,7 +153,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
// A restoring deployment must not re-attach the deleted item. The client could never see that
|
||||
// happen — it has deleted the item — so the server-side fan-out is what is asserted.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -410,7 +410,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
@@ -418,7 +418,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment");
|
||||
|
||||
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
|
||||
@@ -1049,7 +1049,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826, native variant, over the wire: a native alarm materialises once at its raw tag and is wired as an
|
||||
/// extra event notifier into every referencing equipment folder. A deployment that drops one equipment's reference
|
||||
/// installs its generation BEFORE the rebuild that unwires the folder (and an apply that fails keeps the old
|
||||
/// wiring), so the folder's subscribers used to keep receiving the alarm — EventId included — through the SDK's
|
||||
/// per-folder fan-out, which no ACL decision reached, and through the Server object, whose scopes admitted every
|
||||
/// wired folder. Both now ask the CURRENT generation whether it still projects the raw tag into that equipment.
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmFolderEventAclWireTests
|
||||
{
|
||||
private const string EqA = ServerEventRig.EqA;
|
||||
private const string EqB = ServerEventRig.EqB;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous holds EqA, the key holds EqB, the alarm is wired into both. The generation drops EqA's reference
|
||||
/// with the wiring still in place: EqA's folder item and the anonymous Server-object item stop receiving the
|
||||
/// alarm (live AND ConditionRefresh), while the key's EqB folder and Server-object items receive the same events
|
||||
/// (positive control). Restoring the reference restores EqA's feed with no client action — the SDK's memoized
|
||||
/// per-event verdict is invalidated by the generation change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_alarm_stops_reaching_a_folder_whose_reference_the_generation_dropped()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder826Native", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
NodeAcl[] grants = [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly), KeyEquipmentGrant(EqB)];
|
||||
var state = rig.Enforce(true, [ServerEventRig.Key()], grants);
|
||||
|
||||
using var anonymous = await rig.ConnectAsync(anonymous: true, ct);
|
||||
using var keyed = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
var anonEvents = new RecordedEvents();
|
||||
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
|
||||
var onA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA));
|
||||
var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server);
|
||||
await anonSub.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
var keyEvents = new RecordedEvents();
|
||||
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
|
||||
var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB));
|
||||
var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server);
|
||||
await keySub.ApplyChangesAsync(ct);
|
||||
onB.Status.Error.ShouldBeNull();
|
||||
|
||||
// Baseline: every item receives the alarm while both references are deployed.
|
||||
rig.Project(native: true, State("t1"));
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.Has(onA, "t1") && anonEvents.Has(anonServer, "t1")
|
||||
&& keyEvents.Has(onB, "t1") && keyEvents.Has(keyServer, "t1"));
|
||||
|
||||
// The next generation drops EqA's reference; the address space still wires the alarm into EqA.
|
||||
Deploy(state, "rev-2", grants, EqB);
|
||||
|
||||
rig.Project(native: true, State("t2"));
|
||||
await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, "t2") && keyEvents.Has(keyServer, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
anonEvents.Has(onA, "t2").ShouldBeFalse("the folder the generation dropped must not receive the alarm");
|
||||
anonEvents.Has(anonServer, "t2").ShouldBeFalse("nor may that folder's grant admit it through the Server object");
|
||||
|
||||
// ConditionRefresh on the stale folder respects the same rule; the still-referenced folder replays it.
|
||||
await RefreshAsync(anonymous, anonSub, ct);
|
||||
await RefreshAsync(keyed, keySub, ct);
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.HasType(onA, ObjectTypeIds.RefreshEndEventType)
|
||||
&& keyEvents.ConditionEvents(onB, "t2").Count == 2);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
anonEvents.Has(onA, "t2").ShouldBeFalse("a refresh must not replay the alarm to the dropped folder");
|
||||
anonEvents.Has(anonServer, "t2").ShouldBeFalse();
|
||||
|
||||
// Restoring the reference restores the feed on the next event — no client action, no stale cached deny.
|
||||
Deploy(state, "rev-3", grants, EqA, EqB);
|
||||
rig.Project(native: true, State("t3"));
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.Has(onA, "t3") && anonEvents.Has(anonServer, "t3") && keyEvents.Has(onB, "t3"));
|
||||
|
||||
await anonymous.CloseAsync(ct);
|
||||
await keyed.CloseAsync(ct);
|
||||
}
|
||||
|
||||
private static AlarmConditionSnapshot State(string message) =>
|
||||
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
|
||||
|
||||
private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) =>
|
||||
state.Apply(
|
||||
ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(),
|
||||
new Dictionary<string, IReadOnlySet<string>> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() });
|
||||
|
||||
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-key-{equipment}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = ServerEventRig.KeyPrincipal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
|
||||
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
||||
await session.CallAsync(
|
||||
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
}
|
||||
@@ -127,7 +127,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(
|
||||
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes());
|
||||
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes(),
|
||||
TagReferences());
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
|
||||
@@ -163,7 +164,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
PermissionFlags = permissions,
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
public static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
|
||||
@@ -195,6 +196,13 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
[EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The rig's deployed UNS references (Gitea #826): its native alarm's raw tag is projected into both equipment —
|
||||
/// the folders <see cref="MaterialiseNative"/> wires it into, so per-folder delivery of it is current.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferences() =>
|
||||
new Dictionary<string, IReadOnlySet<string>> { [RawAlarmPath] = new HashSet<string> { EqA, EqB } };
|
||||
|
||||
/// <summary>The rig's deployed scripted-alarm owners: its one scripted alarm belongs to <see cref="EqA"/>.</summary>
|
||||
public static IReadOnlyDictionary<string, string> ScriptedAlarmOwners() =>
|
||||
new Dictionary<string, string> { [ScriptedAlarmId] = EqA };
|
||||
|
||||
+109
-2
@@ -210,6 +210,65 @@ public sealed class UnsFolderScopeWireTests
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 — the same stale window seen by a DIRECT event item on the old owner's equipment folder, which the
|
||||
/// SDK feeds through its per-folder fan-out rather than the Server object. The old owner's folder subscription
|
||||
/// receives neither the moved alarm's live events nor its ConditionRefresh replay (EventId included), while its
|
||||
/// correctly placed alarm keeps arriving on the same item (positive control). After the rebuild re-materialises
|
||||
/// the alarm under its deployed owner, the new owner's folder subscription receives it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_moved_alarm_stops_reaching_its_old_folders_subscribers_until_the_rebuild_places_it()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns826FolderMoved", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
// The key holds the OLD owner (Pump); anonymous holds the NEW one (Fan). The generation already moved Alarm.
|
||||
Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan),
|
||||
EquipmentGrant(Pump, Operator), AnonymousGrant(NodeAclScopeKind.Equipment, Fan));
|
||||
|
||||
using var oldOwner = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var newOwner = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var oldEvents = new RecordedEvents();
|
||||
var oldSubscription = await RecordedEvents.SubscribeAsync(oldOwner, oldEvents, ct);
|
||||
var pumpFolder = RecordedEvents.AddEventItem(oldSubscription, Uns(oldOwner, Pump));
|
||||
await oldSubscription.ApplyChangesAsync(ct);
|
||||
pumpFolder.Status.Error.ShouldBeNull();
|
||||
var newEvents = new RecordedEvents();
|
||||
var newSubscription = await RecordedEvents.SubscribeAsync(newOwner, newEvents, ct);
|
||||
var fanFolder = RecordedEvents.AddEventItem(newSubscription, Uns(newOwner, Fan));
|
||||
await newSubscription.ApplyChangesAsync(ct);
|
||||
fanFolder.Status.Error.ShouldBeNull();
|
||||
|
||||
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
|
||||
Project(rig, Alarm, pre with { Message = "moved" }); // reported first: the control arrives after its verdict
|
||||
Project(rig, PumpLo, pre with { Message = "control" });
|
||||
await RecordedEvents.WaitUntilAsync(() => oldEvents.Has(pumpFolder, "control"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("the moved alarm must not reach its old owner's folder item");
|
||||
|
||||
// ConditionRefresh on the folder item replays the placed alarm and withholds the moved one.
|
||||
await RefreshAsync(oldOwner, oldSubscription, ct);
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
oldEvents.HasType(pumpFolder, ObjectTypeIds.RefreshEndEventType)
|
||||
&& oldEvents.ConditionEvents(pumpFolder, "control").Count == 2);
|
||||
oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("a refresh must not replay the moved alarm to the old folder");
|
||||
|
||||
// The rebuild places it under its deployed owner.
|
||||
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
Project(rig, Alarm, pre with { Message = "placed" });
|
||||
Project(rig, PumpLo, pre with { Message = "control-2" });
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
newEvents.Has(fanFolder, "placed") && oldEvents.Has(pumpFolder, "control-2"));
|
||||
oldEvents.Has(pumpFolder, "placed").ShouldBeFalse();
|
||||
|
||||
await oldOwner.CloseAsync(ct);
|
||||
await newOwner.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
|
||||
@@ -294,6 +353,34 @@ public sealed class UnsFolderScopeWireTests
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 (secondary edge of #808): an Area NAMED exactly like one of its equipment's opaque ids. That
|
||||
/// equipment's reference variable (<c>s=<AreaName>/Line/Equipment/Name</c>) then starts with the
|
||||
/// equipment's own id, which the id rule alone read as folder-owned — losing its Tag-grant match. The variable
|
||||
/// is now known to be a reference by the Organizes edge the reference pass wires to its raw node, so the Tag
|
||||
/// grant reaches it; the equipment folder itself stays denied to that grant (control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reference_variable_under_an_area_named_like_its_equipment_id_keeps_its_tag_grant()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns826AreaNamedLikeId", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
var raw = rig.EnsureRawVariable("flow", "Double", writable: false);
|
||||
var reference = $"{Pump}/Line1/Pump/Flow"; // the Area's NAME is Pump's id
|
||||
rig.Sink.EnsureVariable(reference, Pump, "Flow", "Double", writable: false, AddressSpaceRealm.Uns);
|
||||
rig.Sink.AddReference(reference, AddressSpaceRealm.Uns, raw, AddressSpaceRealm.Raw);
|
||||
Install(rig, pumpLine: Line1, Deployed(), areaName: Pump, tagArea: Pump, TagGrant());
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await ReadAsync(session, Uns(session, reference), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(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 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
|
||||
@@ -481,13 +568,19 @@ public sealed class UnsFolderScopeWireTests
|
||||
/// 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)
|
||||
params NodeAcl[] grants) =>
|
||||
Install(rig, pumpLine, deployedOwners, areaName, tagArea: "Area1", grants);
|
||||
|
||||
/// <summary>As above, with the Tag grant's UNS projection under the Area NAMED <paramref name="tagArea"/>.</summary>
|
||||
private static void Install(
|
||||
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, string areaName,
|
||||
string tagArea, 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"])],
|
||||
[new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, tagArea, "Line1", "Pump", "Flow"])],
|
||||
};
|
||||
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
|
||||
|
||||
@@ -535,6 +628,20 @@ public sealed class UnsFolderScopeWireTests
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeAcl AnonymousGrant(NodeAclScopeKind kind, string scopeId) => new()
|
||||
{
|
||||
NodeAclId = $"anon-{scopeId}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = OpcUaPrincipals.Anonymous,
|
||||
ScopeKind = kind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
|
||||
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
||||
await session.CallAsync(
|
||||
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
|
||||
private static List<AlarmCommand> Route(ServerEventRig rig)
|
||||
{
|
||||
var routed = new List<AlarmCommand>();
|
||||
|
||||
+25
@@ -312,4 +312,29 @@ public sealed class DeploymentArtifactScopePathTests
|
||||
|
||||
projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 — the projection says which equipment each raw tag is projected into, keyed on the RawPath the
|
||||
/// same resolver builds (a native alarm's <c>ConditionId</c>), so the node manager can tell a native alarm's
|
||||
/// still-referenced equipment folders from one whose reference the generation dropped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_projection_carries_each_raw_tags_referencing_equipment_under_its_raw_path()
|
||||
{
|
||||
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
||||
|
||||
const string rawPath = "Plant/Line1/ModbusDrv/dev1/Fast/Speed";
|
||||
projection.TagReferencingEquipment.Keys.ShouldBe([rawPath]);
|
||||
projection.TagReferencingEquipment[rawPath].ShouldBe(["EQ-mixer"]);
|
||||
|
||||
var snapshot = new DataPlaneAuthorityState().Apply(
|
||||
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths,
|
||||
projection.ScriptedAlarmOwners, projection.UnsFolderScopes, projection.TagReferencingEquipment);
|
||||
snapshot.IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeTrue();
|
||||
snapshot.IsTagReferencedBy(rawPath, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers");
|
||||
snapshot.IsTagReferencedBy(rawPath, "EQ-other").ShouldBeFalse();
|
||||
snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Other", "EQ-mixer").ShouldBeFalse();
|
||||
new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths)
|
||||
.IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -667,12 +667,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
|
||||
{
|
||||
Interlocked.Increment(ref _applies);
|
||||
return new DataPlaneAuthorityState().Apply(
|
||||
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners,
|
||||
unsFolderScopes);
|
||||
unsFolderScopes, tagReferencingEquipment);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user