From 8e1e73f93c2ba4be2fc91c0cc99f960d6392b6a4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:39:06 -0500 Subject: [PATCH 1/2] 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. --- .claude/rules/data-plane-auth.md | 30 ++- docs/security.md | 30 ++- .../Authorization/AclScopePathResolver.cs | 11 + .../Authorization/DataPlaneAuthority.cs | 59 ++++- .../OtOpcUaNodeManager.cs | 249 +++++++++++++++++- .../Drivers/DeploymentArtifact.cs | 37 ++- .../OpcUa/OpcUaPublishActor.cs | 3 +- .../AclEventSubscriptionEnforcementTests.cs | 14 +- .../NativeAlarmFolderEventAclWireTests.cs | 117 ++++++++ .../ServerEventRig.cs | 12 +- .../UnsFolderScopeWireTests.cs | 111 +++++++- .../DeploymentArtifactScopePathTests.cs | 25 ++ .../OpcUaPublishActorDeferredWriteTests.cs | 5 +- 13 files changed, 666 insertions(+), 37 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index feb04ccf3..221add473 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -121,11 +121,12 @@ Two things to know before relying on any of this: same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise - poison a real folder's cached verdict). ⚠️ The folder-owned rule keys on "first id segment == nearest slash-free - live UNS folder ancestor's id", which can only narrow (an Area named exactly like its equipment's id loses that - variable's Tag-grant match). ⚠️ `ConditionVisibilityScopes` returns the condition ALONE for a scripted condition, - so a moved alarm can't reach its old owner via the Server object; direct per-folder event items still ride the - SDK fan-out of the folder it hangs under (follow-up). A UNS node with NO live hierarchy-folder ancestor (rig-only + poison a real folder's cached verdict). The folder-owned rule keys on "first id segment == nearest slash-free + live UNS folder ancestor's id", EXCEPT for a variable tracked in `_unsReferenceVariables` (the source of a + UNS→Raw `Organizes` edge from `AddReference`), which is always a reference variable (#826 — an Area named exactly + like its equipment's id used to make its references folder-owned). ⚠️ `ConditionVisibilityScopes` returns the + condition ALONE for a scripted condition, so a moved alarm can't reach its old owner via the Server object — and + since #826 not via a direct folder item either (below). A UNS node with NO live hierarchy-folder ancestor (rig-only shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by `DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`) @@ -175,6 +176,25 @@ Two things to know before relying on any of this: **Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other event type fails closed. Auditing is off by default (never configured in this repo). + **Direct (per-folder) event items are filtered per event too (Gitea #826).** The SDK's `MonitoredNode2` fan-out + asks `INodeManager3.ValidateEventRolePermissions` per (event, item) on its own delivery task; the base method is + NOT virtual, so `OtOpcUaNodeManager` **re-implements `INodeManager3`** and adds `IsNotifierEventVisibleTo`: the + event's condition (snapshot handle, or an audit's SourceNode) must be the LIVE instance, the item's notifier must + still be a legitimate notifier of it (an ancestor, or a wired folder the CURRENT generation still projects its + RawPath into — `DataPlaneAuthoritySnapshot.TagReferencingEquipment`, from the artifact's `UnsTagReferences` via + `AclScopePathResolver.ResolveTagRawPath`), and `IsConditionVisibleTo(item, ConditionVisibilityScopes(c))` — the + Server-object rule, whose wired folders are ALSO generation-filtered now. ConditionRefresh on a direct item no + longer calls the base (it uses the non-virtual base check, which bypasses the re-implementation): `RefreshDirectItems` + mirrors the base loop plus the same decision. ⚠️ **The SDK memoizes that verdict per `(item, eventType, + SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one + `MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change: + the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and + `CompleteAddressSpaceApply`. Without it a cached allow survives the deploy (caught by the native wire test's + restore leg / `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ A hand-built + authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under + enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests: + `UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`, + `NativeAlarmFolderEventAclWireTests`. - **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`, `NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node, diff --git a/docs/security.md b/docs/security.md index be8288f35..00b3c2b77 100644 --- a/docs/security.md +++ b/docs/security.md @@ -600,9 +600,13 @@ and Write under old and new names until the address space re-points them. An id folder / virtual tag / condition, a probe, a NodeDeleted model change) is decided only by a `Cluster` or `uns` `Namespace` grant — so narrow grantees do not receive NodeDeleted for removed opaque-id nodes, and a removed id can never match an unrelated narrow grant that shares its name; a four-segment (reference-shaped) id keeps its split. -Identifiers containing NUL are refused. Server-object (`i=2253`) delivery of a +Identifiers containing NUL are refused. A UNS variable the reference pass wired to its raw node (the `Organizes` +edge) is placed as a reference variable whatever its id spells (Gitea #826 — the id rule alone made an equipment's +references folder-owned when an Area was named exactly like that equipment's id, losing their `Tag`-grant match). +Server-object (`i=2253`) delivery of a **scripted** condition is now decided on the condition alone (its folders add nothing in steady state and would -leak a moved alarm to its old owner). So a key holding only an `Equipment` grant can navigate to its equipment, +leak a moved alarm to its old owner). Direct event items on a folder get the same per-event decision (Gitea #826, +below). So a key holding only an `Equipment` grant can navigate to its equipment, read its alarm children, receive its scripted alarm events through the Server object and Acknowledge / Confirm / AddComment with the EventId it received. Fail-closed throughout: an unplaceable node is denied under every grant. | HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** | @@ -709,6 +713,28 @@ Behaviours worth knowing before enabling it: condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock. + **Direct (per-folder) event items are filtered per event too** (Gitea #826). An item on an equipment or raw + device folder is fed by the SDK's own per-notifier fan-out (`MonitoredNode2`), which the #586 dispatcher never + touches — so after a deployment 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, and + indefinitely after a failed apply (commands were already denied). With the flag **on**, each event to such an + item now also needs, against the CURRENT state: + - the event's condition to be the **live** instance (a torn-down one is withheld); + - the folder to still be a legitimate notifier of it — a node it hangs beneath, or a folder it is wired into + **that the current generation still projects its raw tag into** (`DataPlaneAuthoritySnapshot.TagReferencingEquipment`, + `RawPath → EquipmentIds`, decoded from the artifact's `UnsTagReferences` by the same resolver as the grants); + - and the condition to pass exactly the Server-object rule above for the item's session. + + The generation check also narrows the **Server-object** rule: a wired folder whose reference the generation + dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the + same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the + base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node + manager invalidates it on every new generation or authorizer, on each `AclRevalidationTimer` tick, and at the + end of every apply. Withheld events are not replayed and the client is not told; restoring the reference or + grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under + enforcement only while the installed generation carries that reference — a hand-built authority (tests) must + supply `tagReferencingEquipment`. Inert with the flag off. + **Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the `AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs index ea473172d..086d2a719 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs @@ -231,6 +231,17 @@ public sealed class AclScopePathResolver } } + /// + /// The RawPath of raw tag — the identifier its raw NodeId (and a native alarm on it, as + /// ConditionId) carries — or null when its chain does not resolve (Gitea #826). + /// + /// The raw tag's logical id. + /// The RawPath, or null. + public string? ResolveTagRawPath(string tagId) => + !string.IsNullOrEmpty(tagId) && _tags.TryGetValue(tagId, out var tag) + ? _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) + : null; + private IReadOnlyList ResolveTag(string tagId) { var paths = new List(); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index e6aa7e761..57a7f20fc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -37,7 +37,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary> grantsByPrincipal, IReadOnlyDictionary equipmentScopePaths, IReadOnlyDictionary scriptedAlarmOwners, - IReadOnlyDictionary unsFolderScopes) + IReadOnlyDictionary unsFolderScopes, + IReadOnlyDictionary> tagReferencingEquipment) { ClusterId = clusterId; RevisionHash = revisionHash; @@ -51,6 +52,7 @@ public sealed class DataPlaneAuthoritySnapshot EquipmentScopePaths = equipmentScopePaths; ScriptedAlarmOwners = scriptedAlarmOwners; UnsFolderScopes = unsFolderScopes; + TagReferencingEquipment = tagReferencingEquipment; } private readonly IReadOnlyDictionary _keysById; @@ -160,6 +162,31 @@ public sealed class DataPlaneAuthoritySnapshot /// public IReadOnlyDictionary UnsFolderScopes { get; } + /// + /// RawPath → the EquipmentIds whose UnsTagReference projects that raw tag, as THIS generation deploys + /// them (Gitea #826). Keyed Ordinal (a RawPath is a NodeId identifier); the id sets are + /// OrdinalIgnoreCase (identifier rule). + /// + /// + /// A native alarm materialises once at its raw tag and is wired as an extra event notifier into every + /// referencing equipment's folder. The generation is installed BEFORE its address space is applied, and an + /// apply that throws keeps the old one, so a dropped reference leaves the alarm wired into an equipment folder + /// this generation no longer projects it into. The node manager admits a wired folder only while this map + /// agrees. An entry confers nothing; an absent RawPath means no equipment references it. + /// + public IReadOnlyDictionary> TagReferencingEquipment { get; } + + /// Whether this generation projects the raw tag at into + /// (Gitea #826). + /// The raw tag's RawPath (a native condition's ConditionId). + /// The equipment (its folder's s= identifier). + /// when a reference of this generation connects the two. + public bool IsTagReferencedBy(string rawPath, string equipmentId) => + !string.IsNullOrEmpty(rawPath) + && !string.IsNullOrEmpty(equipmentId) + && TagReferencingEquipment.TryGetValue(rawPath, out var equipment) + && equipment.Contains(equipmentId); + /// /// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder , /// provided the live folder's ancestry is exactly the one this generation @@ -279,6 +306,8 @@ public sealed class DataPlaneAuthoritySnapshot /// (see ); null ⇒ none. /// Every UNS folder's path + id chain (see ); /// null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds (see ); + /// null ⇒ none, so no native alarm reaches an equipment folder's event items under enforcement. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -290,7 +319,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyList? droppedGrants = null, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -359,9 +389,21 @@ public sealed class DataPlaneAuthoritySnapshot } } + var referencing = new Dictionary>(StringComparer.Ordinal); + if (tagReferencingEquipment is not null) + { + foreach (var (rawPath, equipmentIds) in tagReferencingEquipment) + { + if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue; + // Re-keyed under the identifier comparer, as the maps above are. + referencing[rawPath] = new HashSet( + equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + } + } + return new DataPlaneAuthoritySnapshot( clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen, - equipment, owners, folders); + equipment, owners, folders, referencing); } } @@ -446,6 +488,9 @@ public interface IDataPlaneAuthoritySink /// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement. /// Every UNS Area/Line/Equipment folder's path + id chain from the same /// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds from the same artifact's + /// UnsTagReferences (Gitea #826). Null ⇒ none, so no native alarm reaches an equipment folder's event items + /// under enforcement. /// The installed snapshot. DataPlaneAuthoritySnapshot Apply( string? clusterId, @@ -455,7 +500,8 @@ public interface IDataPlaneAuthoritySink IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null); + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null); } /// @@ -506,7 +552,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); @@ -540,7 +587,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl published = DataPlaneAuthoritySnapshot.Create( clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths, - scriptedAlarmOwners, unsFolderScopes); + scriptedAlarmOwners, unsFolderScopes, tagReferencingEquipment); Volatile.Write(ref _current, published); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 4b13d3931..525d06976 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -39,7 +39,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// the legacy EquipmentNodeWalker server-side integration was retired in favour of the /// (composer → applier → sink → node-manager) chain. /// -public sealed class OtOpcUaNodeManager : CustomNodeManager2 +// INodeManager3 is RE-IMPLEMENTED (Gitea #826): the SDK's per-folder event delivery (MonitoredNode2) asks +// INodeManager3.ValidateEventRolePermissions for every event and item, and the base implementation is not virtual, so +// re-implementing the interface is the only way to put the per-event ACL decision on that path. Every other member +// maps to the inherited implementation exactly as before. +public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { /// v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are /// registered with the SDK so nodes can be minted in either. is @@ -57,6 +61,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri; private readonly ConcurrentDictionary _variables = new(); + + /// + /// UNS variables PROVEN to be UNS reference variables — the source of a forward Organizes edge into the raw + /// namespace (), which only the applier's reference pass wires. Gitea #826: the + /// reference-vs-folder-owned decision in no longer rests on the id rule alone + /// (an Area named exactly like its equipment's id made that equipment's references look folder-owned). Holds the + /// instance, so a stale entry for a replaced node never matches; cleared with . + /// + private readonly ConcurrentDictionary _unsReferenceVariables = new(); private readonly ConcurrentDictionary _folders = new(); private readonly ConcurrentDictionary _alarmConditions = new(); /// H6a: the subset of node ids materialised as NATIVE @@ -567,6 +580,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // walking its own dead item map on each deploy. previous.Changed -= OnAuthorityChanged; next.Changed += OnAuthorityChanged; + InvalidateFolderEventVerdicts(); } } @@ -641,6 +655,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // which only ever DENIES narrow grants, so a miss would read as working enforcement). if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject; _authorizer = value; + InvalidateFolderEventVerdicts(); } } @@ -3604,6 +3619,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false); if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false); + + // Gitea #826: a UNS variable organizing a raw node IS a UNS reference variable — recorded so the ACL + // placement can say so explicitly instead of inferring it from the id's first segment. + if (sourceRealm == AddressSpaceRealm.Uns && targetRealm == AddressSpaceRealm.Raw + && refType == ReferenceTypeIds.Organizes + && source is BaseDataVariableState variable + && _variables.TryGetValue(sourceKey, out var live) && ReferenceEquals(live, variable)) + { + _unsReferenceVariables[sourceKey] = variable; + } } } @@ -3851,6 +3876,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId); _variables.Clear(); + _unsReferenceVariables.Clear(); // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. _historizedTagnames.Clear(); _arrayOnScalarRefused.Clear(); @@ -3898,6 +3924,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public void CompleteAddressSpaceApply() { + // Gitea #826: the apply moved conditions, wiring and folders, and the SDK memoizes each folder item's per-event + // verdict per (item, event type, condition) until told otherwise — forget them before the restored reports + // below go out, so the next event on every folder item is decided against the address space as applied. + InvalidateFolderEventVerdicts(); int reported = 0, discarded; lock (Lock) { @@ -3938,6 +3968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + _unsReferenceVariables.TryRemove(key, out _); // Conservatively invalidate every outstanding history cursor on topology removal. Interlocked.Increment(ref _historyContinuationEpoch); UnindexChild(key); @@ -4105,6 +4136,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return; } + _unsReferenceVariables.TryRemove(key, out _); if (_variables.TryRemove(key, out var v)) { UnindexChild(key); @@ -4301,6 +4333,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the // current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's // variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them). + // Gitea #826: a variable the reference pass wired to its raw node is a reference variable whatever its id + // spells — so an Area named exactly like its equipment's id no longer turns that equipment's references + // folder-owned (which only ever narrowed: it lost their Tag-grant match). Untracked nodes keep the id rule. + if (node is BaseDataVariableState v + && _unsReferenceVariables.TryGetValue(key, out var reference) && ReferenceEquals(reference, v)) + { + return UnsAclSubject.ReferenceVariable(chain); + } + return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString) ? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning)) : UnsAclSubject.ReferenceVariable(chain); @@ -5478,13 +5519,174 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring) && ReferenceEquals(wiring.Alarm, condition)) { - scopes.AddRange(wiring.Folders.Select(f => f.NodeId)); + // Gitea #826: only a folder THIS generation still projects the alarm's raw tag into. A dropped + // reference leaves the wiring in place until the rebuild lands (or indefinitely after a failed apply), + // and that folder's grant must stop admitting the alarm the moment the generation drops it. + var authority = DataPlaneAuthority.Current; + foreach (var folder in wiring.Folders) + { + if (IsWiredFolderCurrent(authority, condition, folder)) scopes.Add(folder.NodeId); + } } } return scopes; } + /// + /// Whether , wired as an extra notifier of the native , is + /// one the current generation still projects the condition's raw tag into (Gitea #826). + /// + /// The current generation. + /// A native condition (ConditionId = RawPath). + /// A folder wired for it. + /// when a reference of the generation connects them. + /// Only UNS equipment folders are ever wired; anything else is not a current wiring (fail closed). + private bool IsWiredFolderCurrent(DataPlaneAuthoritySnapshot authority, NodeState condition, FolderState folder) => + folder.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns) + && folder.NodeId.TryGetValue(out string equipmentId) + && condition.NodeId.TryGetValue(out string rawPath) + && authority.IsTagReferencedBy(rawPath, equipmentId); + + /// + /// The per-event decision for an event item on one of this manager's own notifiers (an equipment folder, a raw + /// device folder) — the SDK fan-out path the Server-object filtering (#586) does not reach (Gitea #826). + /// + /// The event item; its manager handle names the notifier it is attached to. + /// The event as the SDK delivers it (a condition snapshot, or a cloned audit event). + /// when the item's session may receive it through that notifier. + /// + /// + /// Three conditions, all against the CURRENT state: the event's condition is the LIVE instance (a torn-down + /// one denies); the notifier is still a legitimate notifier of it — the condition itself, a node it hangs + /// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is + /// visible to the session under exactly the Server-object rule ( over + /// ), so a moved scripted alarm is withheld from its old folder just + /// as it is from the Server object. + /// + /// + /// An event that names no live condition of this manager is withheld (fail closed): only condition events + /// and the condition's own method audits reach these notifiers. + /// + /// + /// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes + /// Lock (via GetNodeMetadata); the scope resolution here takes it the same way. + /// + /// + private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e) + { + if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false; + if (EventConditionOf(e) is not { } condition) return false; + if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) || !ReferenceEquals(live, condition)) + return false; + if (!IsLiveNotifierOf(live, notifier)) return false; + return IsConditionVisibleTo(item, ConditionVisibilityScopes(live)); + } + + /// The condition an event delivered through a notifier is about, or null. + /// The event: a condition's snapshot, the condition itself, or an event whose SourceNode names one. + /// The condition state, or null. + private ConditionState? EventConditionOf(IFilterTarget e) + { + var node = ConditionOf(e); + if (node is ConditionState condition) return condition; + + // A condition's Part 9 method audit (cloned by the SDK on its way through a notifier): its SourceNode is the + // condition. + return node is BaseEventState { SourceNode.Value: { IsNull: false } source } + && OwnsNamespace(source) + && _alarmConditions.TryGetValue(MapKey(source), out var named) + ? named + : null; + } + + /// Whether is, right now, a node the condition's events legitimately bubble + /// through (Gitea #826). + /// The live condition. + /// The node the event item is attached to. + /// for the condition itself, a node above it, or a wired folder the current + /// generation still projects it into. + private bool IsLiveNotifierOf(AlarmConditionState condition, NodeState notifier) + { + if (ReferenceEquals(notifier, condition)) return true; + + lock (Lock) + { + for (var ancestor = (condition as BaseInstanceState)?.Parent; + ancestor is not null; + ancestor = (ancestor as BaseInstanceState)?.Parent) + { + if (ReferenceEquals(ancestor, notifier)) return true; + } + + return notifier is FolderState folder + && _alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring) + && ReferenceEquals(wiring.Alarm, condition) + && wiring.Folders.Contains(folder) + && IsWiredFolderCurrent(DataPlaneAuthority.Current, condition, folder); + } + } + + /// + /// Validates whether an event item may receive an event: the SDK's role-permission check, and — with enforcement + /// on, for an item on one of this manager's own nodes — (Gitea #826). + /// + /// The event item. + /// The event. + /// Good to deliver; otherwise the veto. + /// + /// + /// The SDK's MonitoredNode2 asks this, per event and per item, before it queues an event to an item + /// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide + /// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm + /// whose reference was dropped) the moment the generation says so, not when the rebuild lands. + /// + /// + /// ⚠️ The SDK MEMOIZES the verdict per (item, event type, SourceNode) until the monitored node's + /// permission generation moves, so must run whenever the inputs + /// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A + /// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions. + /// + /// + ServiceResult INodeManager3.ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget) + { + var result = ValidateEventRolePermissions(monitoredItem, filterTarget); + if (ServiceResult.IsBad(result) || !AclEnforced || !OwnsNamespace(monitoredItem.NodeId)) return result; + return IsNotifierEventVisibleTo(monitoredItem, filterTarget) ? result : FolderEventDenied; + } + + /// The verdict a withheld folder event carries back to the SDK (never reaches the client). + private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied); + + /// + /// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826), + /// so the next event on each is decided afresh by . + /// + /// + /// MonitoredNode2.InvalidatePermissionCacheForSession is the SDK's only public lever: it advances that + /// node's permission generation (a verdict computed concurrently under the old one is recomputed, not stored) + /// and drops the session's entries. Cost is one call per (notifier, session) pair — never per condition. The + /// node list is copied under Lock; the invalidation itself takes no lock of ours. + /// + private void InvalidateFolderEventVerdicts() + { + if (_root is null) return; + + List nodes; + lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList(); + + foreach (var node in nodes) + { + var sessions = new HashSet(); + foreach (var item in node.EventMonitoredItems.Values) + { + if (item?.Session?.Id is { IsNull: false } id) sessions.Add(id); + } + + foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session); + } + } + /// Whether 's session may see a condition with the given scopes. /// The Server-object event item. /// From . @@ -5530,10 +5732,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 (item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item); } - if (direct.Count > 0) base.ConditionRefresh(context, direct); + var systemContext = SystemContext.Copy(context); + if (direct.Count > 0) RefreshDirectItems(systemContext, direct); if (serverItems.Count == 0) return ServiceResult.Good; - var systemContext = SystemContext.Copy(context); List notifiers; lock (Lock) notifiers = RootNotifiers?.ToList() ?? []; @@ -5562,6 +5764,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return ServiceResult.Good; } + /// + /// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK + /// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not + /// refreshed — plus the per-event decision live delivery makes (), so a + /// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold. + /// + /// The refresh's system context. + /// The direct (non-Server-object) items being refreshed. + private void RefreshDirectItems(ServerSystemContext systemContext, List items) + { + foreach (var item in items) + { + NodeState? notifier; + lock (Lock) + { + if (!MonitoredItems.ContainsKey(item.Id)) continue; + notifier = (item.ManagerHandle as NodeHandle)?.Node; + } + + if (notifier is null) continue; + + var events = new List(); + notifier.ConditionRefresh(systemContext, events, true); + foreach (var e in events) + { + if (!IsNotifierEventVisibleTo(item, e)) continue; + if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; + item.QueueEvent(e); + } + } + } + /// The condition a refresh event describes, or null when it names none (withheld: fail closed). /// A refresh event. /// The condition node. @@ -5663,7 +5897,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public void RevalidateMonitoredItems() { - if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return; + if (!AclEnforced) return; + + // Gitea #826: both triggers (a deploy, the clock) also reach the per-event decision on folder event items, whose + // verdicts the SDK memoizes — a deploy that moves an alarm must not keep serving its old folder's allow. + InvalidateFolderEventVerdicts(); + if (_liveItems.IsEmpty && _eventItems.IsEmpty) return; var now = UtcNow; var stoodDown = 0; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d4c6e9d33..d287bb9ef 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -93,6 +93,16 @@ public sealed record DataPlaneAuthorityProjection( public IReadOnlyDictionary UnsFolderScopes { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// + /// RawPath → EquipmentIds from the artifact's UnsTagReferences (Gitea #826): which equipment + /// folders this generation projects each raw tag into — the folders a native alarm on that tag may be + /// delivered through. RawPaths come from the SAME as + /// , so they equal the native conditions' ConditionIds by construction. Empty + /// when the artifact carries no grants. + /// + public IReadOnlyDictionary> TagReferencingEquipment { get; init; } = + new Dictionary>(StringComparer.Ordinal); + /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. public static readonly DataPlaneAuthorityProjection Empty = new(null, [], [], new Dictionary>()); @@ -938,7 +948,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable grants.Add(acl); } - var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants); + var (scopePaths, equipmentScopePaths, unsFolderScopes, tagReferencingEquipment) = BuildAclScopePaths(root, grants); // Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of // the grant count: it is a topology fact, cheap, and an absent map only ever denies. var scriptedAlarmOwners = new Dictionary(StringComparer.Ordinal); @@ -955,6 +965,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable EquipmentScopePaths = equipmentScopePaths, ScriptedAlarmOwners = scriptedAlarmOwners, UnsFolderScopes = unsFolderScopes, + TagReferencingEquipment = tagReferencingEquipment, }; } @@ -964,10 +975,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable /// path and the RawPath that forms a node's NodeId cannot drift apart. /// /// The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup, - /// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver. + /// Gitea #797), every UNS folder's path + id chain (Gitea #808) and every referenced raw tag's referencing + /// equipment (Gitea #826) from the same resolver. private static (IReadOnlyDictionary> ScopePaths, IReadOnlyDictionary EquipmentScopePaths, - IReadOnlyDictionary UnsFolderScopes) BuildAclScopePaths( + IReadOnlyDictionary UnsFolderScopes, + IReadOnlyDictionary> TagReferencingEquipment) BuildAclScopePaths( JsonElement root, IReadOnlyList grants) { @@ -975,7 +988,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable { return (new Dictionary>(), new Dictionary(StringComparer.OrdinalIgnoreCase), - new Dictionary(StringComparer.OrdinalIgnoreCase)); + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary>(StringComparer.Ordinal)); } var folders = new Dictionary(StringComparer.Ordinal); @@ -1077,7 +1091,20 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; } - return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment)); + // Which equipment folders each raw tag is projected into (Gitea #826) — the folders a native alarm on that tag + // may legitimately be delivered through. Keyed on the RawPath the SAME resolver builds, which is the native + // condition's ConditionId. + var referencing = new Dictionary>(StringComparer.Ordinal); + foreach (var reference in references) + { + if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue; + if (!referencing.TryGetValue(rawPath, out var set)) + referencing[rawPath] = set = new HashSet(StringComparer.OrdinalIgnoreCase); + set.Add(reference.EquipmentId); + } + + return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment), + referencing.ToDictionary(kv => kv.Key, kv => (IReadOnlySet)kv.Value, StringComparer.Ordinal)); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 2165144ef..f90d229c4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -1426,7 +1426,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes)); var snapshot = _dataPlaneAuthority.Apply( projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths, - projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); + projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes, + projection.TagReferencingEquipment); _log.Info( "OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs index f1e046d24..79f3be8eb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs @@ -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( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs new file mode 100644 index 000000000..0636d6ece --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs @@ -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; + +/// +/// 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. +/// +public sealed class NativeAlarmFolderEventAclWireTests +{ + private const string EqA = ServerEventRig.EqA; + private const string EqB = ServerEventRig.EqB; + + /// + /// 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. + /// + [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> { [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); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index d3a4af131..44ae8279e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -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> ScopePaths() => + public static IReadOnlyDictionary> ScopePaths() => new Dictionary> { [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]), }; + /// + /// The rig's deployed UNS references (Gitea #826): its native alarm's raw tag is projected into both equipment — + /// the folders wires it into, so per-folder delivery of it is current. + /// + public static IReadOnlyDictionary> TagReferences() => + new Dictionary> { [RawAlarmPath] = new HashSet { EqA, EqB } }; + /// The rig's deployed scripted-alarm owners: its one scripted alarm belongs to . public static IReadOnlyDictionary ScriptedAlarmOwners() => new Dictionary { [ScriptedAlarmId] = EqA }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs index 0b004a00d..437123188 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs @@ -210,6 +210,65 @@ public sealed class UnsFolderScopeWireTests await session.CloseAsync(ct); } + /// + /// 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. + /// + [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); + } + /// /// 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); } + /// + /// Gitea #826 (secondary edge of #808): an Area NAMED exactly like one of its equipment's opaque ids. That + /// equipment's reference variable (s=<AreaName>/Line/Equipment/Name) 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). + /// + [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); + } + /// /// Review of #808 (cache poisoning): the resolved-scope cache keys are NUL-prefixed, so a client that first /// Reads the nonexistent s="\0Area1\0Line1\0Pump" — 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 Area1). private static void Install( ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, - params NodeAcl[] grants) + params NodeAcl[] grants) => + Install(rig, pumpLine, deployedOwners, areaName, tagArea: "Area1", grants); + + /// As above, with the Tag grant's UNS projection under the Area NAMED . + private static void Install( + ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, + string tagArea, params NodeAcl[] grants) { var resolver = Resolver(pumpLine, areaName); var scopePaths = new Dictionary>(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 Route(ServerEventRig rig) { var routed = new List(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs index 7f87d136d..b8df2b82e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs @@ -312,4 +312,29 @@ public sealed class DeploymentArtifactScopePathTests projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]); } + + /// + /// 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 ConditionId), so the node manager can tell a native alarm's + /// still-referenced equipment folders from one whose reference the generation dropped. + /// + [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)"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index b08d5bafd..9179d3152 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -667,12 +667,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { Interlocked.Increment(ref _applies); return new DataPlaneAuthorityState().Apply( clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners, - unsFolderScopes); + unsFolderScopes, tagReferencingEquipment); } } From 489664d105f5bbb25be15953f410883e2c518a12 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 02:25:24 -0500 Subject: [PATCH 2/2] fix(acl): folder event verdict invalidation reaches orphaned items, decides on the live instance, skips ACL-off; alarm-bearing reference map (#826 review) 1. Orphaned subscriptions (session closed without deleting subs) have Session == null, so the public MonitoredNode2.InvalidatePermissionCacheForSession never reached their items: a cached allow survived a deploy, the moved alarm's events were queued and delivered on TransferSubscriptions. Each notifier's cache is now cleared whole through the SDK's private MonitoredNode2.InvalidatePermissionCaches (reflection, guard-tested; fallback = per-session drop + Remove/Add of session-less items). 2. The verdict is decided on the condition LIVE under the event's NodeId, not the event's own instance: the SDK key has no instance identity, so a replaced instance's late event cached a deny its successor inherited until the next invalidation. 3. Invalidation is skipped while enforcement is off (forced across an on/off authorizer transition) and is one O(cache) clear per node instead of a whole-cache scan per item. 4. TagReferencingEquipment carries alarm-bearing raw tags only (TagConfigIntent.Alarm); the snapshot adopts an OrdinalIgnoreCase set instead of copying it again. Tests (each fails with its fix reverted): FolderEventVerdictInvalidationTests (orphan + deploy + transfer; replaced instance's late event; ACL-off skip; SDK private-method guard), DeploymentArtifactScopePathTests (alarm-bearing projection). --- .claude/rules/data-plane-auth.md | 13 +- docs/security.md | 7 +- .../Authorization/DataPlaneAuthority.cs | 13 +- .../OtOpcUaNodeManager.cs | 88 ++++++-- .../Drivers/DeploymentArtifact.cs | 22 +- .../FolderEventVerdictInvalidationTests.cs | 188 ++++++++++++++++++ .../DeploymentArtifactScopePathTests.cs | 61 ++++-- 7 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 221add473..ae4301f88 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -189,8 +189,17 @@ Two things to know before relying on any of this: SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one `MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change: the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and - `CompleteAddressSpaceApply`. Without it a cached allow survives the deploy (caught by the native wire test's - restore leg / `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ A hand-built + `CompleteAddressSpaceApply` — and is SKIPPED while enforcement is off (the authorizer setter forces it across an + on/off transition). Without it a cached allow survives the deploy (caught by the native wire test's restore leg / + `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ (review) It clears each node's + cache WHOLE through the SDK's private `MonitoredNode2.InvalidatePermissionCaches` (reflection, pinned by + `FolderEventVerdictInvalidationTests.The_sdks_node_wide…`): the public `InvalidatePermissionCacheForSession` + misses ORPHANED subscriptions (session closed without deleting subs ⇒ `Session` null), which then queued a moved + alarm's events and delivered them on `TransferSubscriptions`, and rescanned the cache per item. Fallback if the SDK + drops it: per-session drop + `Remove`/`Add` of each session-less item. ⚠️ The verdict is decided on the LIVE + instance holding the event's condition NodeId, never the event's own instance — the SDK key has no instance + identity, so denying a replaced instance's late event cached a deny the new instance inherited. + `TagReferencingEquipment` holds ALARM-BEARING tags only (`TagConfigIntent.Alarm`). ⚠️ A hand-built authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests: `UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`, diff --git a/docs/security.md b/docs/security.md index 00b3c2b77..30fe71689 100644 --- a/docs/security.md +++ b/docs/security.md @@ -729,8 +729,11 @@ Behaviours worth knowing before enabling it: dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node - manager invalidates it on every new generation or authorizer, on each `AclRevalidationTimer` tick, and at the - end of every apply. Withheld events are not replayed and the client is not told; restoring the reference or + manager clears each notifier's cache whole (sessions and orphaned, session-less subscriptions alike — an orphaned + subscription must not queue a moved alarm's events for a later `TransferSubscriptions`) on every new generation or + authorizer, on each `AclRevalidationTimer` tick, and at the end of every apply, and not at all while enforcement is + off. A verdict is decided on the condition currently live under the event's NodeId, so a replaced instance's late + event cannot leave a deny behind for its successor. Only alarm-bearing raw tags are carried in the reference map. Withheld events are not replayed and the client is not told; restoring the reference or grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under enforcement only while the installed generation carries that reference — a hand-built authority (tests) must supply `tagReferencingEquipment`. Inert with the flag off. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index 57a7f20fc..dc4a03bd2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -163,7 +163,7 @@ public sealed class DataPlaneAuthoritySnapshot public IReadOnlyDictionary UnsFolderScopes { get; } /// - /// RawPath → the EquipmentIds whose UnsTagReference projects that raw tag, as THIS generation deploys + /// RawPath → the EquipmentIds whose UnsTagReference projects that ALARM-BEARING raw tag, as THIS generation deploys /// them (Gitea #826). Keyed Ordinal (a RawPath is a NodeId identifier); the id sets are /// OrdinalIgnoreCase (identifier rule). /// @@ -395,9 +395,14 @@ public sealed class DataPlaneAuthoritySnapshot foreach (var (rawPath, equipmentIds) in tagReferencingEquipment) { if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue; - // Re-keyed under the identifier comparer, as the maps above are. - referencing[rawPath] = new HashSet( - equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + // Re-keyed under the identifier comparer, as the maps above are — but a set already built under it + // (what DeploymentArtifact produces) is adopted rather than copied a second time. + referencing[rawPath] = equipmentIds is HashSet set + && ReferenceEquals(set.Comparer, StringComparer.OrdinalIgnoreCase) + && !set.Any(string.IsNullOrWhiteSpace) + ? set + : new HashSet( + equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 525d06976..62489d97c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -654,8 +654,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // there is no second wiring that could silently not run (an unattached resolver splits opaque ids, // which only ever DENIES narrow grants, so a miss would read as working enforcement). if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject; + var wasEnforced = AclEnforced; _authorizer = value; - InvalidateFolderEventVerdicts(); + // Across an on/off transition the cached verdicts were decided under the other mode. + InvalidateFolderEventVerdicts(force: wasEnforced || AclEnforced); } } @@ -5557,8 +5559,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// when the item's session may receive it through that notifier. /// /// - /// Three conditions, all against the CURRENT state: the event's condition is the LIVE instance (a torn-down - /// one denies); the notifier is still a legitimate notifier of it — the condition itself, a node it hangs + /// Three conditions, all against the CURRENT state: a live condition holds the event's condition NodeId; + /// the notifier is still a legitimate notifier of THAT live instance — the condition itself, a node it hangs /// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is /// visible to the session under exactly the Server-object rule ( over /// ), so a moved scripted alarm is withheld from its old folder just @@ -5569,6 +5571,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// and the condition's own method audits reach these notifiers. /// /// + /// ⚠️ Decided on the live instance, never on the event's own (review of #826). The SDK caches the + /// verdict per (item, event type, SourceNode) — no instance identity — so a verdict must be a + /// function of the condition's NodeId and the current state alone. Denying an event because it came from a + /// REPLACED instance (a surgical remove + re-materialise under the same NodeId, the old instance's last + /// event still in the SDK's queue) cached a deny the new instance then inherited until the next + /// invalidation. A late event from a replaced instance is therefore delivered exactly when the live one's + /// would be. + /// + /// /// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes /// Lock (via GetNodeMetadata); the scope resolution here takes it the same way. /// @@ -5577,8 +5588,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false; if (EventConditionOf(e) is not { } condition) return false; - if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) || !ReferenceEquals(live, condition)) - return false; + if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live)) return false; if (!IsLiveNotifierOf(live, notifier)) return false; return IsConditionVisibleTo(item, ConditionVisibilityScopes(live)); } @@ -5662,31 +5672,85 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826), /// so the next event on each is decided afresh by . /// + /// Run even while enforcement is off — the authorizer setter's on/off transition, where the + /// cached verdicts were decided under the other mode. /// - /// MonitoredNode2.InvalidatePermissionCacheForSession is the SDK's only public lever: it advances that - /// node's permission generation (a verdict computed concurrently under the old one is recomputed, not stored) - /// and drops the session's entries. Cost is one call per (notifier, session) pair — never per condition. The - /// node list is copied under Lock; the invalidation itself takes no lock of ours. + /// + /// Node-wide, session-agnostic. Each notifier's cache is cleared whole through the SDK's own private + /// MonitoredNode2.InvalidatePermissionCaches (what the SDK runs when namespace default permissions + /// change, and on Rebind): it advances the node's permission generation — a verdict computed + /// concurrently under the old one is recomputed, never stored — and clears the cache in one O(cache) pass. + /// The public InvalidatePermissionCacheForSession is NOT sufficient (review of #826): it reaches only + /// items whose Session is set, and an ORPHANED subscription's items (session closed without deleting + /// its subscriptions) have none — they kept a stale allow through a deploy, queued the moved alarm's events + /// and delivered them on TransferSubscriptions. It also rescanned the whole cache once per item + /// (items² × conditions per folder). + /// + /// + /// The private method is resolved once by reflection against the pinned SDK and pinned by + /// FolderEventVerdictInvalidationGuardTests. Should a future SDK drop it, this falls back to the + /// supported route: the per-session drop for items with a session, and a detach + re-attach of each + /// session-less item on its node (Remove drops that item's entries), which can miss an event racing + /// the swap — the reason it is only the fallback. + /// + /// + /// Skipped while enforcement is off ( aside): the verdicts are then the SDK's own + /// role check, which nothing here changes. The node list is copied under Lock; the invalidation + /// takes no lock of ours. + /// /// - private void InvalidateFolderEventVerdicts() + private void InvalidateFolderEventVerdicts(bool force = false) { - if (_root is null) return; + if (_root is null || (!force && !AclEnforced)) return; List nodes; lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList(); foreach (var node in nodes) { + Interlocked.Increment(ref _folderEventVerdictInvalidations); + if (InvalidateNodePermissionCaches is { } invalidate) + { + invalidate(node); + continue; + } + var sessions = new HashSet(); + var sessionless = new List(); foreach (var item in node.EventMonitoredItems.Values) { - if (item?.Session?.Id is { IsNull: false } id) sessions.Add(id); + if (item is null) continue; + if (item.Session?.Id is { IsNull: false } id) sessions.Add(id); + else sessionless.Add(item); } foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session); + foreach (var item in sessionless) + { + node.Remove(item); + node.Add(item); + } } } + /// Notifier caches has invalidated since start. Test + /// observability (it must not run while enforcement is off). + internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations); + + private long _folderEventVerdictInvalidations; + + /// + /// The SDK's private node-wide cache invalidation (MonitoredNode2.InvalidatePermissionCaches), or null + /// when the SDK no longer has it — see . + /// + internal static readonly Action? InvalidateNodePermissionCaches = + typeof(MonitoredNode2).GetMethod( + "InvalidatePermissionCaches", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + Type.EmptyTypes) is { ReturnType: var returns } method && returns == typeof(void) + ? method.CreateDelegate>() + : null; + /// Whether 's session may see a condition with the given scopes. /// The Server-object event item. /// From . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d287bb9ef..882d3d408 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -95,7 +95,7 @@ public sealed record DataPlaneAuthorityProjection( /// /// RawPath → EquipmentIds from the artifact's UnsTagReferences (Gitea #826): which equipment - /// folders this generation projects each raw tag into — the folders a native alarm on that tag may be + /// folders this generation projects each ALARM-BEARING raw tag (TagConfig.alarm) into — the folders a native alarm on that tag may be /// delivered through. RawPaths come from the SAME as /// , so they equal the native conditions' ConditionIds by construction. Empty /// when the artifact carries no grants. @@ -1027,12 +1027,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable } var tags = new Dictionary(StringComparer.Ordinal); + // TagConfig kept unparsed: only a REFERENCED tag's is parsed below (Gitea #826 — alarm-bearing ones only). + var tagConfigs = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Tags")) { var id = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); if (id is null || deviceId is null) continue; tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id); + tagConfigs[id] = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String + ? tcEl.GetString() + : null; } var areas = new Dictionary(StringComparer.Ordinal); @@ -1091,12 +1096,21 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; } - // Which equipment folders each raw tag is projected into (Gitea #826) — the folders a native alarm on that tag - // may legitimately be delivered through. Keyed on the RawPath the SAME resolver builds, which is the native - // condition's ConditionId. + // Which equipment folders each ALARM-BEARING raw tag is projected into (Gitea #826) — the folders a native alarm + // on that tag may legitimately be delivered through; only native conditions are ever looked up, so plain value + // tags are left out. Keyed on the RawPath the SAME resolver builds, which is the native condition's ConditionId. + // Alarm-bearing is decided by the SAME TagConfigIntent the composer materialises native conditions from. + var alarmBearing = new Dictionary(StringComparer.Ordinal); var referencing = new Dictionary>(StringComparer.Ordinal); foreach (var reference in references) { + if (!alarmBearing.TryGetValue(reference.TagId, out var isAlarm)) + { + isAlarm = TagConfigIntent.Parse(tagConfigs.GetValueOrDefault(reference.TagId)).Alarm is not null; + alarmBearing[reference.TagId] = isAlarm; + } + + if (!isAlarm) continue; if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue; if (!referencing.TryGetValue(rawPath, out var set)) referencing[rawPath] = set = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs new file mode 100644 index 000000000..37fe86526 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs @@ -0,0 +1,188 @@ +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; + +/// +/// Review of Gitea #826: the SDK memoizes each folder event item's per-event verdict per +/// (item, event type, SourceNode), so the per-event decision is only as fresh as its invalidation. These pin +/// the three ways it went stale or cost too much: an ORPHANED subscription (no session to invalidate by), a stale +/// DENY left by a replaced instance's late event, and invalidation running with enforcement off. +/// +public sealed class FolderEventVerdictInvalidationTests +{ + private const string EqA = ServerEventRig.EqA; + private const string EqB = ServerEventRig.EqB; + + /// + /// A subscription orphaned by its session (closed without deleting its subscriptions) keeps its items on the + /// folder fan-out with NO session. A deployment that drops EqA's reference must still reach those items: the + /// native alarm's events raised while orphaned are not queued to the EqA item, so a TransferSubscriptions to a + /// new session delivers them only through EqB (positive control on the same subscription and event). + /// + [Fact] + public async Task An_orphaned_folder_item_does_not_keep_a_stale_allow_across_a_deploy_and_a_transfer() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826Orphan", encrypted: false, ct); + rig.KeyRoles = []; + rig.MaterialiseNative(); + NodeAcl[] grants = [KeyEquipmentGrant(EqA), KeyEquipmentGrant(EqB)]; + var state = rig.Enforce(true, [ServerEventRig.Key()], grants); + + using var first = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(first, events, ct); + var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqA)); + var onB = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqB)); + await subscription.ApplyChangesAsync(ct); + onA.Status.Error.ShouldBeNull(); + + // Seed the SDK's cache with an ALLOW for the EqA item. + rig.Project(native: true, State("t1")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "t1") && events.Has(onB, "t1")); + + // Orphan the subscription: a raw CloseSession with deleteSubscriptions=false, so the subscription and its items + // stay on the server with no session. The raw service leaves the client session OBJECT alive (Session.CloseAsync + // would dispose it), as a reconnecting client's is, so the high-level transfer can take the subscription over. + var orphaned = new SubscriptionCollection(first.Subscriptions); + await ((ISessionClient)first).CloseSessionAsync(null, deleteSubscriptions: false, ct); + + // The deployment drops EqA's reference; the alarm reports while the subscription has no session. + Deploy(state, "rev-2", grants, EqB); + rig.Project(native: true, State("t2")); + + using var second = await rig.ConnectAsync(anonymous: false, ct); + (await second.TransferSubscriptionsAsync(orphaned, sendInitialValues: false, ct)).ShouldBeTrue(); + + rig.Project(native: true, State("t3")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onB, "t2") && events.Has(onB, "t3")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + events.Has(onA, "t2").ShouldBeFalse("an event raised while orphaned must not be queued on a stale allow"); + events.Has(onA, "t3").ShouldBeFalse(); + + await second.CloseAsync(ct); + } + + /// + /// A surgical replace of a scripted condition under the SAME NodeId (remove + re-materialise, the folder item + /// survives), with the old instance's last event still waiting in the SDK's per-folder queue when the apply's + /// invalidation runs. That late event must not leave a cached DENY the new instance inherits: the new + /// instance's events keep reaching the folder subscriber. + /// + [Fact] + public async Task A_replaced_instances_late_event_does_not_leave_a_cached_deny_for_the_new_instance() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826Replace", encrypted: false, ct); + rig.KeyRoles = []; + rig.MaterialiseScripted(); + rig.MaterialiseNative(); // also wired into EqA: its event is the deterministic blocker below + rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); + await subscription.ApplyChangesAsync(ct); + onA.Status.Error.ShouldBeNull(); + + rig.Project(native: false, State("s1")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s1")); + + // Holding the node-manager lock parks EqA's SDK delivery task: the native event "n1" is queued first and its + // verdict (a cache miss after the first invalidation) needs the lock in the inherited role check — its node is + // never removed, so that check always takes it. The OLD scripted instance's "s2", queued behind it on the same + // single-reader queue, is therefore decided only after the replace and the apply's invalidation. + lock (rig.Nm.Lock) + { + rig.Nm.CompleteAddressSpaceApply(); // forget the cached allows, so both verdicts must be computed + rig.Project(native: true, State("n1")); + rig.Project(native: false, State("s2")); + ((ISurgicalAddressSpaceSink)rig.Sink) + .RemoveAlarmConditionNode(ServerEventRig.ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue(); + rig.MaterialiseScripted(); + rig.Nm.CompleteAddressSpaceApply(); + } + + rig.Project(native: false, State("s3")); + // The new instance's event arrives (a cached deny would withhold it until the next invalidation), and so does + // the old instance's late one — decided on the live instance, exactly as the live one's would be. + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s3") && events.Has(onA, "s2")); + + await session.CloseAsync(ct); + } + + /// + /// Review of #826 (perf): with enforcement OFF the folder verdicts are the SDK's own role check, so nothing + /// invalidates them — not the revalidation tick, not a deploy, not the end of an apply. Positive control: the + /// same calls invalidate once enforcement is on. + /// + [Fact] + public async Task Folder_verdicts_are_not_invalidated_while_enforcement_is_off() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826AclOff", encrypted: false, ct); + rig.MaterialiseNative(); + var state = rig.Enforce(false, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); + await subscription.ApplyChangesAsync(ct); + + var before = rig.Nm.FolderEventVerdictInvalidations; + rig.Nm.RevalidateMonitoredItems(); + rig.Nm.CompleteAddressSpaceApply(); + state.Apply(ServerEventRig.Cluster, "rev-off-2", [], [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)], + ServerEventRig.ScopePaths()); + rig.Nm.FolderEventVerdictInvalidations.ShouldBe(before, "enforcement off: nothing to invalidate"); + + rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + var enforced = rig.Nm.FolderEventVerdictInvalidations; + enforced.ShouldBeGreaterThan(before, "switching enforcement on invalidates the verdicts decided while off"); + rig.Nm.CompleteAddressSpaceApply(); + rig.Nm.FolderEventVerdictInvalidations.ShouldBeGreaterThan(enforced); + + await session.CloseAsync(ct); + } + + /// + /// The node-wide invalidation rests on a PRIVATE method of the pinned SDK (MonitoredNode2.InvalidatePermissionCaches). + /// If an SDK upgrade drops it the node manager silently falls back to the weaker supported route — this fails + /// first, so the upgrade is looked at. + /// + [Fact] + public void The_sdks_node_wide_permission_cache_invalidation_is_still_resolvable() + { + OtOpcUaNodeManager.InvalidateNodePermissionCaches.ShouldNotBeNull( + "MonitoredNode2.InvalidatePermissionCaches() is gone — re-check InvalidateFolderEventVerdicts against the new SDK"); + } + + 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> { [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, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs index b8df2b82e..f659051ba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs @@ -314,27 +314,62 @@ public sealed class DeploymentArtifactScopePathTests } /// - /// 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 ConditionId), so the node manager can tell a native alarm's - /// still-referenced equipment folders from one whose reference the generation dropped. + /// Gitea #826 — the projection says which equipment each ALARM-BEARING raw tag is projected into, keyed on the + /// RawPath the same resolver builds (a native alarm's ConditionId), so the node manager can tell a native + /// alarm's still-referenced equipment folders from one whose reference the generation dropped. Plain value tags + /// are never looked up and are left out (review: memory). /// [Fact] - public void The_projection_carries_each_raw_tags_referencing_equipment_under_its_raw_path() + public void The_projection_carries_each_alarm_bearing_raw_tags_referencing_equipment_under_its_raw_path() { - var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); + var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(AlarmArtifact), NodeId); - const string rawPath = "Plant/Line1/ModbusDrv/dev1/Fast/Speed"; - projection.TagReferencingEquipment.Keys.ShouldBe([rawPath]); - projection.TagReferencingEquipment[rawPath].ShouldBe(["EQ-mixer"]); + const string hot = "Plant/Line1/ModbusDrv/dev1/Fast/Hot"; + projection.TagReferencingEquipment.Keys.ShouldBe([hot], "the plain Speed tag is not alarm-bearing"); + projection.TagReferencingEquipment[hot].Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "EQ-oven"]); 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(); + snapshot.IsTagReferencedBy(hot, "EQ-oven").ShouldBeTrue(); + snapshot.IsTagReferencedBy(hot, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers"); + snapshot.IsTagReferencedBy(hot, "EQ-other").ShouldBeFalse(); + snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Speed", "EQ-mixer").ShouldBeFalse(); new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths) - .IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); + .IsTagReferencedBy(hot, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); } + + // The fixture's topology plus an alarm-bearing Hot tag referenced by two equipment; Speed stays a plain value. + private const string AlarmArtifact = """ + { + "RawFolders": [ + {"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"}, + {"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"} + ], + "DriverInstances": [ + {"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"} + ], + "Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}], + "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}], + "Tags": [ + {"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}, + {"TagId":"TAG-hot","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Hot", + "TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\"}}"} + ], + "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], + "UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}], + "Equipment": [ + {"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}, + {"EquipmentId":"EQ-oven","UnsLineId":"UL-1","Name":"Oven"} + ], + "UnsTagReferences": [ + {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed"}, + {"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-hot"}, + {"UnsTagReferenceId":"REF-3","EquipmentId":"EQ-oven","TagId":"TAG-hot"} + ], + "NodeAcls": [ + {"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16} + ] + } + """; }