diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 6488dfddd..feb04ccf3 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -99,10 +99,37 @@ 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, which ALSO requires the owner folder's live chain to match (review: else the new line could command a moved + equipment's alarm while its folder is denied). Reference variables (`s=Area/Line/Equip/Name`) keep their split + path (so Tag grants match) but only while it equals their live equipment folder's placement + one name — a + moved/renamed equipment's old-name variables are denied (Read AND Write) under old and new names until + re-pointed. An id NO live node holds (removed / probe / NodeDeleted `Affected`) is `Unknown`: placed at + `[\0unknown]`, so only Cluster / Namespace-uns grants decide it and a removed opaque id can never split onto a + 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 + 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]`) + install folder scopes explicitly — without them every UNS folder and scripted condition 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..be8288f35 100644 --- a/docs/security.md +++ b/docs/security.md @@ -579,8 +579,32 @@ 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. A scripted condition additionally requires +its owner folder's live ancestry to match the generation's (the folder rule). UNS reference variables +(`s=Area/Line/Equipment/Name`) keep their id as their path — but only while it equals their live equipment folder's +placement plus one name, so a moved or renamed equipment's variables (still on the old-name id) are denied for Read +and Write under old and new names until the address space re-points them. An id that no live node holds (a removed +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 +**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..4b13d3931 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; @@ -2296,6 +2304,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // authority. Never a fallback to splitting the opaque id. var native = false; string? owner = null; + List? ownerChain = null; lock (Lock) { var key = MapKey(condition.NodeId); @@ -2303,12 +2312,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { native = true; } - else if (_scriptedAlarmOwners.TryGetValue(key, out var binding) - && ReferenceEquals(binding.Alarm, condition) - && _alarmConditions.TryGetValue(key, out var live) - && ReferenceEquals(live, condition)) + else { - owner = binding.EquipmentId; + owner = BoundScriptedOwner(key, condition); + // Gitea #808 review: the owner's folder must also sit where the generation deploys it. + ownerChain = BoundScriptedOwnerChain(key, condition); } } @@ -2316,7 +2324,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ? _authorizer!.Authorize( identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId)) : _authorizer!.AuthorizeScriptedCondition( - identity, AlarmOperationFor(operation), condition.NodeId, owner); + identity, AlarmOperationFor(operation), condition.NodeId, owner, ownerChain); } // The activation-time AlarmAck role is honoured only while the key that earned it is @@ -4203,6 +4211,162 @@ 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 — but only while it equals its live equipment folder's + /// placement + one name (, #808 review). + /// Anything no live node holds (removed, never deployed, probed): + /// — decided only by a realm-wide grant. + /// 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 held by NO live node — removed, never deployed, or a + // probe — and is Unknown: only a realm-wide grant decides it (review of #808: splitting a removed opaque + // id let it match a narrow grant on an unrelated entity of the same name). + var conditionId = slash < 0 ? identifier : identifier[..slash]; + var conditionKey = MapKey(AddressSpaceRealm.Uns, conditionId); + if (!_alarmConditions.TryGetValue(conditionKey, out var condition)) return UnsAclSubject.Unknown; + + lock (Lock) + { + if (_nativeAlarmNodeIds.Contains(conditionKey)) return UnsAclSubject.Path; + return UnsAclSubject.ScriptedCondition( + conditionId, BoundScriptedOwner(conditionKey, condition), BoundScriptedOwnerChain(conditionKey, condition)); + } + } + + // 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; + } + + // No live hierarchy folder above it: a shape only test rigs mint (a UNS node hung straight under the root). + if (owning is null) return UnsAclSubject.Path; + + var chain = LiveHierarchyChain(owning); + + // A node whose first id segment is NOT its equipment's id is a UNS reference variable + // (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). + return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString) + ? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning)) + : UnsAclSubject.ReferenceVariable(chain); + } + + /// + /// The live ancestry of , root-first and ending at it: every live hierarchy folder (a + /// slash-free UNS folder id) up to the first node that is not one (the shared root). + /// + /// A live hierarchy folder. + /// The id chain. + private List LiveHierarchyChain(NodeState folder) + { + var chain = new List(3); + NodeState? current = folder; + 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 chain; + } + + /// The EquipmentId the exact live is bound to, or null. Call under Lock. + private string? BoundScriptedOwner(RealmKey key, ConditionState condition) => + _scriptedAlarmOwners.TryGetValue(key, out var binding) + && ReferenceEquals(binding.Alarm, condition) + && _alarmConditions.TryGetValue(key, out var live) + && ReferenceEquals(live, condition) + ? binding.EquipmentId + : null; + + /// + /// The live folder-id ancestry of the equipment folder a BOUND scripted condition hangs under (the #808 folder + /// rule applied to the #797 owner), or null when unbound or no longer under that folder. Call under Lock. + /// + private List? BoundScriptedOwnerChain(RealmKey key, ConditionState condition) => + BoundScriptedOwner(key, condition) is { } owner + && ParentOf(condition) is FolderState parent + && LiveUnsFolderId(parent) is { } parentId + && string.Equals(parentId, owner, StringComparison.Ordinal) + ? LiveHierarchyChain(parent) + : null; + + /// 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 +5452,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..36ec94171 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,41 @@ 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"; + + /// + /// The single segment, directly under the UNS realm, that an identifier no live node holds is placed at + /// (Gitea #808 review): only a realm-wide grant reaches it, and — unlike the realm root itself — it carries no + /// Browse transit, so a narrow grantee is never handed a removed opaque id (a NodeDeleted model change, a + /// probe) and a removed id can never match a narrow grant on an unrelated entity that shares its name. + /// + internal const string UnknownNodeSegment = "\u0000unknown"; + + /// The depth of a UNS reference-variable id: Area/Line/Equipment/EffectiveName. + private const int ReferenceVariableSegments = 4; + /// /// Authorizes on through the decision cache — /// the entry point for Read, Browse and subscription creation, which ask the same question many @@ -275,12 +310,15 @@ public sealed class DataPlaneAuthorizer /// The condition the method targets (for the denial log only — never parsed). /// The EquipmentId the live condition is bound to, or null when the /// node manager holds no binding for this exact instance. + /// The LIVE folder-id ancestry of the equipment folder the condition hangs under + /// (root-first, ending at ), or null when unbound (Gitea #808 review). /// null to proceed; otherwise the veto to return to the SDK. public ServiceResult? AuthorizeScriptedCondition( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId conditionNodeId, - string? ownerEquipmentId) + string? ownerEquipmentId, + IReadOnlyList? ownerFolderChain) { if (identity is null) return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity"); @@ -300,28 +338,11 @@ 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)) + conditionNodeId.TryGetValue(out string alarmId); + if (!TryPlaceScriptedCondition( + snapshot, alarmId, ownerEquipmentId, ownerFolderChain, out var segments, out var reason)) { - 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}"); + return Deny(operation, conditionNodeId, identity.Principal, reason); } var scope = new NodeScope @@ -336,6 +357,173 @@ 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 live id ancestry of the owner's folder, 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, + IReadOnlyList? ownerFolderChain, + 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; + } + + // The owner's FOLDER must also sit where this generation deploys it (the folder rule, #808 review): an + // equipment moved to another line keeps its id, so the owner checks above pass while its folder — and this + // alarm under it — still hangs under the old line. Without this the new line's grant could read and command + // the alarm while the folder above it is denied. + if (ownerFolderChain is null + || !snapshot.TryGetUnsFolderSegments(ownerEquipmentId, ownerFolderChain, out var folderSegments) + || !folderSegments.SequenceEqual(segments, StringComparer.Ordinal)) + { + segments = []; + reason = $"owner equipment {ownerEquipmentId}'s folder is not placed as generation {snapshot.GenerationId} deploys it (moved, or not yet rebuilt)"; + return false; + } + + reason = string.Empty; + return true; + } + + /// + /// Turns a non- UNS subject into the segments it is authorized at in + /// (Gitea #808). For a folder, a node a folder owns, a reference variable and a + /// scripted condition every gap fails — the caller denies, with no fallback to splitting the identifier, to + /// the realm root or to the cluster. The one deliberate exception is + /// (no live node holds the id): it is placed at directly under the realm, so + /// only a realm-wide (Cluster / Namespace uns) grant decides it — never a narrow grant that + /// happens to share a name with a removed opaque id — except a reference-variable-shaped id (exactly four + /// segments), which keeps its name-path split. + /// + /// The node's s= identifier (split only for the shapes that are paths). + /// 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( + string identifier, + DataPlaneAuthoritySnapshot snapshot, + in UnsAclSubject subject, + out IReadOnlyList segments, + out string reason) + { + IReadOnlyList owner; + bool owned; + switch (subject.Kind) + { + case UnsAclSubjectKind.Unknown: + { + var split = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries); + segments = split.Length == ReferenceVariableSegments ? split : [UnknownNodeSegment]; + reason = string.Empty; + return true; + } + + case UnsAclSubjectKind.ReferenceVariable: + { + // A reference variable's id IS its name path, so a Tag grant keeps matching it — but only while that + // path is where THIS generation puts its equipment (plus one effective-name segment). An equipment + // moved to another line, or an Area/Line/Equipment renamed, leaves the variable's id on the OLD names + // until the address space re-points it; the old names' grants must not keep reading / writing it. + var split = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (subject.FolderChain is not { Count: > 0 } equipmentChain + || !snapshot.TryGetUnsFolderSegments(equipmentChain[^1], equipmentChain, out var equipment) + || split.Length != equipment.Count + 1 + || !split.Take(equipment.Count).SequenceEqual(equipment, StringComparer.Ordinal)) + { + segments = []; + reason = $"the UNS reference variable's path does not match its equipment {subject.FolderChain?.LastOrDefault() ?? "(none)"}'s placement in generation {snapshot.GenerationId} (moved, renamed, or not yet re-pointed)"; + return false; + } + + segments = split; + reason = string.Empty; + return true; + } + + 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, subject.FolderChain, 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 s= identifier — which Evaluate ENFORCES by denying any + /// identifier that contains a NUL before the cache is consulted (review of #808: otherwise a client reading + /// the nonexistent s="\0Area1\0Line1\0Pump" would memoize a deny under the real folder's key for its + /// whole key shard). + /// + /// 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 @@ -400,10 +588,38 @@ public sealed class DataPlaneAuthorizer "the node id carries no string identifier, so it addresses no ACL scope", cached); } + // No node this server mints carries a NUL, and the resolved-scope cache keys are NUL-prefixed: refusing NUL + // here keeps the two keyspaces disjoint, so a client-chosen identifier can never collide with (and poison) + // a resolved scope's memoized verdict. Denied uncached, before the lookup. + if (identifier.Contains('\u0000')) + { + return Deny(operation, nodeId, identity.Principal, + "the node id carries a NUL, which no node in this address space does", cached); + } + 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(identifier, 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 +628,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 +650,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..f5c6c6238 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/UnsAclSubject.cs @@ -0,0 +1,120 @@ +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 and nothing live vouches for it — a UNS node with no live + /// hierarchy-folder ancestor (only test rigs mint those). Split as before. + /// + Path, + + /// + /// No live node holds the identifier (removed, never deployed, or a client probing). Only a realm-wide grant + /// may decide it — except a reference-variable-shaped id (exactly four segments), which keeps its split so a + /// removed reference variable's NodeDeleted still reaches the grantees of that name path. + /// + Unknown, + + /// + /// A UNS reference variable (s=Area/Line/Equipment/EffectiveName) hanging under a live equipment folder: + /// authorized at its split path, but only while that path equals the equipment's placement in the current + /// generation plus one effective-name segment (a moved or renamed equipment's variables are denied until the + /// address space re-points them). + /// + ReferenceVariable, + + /// + /// 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 }; + + /// No live node holds the identifier. + public static readonly UnsAclSubject Unknown = new() { Kind = UnsAclSubjectKind.Unknown }; + + /// Which placement rule applies. + public UnsAclSubjectKind Kind { get; init; } + + /// + /// The owning folder's LIVE id ancestry, root-first, ending at the owning folder itself + /// (e.g. [UA-…, UL-…, EQ-…]): for and + /// the folder at or above the node; for + /// the folder the bound condition hangs under (null when + /// unbound). + /// + 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 live id ancestry of the folder the condition hangs under, or null. + /// The subject. + public static UnsAclSubject ScriptedCondition( + string alarmId, string? ownerEquipmentId, IReadOnlyList? ownerFolderChain) => + new() + { + Kind = UnsAclSubjectKind.ScriptedCondition, + AlarmId = alarmId, + OwnerEquipmentId = ownerEquipmentId, + FolderChain = ownerFolderChain, + }; + + /// A UNS reference variable under a live equipment folder. + /// Root-first live ancestry ending at the variable's equipment folder. + /// The subject. + public static UnsAclSubject ReferenceVariable(IReadOnlyList equipmentChain) => + new() { Kind = UnsAclSubjectKind.ReferenceVariable, FolderChain = equipmentChain }; +} 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..35534fb7c 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); @@ -287,6 +298,23 @@ public sealed class ScriptedAlarmOwnerScopeWireTests new Dictionary { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") }, []); + /// + /// The topology's UNS folder placements (Gitea #808), from the same resolver: a scripted condition is placed + /// only while its owner's live folder ancestry matches the generation's. + /// + private static Dictionary FolderScopes() + { + var resolver = Resolver(); + NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); + return new Dictionary + { + [AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]), + [LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]), + [Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]), + [Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]), + }; + } + private static Dictionary OwnerPaths() { var resolver = Resolver(); @@ -313,7 +341,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests private static void Reapply( DataPlaneAuthorityState state, string revision, IReadOnlyDictionary deployedOwners, ApiKey[] keys, params NodeAcl[] grants) => - state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners); + state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes()); /// The generation's deployed scripted-alarm owners; moves the Pump alarm. private static Dictionary Deployed(string alarmOwner = Pump) => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs index d50080820..8375fbb6e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs @@ -18,6 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; public sealed class Sdk2HistoryAuthorityWireTests { private const string ValuePath = RawDeviceFolder + "/History"; + + // Gitea #808 review: a UNS variable is authorized at its placed path, and a reference-variable id that is not + // its equipment's path + a name is denied (a moved/renamed equipment's stale ids). The alias used to reuse the + // RAW path as its UNS id — a shape production never mints — so it now carries a UNS-shaped id under EqA. + private const string AliasPath = EqA + "/HistoryAlias"; private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); public static TheoryData Cases => new( @@ -79,20 +84,20 @@ public sealed class Sdk2HistoryAuthorityWireTests private static void Apply(DataPlaneAuthorityState state, ApiKey key, NodeAcl[] grants) => state.Apply(ServerEventRig.Cluster, Guid.NewGuid().ToString("N"), [key], grants, - new Dictionary>()); + new Dictionary>(), unsFolderScopes: FolderScopes()); private static void Materialise(ServerEventRig rig) { rig.MaterialiseNative(); rig.Sink.EnsureVariable(ValuePath, RawDeviceFolder, "History", "Double", false, AddressSpaceRealm.Raw, "same-backend-tag"); - rig.Sink.EnsureVariable(ValuePath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag"); + rig.Sink.EnsureVariable(AliasPath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag"); } private static async Task PageAsync(ISession session, string kind, bool alias, ByteString point, CancellationToken ct) { var realm = alias ? V3NodeIds.UnsNamespaceUri : V3NodeIds.RawNamespaceUri; - var path = kind == "events" ? alias ? EqA : RawDeviceFolder : ValuePath; + var path = kind == "events" ? alias ? EqA : RawDeviceFolder : alias ? AliasPath : ValuePath; var node = new NodeId(path, (ushort)session.NamespaceUris.GetIndex(realm)); HistoryReadDetails details = kind == "events" ? new ReadEventDetails { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs index 9b3bfd7dd..4f512cf39 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs @@ -95,7 +95,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests if (change == "grant") grants = []; if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; } if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20)); - else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners()); + else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes()); var error = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, methodId, ct, Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded")))); @@ -107,7 +107,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests confirmNode.Value.ShouldBe(beforeConfirm); // A subsequent authorized same-item sentinel drains any queued method event before the assertion. clock.Reset(); - state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners()); + state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes()); rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel")); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel"); 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..0b004a00d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs @@ -0,0 +1,605 @@ +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, everything it owns, its scripted alarm (read + /// AND Acknowledge) and its UNS reference variable (still on its OLD-name id — read AND write) are denied under + /// the new line's grant AND the old one's. After the rebuild re-parents it (and re-ids the reference variable), + /// the new line's grant reaches all of it and the old line's reaches none of it. + /// + [Theory] + [InlineData(Line1)] + [InlineData(Line2)] + public async Task A_moved_equipment_and_everything_under_it_is_denied_until_the_rebuild_reparents_it(string grantedLine) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync($"Uns808Folder{grantedLine[^1..]}", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line2, Deployed(), LineGrant(grantedLine)); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var oldFlow = FlowId(Line1); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, oldFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await WriteAsync(session, Uns(session, oldFlow), 1.5, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + // The condition must carry a real EventId first, or the SDK refuses the call before any gate runs. + Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base")); + (await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct))) + .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.ShouldBeEmpty(); + // Positive control: the sibling, placed identically by both generations, follows the grant. + (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)) + .ShouldBe(grantedLine == Line1 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied); + + rig.Sink.RebuildAddressSpace(); + Topology(rig, pumpLine: Line2); + + var allowed = grantedLine == Line2; + var expected = allowed ? StatusCodes.Good : StatusCodes.BadUserAccessDenied; + var newFlow = FlowId(Line2); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(expected); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(expected); + (await ReadAsync(session, Uns(session, newFlow), Attributes.DisplayName, ct)).ShouldBe(expected); + (await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(expected); + var write = await WriteAsync(session, Uns(session, newFlow), 1.5, ct); + if (allowed) write.ShouldNotBe(StatusCodes.BadUserAccessDenied, "the new line's grant reaches the re-ided variable"); + else write.ShouldBe(StatusCodes.BadUserAccessDenied); + + Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "rebuilt")); + var ack = ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct); + if (allowed) await ack; + else (await Should.ThrowAsync(ack)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.Count.ShouldBe(allowed ? 1 : 0); + + await session.CloseAsync(ct); + } + + /// + /// An Area RENAMED by the generation before the address space re-ids its reference variables: the folders keep + /// their opaque ids (still placed — the Equipment grant reads the folder and its virtual tag), but the reference + /// variable's id still spells the OLD name, so it is denied until it is re-pointed. + /// + [Fact] + public async Task A_reference_variable_on_a_stale_name_path_is_denied_while_its_folder_is_placed() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808Rename", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + // The Tag grant's path still spells the OLD Area name (Install plants it at Area1/Line1/Pump/Flow) — standing in + // for any grant that names the old path, e.g. another Area that has since been given the name "Area1". + Install(rig, pumpLine: Line1, Deployed(), areaName: "AreaRenamed", EquipmentGrant(Pump, Operator), TagGrant()); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + + await session.CloseAsync(ct); + } + + /// + /// 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 + /// not memoize a deny the real folder then inherits. A NUL-bearing id is refused before the cache. + /// + [Fact] + public async Task A_nul_bearing_identifier_cannot_poison_a_resolved_scopes_cached_verdict() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808Nul", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator)); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + (await ReadAsync(session, Uns(session, "\u0000Area1\u0000Line1\u0000Pump"), Attributes.DisplayName, ct)) + .ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + + await session.CloseAsync(ct); + } + + /// + /// Review of #808: an identifier NO live node holds (a removed folder, virtual tag or scripted alarm — what a + /// NodeDeleted model change names — or a probe) is decided only by a realm-wide grant. It no longer splits, so a + /// removed opaque id never matches a narrow grant on an unrelated entity of the same NAME (an Area named like a + /// removed alarm id). A reference-variable-shaped id (four segments) keeps its name-path split. + /// + [Fact] + public async Task An_unknown_uns_identifier_is_decided_only_by_a_realm_wide_grant() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808Unknown", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + Install(rig, pumpLine: Line1, Deployed(), + KeyGrant("area", NodeAclScopeKind.UnsArea, AreaId, NodePermissions.ReadOnly), + new NodeAcl + { + NodeAclId = "anon-cluster", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous, + ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.ReadOnly, + }); + + using var narrow = await rig.ConnectAsync(anonymous: false, ct); + using var wide = await rig.ConnectAsync(anonymous: true, ct); + + // "Area1" is the granted Area's NAME — a removed scripted alarm with that id used to split onto it. + foreach (var gone in new[] { "Area1", "EQ-00000000dead", "Area1/Line1", "scripted-removed/EnabledState" }) + { + (await ReadAsync(narrow, Uns(narrow, gone), Attributes.DisplayName, ct)) + .ShouldBe(StatusCodes.BadUserAccessDenied, gone); + (await ReadAsync(wide, Uns(wide, gone), Attributes.DisplayName, ct)) + .ShouldBe(StatusCodes.BadNodeIdUnknown, $"{gone}: a realm-wide grant still decides it"); + } + + // A four-segment (reference-variable-shaped) id keeps its name-path split under the Area grant. + (await ReadAsync(narrow, Uns(narrow, "Area1/Line1/Pump/Gone"), Attributes.DisplayName, ct)) + .ShouldBe(StatusCodes.BadNodeIdUnknown); + // Positive control: the live Area folder itself. + (await ReadAsync(narrow, Uns(narrow, AreaId), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + + await narrow.CloseAsync(ct); + await wide.CloseAsync(ct); + } + + /// + /// The placements through the PRODUCTION producer: a deployment artifact's UNS rows, scripted alarms and grant → + /// DeploymentArtifact.ParseDataPlaneAuthority (its folder scopes, owner paths, deployed owners and grant + /// paths) → install → wire decisions, with no hand-built scope anywhere. + /// + [Fact] + public async Task Placements_decoded_from_a_deployment_artifact_authorize_a_narrow_grant_over_the_wire() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns808Artifact", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + var routed = Route(rig); + + var artifact = $$""" + { + "UnsAreas": [{"UnsAreaId":"{{AreaId}}","ClusterId":"{{ServerEventRig.Cluster}}","Name":"Area1"}], + "UnsLines": [ + {"UnsLineId":"{{Line1}}","UnsAreaId":"{{AreaId}}","Name":"Line1"}, + {"UnsLineId":"{{Line2}}","UnsAreaId":"{{AreaId}}","Name":"Line2"} + ], + "Equipment": [ + {"EquipmentId":"{{Pump}}","UnsLineId":"{{Line1}}","Name":"Pump"}, + {"EquipmentId":"{{Fan}}","UnsLineId":"{{Line1}}","Name":"Fan"} + ], + "ScriptedAlarms": [ + {"ScriptedAlarmId":"{{Alarm}}","EquipmentId":"{{Pump}}","Name":"hi"}, + {"ScriptedAlarmId":"{{PumpLo}}","EquipmentId":"{{Pump}}","Name":"lo"}, + {"ScriptedAlarmId":"{{FanAlarm}}","EquipmentId":"{{Fan}}","Name":"fan hi"} + ], + "NodeAcls": [ + {"NodeAclId":"acl-pump","ClusterId":"{{ServerEventRig.Cluster}}","Principal":"{{ServerEventRig.KeyPrincipal}}", + "ScopeKind":{{(int)NodeAclScopeKind.Equipment}},"ScopeId":"{{Pump}}","PermissionFlags":{{(int)Operator}}} + ] + } + """; + var projection = ZB.MOM.WW.OtOpcUa.Runtime.Drivers.DeploymentArtifact.ParseDataPlaneAuthority( + System.Text.Encoding.UTF8.GetBytes(artifact), "node-a:4840"); + projection.UnsFolderScopes.Keys.Order(StringComparer.Ordinal).ShouldBe([Pump, Fan, AreaId, Line1, Line2]); + + var state = new DataPlaneAuthorityState(); + state.Apply(ServerEventRig.Cluster, "rev-artifact", [ServerEventRig.Key()], projection.Grants, + projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); + rig.Nm.DataPlaneAuthority = state; + rig.Nm.Authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + (await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct)) + .ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, FanAlarm, pre with { Message = "fan-base" }); + Project(rig, Alarm, pre); + await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base")); + events.Has(server, "fan-base").ShouldBeFalse(); + + await ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct); + routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]); + + await session.CloseAsync(ct); + } + + // ───────────────────────────── helpers ───────────────────────────── + + /// + /// 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(FlowId(pumpLine), Pump, "Flow", "Double", writable: true, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(PumpLo, Pump, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + } + + /// The live reference variable's id when Pump hangs under (its NAME path). + private static string FlowId(string pumpLine) => $"Area1/{(pumpLine == Line1 ? "Line1" : "Line2")}/Pump/Flow"; + + private static AclScopePathResolver Resolver(string pumpLine, string areaName) => new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary { [AreaId] = areaName }, + 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) => + Install(rig, pumpLine, deployedOwners, "Area1", grants); + + /// As above, with the generation naming the Area (a rename the address space + /// 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) + { + 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"])], + }; + 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, Operator | NodePermissions.WriteOperate); + + private static NodeAcl TagGrant() => KeyGrant("tag-flow", NodeAclScopeKind.Tag, FlowTagId, Operator); + + private static NodeAcl KeyGrant(string id, NodeAclScopeKind kind, string scopeId, NodePermissions flags) => new() + { + NodeAclId = id, + ClusterId = ServerEventRig.Cluster, + Principal = ServerEventRig.KeyPrincipal, + ScopeKind = kind, + ScopeId = scopeId, + PermissionFlags = flags, + }; + + private static List 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 WriteAsync(ISession session, NodeId node, double value, CancellationToken ct) + { + var response = await session.WriteAsync(null, new WriteValue[] + { + new() { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) }, + }, ct); + return response.Results.ToList().ShouldHaveSingleItem(); + } + + private static async Task<(StatusCode Status, List 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..69ca4ff7f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs @@ -72,6 +72,75 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// Gitea #808 review — NodeDeleted delivery on a production-shaped (opaque-id) tree. A NodesAdded change for a + /// LIVE equipment folder reaches the narrow Equipment grantee (it is placed); a NodeDeleted change names a node + /// no live map holds any more, so only a realm-wide grantee receives it — the removed opaque id (a virtual tag, + /// a scripted alarm whose id equals the granted Area's NAME) is never split onto an unrelated narrow grant. + /// + [Fact] + public async Task A_removed_opaque_uns_node_is_announced_only_to_realm_wide_grantees() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + sink.EnsureFolder("UA-1", null, "Area1", AddressSpaceRealm.Uns); + sink.EnsureFolder("UL-1", "UA-1", "Line1", AddressSpaceRealm.Uns); + sink.EnsureFolder("EQ-1", "UL-1", "Pump", AddressSpaceRealm.Uns); + + var state = new DataPlaneAuthorityState(); + NodeAcl[] grants = + [ + new NodeAcl + { + NodeAclId = "acl-eq", ClusterId = Cluster, Principal = Principal, + ScopeKind = NodeAclScopeKind.Equipment, ScopeId = "EQ-1", PermissionFlags = NodePermissions.Browse, + }, + new NodeAcl + { + NodeAclId = "acl-area", ClusterId = Cluster, Principal = "area-key", + ScopeKind = NodeAclScopeKind.UnsArea, ScopeId = "UA-1", PermissionFlags = NodePermissions.Browse, + }, + new NodeAcl + { + NodeAclId = "acl-all", ClusterId = Cluster, Principal = "everything", + ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Browse, + }, + ]; + var area = new NodeAclPath([NodeAclRealms.Uns, "Area1"]); + var line = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1"]); + var pump = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump"]); + state.Apply(Cluster, "rev-1", [], grants, + new Dictionary> + { + [new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-1")] = [pump], + [new AclScopeRef(NodeAclScopeKind.UnsArea, "UA-1")] = [area], + }, + new Dictionary { ["EQ-1"] = pump }, + unsFolderScopes: new Dictionary + { + ["UA-1"] = new(area, ["UA-1"]), + ["UL-1"] = new(line, ["UA-1", "UL-1"]), + ["EQ-1"] = new(pump, ["UA-1", "UL-1", "EQ-1"]), + }); + nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance); + + var added = nm.BuildNodesAddedModelChange("EQ-1", AddressSpaceRealm.Uns); + nm.FilterModelChangeFor(Identity(Principal), added).ShouldBeSameAs(added, "the live folder is placed"); + + foreach (var gone in new[] { "EQ-1/Speed", "Area1", "scripted-gone" }) + { + var deleted = nm.BuildNodesRemovedModelChange(gone, AddressSpaceRealm.Uns); + nm.FilterModelChangeFor(Identity(Principal), deleted).ShouldBeNull(gone); + nm.FilterModelChangeFor(Identity("area-key"), deleted).ShouldBeNull($"{gone}: no name-collision match"); + nm.FilterModelChangeFor(Identity("everything"), deleted).ShouldBeSameAs(deleted, gone); + } + } + finally { await host.DisposeAsync(); } + } + private static DataPlaneAuthorizer Authorizer(params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); @@ -79,6 +148,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.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs index 14fd89e02..50f5e907d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs @@ -234,7 +234,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable // The generation no longer places the owner (equipment deleted from the artifact while the // node is still served): the grant's own scope stays resolvable, the owner does not. state.Apply(Cluster, "rev-2", [key], [grant], Resolver().Resolve([grant]), - new Dictionary(), Deployed()); + new Dictionary(), Deployed(), FolderScopes()); break; } @@ -595,7 +595,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable { [new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])], }; - state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed()); + state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed(), FolderScopes()); condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good); condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good); @@ -684,6 +684,23 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable }; } + /// + /// Gitea #808 review: the scripted-condition placement also requires the owner's LIVE folder ancestry to match + /// the generation's, so every install here carries the topology's folder scopes (same resolver as the owners). + /// + private static Dictionary FolderScopes() + { + var resolver = Resolver(); + NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); + return new Dictionary + { + [AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]), + [LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]), + [Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]), + [Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]), + }; + } + private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) => Wire(nm, clock, keys, enabled: true, Areas(), grants); @@ -702,7 +719,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable IReadOnlyDictionary areas, NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); - state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed()); + state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed(), FolderScopes()); var authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock); nm.DataPlaneAuthority = state; @@ -717,7 +734,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable private static void Reapply( DataPlaneAuthorityState state, string revision, IReadOnlyDictionary deployedOwners, ApiKey[] keys, params NodeAcl[] grants) => - state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners); + state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes()); /// /// The generation's deployed scripted-alarm owners (the artifact's ScriptedAlarms rows); 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); } }