From af5c9744b79012b1039b9dffc6003d6736084402 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:12:46 -0500 Subject: [PATCH] fix(acl): place opaque-id UNS folders, virtual tags and scripted conditions for every operation (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNS Area/Line/Equipment folders are minted under their opaque entity ids (s=UA-/UL-/EQ-...), virtual tags under s=EQ-.../Name and scripted conditions (+ #629 children) under s=[/...], while every UNS grant is planted at a NAME path. DataPlaneAuthorizer split those ids, so with Security:Acl:Enabled a narrow (Area/Line/Equipment) grant could not Browse, Read, subscribe or HistoryRead them, and Server-object delivery never reached it — so a narrow key never held the EventId Ack/Confirm/AddComment need. - The generation carries UnsFolderScopes (folderId -> path + id chain), built by DeploymentArtifact from the same AclScopePathResolver as the grants; ids two entity kinds share are dropped; plumbed through IDataPlaneAuthoritySink. - OtOpcUaNodeManager.ResolveUnsAclSubject reports live facts (folder id ancestry / scripted owner binding); attached by the Authorizer setter. - Evaluate places the subject against the CURRENT generation before the decision cache, which now caches the walk keyed on the resolved scope. Folders require live chain == generation chain (a moved equipment is denied under old and new line until the rebuild). Owned nodes (vtags, conditions, children) get owner path + a reserved segment, so Tag grants and their Browse transit never expose them. Conditions share TryPlaceScriptedCondition with the #797 command gate. - Server-object visibility of a scripted condition is decided on the condition alone, so a moved alarm cannot reach its old owner there. Tests: UnsFolderScopeWireTests (opaque ids, end-to-end browse -> EventId -> Acknowledge on an Equipment grant; sibling denied; Tag-only grant; moved alarm and moved equipment stale windows). Rig tests now install folder scopes explicitly (ServerEventRig.FolderScopes); the #797 recreate test deliberately asserts the moved alarm is invisible to the Cluster observer in the window. --- .claude/rules/data-plane-auth.md | 25 +- docs/security.md | 21 +- .../Authorization/DataPlaneAuthority.cs | 109 ++++- .../OtOpcUaNodeManager.cs | 141 ++++++- .../Security/DataPlaneAuthorizer.cs | 188 +++++++-- .../Security/UnsAclSubject.cs | 82 ++++ .../Drivers/DeploymentArtifact.cs | 57 ++- .../OpcUa/OpcUaPublishActor.cs | 2 +- .../AclEventSubscriptionEnforcementTests.cs | 25 +- .../ScriptedAlarmOwnerScopeWireTests.cs | 21 +- .../ServerEventRig.cs | 16 +- .../UnsFolderScopeWireTests.cs | 397 ++++++++++++++++++ .../NodeManagerModelChangeFilterTests.cs | 7 + .../DeploymentArtifactScopePathTests.cs | 58 +++ .../OpcUaPublishActorDeferredWriteTests.cs | 6 +- 15 files changed, 1095 insertions(+), 60 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/UnsAclSubject.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 6488dfddd..eeb34fecd 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -99,10 +99,27 @@ Two things to know before relying on any of this: closes the install-authority-then-rebuild window and a failed apply leaving a moved alarm on its old equipment. ⚠️ **Fail closed, even for Cluster grants:** an unbound / replaced / undeployed / line-less owner is denied under every grant (no realm-root fallback). Native alarms still use RawPath; ACL-off - is unchanged. ⚠️ **Not end-to-end yet — #808:** UNS folders and scripted condition children still - split opaque ids for Read/Browse/Subscribe/Server-object event visibility, so a narrow principal can't - obtain the EventId Ack/Confirm/AddComment need (Shelve/Enable work). `ServerEventRig`'s folder id - `EqA` == scope path `[uns, EqA]` hides this — tests must use opaque `UA-/UL-/EQ-` ids. + is unchanged. + - **Opaque-id UNS nodes are PLACED, never split, for every operation (#808).** UNS Area/Line/Equipment folders + are minted under their entity ids (`s=UA-…/UL-…/EQ-…`), virtual tags under `s=EQ-…/Name`, scripted + conditions + children under `s=[/…]`, while grants sit at NAME paths. `OtOpcUaNodeManager.Authorizer`'s + setter attaches `ResolveUnsAclSubject` as `DataPlaneAuthorizer.UnsSubjectResolver` (one wiring, no second + site), which reports LIVE facts (`UnsAclSubject`: folder id ancestry / owner binding); `Evaluate` places them + against the CURRENT generation **before** the decision cache and caches only the walk, keyed on the resolved + scope (`\0`-prefixed) — never on the NodeId, or a deny would stick past a rebuild. Folders use the generation's + `UnsFolderScopes` (folderId → path + id chain, `DeploymentArtifact.BuildUnsFolderScopes`, same resolver as the + grants; ids two kinds share are dropped) and **require live chain == generation chain** (moved equipment ⇒ + denied under old AND new line until the rebuild). Owned nodes (vtags, conditions, children) get + `owner path + DataPlaneAuthorizer.OwnedNodeSegment` — only equipment-or-ancestor grants confer them, and a Tag + grant's Browse transit can't enumerate them. Conditions share `TryPlaceScriptedCondition` with the #797 command + gate. Reference variables (`s=Area/Line/Equip/Name`) keep the split. ⚠️ 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. Unplaceable ⇒ denied for every + grant incl. Cluster (a behaviour change for Cluster keys only in the stale window / corrupt topology). Tests: + `UnsFolderScopeWireTests` (opaque ids); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`) now + install folder scopes explicitly — without them every UNS folder denies. - **Revocation reaches live subscriptions** (stage 6): every other operation re-asks per request, but a subscription is authorized once and then streams — and streaming is how machine consumers actually read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied diff --git a/docs/security.md b/docs/security.md index 8928634d3..71f1a6b90 100644 --- a/docs/security.md +++ b/docs/security.md @@ -579,8 +579,25 @@ re-binds it. Fail-closed: an unbound condition (owner folder missing), a removed/replaced instance, or an owner the generation cannot place is denied for **every** grant, `Cluster` included — there is no realm-root or id-split fallback. The key, grant and generation are re-read on every call (uncached). NodeIds and `AlarmCommand.AlarmId` are unchanged. With the flag **off** none of this is consulted. -⚠️ Read/Browse/Subscribe of UNS folders and scripted conditions, and Server-object event visibility, are still -decided on the NodeId's split, and UNS folder ids are opaque entity ids — Gitea #808. + +**Where an opaque-id UNS node is placed for Read / Browse / Subscribe / HistoryRead (Gitea #808).** UNS Area / +Line / Equipment folders are minted under their opaque entity ids (`s=UA-…`, `s=UL-…`, `s=EQ-…`), an equipment's +virtual tags under `s=EQ-…/Name`, and scripted conditions plus their instance-scoped children under +`s=[/…]` — none of which is a grant path, so splitting them reached only `Cluster` / `uns` grants. +`DataPlaneAuthorizer` now asks the node manager (`ResolveUnsAclSubject`, attached when the authorizer is set) how +the node sits in the LIVE address space and places it against the CURRENT generation: a hierarchy folder at its +entity's path from the generation's `UnsFolderScopes` (built by the same `AclScopePathResolver` call that plants +`UnsArea`/`UnsLine`/`Equipment` grants), **only while the live folder-id ancestry equals the one the generation +deploys** (a moved equipment is denied under its old and new line's grants until the rebuild re-parents it); a +virtual tag / its sub-folder at its equipment's path plus a reserved segment; a scripted condition and its children +at the owning equipment's path plus that reserved segment, through exactly the #797 owner rule. The reserved segment +means only grants on the equipment or an ancestor confer them — a `Tag` grant beneath the equipment never does, and +its Browse transit does not expose the equipment's alarms or virtual tags. UNS reference variables +(`s=Area/Line/Equipment/Name`) are unchanged: their id IS their path. 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, +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** | | HistoryUpdate (insert / replace / update / delete) | role `HistoryUpdate` (from a `Cluster`-scoped grant) + key re-check | `HistoryUpdate` on that node's scope, decided **per node in the batch** | | Read (every attribute, not just Value) | nothing | `Read` on that node's scope, decided per node in the batch | 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 0dfabc31f..e6aa7e761 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -36,7 +36,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary keysById, IReadOnlyDictionary> grantsByPrincipal, IReadOnlyDictionary equipmentScopePaths, - IReadOnlyDictionary scriptedAlarmOwners) + IReadOnlyDictionary scriptedAlarmOwners, + IReadOnlyDictionary unsFolderScopes) { ClusterId = clusterId; RevisionHash = revisionHash; @@ -49,6 +50,7 @@ public sealed class DataPlaneAuthoritySnapshot _grantsByPrincipal = grantsByPrincipal; EquipmentScopePaths = equipmentScopePaths; ScriptedAlarmOwners = scriptedAlarmOwners; + UnsFolderScopes = unsFolderScopes; } private readonly IReadOnlyDictionary _keysById; @@ -131,6 +133,69 @@ public sealed class DataPlaneAuthoritySnapshot return true; } + /// + /// UNS folder id → (its ACL path, its folder-id chain) for every UNS Area, Line and Equipment in the + /// artifact this generation was built from (Gitea #808). Keyed OrdinalIgnoreCase (identifier rule). + /// + /// + /// + /// Why this exists. The address space mints UNS Area / Line / Equipment folders under their opaque + /// entity ids (s=UA-…, s=UL-…, s=EQ-…) — and an equipment's virtual tags under + /// s=EQ-…/Name — while every UNS grant is planted at a NAME path + /// ([uns, Area, Line, Equipment]). Splitting such a NodeId can never reach a narrow grant, so the + /// authorizer resolves the folder id to its path HERE instead, against the same generation as the grants. + /// + /// + /// is the folder's ancestry as THIS generation deploys it + /// ([areaId, lineId, equipmentId]). The authorizer requires the LIVE folder's ancestry to equal it: + /// the generation is installed before its address space is applied, and an apply that throws keeps the + /// old address space, so a folder moved to another line would otherwise be authorized at its new path + /// while it still hangs under the old one. A mismatch denies until the address space catches up. + /// + /// + /// An entry confers nothing and is never consulted by the trie build. A folder absent here (an orphan + /// line, a line-less equipment, an id two entity kinds share) has no path, and every node resolved + /// through it is denied under enforcement. + /// + /// + public IReadOnlyDictionary UnsFolderScopes { get; } + + /// + /// 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 + /// deploys. False (the caller must deny) when the folder is unknown, misshapen or placed differently. + /// + /// The folder's id (its s= identifier). + /// The live folder ancestry, root-first, ending at . + /// The path below the realm root, e.g. [Area, Line, Equipment]. + /// when the folder is known and placed as this generation deploys it. + public bool TryGetUnsFolderSegments( + string folderId, IReadOnlyList liveIdChain, out IReadOnlyList unsSegments) + { + unsSegments = []; + if (string.IsNullOrEmpty(folderId) || liveIdChain is null || liveIdChain.Count == 0) return false; + if (!string.Equals(liveIdChain[^1], folderId, StringComparison.OrdinalIgnoreCase)) return false; + if (!UnsFolderScopes.TryGetValue(folderId, out var scope)) return false; + + // Shape-checked rather than trusted, as TryGetEquipmentUnsSegments is: a path that does not start at the + // UNS realm, or names the realm alone, places nothing — accepting it would evaluate at the realm root. + if (scope.Path.Segments.Count < 2 + || !string.Equals(scope.Path.Segments[0], NodeAclRealms.Uns, StringComparison.Ordinal) + || scope.IdChain.Count != scope.Path.Segments.Count - 1 + || scope.IdChain.Count != liveIdChain.Count) + { + return false; + } + + for (var i = 0; i < liveIdChain.Count; i++) + { + if (!string.Equals(scope.IdChain[i], liveIdChain[i], StringComparison.OrdinalIgnoreCase)) return false; + } + + unsSegments = scope.Path.Segments.Skip(1).ToArray(); + return true; + } + /// /// The cluster this node belongs to per the artifact's ClusterNode row, or null on a /// single-cluster / legacy artifact where no scoping applies. @@ -212,6 +277,8 @@ public sealed class DataPlaneAuthoritySnapshot /// null ⇒ none, which denies every scripted-condition command under enforcement. /// ScriptedAlarmId → EquipmentId as this generation deploys them /// (see ); null ⇒ none. + /// Every UNS folder's path + id chain (see ); + /// null ⇒ none, which denies every UNS folder-scoped node under enforcement. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -222,7 +289,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary> scopePaths, IReadOnlyList? droppedGrants = null, IReadOnlyDictionary? equipmentScopePaths = null, - IReadOnlyDictionary? scriptedAlarmOwners = null) + IReadOnlyDictionary? scriptedAlarmOwners = null, + IReadOnlyDictionary? unsFolderScopes = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -279,12 +347,37 @@ public sealed class DataPlaneAuthoritySnapshot } } + var folders = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (unsFolderScopes is not null) + { + foreach (var (folderId, scope) in unsFolderScopes) + { + if (string.IsNullOrWhiteSpace(folderId) || scope?.Path is null || scope.IdChain is null) continue; + // A case-variant duplicate can only come from a corrupt input; mark it unplaceable rather than let + // iteration order decide which placement authorizes. + if (!folders.TryAdd(folderId, scope)) folders[folderId] = UnsFolderScope.Unplaceable; + } + } + return new DataPlaneAuthoritySnapshot( clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen, - equipment, owners); + equipment, owners, folders); } } +/// +/// Where one UNS Area / Line / Equipment folder sits, as a generation deploys it (Gitea #808). +/// +/// Its ACL path, [uns, Area(, Line(, Equipment))] — the exact path a grant on that +/// entity is planted at. +/// Its folder-id ancestry, root-first, ending at the folder itself +/// ([areaId, lineId, equipmentId]) — one id per path segment below the realm. +public sealed record UnsFolderScope(NodeAclPath Path, IReadOnlyList IdChain) +{ + /// A placement no live folder can match (a realm-only path) — the deny marker for a corrupt duplicate. + public static readonly UnsFolderScope Unplaceable = new(new NodeAclPath([NodeAclRealms.Uns]), []); +} + /// Read side of the node's current data-plane authorization inputs. public interface IDataPlaneAuthoritySource { @@ -351,6 +444,8 @@ public interface IDataPlaneAuthoritySink /// the owner lookup for scripted alarm conditions (Gitea #797). Null ⇒ none. /// ScriptedAlarmId → EquipmentId as the same artifact deploys /// 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. /// The installed snapshot. DataPlaneAuthoritySnapshot Apply( string? clusterId, @@ -359,7 +454,8 @@ public interface IDataPlaneAuthoritySink IReadOnlyList grants, IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, - IReadOnlyDictionary? scriptedAlarmOwners = null); + IReadOnlyDictionary? scriptedAlarmOwners = null, + IReadOnlyDictionary? unsFolderScopes = null); } /// @@ -409,7 +505,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl IReadOnlyList grants, IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, - IReadOnlyDictionary? scriptedAlarmOwners = null) + IReadOnlyDictionary? scriptedAlarmOwners = null, + IReadOnlyDictionary? unsFolderScopes = null) { ArgumentNullException.ThrowIfNull(revisionHash); @@ -443,7 +540,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl published = DataPlaneAuthoritySnapshot.Create( clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths, - scriptedAlarmOwners); + scriptedAlarmOwners, unsFolderScopes); 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 7f4d5862f..85b202de7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -633,7 +633,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public DataPlaneAuthorizer? Authorizer { get => _authorizer; - set => _authorizer = value; + set + { + // Gitea #808: the authorizer places UNS folders, the nodes they own and scripted conditions through THIS + // manager's live address space. Attached here — the one place an authorizer meets its node manager — so + // 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; + _authorizer = value; + } } private volatile DataPlaneAuthorizer? _authorizer; @@ -4203,6 +4211,122 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 !OwnsNamespace(nodeId) || _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId)); + /// Guard against a corrupt (cyclic) parent chain in . + private const int MaxUnsAncestry = 64; + + /// + /// Gitea #808 — reports how the UNS-namespace node is placed in the LIVE address + /// space, for to turn into a scope against the current generation. + /// + /// + /// + /// Three shapes share the UNS namespace, and only one of them has a path for an identifier: + /// + /// + /// UNS reference variables — s=Area/Line/Equipment/EffectiveName, NAMES: the id IS the + /// ACL path (), so a Tag grant keeps matching it. + /// Hierarchy folders and what they own — Area / Line / Equipment folders under their opaque + /// entity ids (s=UA-…/s=UL-…/s=EQ-…), and an equipment's virtual tags and their + /// sub-folders (s=EQ-…/Name): , with the live id ancestry. + /// Scripted conditions and their children — s=<ScriptedAlarmId>[/…]: + /// , with the exact live instance's owner binding (#797). + /// + /// + /// A folder-owned node is recognised by an id rule that can only NARROW, never widen. A node is folder- + /// owned when its first id segment is the id of its NEAREST live hierarchy-folder ancestor (a folder whose id + /// carries no / — Area/Line/Equipment ids are ^[A-Za-z0-9_-]+$; a virtual-tag sub-folder's id + /// always does). A reference variable's first segment is its Area's NAME, so the rule can only misfire if an + /// Area is named exactly like the referencing equipment's own id — and then the variable is authorized at that + /// very equipment (its real parent), losing only its Tag-grant match: fail closed. + /// + /// + /// Lock-free except for the scripted-condition branch (the native flag and owner binding are guarded by + /// Lock, which is reentrant): the folder and variable maps are concurrent and a node's + /// is a reference read. Every folder on the walk must be the instance the + /// folder map holds now, so a node torn down by a rebuild cannot place itself through a dead ancestry. + /// + /// + /// The node's s= identifier in the UNS namespace. + /// The placement facts; when the identifier is its own path. + internal UnsAclSubject ResolveUnsAclSubject(string identifier) + { + if (string.IsNullOrEmpty(identifier)) return UnsAclSubject.Path; + + var key = MapKey(AddressSpaceRealm.Uns, identifier); + NodeState? node = _folders.TryGetValue(key, out var folder) + ? folder + : _variables.TryGetValue(key, out var variable) ? variable : null; + + var slash = identifier.IndexOf('/'); + var firstSegment = slash < 0 ? identifier.AsSpan() : identifier.AsSpan(0, slash); + + if (node is null) + { + // Not a folder or variable: a scripted condition (s=) or one of its instance-scoped children + // (s=/, #629). Anything else is an unknown id and keeps the split. + var conditionId = slash < 0 ? identifier : identifier[..slash]; + var conditionKey = MapKey(AddressSpaceRealm.Uns, conditionId); + if (!_alarmConditions.TryGetValue(conditionKey, out var condition)) return UnsAclSubject.Path; + + lock (Lock) + { + if (_nativeAlarmNodeIds.Contains(conditionKey)) return UnsAclSubject.Path; + var owner = _scriptedAlarmOwners.TryGetValue(conditionKey, out var binding) + && ReferenceEquals(binding.Alarm, condition) + && _alarmConditions.TryGetValue(conditionKey, out var live) + && ReferenceEquals(live, condition) + ? binding.EquipmentId + : null; + return UnsAclSubject.ScriptedCondition(conditionId, owner); + } + } + + // The nearest live hierarchy folder at or above the node. + FolderState? owning = null; + var current = ReferenceEquals(node, folder) ? node : ParentOf(node); + for (var depth = 0; current is not null && depth < MaxUnsAncestry; depth++, current = ParentOf(current)) + { + if (LiveUnsFolderId(current) is not { } id) break; + if (id.Contains('/')) continue; + owning = (FolderState)current; + break; + } + + if (owning is null || !firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)) return UnsAclSubject.Path; + + // Its live ancestry, root-first: every live hierarchy folder up to the first non-UNS-folder (the root). + var chain = new List(3); + current = owning; + for (var depth = 0; current is not null && depth < MaxUnsAncestry; depth++, current = ParentOf(current)) + { + if (LiveUnsFolderId(current) is not { } id || id.Contains('/')) break; + chain.Add(id); + } + + chain.Reverse(); + return UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning)); + } + + /// The node's parent in the instance tree, or null for a type/root node. + /// The node. + /// Its parent. + private static NodeState? ParentOf(NodeState node) => (node as BaseInstanceState)?.Parent; + + /// + /// The id of when it is a UNS folder this manager currently holds — the exact instance + /// the folder map holds, not a torn-down one — else null. + /// + /// A node on an ancestry walk. + /// The folder's s= id, or null. + private string? LiveUnsFolderId(NodeState node) => + node is FolderState f + && f.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns) + && f.NodeId.TryGetValue(out string id) + && _folders.TryGetValue(MapKey(AddressSpaceRealm.Uns, id), out var live) + && ReferenceEquals(live, f) + ? id + : null; + // Denied slots are answered and marked ReadValueId.Processed before the base // runs, so a refused read never resolves the node or samples its value. Every attribute of a node // is gated by the single Read permission rather than only Value: the finer split @@ -5288,6 +5412,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { + // Gitea #808: a SCRIPTED condition is admitted by its own node alone. The authorizer places it at its + // owning equipment (#797 owner binding + this generation's deployed owner), so every grant its equipment + // folder would admit admits the condition too — the folders add nothing in steady state. What they WOULD + // add is the stale window: a moved alarm still hanging under its old equipment's folder (generation + // installed, address space not yet rebuilt, or the apply failed) would keep reaching the old owner's + // sessions through that folder while its node, its children and its commands are denied to them. + var key = MapKey(condition.NodeId); + if (condition.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns) + && _alarmConditions.TryGetValue(key, out var live) + && ReferenceEquals(live, condition) + && !_nativeAlarmNodeIds.Contains(key)) + { + return scopes; + } + for (var ancestor = (condition as BaseInstanceState)?.Parent; ancestor is not null; ancestor = (ancestor as BaseInstanceState)?.Parent) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs index 1e64076d2..04a81049d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs @@ -123,6 +123,30 @@ public sealed class DataPlaneAuthorizer /// public SessionAclDecisionCache Decisions { get; } = new(); + /// + /// The live-address-space lookup that says how a UNS-namespace node is placed (Gitea #808), attached by the + /// node manager that owns the address space this authorizer gates. Null ⇒ every UNS identifier is split as a + /// path, the pre-#808 behaviour (a test authorizer used without a node manager). + /// + /// + /// Read per decision, BEFORE the decision cache: the facts it reports (a folder's live ancestry, a scripted + /// condition's live owner binding) change with the address space rather than with the generation, so they + /// must never be memoized. What the cache then memoizes is only the grant walk at the RESOLVED scope, keyed on + /// that scope — which is a pure function of the generation. Must be cheap and non-throwing: it runs on every + /// Read / Browse / subscribe decision on a UNS node. + /// + public Func? UnsSubjectResolver { get; set; } + + /// + /// The path segment appended below an owning equipment / folder for a node that is NOT a UNS reference + /// variable — a scripted condition and its children, a virtual tag (Gitea #808). It can never be a grant path + /// segment (those are entity names), so the walk confers exactly the grants on the owner or an ancestor of it: + /// a Tag grant beneath the same equipment never matches, and — the reason it is not simply the owner's + /// own path — the owner's Browse TRANSIT (an ancestor of a Tag grant) is not inherited, so a principal holding + /// only a Tag grant cannot enumerate the equipment's alarms and virtual tags by browsing. + /// + internal const string OwnedNodeSegment = "\u0000owned"; + /// /// Authorizes on through the decision cache — /// the entry point for Read, Browse and subscription creation, which ask the same question many @@ -300,29 +324,9 @@ public sealed class DataPlaneAuthorizer if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null) return DenyRevokedKey(operation, conditionNodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false); - if (string.IsNullOrEmpty(ownerEquipmentId)) - { - return Deny(operation, conditionNodeId, identity.Principal, - "the scripted condition has no live owner binding (unknown, removed or replaced)"); - } - - // The live binding describes the address space as last materialised; the generation is installed BEFORE - // its address space is applied, and a failed apply keeps the old one. Require both to name the SAME owner - // for this exact alarm id, so a moved alarm is denied — under its old AND its new owner's grant — until - // the address space catches up, rather than staying commandable with the old owner's grant. - if (!conditionNodeId.TryGetValue(out string alarmId) - || !snapshot.TryGetScriptedAlarmOwner(alarmId, out var deployedOwner) - || !string.Equals(deployedOwner, ownerEquipmentId, StringComparison.OrdinalIgnoreCase)) - { - return Deny(operation, conditionNodeId, identity.Principal, - $"the live owner binding ({ownerEquipmentId}) does not match generation {snapshot.GenerationId}'s deployed owner of this scripted alarm"); - } - - if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments)) - { - return Deny(operation, conditionNodeId, identity.Principal, - $"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}"); - } + conditionNodeId.TryGetValue(out string alarmId); + if (!TryPlaceScriptedCondition(snapshot, alarmId, ownerEquipmentId, out var segments, out var reason)) + return Deny(operation, conditionNodeId, identity.Principal, reason); var scope = new NodeScope { @@ -336,6 +340,117 @@ public sealed class DataPlaneAuthorizer : DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false); } + /// + /// Places a scripted condition at its owning equipment's UNS path in — the one + /// rule both the Part 9 command gate (#797) and the Read / Browse / subscribe / event-visibility path (#808) + /// use, so "can see" and "can command" can never disagree about where the alarm lives. + /// + /// The generation in force. + /// The condition's ScriptedAlarmId. + /// The live owner binding, or null when unbound. + /// The owning equipment's path below the UNS realm root. + /// Why placement failed (the caller denies). + /// when the condition is placed. + private static bool TryPlaceScriptedCondition( + DataPlaneAuthoritySnapshot snapshot, + string? alarmId, + string? ownerEquipmentId, + out IReadOnlyList segments, + out string reason) + { + segments = []; + if (string.IsNullOrEmpty(ownerEquipmentId)) + { + reason = "the scripted condition has no live owner binding (unknown, removed or replaced)"; + return false; + } + + // The live binding describes the address space as last materialised; the generation is installed BEFORE + // its address space is applied, and a failed apply keeps the old one. Require both to name the SAME owner + // for this exact alarm id, so a moved alarm is denied — under its old AND its new owner's grant — until + // the address space catches up, rather than staying visible / commandable with the old owner's grant. + if (string.IsNullOrEmpty(alarmId) + || !snapshot.TryGetScriptedAlarmOwner(alarmId, out var deployedOwner) + || !string.Equals(deployedOwner, ownerEquipmentId, StringComparison.OrdinalIgnoreCase)) + { + reason = $"the live owner binding ({ownerEquipmentId}) does not match generation {snapshot.GenerationId}'s deployed owner of this scripted alarm"; + return false; + } + + if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out segments)) + { + reason = $"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}"; + return false; + } + + reason = string.Empty; + return true; + } + + /// + /// Turns a non- UNS subject into the segments it is authorized at in + /// (Gitea #808). Every gap fails — the caller denies; there is no fallback to + /// splitting the identifier, to the realm root, or to the cluster. + /// + /// The generation in force. + /// The live facts the node manager reported. + /// The resolved path below the UNS realm root. + /// Why placement failed. + /// when the subject is placed. + private static bool TryPlaceUnsSubject( + DataPlaneAuthoritySnapshot snapshot, + in UnsAclSubject subject, + out IReadOnlyList segments, + out string reason) + { + IReadOnlyList owner; + bool owned; + switch (subject.Kind) + { + case UnsAclSubjectKind.Folder: + if (subject.FolderChain is not { Count: > 0 } chain + || !snapshot.TryGetUnsFolderSegments(chain[^1], chain, out owner)) + { + segments = []; + reason = $"the UNS folder {subject.FolderChain?.LastOrDefault() ?? "(none)"} is not placed as generation {snapshot.GenerationId} deploys it (unknown, moved, or not yet rebuilt)"; + return false; + } + + owned = !subject.IsFolderItself; + break; + + case UnsAclSubjectKind.ScriptedCondition: + if (!TryPlaceScriptedCondition(snapshot, subject.AlarmId, subject.OwnerEquipmentId, out owner, out reason)) + { + segments = []; + return false; + } + + owned = true; + break; + + default: + segments = []; + reason = "unrecognised UNS subject"; + return false; + } + + segments = owned ? [.. owner, OwnedNodeSegment] : owner; + reason = string.Empty; + return true; + } + + /// + /// The decision-cache key for a node authorized at a RESOLVED scope rather than at its split identifier. It + /// names the scope, not the node, because the cached grant walk depends only on the scope — and keying on the + /// node would let a verdict outlive a re-bind or re-parent the resolver sees but the generation does not. The + /// leading NUL keeps it disjoint from every real s= identifier. + /// + /// The resolved path below the UNS realm root. + /// The cache key. + private static string ResolvedScopeCacheKey(IReadOnlyList segments) => + "\u0000" + string.Join('\u0000', segments); + /// /// The decision itself. Ordered so the cached seams reach their answer having allocated nothing: /// every check that must run per call comes first, the memoized verdict is consulted next, and the @@ -402,8 +517,27 @@ public sealed class DataPlaneAuthorizer var sessionKey = identity.ApiKeyId ?? identity.Principal; + // Gitea #808: a UNS folder minted under an opaque entity id, a node it owns, and a scripted condition (or + // child) are placed through the live address space + this generation instead of by splitting the id. The + // resolver runs on EVERY call, ahead of the cache, because what it reads moves with the address space; the + // cache below then memoizes only the grant walk at the resolved scope, keyed on that scope. + var cacheIdentifier = identifier; + IReadOnlyList? resolvedSegments = null; + if (realm == AddressSpaceRealm.Uns && UnsSubjectResolver is { } resolve) + { + var subject = resolve(identifier); + if (subject.Kind != UnsAclSubjectKind.Path) + { + if (!TryPlaceUnsSubject(snapshot, subject, out var placed, out var reason)) + return Deny(operation, nodeId, identity.Principal, reason, cached); + + resolvedSegments = placed; + cacheIdentifier = ResolvedScopeCacheKey(placed); + } + } + if (cached && - Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, identifier, out var memoized)) + Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, cacheIdentifier, out var memoized)) { // The whole point of the reorder: a hit returns HERE, having built no scope, no closure and no // UserAuthorizationState. One Browse of a device folder asks this once per child. @@ -412,7 +546,9 @@ public sealed class DataPlaneAuthorizer : DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached); } - var scope = BuildScope(identifier, realm, snapshot.ClusterId); + var scope = resolvedSegments is null + ? BuildScope(identifier, realm, snapshot.ClusterId) + : new NodeScope { ClusterId = snapshot.ClusterId!, Kind = NodeHierarchyKind.Uns, Segments = resolvedSegments }; var allowed = Walk(snapshot, identity, operation, scope, now); // The single shared anchor fronts BOTH realm trees, so its browse must consider transit in @@ -432,7 +568,7 @@ public sealed class DataPlaneAuthorizer now); } - if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, identifier, allowed); + if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, cacheIdentifier, allowed); return allowed ? null diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/UnsAclSubject.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/UnsAclSubject.cs new file mode 100644 index 000000000..20319a020 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/UnsAclSubject.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +/// How a UNS-namespace node is to be placed in the ACL scope tree (Gitea #808). +public enum UnsAclSubjectKind +{ + /// The node's s= identifier IS its ACL path (a UNS reference variable, or an unknown id). + Path, + + /// + /// A UNS Area / Line / Equipment folder minted under its opaque entity id, or a node owned by one + /// (a virtual tag EQ-…/Name or its sub-folder): placed at the folder's path in the current generation. + /// + Folder, + + /// + /// A scripted alarm condition (s=<ScriptedAlarmId>) or one of its instance-scoped children + /// (s=<ScriptedAlarmId>/…): placed at its owning equipment, as the #797 command gate does. + /// + ScriptedCondition, +} + +/// +/// The structural facts the node manager reports about one UNS-namespace node, read from the LIVE address +/// space at decision time. The authorizer turns them into a scope against the CURRENT authority generation — +/// the node manager never computes a scope itself, so placement and grants always come from one generation. +/// +/// +/// +/// Why the identifier cannot simply be split. UNS folders are minted under opaque entity ids +/// (s=EQ-1a2b…) and scripted conditions under their opaque ScriptedAlarmId, while every +/// UNS grant is planted at a NAME path ([uns, Area, Line, Equipment]). Splitting those ids can +/// never reach a narrow grant (and a one-segment split could coincide with an unrelated Area's name). +/// +/// +/// A subject carries the folder's live id ancestry so the authorizer +/// can require it to equal the generation's; a subject +/// carries the live owner binding (null when unbound ⇒ denied under every grant). +/// +/// +public readonly record struct UnsAclSubject +{ + /// The identifier is the ACL path — the pre-#808 behaviour. + public static readonly UnsAclSubject Path = new() { Kind = UnsAclSubjectKind.Path }; + + /// Which placement rule applies. + public UnsAclSubjectKind Kind { get; init; } + + /// + /// For : the owning folder's LIVE id ancestry, root-first, ending at + /// the owning folder itself (e.g. [UA-…, UL-…, EQ-…]). + /// + public IReadOnlyList? FolderChain { get; init; } + + /// + /// For : true when the node IS the folder; false when it is a node the + /// folder owns (a virtual tag, its sub-folder). + /// + public bool IsFolderItself { get; init; } + + /// For : the condition's ScriptedAlarmId. + public string? AlarmId { get; init; } + + /// + /// For : the EquipmentId the LIVE condition instance + /// is bound to, or null when the node manager holds no binding for it. + /// + public string? OwnerEquipmentId { get; init; } + + /// A UNS folder (or a node it owns) with its live id ancestry. + /// Root-first live ancestry ending at the owning folder. + /// Whether the node is the folder itself. + /// The subject. + public static UnsAclSubject Folder(IReadOnlyList chain, bool isFolderItself) => + new() { Kind = UnsAclSubjectKind.Folder, FolderChain = chain, IsFolderItself = isFolderItself }; + + /// A scripted condition (or one of its children) and its live owner binding. + /// The condition's ScriptedAlarmId. + /// The live owner binding, or null when unbound. + /// The subject. + public static UnsAclSubject ScriptedCondition(string alarmId, string? ownerEquipmentId) => + new() { Kind = UnsAclSubjectKind.ScriptedCondition, AlarmId = alarmId, OwnerEquipmentId = ownerEquipmentId }; +} 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 42cfe1373..d4c6e9d33 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -82,6 +82,17 @@ public sealed record DataPlaneAuthorityProjection( public IReadOnlyDictionary ScriptedAlarmOwners { get; init; } = new Dictionary(StringComparer.Ordinal); + /// + /// folder id → (ACL path, folder-id chain) for every UNS Area, Line and Equipment whose UNS chain + /// resolves (Gitea #808) — how the authorizer places a node minted under an opaque UNS folder id + /// (s=UA-…, s=UL-…, s=EQ-…, s=EQ-…/VirtualTag). Paths come from the SAME + /// instance as , so a folder and the grant planted + /// on its entity are equal by construction. An id two entity kinds share is omitted (fail closed). Empty when + /// the artifact carries no grants. + /// + public IReadOnlyDictionary UnsFolderScopes { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. public static readonly DataPlaneAuthorityProjection Empty = new(null, [], [], new Dictionary>()); @@ -927,7 +938,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable grants.Add(acl); } - var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants); + var (scopePaths, equipmentScopePaths, unsFolderScopes) = 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); @@ -943,6 +954,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable { EquipmentScopePaths = equipmentScopePaths, ScriptedAlarmOwners = scriptedAlarmOwners, + UnsFolderScopes = unsFolderScopes, }; } @@ -952,16 +964,18 @@ 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) from the same resolver. + /// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver. private static (IReadOnlyDictionary> ScopePaths, - IReadOnlyDictionary EquipmentScopePaths) BuildAclScopePaths( + IReadOnlyDictionary EquipmentScopePaths, + IReadOnlyDictionary UnsFolderScopes) BuildAclScopePaths( JsonElement root, IReadOnlyList grants) { if (grants.Count == 0) { return (new Dictionary>(), - new Dictionary(StringComparer.OrdinalIgnoreCase)); + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase)); } var folders = new Dictionary(StringComparer.Ordinal); @@ -1063,7 +1077,40 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; } - return (resolver.Resolve(grants), equipmentPaths); + return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment)); + } + + /// + /// Every UNS Area / Line / Equipment folder's ACL path and folder-id chain (Gitea #808), through the SAME + /// resolver call a grant on that entity takes. A folder whose chain does not resolve is absent; an id two + /// entity kinds share (so the address space could only hold one folder for it) is dropped entirely rather + /// than placed by whichever kind came first — fail closed. + /// + private static Dictionary BuildUnsFolderScopes( + AclScopePathResolver resolver, + IReadOnlyDictionary areas, + IReadOnlyDictionary lines, + IReadOnlyDictionary equipment) + { + var scopes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string id, NodeAclScopeKind kind, string[] chain) + { + if (resolver.ResolveOne(kind, id) is not [var path]) return; + if (!scopes.TryAdd(id, new UnsFolderScope(path, chain))) ambiguous.Add(id); + } + + foreach (var areaId in areas.Keys) Add(areaId, NodeAclScopeKind.UnsArea, [areaId]); + foreach (var (lineId, line) in lines) Add(lineId, NodeAclScopeKind.UnsLine, [line.UnsAreaId, lineId]); + foreach (var (equipmentId, equip) in equipment) + { + if (!lines.TryGetValue(equip.UnsLineId, out var line)) continue; + Add(equipmentId, NodeAclScopeKind.Equipment, [line.UnsAreaId, equip.UnsLineId, equipmentId]); + } + + foreach (var id in ambiguous) scopes.Remove(id); + return scopes; } /// Reads one ApiKeys element; null when a field the credential check depends on is 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 196136a6f..2165144ef 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,7 @@ 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.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); _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 0ef082026..f1e046d24 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs @@ -87,7 +87,8 @@ public sealed class AclEventSubscriptionEnforcementTests await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1")); // The next deployment carries only EqB's grant. - state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths()); + state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.AttachedEventItems.ShouldBe(1); rig.Transition("t2", active: false); @@ -98,7 +99,8 @@ public sealed class AclEventSubscriptionEnforcementTests collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment"); // A deployment that restores the grant brings the feed back on its own. - state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths()); + state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.AttachedEventItems.ShouldBe(2); rig.Transition("t3", active: true); @@ -140,7 +142,8 @@ public sealed class AclEventSubscriptionEnforcementTests await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1")); rig.AttachedEventItems.ShouldBe(2); - state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths()); + state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.AttachedEventItems.ShouldBe(1); subscription.RemoveItem(onA); @@ -149,7 +152,8 @@ 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()); + state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out"); rig.Transition("t2", active: false); @@ -405,14 +409,16 @@ public sealed class AclEventSubscriptionEnforcementTests rig.Transition("t1", active: true); await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1")); - state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths()); + state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.Transition("t2", active: false); await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); 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()); + state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); rig.Transition("t3", active: true); await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3")); @@ -885,6 +891,10 @@ public sealed class AclEventSubscriptionEnforcementTests PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe, }; + // Gitea #808: every Apply above also installs the placements the generation now carries — the equipment owner + // paths + scripted-alarm owners (#797) and the UNS folder scopes (#808). This rig's folders are synthetic + // root-level ids (EqA ⇒ [uns, EqA]), shared with ServerEventRig; production-shaped opaque ids are covered by + // UnsFolderScopeWireTests. Without them every UNS folder and the scripted condition deny under EVERY grant. private static IReadOnlyDictionary> ScopePaths() => new Dictionary> { @@ -1038,7 +1048,8 @@ public sealed class AclEventSubscriptionEnforcementTests public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); - state.Apply(Cluster, "rev-1", keys, grants, ScopePaths()); + state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs index e54b3cc4b..1b17c323d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs @@ -237,18 +237,29 @@ public sealed class ScriptedAlarmOwnerScopeWireTests rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); rig.Nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); Project(rig, Alarm, pre with { Message = "recreated" }); - await RecordedEvents.WaitUntilAsync(() => events.Has(item, "recreated")); + // Gitea #808 — deliberate change: while the live binding (Fan) disagrees with the generation (Pump), the + // condition is unplaceable for EVERY grant, so even the Cluster-granted observer does not receive its event + // (it used to: the opaque id split to [uns, scripted-eqa-hi], which only a Cluster grant reached). Positive + // control: a later event on the correctly placed sibling arrives, so "recreated" would have arrived first. + Project(rig, FanAlarm, pre with { Message = "fan-control" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "fan-control")); + events.Has(item, "recreated").ShouldBeFalse("a moved alarm is invisible until the generation agrees"); + + var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(); var oldGrant = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( - session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct)); + session, Uns(session, Alarm), "Acknowledge", staleEventId, ct)); oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.Count.ShouldBe(1); // The old grant was refused above with the generation STILL deploying the alarm under Pump (binding ≠ - // generation); now the deploy that moved it lands, granting the new owner. + // generation); now the deploy that moved it lands, granting the new owner — and placing it again for the + // observer, which sees its next event. Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer()); + Project(rig, Alarm, pre with { Message = "moved" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "moved")); await ConditionMethodEventTests.CallAsync( - session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct); + session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "moved"), ct); var unknown = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct)); @@ -256,7 +267,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); - events.ConditionEvents(item, "recreated").Count.ShouldBe(2, "the recreated base plus the new owner's one ack"); + events.ConditionEvents(item, "moved").Count.ShouldBe(2, "the re-placed base plus the new owner's one ack"); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]); await observer.CloseAsync(ct); 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 93405a1b0..d3a4af131 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -126,7 +126,8 @@ internal sealed class ServerEventRig : IAsyncDisposable IReadOnlyDictionary equipmentOwnerPaths, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); - state.Apply(Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners()); + state.Apply( + Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System); @@ -181,6 +182,19 @@ internal sealed class ServerEventRig : IAsyncDisposable [EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]), }; + /// + /// The rig's UNS folder placements (Gitea #808): each equipment folder hangs directly under the root, so its + /// id chain is itself and its path is [uns, EqA] / [uns, EqB] — the synthetic shape every + /// pre-#808 rig test was written against. ⚠️ Production folder ids are opaque and their paths are NAMES; the + /// #808 tests (UnsFolderScopeWireTests) use that shape instead, which this rig's coincidence hid. + /// + public static IReadOnlyDictionary FolderScopes() => + new Dictionary + { + [EqA] = new(new NodeAclPath([NodeAclRealms.Uns, EqA]), [EqA]), + [EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [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 new file mode 100644 index 000000000..17534adc4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs @@ -0,0 +1,397 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Gitea #808 over the wire — a real activated apikey session with zero server-wide roles and only a +/// NARROW UNS grant, against a production-shaped UNS tree: every Area / Line / Equipment folder is minted under its +/// opaque entity id (UA-…, UL-…, EQ-…), virtual tags under EQ-…/Name, scripted conditions +/// under their opaque ScriptedAlarmId, while every grant is planted at a NAME path resolved by +/// . Before #808 such a session could not browse, read or subscribe any of those +/// nodes, and never received a scripted-alarm event through the Server object — so it never held the EventId that +/// Acknowledge / Confirm / AddComment need. 's own folders (EqA ⇒ +/// [uns, EqA]) are deliberately NOT used: their id coincides with their path, which is what hid this. +/// +public sealed class UnsFolderScopeWireTests +{ + private const string AreaId = "UA-0000000001"; + private const string Line1 = "UL-0000000001"; + private const string Line2 = "UL-0000000002"; + private const string Pump = "EQ-00000000a1b2"; + private const string Fan = "EQ-00000000c3d4"; + private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi", deployed under Pump + private const string PumpLo = "scripted-pump-lo"; + private const string FanAlarm = "scripted-fan-hi"; + private const string PumpSpeed = Pump + "/Speed"; // a virtual tag: s=/ + private const string FanSpeed = Fan + "/Speed"; + private const string PumpFlow = "Area1/Line1/Pump/Flow"; // a UNS reference variable: s=/// + private const string FlowTagId = "TAG-flow"; + + private const NodePermissions Operator = + NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm; + + /// + /// The ScadaBridge path end to end on an Equipment grant alone: navigate from the shared root down to the + /// equipment, read its folder / alarm children / virtual tag / reference variable, receive its scripted alarm + /// through the Server object, and Acknowledge with the EventId the session obtained ITSELF. Every sibling + /// (another equipment's folder, alarm, alarm child, virtual tag, folder event item) is denied on the same + /// session, and the sibling's alarm event — reported FIRST — never arrives. + /// + [Fact] + public async Task A_narrow_equipment_grant_browses_reads_receives_and_acknowledges_end_to_end() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808EndToEnd", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator)); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + AssertNoRoles(rig); + + // Navigation: shared root → area → line → equipment, each step by Browse transit / the grant itself. + (await BrowseAsync(session, RootNodeId(session), ct)).Ids.ShouldContain(AreaId); + (await BrowseAsync(session, Uns(session, AreaId), ct)).Ids.ShouldBe([Line1], "Line2 holds nothing granted"); + (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump], "the sibling equipment is withheld"); + var inPump = (await BrowseAsync(session, Uns(session, Pump), ct)).Ids; + inPump.ShouldContain(Alarm); + inPump.ShouldContain(PumpLo); + inPump.ShouldContain(PumpSpeed); + inPump.ShouldContain(PumpFlow); + (await BrowseAsync(session, Uns(session, Fan), ct)).Status.ShouldBe(StatusCodes.BadUserAccessDenied); + + // Reads: the folder, a scripted condition child (#629 instance-scoped id), the virtual tag, the reference. + var pumpEnabled = ChildId(rig, Alarm, c => c.EnabledState!.Id!); + var fanEnabled = ChildId(rig, FanAlarm, c => c.EnabledState!.Id!); + pumpEnabled.ShouldStartWith(Alarm + "/"); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, pumpEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, FanAlarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, fanEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, FanSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, AreaId), Attributes.DisplayName, ct)) + .ShouldBe(StatusCodes.BadUserAccessDenied, "transit confers Browse on the ancestors, never Read"); + + // Events: the session's OWN Server-object item, plus direct items on its equipment folder and the sibling's. + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + var pumpFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Pump)); + var fanFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Fan)); + await subscription.ApplyChangesAsync(ct); + server.Status.Error.ShouldBeNull(); + pumpFolder.Status.Error.ShouldBeNull(); + fanFolder.Status.Error.ShouldNotBeNull().StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, FanAlarm, pre with { Message = "fan-base" }); // reported first: a late copy would precede "base" + Project(rig, Alarm, pre); + await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base") && events.Has(pumpFolder, "base")); + events.Has(server, "fan-base").ShouldBeFalse("the sibling's alarm is not delivered through the Server object"); + + // Acknowledge with the EventId this narrow session received itself — the ScadaBridge flow #797 alone could + // not complete. + await ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(server, "base").Count == 2); + events.ConditionEvents(server, "base")[1][RecordedEvents.AckedField].TryGetValue(out bool acked).ShouldBeTrue(); + acked.ShouldBeTrue(); + + var siblingEventId = rig.Nm.TryGetAlarmCondition(FanAlarm)!.EventId!.Value.ToArray(); + var denied = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, FanAlarm), "Acknowledge", siblingEventId, ct)); + denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + + Project(rig, FanAlarm, pre with { Message = "fan-sentinel" }); + Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(server, "sentinel")); + events.Of(server).Count(f => RecordedEvents.Message(f)?.StartsWith("fan-", StringComparison.Ordinal) == true) + .ShouldBe(0); + routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]); + + await session.CloseAsync(ct); + } + + /// + /// A principal holding only a TAG grant (one reference variable beneath the equipment) can navigate to it and + /// read it, but is NOT handed the equipment's other contents: its folder is browsable by transit and nothing + /// more, and the equipment's scripted alarms and virtual tags stay hidden from Browse and Read and its events + /// do not reach the Server object — a Tag grant is about that signal, not the equipment. + /// + [Fact] + public async Task A_tag_grant_reaches_its_reference_without_exposing_the_equipments_alarms_or_virtual_tags() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808TagOnly", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line1, Deployed(), TagGrant()); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + + (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]); + (await BrowseAsync(session, Uns(session, Pump), ct)).Ids.ShouldBe([PumpFlow]); + (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + + await session.CloseAsync(ct); + } + + /// + /// A scripted alarm the generation has MOVED (deployed under Fan) while the address space still binds it to Pump — + /// the window between installing a generation and rebuilding its address space, or indefinitely after a failed + /// apply. Under BOTH owners' grants it is invisible (no Server-object event, no Read of the condition or its + /// children) and not commandable; the owner's other, correctly placed alarm keeps working (positive control). + /// Once the address space catches up the new owner's grant sees it and the old owner's does not. + /// + [Theory] + [InlineData(Pump)] + [InlineData(Fan)] + public async Task A_moved_alarm_is_denied_under_both_owners_until_the_address_space_catches_up(string grantedEquipment) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync($"Uns808Moved{grantedEquipment[^4..]}", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), EquipmentGrant(grantedEquipment, Operator)); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + var control = grantedEquipment == Pump ? PumpLo : FanAlarm; + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, Alarm, pre with { Message = "moved" }); + Project(rig, control, pre with { Message = "control" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control")); + events.Has(server, "moved").ShouldBeFalse("a moved alarm is unplaceable for every grant during the window"); + + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct)) + .ShouldBe(StatusCodes.BadUserAccessDenied); + var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(); + (await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", staleEventId, ct))) + .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.ShouldBeEmpty(); + + // The address space catches up: the alarm is re-materialised under its deployed owner. + ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); + rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + Project(rig, Alarm, pre with { Message = "placed" }); + Project(rig, control, pre with { Message = "control-2" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control-2")); + + var visible = grantedEquipment == Fan; + events.Has(server, "placed").ShouldBe(visible); + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)) + .ShouldBe(visible ? StatusCodes.Good : StatusCodes.BadUserAccessDenied); + + await session.CloseAsync(ct); + } + + /// + /// 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 and everything it owns are denied under the + /// new line's grant AND the old one's. After the rebuild re-parents it, the new line's grant reads it and the old + /// line's does not. + /// + [Theory] + [InlineData(Line1)] + [InlineData(Line2)] + public async Task A_moved_equipment_folder_is_denied_until_the_rebuild_reparents_it(string grantedLine) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync($"Uns808Folder{grantedLine[^1..]}", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line2, Deployed(), LineGrant(grantedLine)); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + // Positive control: the sibling, placed identically by both, follows the grant. + (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)) + .ShouldBe(grantedLine == Line1 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied); + + rig.Sink.RebuildAddressSpace(); + Topology(rig, pumpLine: Line2); + + var expected = grantedLine == Line2 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied; + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(expected); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(expected); + + await session.CloseAsync(ct); + } + + // ───────────────────────────── helpers ───────────────────────────── + + /// + /// Area1 / {Line1, Line2}; Pump under and Fan under Line1 — every folder an opaque + /// entity id, as AddressSpaceApplier.MaterialiseHierarchy mints them. Each equipment carries scripted + /// alarms and a virtual tag; Pump also carries a UNS reference variable (a NAME-path id). + /// + private static void Topology(ServerEventRig rig, string pumpLine) + { + rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Line1, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Line2, parentNodeId: AreaId, displayName: "Line2", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Pump, parentNodeId: pumpLine, displayName: "Pump", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Fan, parentNodeId: Line1, displayName: "Fan", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureVariable(PumpSpeed, Pump, "Speed", "Double", writable: false, AddressSpaceRealm.Uns); + rig.Sink.EnsureVariable(FanSpeed, Fan, "Speed", "Double", writable: false, AddressSpaceRealm.Uns); + rig.Sink.EnsureVariable(PumpFlow, Pump, "Flow", "Double", writable: false, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(PumpLo, Pump, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + } + + private static AclScopePathResolver Resolver(string pumpLine) => new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary { [AreaId] = "Area1" }, + new Dictionary { [Line1] = (AreaId, "Line1"), [Line2] = (AreaId, "Line2") }, + new Dictionary { [Pump] = (pumpLine, "Pump"), [Fan] = (Line1, "Fan") }, + []); + + /// + /// Installs a generation whose topology puts Pump under — the owner paths, folder + /// scopes and grant paths all from ONE resolver, as DeploymentArtifact.ParseDataPlaneAuthority does. + /// + private static void Install( + ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, params NodeAcl[] grants) + { + var resolver = Resolver(pumpLine); + 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"])], + }; + NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); + + var owners = new Dictionary + { + [Pump] = One(NodeAclScopeKind.Equipment, Pump), + [Fan] = One(NodeAclScopeKind.Equipment, Fan), + }; + var folders = new Dictionary + { + [AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]), + [Line1] = new(One(NodeAclScopeKind.UnsLine, Line1), [AreaId, Line1]), + [Line2] = new(One(NodeAclScopeKind.UnsLine, Line2), [AreaId, Line2]), + [Pump] = new(owners[Pump], [AreaId, pumpLine, Pump]), + [Fan] = new(owners[Fan], [AreaId, Line1, Fan]), + }; + + var state = new DataPlaneAuthorityState(); + state.Apply( + ServerEventRig.Cluster, "rev-1", [ServerEventRig.Key()], grants, scopePaths, owners, deployedOwners, folders); + rig.Nm.DataPlaneAuthority = state; + rig.Nm.Authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System); + } + + /// The generation's deployed scripted-alarm owners; moves . + private static Dictionary Deployed(string alarmOwner = Pump) => + new() { [Alarm] = alarmOwner, [PumpLo] = Pump, [FanAlarm] = Fan }; + + private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) => + KeyGrant($"eq-{equipmentId}", NodeAclScopeKind.Equipment, equipmentId, flags); + + private static NodeAcl LineGrant(string lineId) => + KeyGrant($"line-{lineId}", NodeAclScopeKind.UnsLine, lineId, NodePermissions.ReadOnly); + + private static NodeAcl TagGrant() => KeyGrant("tag-flow", NodeAclScopeKind.Tag, FlowTagId, Operator); + + private static NodeAcl KeyGrant(string id, NodeAclScopeKind kind, string scopeId, NodePermissions flags) => new() + { + NodeAclId = id, + ClusterId = ServerEventRig.Cluster, + Principal = ServerEventRig.KeyPrincipal, + ScopeKind = kind, + ScopeId = scopeId, + PermissionFlags = flags, + }; + + private static List Route(ServerEventRig rig) + { + var routed = new List(); + rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); }; + return routed; + } + + private static void AssertNoRoles(ServerEventRig rig) => + rig.Server.CurrentInstance.SessionManager.GetSessions() + .Select(s => s.EffectiveIdentity).OfType() + .Single(i => i.ApiKeyId == ServerEventRig.KeyId) + .Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role"); + + /// The s= id of one of a live condition's instance-scoped children. + private static string ChildId(ServerEventRig rig, string alarmId, Func child) => + child(rig.Nm.TryGetAlarmCondition(alarmId).ShouldNotBeNull()).NodeId.IdentifierAsString; + + private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) => + rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns); + + private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id); + + private static NodeId RootNodeId(ISession session) => + new(V3NodeIds.SharedRootFolderId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + + private static async Task ReadAsync(ISession session, NodeId node, uint attribute, CancellationToken ct) + { + var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[] + { + new() { NodeId = node, AttributeId = attribute }, + }, ct); + return response.Results.ToList().ShouldHaveSingleItem().StatusCode; + } + + private static async Task<(StatusCode Status, List Ids)> BrowseAsync( + ISession session, NodeId parent, CancellationToken ct) + { + var response = await session.BrowseAsync( + requestHeader: null, + view: null, + requestedMaxReferencesPerNode: 0, + nodesToBrowse: + [ + new BrowseDescription + { + NodeId = parent, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All, + }, + ], + ct: ct); + + var result = response.Results[0]; + return (result.StatusCode, result.References.ToList().Select(r => r.NodeId.IdentifierAsString).ToList()); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs index 6f147a1c8..dce66a0a1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs @@ -79,6 +79,13 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable new Dictionary> { [new AclScopeRef(NodeAclScopeKind.Equipment, "eq-EqA")] = [new NodeAclPath([NodeAclRealms.Uns, "EqA"])], + }, + // Gitea #808: a UNS folder is placed through the generation's folder scopes, never by splitting its id. + // This test is about model-change narrowing, so it keeps its synthetic root-level folder (id EqA, path + // [uns, EqA]) and now says so explicitly; production-shaped placement is UnsFolderScopeWireTests' job. + unsFolderScopes: new Dictionary + { + ["EqA"] = new(new NodeAclPath([NodeAclRealms.Uns, "EqA"]), ["EqA"]), }); return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance); } 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 b92cfeada..7f87d136d 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 @@ -254,4 +254,62 @@ public sealed class DeploymentArtifactScopePathTests owner.ShouldBe("EQ-mixer"); snapshot.TryGetScriptedAlarmOwner("scripted-no-owner", out _).ShouldBeFalse(); } + + /// + /// Gitea #808 — the artifact yields every UNS Area / Line / Equipment folder's placement: the SAME path a grant + /// on that entity is planted at, plus the folder-id chain the address space nests it under. The path equality + /// is what lets a narrow grant reach the opaque-id folder it names; the chain is what the authorizer compares + /// the live folder against. + /// + [Fact] + public void Every_uns_folder_scope_equals_its_entity_grant_path_and_carries_its_id_chain() + { + var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); + + var folders = projection.UnsFolderScopes; + folders.Keys.Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "UA-w", "UL-1"]); + folders["UA-w"].Path.Segments.ShouldBe(["uns", "West"]); + folders["UA-w"].IdChain.ShouldBe(["UA-w"]); + folders["UL-1"].Path.Segments.ShouldBe(["uns", "West", "L1"]); + folders["UL-1"].IdChain.ShouldBe(["UA-w", "UL-1"]); + folders["EQ-mixer"].IdChain.ShouldBe(["UA-w", "UL-1", "EQ-mixer"]); + folders["EQ-mixer"].Path.Segments.ShouldBe( + projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")].ShouldHaveSingleItem().Segments); + + var snapshot = new DataPlaneAuthorityState().Apply( + "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, + projection.ScriptedAlarmOwners, projection.UnsFolderScopes); + snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-1", "EQ-mixer"], out var segments).ShouldBeTrue(); + segments.ShouldBe(["West", "L1", "Mixer"]); + // A live folder nested differently from what the generation deploys (moved, not yet rebuilt) is not placed + // at all — neither at the generation's path nor at its old one. + snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-2", "EQ-mixer"], out _).ShouldBeFalse(); + snapshot.TryGetUnsFolderSegments("EQ-mixer", ["EQ-mixer"], out _).ShouldBeFalse(); + snapshot.TryGetUnsFolderSegments("EQ-gone", ["UA-w", "UL-1", "EQ-gone"], out _).ShouldBeFalse(); + } + + /// + /// Gitea #808 — an id two UNS entity kinds share (the address space can hold only one folder for it) is dropped + /// rather than placed by whichever kind came first; an orphaned line or a line-less equipment has no scope. + /// + [Fact] + public void An_ambiguous_or_orphaned_uns_folder_has_no_scope() + { + var projection = DeploymentArtifact.ParseDataPlaneAuthority( + Encoding.UTF8.GetBytes(""" + { + "UnsAreas": [{"UnsAreaId":"X-1","ClusterId":"c1","Name":"West"}], + "UnsLines": [ + {"UnsLineId":"X-1","UnsAreaId":"X-1","Name":"Clash"}, + {"UnsLineId":"UL-orphan","UnsAreaId":"UA-missing","Name":"Orphan"}, + {"UnsLineId":"UL-ok","UnsAreaId":"X-1","Name":"L1"} + ], + "Equipment": [{"EquipmentId":"EQ-dangling","UnsLineId":"UL-missing","Name":"E"}], + "NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}] + } + """), + NodeId); + + projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]); + } } 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 a975116c0..b08d5bafd 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 @@ -666,11 +666,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase IReadOnlyList grants, IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, - IReadOnlyDictionary? scriptedAlarmOwners = null) + IReadOnlyDictionary? scriptedAlarmOwners = null, + IReadOnlyDictionary? unsFolderScopes = null) { Interlocked.Increment(ref _applies); return new DataPlaneAuthorityState().Apply( - clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners); + clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners, + unsFolderScopes); } }