diff --git a/docs/security.md b/docs/security.md index c68a09a83..9345706c9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -561,6 +561,22 @@ the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource | Alarm Acknowledge / AddComment / Enable / Disable | role `AlarmAck` | `AlarmAcknowledge` on the condition's scope | | Alarm Confirm | role `AlarmAck` | `AlarmConfirm` | | Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` | + +**Whose scope a condition's command is decided at (Gitea #797).** A *native* condition's id is its raw tag's +RawPath, so its commands are decided on that path (a `Tag` / `RawDevice` / … grant covering the raw tag), as +before. A *scripted* condition's id is its opaque, operator-authored `ScriptedAlarmId` (e.g. `scripted-eqa-hi`) +and is **never** split into a scope: `OtOpcUaNodeManager` binds each LIVE scripted condition instance to the +`EquipmentId` it was materialised under (only when it actually hangs under that equipment's folder), and +`DataPlaneAuthorizer.AuthorizeScriptedCondition` resolves that equipment to its `[uns, Area, Line, Equipment]` +path through the CURRENT authority generation's `EquipmentScopePaths` (decoded from the artifact by the same +`AclScopePathResolver` call that places an `Equipment` grant). So a grant on the owning equipment or any +ancestor (line, area, UNS namespace, cluster) authorizes its scripted alarms; a sibling equipment's grant or a +grant on one tag beneath the equipment does not. 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. | 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/scripts/validation/opcua-scripted-alarm-baseline/README.md b/scripts/validation/opcua-scripted-alarm-baseline/README.md index 3188ae6e5..3afd068ab 100644 --- a/scripts/validation/opcua-scripted-alarm-baseline/README.md +++ b/scripts/validation/opcua-scripted-alarm-baseline/README.md @@ -25,8 +25,10 @@ unchanged. Never load this assembly or old SDK into the candidate process. The real session has no application-wide roles. The production-valid opaque scripted ID is denied by an Equipment grant, routes nothing, and is then allowed by an explicit Cluster grant. A passing probe pins **both** outcomes; it does not -claim narrow equipment authorization works. The independent candidate counterpart -is `Sdk2OpaqueScriptedScopeBaselineTests`. +claim narrow equipment authorization works. The candidate counterpart +`Sdk2OpaqueScriptedScopeBaselineTests` pinned the same denial until #797 was fixed; it now pins the fixed +behaviour (owning equipment grant allows, sibling equipment grant denies, cluster grant still allows). This +probe remains the record of the pre-fix behaviour on the original commit. Do not make the ID slash-qualified to get a positive result: the current UI rejects `/`, and that would hide the production scope-resolution defect. A future 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 357230260..b3800a7b1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -34,7 +34,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary> scopePaths, IReadOnlyList droppedGrants, IReadOnlyDictionary keysById, - IReadOnlyDictionary> grantsByPrincipal) + IReadOnlyDictionary> grantsByPrincipal, + IReadOnlyDictionary equipmentScopePaths) { ClusterId = clusterId; RevisionHash = revisionHash; @@ -45,11 +46,64 @@ public sealed class DataPlaneAuthoritySnapshot DroppedGrants = droppedGrants; _keysById = keysById; _grantsByPrincipal = grantsByPrincipal; + EquipmentScopePaths = equipmentScopePaths; } private readonly IReadOnlyDictionary _keysById; private readonly IReadOnlyDictionary> _grantsByPrincipal; + /// + /// EquipmentId → its UNS ACL path ([uns, Area, Line, Equipment]) for every equipment in + /// the artifact this generation was built from, resolved by the same + /// rule that places an Equipment-scoped grant. Keyed OrdinalIgnoreCase (identifier rule, + /// see ). + /// + /// + /// + /// Why this exists (Gitea #797). A scripted alarm's condition NodeId is its opaque + /// ScriptedAlarmId (e.g. scripted-eqa-hi), and its equipment folder's NodeId is the + /// opaque EquipmentId — neither is a topology path, so splitting the NodeId (what + /// DataPlaneAuthorizer does for every other node) can never reach the owning equipment's + /// grant. The node manager instead binds each LIVE scripted condition to its owning + /// EquipmentId, and the authorizer resolves that id to a path HERE, against the same + /// generation the grants and the trie came from — so an equipment rename or move that a deploy + /// carries is seen by grants and by owner paths together, never one ahead of the other. + /// + /// + /// Not a grant table and never consulted by the trie build: an entry confers nothing; it only says + /// where in the UNS tree a given equipment sits. An equipment absent here (deleted, line-less, + /// dangling) has no owner path and its scripted conditions are denied under enforcement. + /// + /// + public IReadOnlyDictionary EquipmentScopePaths { get; } + + /// + /// The owning equipment's path WITHIN the UNS realm (the realm segment stripped), for a scripted + /// condition bound to . False when this generation knows no such + /// equipment or its recorded path is not a UNS path — the caller must deny. + /// + /// The owning equipment's logical id. + /// The path below the realm root, e.g. [Area, Line, Equipment]. + /// when an owner path is known. + public bool TryGetEquipmentUnsSegments(string equipmentId, out IReadOnlyList unsSegments) + { + unsSegments = []; + if (string.IsNullOrEmpty(equipmentId)) return false; + if (!EquipmentScopePaths.TryGetValue(equipmentId, out var path)) return false; + + // Shape-checked rather than trusted: a path that does not start at the UNS realm, or that names the + // realm alone, describes no equipment. Accepting a bare [uns] would evaluate at the realm root — a + // parent fallback this binding exists to rule out. + if (path.Segments.Count < 2 + || !string.Equals(path.Segments[0], NodeAclRealms.Uns, StringComparison.Ordinal)) + { + return false; + } + + unsSegments = 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. @@ -127,6 +181,8 @@ public sealed class DataPlaneAuthoritySnapshot /// The ACL grants for this node's cluster. /// Where each grant's scope sits in the address space. /// Grants the trie build discarded, or empty when no trie was built. + /// Every equipment's UNS ACL path (see ); + /// null ⇒ none, which denies every scripted-condition command under enforcement. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -135,7 +191,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyList keys, IReadOnlyList grants, IReadOnlyDictionary> scopePaths, - IReadOnlyList? droppedGrants = null) + IReadOnlyList? droppedGrants = null, + IReadOnlyDictionary? equipmentScopePaths = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -169,8 +226,22 @@ public sealed class DataPlaneAuthoritySnapshot kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); + // Re-keyed under the identifier comparer here rather than trusted from the caller, the same reason + // AclScopePathResolver re-keys its maps: the binding side (the node manager's EquipmentId) and this + // side must not be able to disagree on case. + var equipment = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (equipmentScopePaths is not null) + { + foreach (var (equipmentId, path) in equipmentScopePaths) + { + if (string.IsNullOrWhiteSpace(equipmentId) || path is null) continue; + equipment[equipmentId] = path; + } + } + return new DataPlaneAuthoritySnapshot( - clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen); + clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen, + equipment); } } @@ -236,13 +307,16 @@ public interface IDataPlaneAuthoritySink /// The API keys the artifact carries. /// The ACL grants for this node's cluster. /// Where each grant's scope sits in the address space. + /// Every equipment's UNS ACL path, resolved from the same artifact — + /// the owner lookup for scripted alarm conditions (Gitea #797). Null ⇒ none. /// The installed snapshot. DataPlaneAuthoritySnapshot Apply( string? clusterId, string revisionHash, IReadOnlyList keys, IReadOnlyList grants, - IReadOnlyDictionary> scopePaths); + IReadOnlyDictionary> scopePaths, + IReadOnlyDictionary? equipmentScopePaths = null); } /// @@ -290,7 +364,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl string revisionHash, IReadOnlyList keys, IReadOnlyList grants, - IReadOnlyDictionary> scopePaths) + IReadOnlyDictionary> scopePaths, + IReadOnlyDictionary? equipmentScopePaths = null) { ArgumentNullException.ThrowIfNull(revisionHash); @@ -323,7 +398,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl var dropped = BuildAndInstallTrie(clusterId, generation, grants, scopePaths); published = DataPlaneAuthoritySnapshot.Create( - clusterId, revisionHash, generation, keys, grants, scopePaths, dropped); + clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths); 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 12a20bf83..7f4d5862f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -66,6 +66,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// a native re-materialise adds, and clears it alongside /// so a re-materialise as the other kind is correct. private readonly HashSet _nativeAlarmNodeIds = new(); + /// + /// Gitea #797 — the explicit binding from each LIVE scripted condition to the equipment that owns it. + /// A scripted condition's NodeId is its opaque ScriptedAlarmId and cannot be split into an ACL + /// scope, so with Security:Acl:Enabled its Part 9 commands are authorized at the owner's scope + /// instead (), resolved through the + /// CURRENT authority generation at each call. + /// + /// Written only by , from the deployed composition's + /// EquipmentId, and only when the condition actually hangs under that equipment's folder. + /// Each entry pins the instance it was made for, so a call that + /// reaches a removed or replaced instance (rebuild, surgical remove, delete + recreate under another + /// equipment) finds no binding and is denied. Removed in lock-step with + /// on every teardown path; cleared by . Never populated for native + /// conditions, which keep their RawPath-scoped decision. Guarded by Lock. + /// + /// + private readonly Dictionary _scriptedAlarmOwners = new(); /// Every NATIVE condition's live state, captured by /// just before it clears , waiting for /// to re-create the same id as native in the same apply. Whatever is unclaimed when @@ -1493,7 +1510,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared // first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. - if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) + // Gitea #797: a scripted condition whose OWNER changed (same id, different equipment) is not "the same" + // and falls through to the drop-and-recreate, so it is re-parented and re-bound rather than left + // under — and authorized as — the equipment it no longer belongs to. + if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative + && (isNative || SameScriptedOwner(conditionKey, equipmentNodeId, realm))) return; // Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it. if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition")) @@ -1522,6 +1543,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new // kind, so always drop the stale native flag first and only re-add it below when isNative. _nativeAlarmNodeIds.Remove(conditionKey); + // Gitea #797: the dropped instance's owner binding goes with it; the new one is bound below. + _scriptedAlarmOwners.Remove(conditionKey); var parent = ResolveParentFolder(equipmentNodeId, realm); @@ -1709,6 +1732,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // H6a: record native (driver-fed) conditions so a later task can route their inbound // Acknowledge to the driver rather than the scripted engine. if (isNative) _nativeAlarmNodeIds.Add(conditionKey); + // Gitea #797: bind the scripted condition to its owning equipment — this exact instance, and only + // when it really hangs under that equipment's folder (an unresolved parent leaves it unbound ⇒ every + // ACL-enforced command on it is denied, never authorized at some guessed scope). + else if (BindableScriptedOwner(equipmentNodeId, realm) is { } owner && ReferenceEquals(parent, owner.Folder)) + _scriptedAlarmOwners[conditionKey] = new ScriptedAlarmOwner(alarm, owner.EquipmentId); // A native condition the rebuild in this apply discarded gets its live state back. Removed // whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it @@ -2089,6 +2117,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId)); } + /// Gitea #797 — the owning equipment a live scripted condition is bound to, for tests and diagnostics. + /// The scripted condition's id (its ScriptedAlarmId). + /// The realm the condition lives in. + /// The bound EquipmentId, or null when the condition is unknown, native or unbound. + internal string? ScriptedAlarmOwnerOf(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + { + lock (Lock) + { + var key = MapKey(realm, alarmNodeId); + return _scriptedAlarmOwners.TryGetValue(key, out var b) + && _alarmConditions.TryGetValue(key, out var live) + && ReferenceEquals(b.Alarm, live) + ? b.EquipmentId + : null; + } + } + + /// + /// The equipment folder a scripted condition materialised with would be + /// bound to: only a UNS condition naming an EXISTING folder qualifies. Call under Lock. + /// + private (string EquipmentId, FolderState Folder)? BindableScriptedOwner(string? equipmentNodeId, AddressSpaceRealm realm) => + realm == AddressSpaceRealm.Uns + && !string.IsNullOrEmpty(equipmentNodeId) + && _folders.TryGetValue(MapKey(realm, equipmentNodeId), out var folder) + ? (equipmentNodeId, folder) + : null; + + /// + /// Whether re-materialising the existing scripted condition at under + /// leaves its owner binding exactly as it is. Call under Lock. + /// + private bool SameScriptedOwner(RealmKey key, string? equipmentNodeId, AddressSpaceRealm realm) + { + var current = _scriptedAlarmOwners.TryGetValue(key, out var b) ? b.EquipmentId : null; + var wanted = BindableScriptedOwner(equipmentNodeId, realm)?.EquipmentId; + return string.Equals(current, wanted, StringComparison.Ordinal); + } + + /// Gitea #797 — one live scripted condition instance and the equipment that owns it. + /// The exact condition instance the binding was made for. + /// The owning equipment's logical id (its UNS folder's s= id). + private sealed record ScriptedAlarmOwner(AlarmConditionState Alarm, string EquipmentId); + /// /// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling /// principal off the SDK , applies the AlarmAck role gate @@ -2218,8 +2290,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (AclEnforced) { - return _authorizer!.Authorize( - identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId)); + // Native conditions (ConditionId = RawPath) keep the NodeId-scoped decision unchanged: their id IS + // the raw tag's path. Everything else is a scripted condition with an opaque id, authorized ONLY at + // the scope of the equipment its exact live instance is bound to (Gitea #797) — no binding, no + // authority. Never a fallback to splitting the opaque id. + var native = false; + string? owner = null; + lock (Lock) + { + var key = MapKey(condition.NodeId); + if (_nativeAlarmNodeIds.Contains(key)) + { + native = true; + } + else if (_scriptedAlarmOwners.TryGetValue(key, out var binding) + && ReferenceEquals(binding.Alarm, condition) + && _alarmConditions.TryGetValue(key, out var live) + && ReferenceEquals(live, condition)) + { + owner = binding.EquipmentId; + } + } + + return native + ? _authorizer!.Authorize( + identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId)) + : _authorizer!.AuthorizeScriptedCondition( + identity, AlarmOperationFor(operation), condition.NodeId, owner); } // The activation-time AlarmAck role is honoured only while the key that earned it is @@ -3755,6 +3852,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a // re-materialise on the next apply (possibly as the other kind) starts from a clean slate. _nativeAlarmNodeIds.Clear(); + _scriptedAlarmOwners.Clear(); foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId); _folders.Clear(); @@ -3913,6 +4011,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.Parent?.RemoveChild(condition); ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); + _scriptedAlarmOwners.Remove(key); LowerAllEventsMonitoring(condition); e = BuildNodesRemovedModelChange(alarmNodeId, realm); } @@ -4021,6 +4120,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c); ForgetPredefinedSubtree(c); _nativeAlarmNodeIds.Remove(key); + _scriptedAlarmOwners.Remove(key); LowerAllEventsMonitoring(c); return; } 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 20dbfb9d6..53110529e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs @@ -239,6 +239,91 @@ public sealed class DataPlaneAuthorizer : null; } + /// + /// Authorizes a Part 9 command on a scripted alarm condition at the scope of the equipment the + /// node manager has bound that exact live condition to (Gitea #797) — never at a scope parsed out of + /// the condition's NodeId. + /// + /// + /// + /// A scripted condition's NodeId is its opaque, operator-authored ScriptedAlarmId + /// (^[A-Za-z0-9_-]+$, e.g. scripted-eqa-hi). would split it as + /// a topology path — [uns, scripted-eqa-hi] — which no Equipment grant can ever + /// match, so before this entry point a session holding the owning equipment's grant was denied + /// while a Cluster grant passed. Worse, that one-segment scope would be satisfied by an + /// unrelated Area that happened to share the alarm's id. + /// + /// + /// Every input is re-read at each call, and every gap denies. The current snapshot (so the + /// current generation, trie and grants), the calling key's usability against this authorizer's + /// clock, and the owner's path in THAT generation's . + /// No owner binding (an unknown, removed or replaced condition), an owner the generation cannot + /// place, or a path that does not name an equipment each deny — there is no fallback to the + /// realm root, the cluster, or the identifier. The decision cache is deliberately not used: the + /// binding lives in the address space, not in the generation, so a cached verdict keyed on the + /// NodeId could outlive a re-bind (delete + recreate of the same id under another equipment). + /// Alarm commands are not a request-rate seam, so the uncached walk costs nothing that matters. + /// + /// + /// The scope is the equipment's OWN node, not the equipment plus a leaf: grants on the equipment + /// or any ancestor (line, area, UNS namespace, cluster) confer the command, and a grant on a single + /// tag beneath the equipment does not — a tag grant is about that signal, not the equipment's alarms. + /// + /// + /// The role-carrying identity off the SDK context, or null. + /// The alarm operation being attempted. + /// 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. + /// null to proceed; otherwise the veto to return to the SDK. + public ServiceResult? AuthorizeScriptedCondition( + RoleCarryingUserIdentity? identity, + OpcUaOperation operation, + NodeId conditionNodeId, + string? ownerEquipmentId) + { + if (identity is null) + return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity"); + + if (string.IsNullOrEmpty(identity.Principal)) + return Deny(operation, conditionNodeId, principal: null, "the session resolved no ACL principal"); + + var snapshot = _authority.Current; + + if (string.IsNullOrWhiteSpace(snapshot.ClusterId)) + { + return Deny(operation, conditionNodeId, identity.Principal, + "this node has no cluster identity, so no permission trie exists to evaluate against"); + } + + var now = _time.GetUtcNow().UtcDateTime; + if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null) + return DenyRevokedKey(operation, conditionNodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false); + + if (string.IsNullOrEmpty(ownerEquipmentId)) + { + return Deny(operation, conditionNodeId, identity.Principal, + "the scripted condition has no live owner binding (unknown, removed or replaced)"); + } + + if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments)) + { + return Deny(operation, conditionNodeId, identity.Principal, + $"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}"); + } + + var scope = new NodeScope + { + ClusterId = snapshot.ClusterId!, + Kind = NodeHierarchyKind.Uns, + Segments = segments, + }; + + return Walk(snapshot, identity, operation, scope, now) + ? null + : DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false); + } + /// /// 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 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 c75bafb33..68c8da45b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -62,6 +62,16 @@ public sealed record DataPlaneAuthorityProjection( IReadOnlyList Grants, IReadOnlyDictionary> ScopePaths) { + /// + /// EquipmentId → [uns, Area, Line, Equipment] for every equipment in the artifact whose UNS + /// chain resolves — the owner paths scripted alarm conditions are authorized at (Gitea #797). Resolved by + /// the SAME instance as , so an + /// Equipment grant's planted path and the owner path of that equipment's scripted alarms are + /// equal by construction. Empty when the artifact carries no grants (nothing could be authorized). + /// + public IReadOnlyDictionary EquipmentScopePaths { 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>()); @@ -907,8 +917,11 @@ internal sealed class ParsedDeploymentArtifact : IDisposable grants.Add(acl); } - return new DataPlaneAuthorityProjection( - scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants)); + var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants); + return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants, scopePaths) + { + EquipmentScopePaths = equipmentScopePaths, + }; } /// @@ -916,11 +929,18 @@ internal sealed class ParsedDeploymentArtifact : IDisposable /// topology, using the same RawPathResolver the address space is built through — so an ACL /// path and the RawPath that forms a node's NodeId cannot drift apart. /// - private static IReadOnlyDictionary> BuildAclScopePaths( + /// The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup, + /// Gitea #797) from the same resolver. + private static (IReadOnlyDictionary> ScopePaths, + IReadOnlyDictionary EquipmentScopePaths) BuildAclScopePaths( JsonElement root, IReadOnlyList grants) { - if (grants.Count == 0) return new Dictionary>(); + if (grants.Count == 0) + { + return (new Dictionary>(), + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } var folders = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "RawFolders")) @@ -1011,7 +1031,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable var resolver = new AclScopePathResolver( folders, drivers, devices, groups, tags, areas, lines, equipment, references); - return resolver.Resolve(grants); + + // Every equipment's own path, through the SAME resolver call an Equipment grant's path takes — the + // scripted-alarm owner binding authorizes at exactly the node an Equipment grant is planted at. An + // equipment whose chain does not resolve is simply absent (its scripted conditions then deny). + var equipmentPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var equipmentId in equipment.Keys) + { + if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; + } + + return (resolver.Resolve(grants), equipmentPaths); } /// 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 be719fc84..6315c9c53 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -1425,7 +1425,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // would be a different byte sequence and would not match the deployment's RevisionHash. var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes)); var snapshot = _dataPlaneAuthority.Apply( - projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths); + projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths, + projection.EquipmentScopePaths); _log.Info( "OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})", diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/DataPlaneAuthorityStateTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/DataPlaneAuthorityStateTests.cs index 8f3120c8b..a1ce556e0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/DataPlaneAuthorityStateTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/DataPlaneAuthorityStateTests.cs @@ -283,6 +283,35 @@ public sealed class DataPlaneAuthorityStateTests snapshot.DroppedGrants.ShouldBeEmpty(); } + /// + /// Gitea #797 — the owner lookup for scripted alarm conditions: a UNS equipment path yields its segments + /// below the realm; anything that does not name an equipment (absent id, a bare realm root, a raw path) + /// yields nothing, so the caller denies rather than evaluating at some broader scope. + /// + [Fact] + public void The_equipment_owner_lookup_returns_only_real_uns_equipment_paths() + { + var sut = new DataPlaneAuthorityState(); + var snapshot = sut.Apply("cluster-a", "rev-1", [], [], NoScopes, new Dictionary + { + ["EQ-1"] = new(["uns", "Area", "Line", "Pump"]), + ["EQ-root"] = new(["uns"]), + ["EQ-raw"] = new(["raw", "Plant", "dev1"]), + }); + + snapshot.TryGetEquipmentUnsSegments("EQ-1", out var segments).ShouldBeTrue(); + segments.ShouldBe(["Area", "Line", "Pump"]); + snapshot.TryGetEquipmentUnsSegments("eq-1", out _).ShouldBeTrue("ids are identifiers: OrdinalIgnoreCase"); + snapshot.TryGetEquipmentUnsSegments("EQ-root", out _).ShouldBeFalse("a bare realm root is not an equipment"); + snapshot.TryGetEquipmentUnsSegments("EQ-raw", out _).ShouldBeFalse("a raw path is not a UNS equipment"); + snapshot.TryGetEquipmentUnsSegments("EQ-unknown", out _).ShouldBeFalse(); + snapshot.TryGetEquipmentUnsSegments("", out _).ShouldBeFalse(); + + // A later generation without the equipment forgets it — the lookup is per generation. + sut.Apply("cluster-a", "rev-2", [], [], NoScopes).TryGetEquipmentUnsSegments("EQ-1", out _).ShouldBeFalse(); + DataPlaneAuthoritySnapshot.Empty.TryGetEquipmentUnsSegments("EQ-1", out _).ShouldBeFalse(); + } + private static NodeAcl Acl(string id, string principal, NodePermissions flags) => new() { NodeAclId = id, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs index 4c311b9ee..37204747f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs @@ -697,7 +697,7 @@ public sealed class ConditionMethodEventTests }; } - private static void AssertNewState(string method, bool native, ArrayOf e) + internal static void AssertNewState(string method, bool native, ArrayOf e) { // A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so). if (native) e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true); @@ -719,7 +719,7 @@ public sealed class ConditionMethodEventTests /// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the /// condition's method declarations — the shelve methods included); throws on a bad result. /// - private static async Task CallAsync( + internal static async Task CallAsync( ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000, string? ackComment = "ack") { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs new file mode 100644 index 000000000..8db2b2542 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ScriptedAlarmOwnerScopeWireTests.cs @@ -0,0 +1,370 @@ +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 #797 over the wire — a real activated apikey session with zero application-wide roles, +/// holding only an Equipment grant, drives the Part 9 methods on a scripted condition whose id is a +/// production-valid opaque ScriptedAlarmId, under a production-shaped UNS tree (every folder an opaque +/// entity id, grant + owner paths resolved by from the NAMES). +/// +/// +/// +/// Observation is through a SEPARATE anonymous session that holds a Cluster grant carrying only +/// Browse/Read/Subscribe/AlarmRead — no alarm-command flag — so it sees every condition event while its +/// own presence can never be what authorizes a command. Every count is read after a later sentinel +/// transition on the same item, so a late duplicate would already be queued ahead of it. +/// +/// +/// The rig's NodeIds stay exactly what production publishes: the condition is s=scripted-eqa-hi in +/// the UNS namespace and every routed is that id unchanged — the +/// ScadaBridge-visible wire contract. +/// +/// +public sealed class ScriptedAlarmOwnerScopeWireTests +{ + private const string AreaId = "UA-0000000001"; + private const string LineId = "UL-0000000001"; + private const string Pump = "EQ-00000000a1b2"; + private const string Fan = "EQ-00000000c3d4"; + private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi" + private const string FanAlarm = "scripted-fan-hi"; + + private const NodePermissions Commands = + NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm + | NodePermissions.AlarmShelve | NodePermissions.MethodCall; + + private const NodePermissions ObserveOnly = + NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead; + + /// + /// Every gated condition method, authorized by the narrow owner grant alone: routes exactly once with the + /// opaque id unchanged and reports exactly one condition event — while the same method on the SIBLING + /// equipment's scripted alarm, on the same session, is denied, routes nothing and reports nothing. + /// + [Theory] + [InlineData("Acknowledge")] + [InlineData("Confirm")] + [InlineData("AddComment")] + [InlineData("OneShotShelve")] + [InlineData("TimedShelve")] + [InlineData("Unshelve")] + [InlineData("Disable")] + [InlineData("Enable")] + public async Task A_narrow_equipment_grant_authorizes_each_method_once_and_its_sibling_is_denied(string method) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync($"Owner797{method}", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig); + Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + AssertNoRoles(rig); + var (events, observer, item) = await ObserveAsync(rig, ct); + + var pre = ConditionMethodEventTests.PreState(method, native: false); + Project(rig, Alarm, pre with { Message = "base" }); + Project(rig, FanAlarm, pre with { Message = "fan-base" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base") && events.Has(item, "fan-base")); + + await ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), method, events.LatestEventId(item, "base"), ct); + var denied = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, FanAlarm), method, events.LatestEventId(item, "fan-base"), ct)); + denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + + Project(rig, Alarm, pre with { Message = "sentinel" }); + Project(rig, FanAlarm, pre with { Message = "fan-sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel") && events.Has(item, "fan-sentinel")); + + var mine = events.ConditionEvents(item, "base"); + mine.Count.ShouldBe(2, $"the base transition plus exactly one {method} event"); + ConditionMethodEventTests.AssertNewState(method, native: false, mine[1]); + events.ConditionEvents(item, "fan-base").Count.ShouldBe(1, "the denied sibling call reports nothing"); + routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, method)]); + + await observer.CloseAsync(ct); + await session.CloseAsync(ct); + } + + /// + /// Initial and repeated Acknowledge / Confirm on the narrow grant: every call is authorized and routed (the + /// engine owns the retry decision), and the condition events are exactly the documented contract — the ack, + /// the COMMENTED re-ack (its comment changed), the confirm, and nothing for the re-confirm (no delta). + /// + [Fact] + public async Task Initial_and_repeated_ack_and_confirm_route_every_call_and_report_exact_events() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Owner797Repeat", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig); + Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + AssertNoRoles(rig); + var (events, observer, item) = await ObserveAsync(rig, ct); + + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, Alarm, pre); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + var condition = Uns(session, Alarm); + + await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "ack"); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); + await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "again"); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3); + await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 4); + await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); + + Project(rig, Alarm, pre with { Acknowledged = true, Confirmed = true, Message = "sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); + + events.ConditionEvents(item, "base").Count.ShouldBe(4, "base + ack + commented re-ack + confirm; the re-confirm is silent"); + routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe( + [(Alarm, "Acknowledge"), (Alarm, "Acknowledge"), (Alarm, "Confirm"), (Alarm, "Confirm")]); + + await observer.CloseAsync(ct); + await session.CloseAsync(ct); + } + + /// + /// The grant, the key and the clock are re-read at every call on the open session: revoking any of them + /// denies the next command (no route, no event, no state change); restoring the authority restores the + /// allow, on the same session, without reconnecting. + /// + [Theory] + [InlineData("grant")] + [InlineData("key")] + [InlineData("expiry")] + public async Task A_revoked_or_expired_authority_denies_the_next_command_on_the_open_session(string change) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync($"Owner797Revoke{change}", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig); + var clock = new Clock(); + var key = ServerEventRig.Key(); + key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10); + var state = Install(rig, clock, [key], OwnerGrant(Pump), Observer()); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + AssertNoRoles(rig); + var (events, observer, item) = await ObserveAsync(rig, ct); + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, Alarm, pre); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + var condition = Uns(session, Alarm); + + await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); + + switch (change) + { + case "grant": Reapply(state, "rev-2", [key], Observer()); break; + case "key": + var disabled = ServerEventRig.Key(); + disabled.Enabled = false; + Reapply(state, "rev-2", [disabled], OwnerGrant(Pump), Observer()); + break; + case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break; + } + + var condState = rig.Nm.TryGetAlarmCondition(Alarm)!; + var commentBefore = condState.Comment!.Value; + foreach (var method in new[] { "AddComment", "Confirm", "OneShotShelve" }) + { + var error = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, condition, method, events.LatestEventId(item, "base"), ct)); + error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, method); + } + + condState.Comment!.Value.ShouldBe(commentBefore); + routed.Count.ShouldBe(1); + + clock.Reset(); + Reapply(state, "rev-3", [key], OwnerGrant(Pump), Observer()); + await ConditionMethodEventTests.CallAsync(session, condition, "AddComment", events.LatestEventId(item, "base"), ct); + + Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); + events.ConditionEvents(item, "base").Count.ShouldBe(3, "base + ack + the restored AddComment; the denied calls report nothing"); + routed.Select(c => c.Operation).ShouldBe(["Acknowledge", "AddComment"]); + + await observer.CloseAsync(ct); + await session.CloseAsync(ct); + } + + /// + /// Delete + recreate of the SAME opaque id under ANOTHER equipment: the old equipment's grant is denied on + /// the recreated condition and the new equipment's grant authorizes it; the routed id never changes. An id + /// with no live condition is refused by the server before any gate, and routes nothing. + /// + [Fact] + public async Task A_recreated_alarm_follows_its_new_owner_and_an_unknown_id_routes_nothing() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Owner797Recreate", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig); + var state = Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); + var routed = Route(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + AssertNoRoles(rig); + var (events, observer, item) = await ObserveAsync(rig, ct); + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, Alarm, pre); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + await ConditionMethodEventTests.CallAsync(session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "base"), ct); + + ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); + 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")); + + var oldGrant = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct)); + oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.Count.ShouldBe(1); + + Reapply(state, "rev-fan", [ServerEventRig.Key()], OwnerGrant(Fan), Observer()); + await ConditionMethodEventTests.CallAsync( + session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct); + + var unknown = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( + session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct)); + unknown.StatusCode.ShouldNotBe(StatusCodes.Good); + + 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"); + routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]); + + await observer.CloseAsync(ct); + await session.CloseAsync(ct); + } + + // ───────────────────────────── helpers ───────────────────────────── + + /// Area1 / Line1 / {Pump, Fan} under opaque entity ids, one scripted alarm under each equipment. + private static void Topology(ServerEventRig rig) + { + rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns); + rig.Sink.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + } + + private static AclScopePathResolver Resolver() => new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary { [AreaId] = "Area1" }, + new Dictionary { [LineId] = (AreaId, "Line1") }, + new Dictionary { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") }, + []); + + private static Dictionary OwnerPaths() + { + var resolver = Resolver(); + return new Dictionary + { + [Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(), + [Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(), + }; + } + + private static DataPlaneAuthorityState Install(ServerEventRig rig, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) + { + var state = new DataPlaneAuthorityState(); + Reapply(state, "rev-1", keys, grants); + rig.Nm.DataPlaneAuthority = state; + rig.Nm.Authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock); + return state; + } + + private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) => + state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths()); + + /// The key's ONLY alarm authority: an Equipment grant on . + private static NodeAcl OwnerGrant(string equipmentId) => new() + { + NodeAclId = $"owner-{equipmentId}", + ClusterId = ServerEventRig.Cluster, + Principal = ServerEventRig.KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, + ScopeId = equipmentId, + PermissionFlags = Commands, + }; + + /// The anonymous observer: sees everything, may command nothing. + private static NodeAcl Observer() => new() + { + NodeAclId = "observer", + ClusterId = ServerEventRig.Cluster, + Principal = OpcUaPrincipals.Anonymous, + ScopeKind = NodeAclScopeKind.Cluster, + PermissionFlags = ObserveOnly, + }; + + 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"); + + private static async Task<(RecordedEvents Events, ISession Observer, MonitoredItem Item)> ObserveAsync( + ServerEventRig rig, CancellationToken ct) + { + var observer = await rig.ConnectAsync(anonymous: true, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct); + var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + item.Status.Error.ShouldBeNull(); + return (events, observer, item); + } + + 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 sealed class Clock : TimeProvider + { + private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow; + private TimeSpan _offset; + public override DateTimeOffset GetUtcNow() => _initial + _offset; + public void Advance(TimeSpan by) => _offset += by; + public void Reset() => _offset = TimeSpan.Zero; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs index bedb008fb..d19a4e6ca 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs @@ -1,4 +1,8 @@ -// The same application-authored opaque ID and two grant outcomes are independently exercised on SDK1.5. +// Gitea #797: this test USED to pin the denial (an Equipment grant could not authorize an opaque scripted +// alarm id; only a Cluster grant could). The owner binding fixed that deliberately, so it now pins the fixed +// behaviour: the owning equipment's grant allows, the sibling equipment's grant denies, a cluster grant still +// allows. The SDK 1.5 baseline probe under scripts/validation/opcua-scripted-alarm-baseline records the +// pre-fix denial on the original application commit and is unaffected. using Opc.Ua; using Opc.Ua.Client; using Shouldly; @@ -13,32 +17,45 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; public sealed class Sdk2OpaqueScriptedScopeBaselineTests { [Fact] - public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant() + public async Task Opaque_scripted_ID_is_allowed_by_its_equipment_grant_denied_by_a_sibling_grant_and_allowed_by_a_cluster_grant() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct); rig.KeyRoles = []; const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall; - rig.Enforce(true, [Key()], new NodeAcl + NodeAcl Narrow(string equipment) => new() { - NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal, - ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions, - }); + NodeAclId = $"narrow-{equipment}", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = permissions, + }; + + // Sibling first: a grant on EqB must not authorize EqA's alarm. + rig.Enforce(true, [Key()], Narrow(EqB)); rig.MaterialiseScripted(); + rig.Nm.ScriptedAlarmOwnerOf(ScriptedAlarmId).ShouldBe(EqA); var routed = 0; rig.Nm.AlarmCommandRouter = _ => routed++; using var session = await rig.ConnectAsync(anonymous: false, ct); - rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope")); + rig.Project(false, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 500, "scope")); var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull(); + condition.NodeId.IdentifierAsString.ShouldBe(ScriptedAlarmId, "the opaque NodeId is unchanged"); var eventId = condition.EventId.ShouldNotBeNull().Value; var denied = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, - MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("denied")) })); + MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("sibling")) })); denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.ShouldBe(0); + + // The owning equipment's narrow grant now authorizes the same opaque id (#797 fixed). + rig.Enforce(true, [Key()], Narrow(EqA)); + await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, + new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("owner")) }); + routed.ShouldBe(1); + + // The existing cluster contract is preserved. rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions)); await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, - new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("allowed")) }); - routed.ShouldBe(1); + new Variant[] { Variant.From(condition.EventId!.Value), Variant.From(new LocalizedText("cluster")) }); + routed.ShouldBe(2); await session.CloseAsync(ct); } } 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 8ab1dad75..430eec1b1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs @@ -56,8 +56,9 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity .ShouldBeOfType(); identity.Roles.ShouldBeEmpty("the positive retry must be authorized by the application grant alone"); - // Native: narrow raw-device scope. Scripted: preserve the existing cluster-scope contract; - // opaque scripted IDs are not under their equipment ACL path (a separately tracked baseline defect). + // Native: narrow raw-device scope. Scripted: the existing cluster-scope contract (the narrow + // equipment-scope case for opaque scripted IDs is Gitea #797's ScriptedAlarmOwnerScopeWireTests). + // Every re-Apply below carries the owner paths, so a scripted denial is the key/grant/clock change's. identity.ApiKeyId.ShouldBe(KeyId); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); @@ -94,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); + else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths()); var error = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, methodId, ct, Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded")))); @@ -106,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); + state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths()); 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 fd8840173..f9e74ed3b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -114,10 +114,19 @@ internal sealed class ServerEventRig : IAsyncDisposable /// Wires the authorizer over the given keys, scope paths and grants, enforcing or not. public DataPlaneAuthorityState Enforce( bool enabled, ApiKey[] keys, IReadOnlyDictionary> scopePaths, - params NodeAcl[] grants) + params NodeAcl[] grants) => + Enforce(enabled, keys, scopePaths, EquipmentOwnerPaths(), grants); + + /// + /// Wires the authorizer over the given keys, scope paths, equipment owner paths (the scripted-alarm owner + /// lookup, Gitea #797) and grants, enforcing or not. + /// + public DataPlaneAuthorityState Enforce( + bool enabled, ApiKey[] keys, IReadOnlyDictionary> scopePaths, + IReadOnlyDictionary equipmentOwnerPaths, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); - state.Apply(Cluster, "rev-1", keys, grants, scopePaths); + state.Apply(Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System); @@ -160,6 +169,18 @@ internal sealed class ServerEventRig : IAsyncDisposable [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])], }; + /// + /// The rig's equipment owner paths: its equipment folders' ids ARE their EquipmentIds, sitting directly at + /// [uns, EqA] / [uns, EqB] — the same nodes plants the Equipment + /// grants at. + /// + public static IReadOnlyDictionary EquipmentOwnerPaths() => + new Dictionary + { + [EqA] = new NodeAclPath([NodeAclRealms.Uns, EqA]), + [EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]), + }; + /// The native condition, wired as an event source of both equipment folders. public void MaterialiseNative() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs new file mode 100644 index 000000000..f8ad12dac --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerScriptedAlarmOwnerScopeTests.cs @@ -0,0 +1,681 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +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.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Gitea #797 — a scripted alarm condition's NodeId is its opaque ScriptedAlarmId, so with ACLs +/// enforced its Part 9 commands are authorized at the scope of the equipment its exact live instance is +/// bound to, resolved through the CURRENT authority generation — never at a scope split out of the id. +/// +/// +/// +/// The topology is production-shaped: every UNS folder id is an opaque entity id (UA-…, +/// UL-…, EQ-…) exactly as AddressSpaceApplier.MaterialiseHierarchy mints them, and +/// the grant paths and owner paths both come from over the area / +/// line / equipment NAMES, as the deployment artifact resolves them. Nothing in the address space is a +/// slash-qualified synthetic id, so a pass here cannot be a coincidence of the id spelling a path. +/// +/// +/// The handlers are driven directly (the SDK delegates on the condition), which reaches the gate +/// exactly as a client call does and additionally lets a test hold on to a REPLACED instance — the +/// one case a real client can no longer address once the NodeId resolves to its successor. +/// +/// +public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable +{ + private const string Cluster = "SITE-A"; + private const string Principal = "scadabridge"; + private const string KeyId = "k-797"; + private const string AreaId = "UA-0000000001"; + private const string LineId = "UL-0000000001"; + private const string Pump = "EQ-00000000a1b2"; + private const string Fan = "EQ-00000000c3d4"; + private const string Alarm = "scripted-eqa-hi"; + private const string FanAlarm = "scripted-fan-hi"; + + private const NodePermissions AlarmOps = + NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm + | NodePermissions.AlarmShelve | NodePermissions.MethodCall; + + private static readonly DateTime Start = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-797-{Guid.NewGuid():N}"); + + // ───────────────────────────── the defect, and its positive ───────────────────────────── + + /// + /// THE #797 allow: a session holding NO roles and only an Equipment grant on the owning equipment + /// acknowledges a scripted condition with a production-valid opaque id, and the command routes once with + /// the id unchanged. Before the binding this was BadUserAccessDenied. + /// + [Fact] + public async Task An_equipment_grant_authorizes_every_command_on_its_own_scripted_condition() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); + var condition = nm.TryGetAlarmCondition(Alarm)!; + var ctx = Context(server, KeyIdentity()); + + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good); + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("re-ack")).ShouldBe(ServiceResult.Good); + condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm")).ShouldBe(ServiceResult.Good); + condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("re-confirm")).ShouldBe(ServiceResult.Good); + condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("note")).ShouldBe(ServiceResult.Good); + condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0).ShouldBe(ServiceResult.Good); + condition.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: 60_000).ShouldBe(ServiceResult.Good); + condition.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0).ShouldBe(ServiceResult.Good); + condition.OnEnableDisable!(ctx, condition, false).ShouldBe(ServiceResult.Good); + condition.OnEnableDisable!(ctx, condition, true).ShouldBe(ServiceResult.Good); + + routed.Select(c => c.Operation).ShouldBe( + ["Acknowledge", "Acknowledge", "Confirm", "Confirm", "AddComment", "OneShotShelve", "TimedShelve", + "Unshelve", "Disable", "Enable"]); + routed.ShouldAllBe(c => c.AlarmId == Alarm && c.User == Principal); + } + finally { await host.DisposeAsync(); } + } + + /// + /// The narrow grant confers exactly its own flags at the owner: Acknowledge alone does not confer Confirm + /// or Shelve, so the operation split survives the move to owner scope. + /// + [Fact] + public async Task The_owner_scope_honours_the_grants_operation_flags() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + Wire(nm, new FakeClock(Start), [Key()], + EquipmentGrant(Pump, NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge)); + var condition = nm.TryGetAlarmCondition(Alarm)!; + var ctx = Context(server, KeyIdentity()); + + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good); + Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm"))); + Denied(condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0)); + + routed.Select(c => c.Operation).ShouldBe(["Acknowledge"]); + } + finally { await host.DisposeAsync(); } + } + + /// + /// An ancestor grant (the line) covers its equipment's alarms; a grant on the SIBLING equipment, or on a + /// single tag beneath the owner, does not. Each deny is preceded on the same session by an allow on a + /// condition the grant does cover, so the deny cannot be a gate that denies everything. + /// + [Fact] + public async Task Sibling_equipment_and_tag_grants_are_denied_and_an_ancestor_grant_is_allowed() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + var clock = new FakeClock(Start); + var (state, _) = Wire(nm, clock, [Key()], EquipmentGrant(Fan, AlarmOps)); + var pump = nm.TryGetAlarmCondition(Alarm)!; + var fan = nm.TryGetAlarmCondition(FanAlarm)!; + var ctx = Context(server, KeyIdentity()); + + fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("fan ok")).ShouldBe(ServiceResult.Good); + Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("sibling"))); + Denied(pump.OnAddComment!(ctx, pump, EventId(), new LocalizedText("sibling"))); + routed.Count.ShouldBe(1); + + // A tag grant beneath the owner is about that signal, not the equipment's alarms. + Reapply(state, "rev-tag", [Key()], new NodeAcl + { + NodeAclId = "tag", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Tag, + ScopeId = "TAG-1", PermissionFlags = AlarmOps, + }, EquipmentGrant(Fan, AlarmOps)); + Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("tag"))); + routed.Count.ShouldBe(1); + + // The line is an ancestor of both equipments: it covers both. + Reapply(state, "rev-line", [Key()], new NodeAcl + { + NodeAclId = "line", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsLine, + ScopeId = LineId, PermissionFlags = AlarmOps, + }); + pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("line")).ShouldBe(ServiceResult.Good); + routed.Select(c => c.AlarmId).ShouldBe([FanAlarm, Alarm]); + } + finally { await host.DisposeAsync(); } + } + + /// + /// The old split scope [uns, scripted-eqa-hi] would have been satisfied by an unrelated AREA whose + /// name happened to equal the alarm's id. The owner binding ignores the id's spelling entirely. + /// + [Fact] + public async Task An_area_whose_name_equals_the_alarm_id_confers_nothing() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + var areas = new Dictionary { [AreaId] = "Area1", ["UA-decoy"] = Alarm }; + Wire(nm, new FakeClock(Start), [Key()], areas, new NodeAcl + { + NodeAclId = "decoy", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsArea, + ScopeId = "UA-decoy", PermissionFlags = AlarmOps, + }); + var condition = nm.TryGetAlarmCondition(Alarm)!; + + Denied(condition.OnAcknowledge!(Context(server, KeyIdentity()), condition, EventId(), new LocalizedText("decoy"))); + routed.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── re-checked at every call ───────────────────────────── + + /// + /// Key, grant and generation are re-read at each call: a deployment that drops the grant or disables the + /// key, or the clock passing the key's expiry, denies the very next command on the same open session; + /// restoring the authority restores the allow. + /// + [Theory] + [InlineData("grant")] + [InlineData("key")] + [InlineData("expiry")] + [InlineData("owner-path")] + public async Task Revocation_reaches_the_next_command_and_restoration_restores_it(string change) + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + var clock = new FakeClock(Start); + var key = Key(expiresUtc: Start.AddMinutes(10)); + var grant = EquipmentGrant(Pump, AlarmOps); + var (state, _) = Wire(nm, clock, [key], grant); + var condition = nm.TryGetAlarmCondition(Alarm)!; + var ctx = Context(server, KeyIdentity()); + + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("before")).ShouldBe(ServiceResult.Good); + + switch (change) + { + case "grant": Reapply(state, "rev-2", [key]); break; + case "key": + var disabled = Key(expiresUtc: Start.AddMinutes(10)); + disabled.Enabled = false; + Reapply(state, "rev-2", [disabled], grant); + break; + case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break; + case "owner-path": + // 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()); + break; + } + + Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("after"))); + Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("after"))); + Denied(condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("after"))); + routed.Count.ShouldBe(1); + + clock.Reset(Start); + Reapply(state, "rev-3", [key], grant); + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("restored")).ShouldBe(ServiceResult.Good); + routed.Count.ShouldBe(2); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── binding lifecycle ───────────────────────────── + + /// + /// Delete + recreate of the same id under ANOTHER equipment: the new instance is bound to the new owner, + /// the old equipment's grant is denied on it, the new equipment's grant is allowed — and the REPLACED + /// instance, still held by a caller, is denied under every grant, even a Cluster one. + /// + [Fact] + public async Task A_recreated_alarm_binds_to_its_new_equipment_and_the_replaced_instance_is_denied() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); + var ctx = Context(server, KeyIdentity()); + var original = nm.TryGetAlarmCondition(Alarm)!; + original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("original")).ShouldBe(ServiceResult.Good); + + nm.RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull("a removed condition has no owner"); + Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("removed"))); + + nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + var recreated = nm.TryGetAlarmCondition(Alarm)!; + recreated.ShouldNotBeSameAs(original); + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); + + Denied(recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("old grant"))); + routed.Count.ShouldBe(1); + + Reapply(state, "rev-fan", [Key()], EquipmentGrant(Fan, AlarmOps)); + recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("new grant")).ShouldBe(ServiceResult.Good); + routed.Count.ShouldBe(2); + + // The replaced instance stays dead under every grant, the broadest included. + Reapply(state, "rev-cluster", [Key()], ClusterGrant(AlarmOps)); + Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("stale"))); + Denied(original.OnAddComment!(ctx, original, EventId(), new LocalizedText("stale"))); + routed.Count.ShouldBe(2); + routed.ShouldAllBe(c => c.AlarmId == Alarm); + } + finally { await host.DisposeAsync(); } + } + + /// + /// A re-materialise of a live scripted condition naming a DIFFERENT owner (no rebuild in between) is not + /// the idempotent no-op it is for an unchanged one: the condition is re-parented under — and re-bound to — + /// the new equipment. An unchanged re-materialise keeps the instance (and its subscriptions) and its binding. + /// + [Fact] + public async Task A_rematerialise_under_another_equipment_rebinds_and_an_unchanged_one_is_a_no_op() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + Topology(nm); + var first = nm.TryGetAlarmCondition(Alarm)!; + + nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + nm.TryGetAlarmCondition(Alarm).ShouldBeSameAs(first); + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); + + nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + var moved = nm.TryGetAlarmCondition(Alarm)!; + moved.ShouldNotBeSameAs(first); + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); + moved.Parent!.NodeId.IdentifierAsString.ShouldBe(Fan); + } + finally { await host.DisposeAsync(); } + } + + /// A full rebuild and an equipment-subtree removal drop every binding they tear down. + [Fact] + public async Task Rebuild_and_equipment_subtree_removal_drop_the_binding() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps)); + var ctx = Context(server, KeyIdentity()); + var fan = nm.TryGetAlarmCondition(FanAlarm)!; + fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("live")).ShouldBe(ServiceResult.Good); + + nm.RemoveEquipmentSubtree(Fan, AddressSpaceRealm.Uns).ShouldBeTrue(); + nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBeNull(); + Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("subtree gone"))); + + var pump = nm.TryGetAlarmCondition(Alarm)!; + nm.RebuildAddressSpace(); + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull(); + Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("rebuilt away"))); + routed.Count.ShouldBe(1); + } + finally { await host.DisposeAsync(); } + } + + /// + /// A scripted condition whose owner folder did not exist when it was materialised hangs under the root and + /// is UNBOUND: with enforcement on, nothing authorizes it — not even a Cluster grant. There is no fallback + /// to the realm root, the cluster, or the id. An unknown id has no condition at all. + /// + [Fact] + public async Task An_unbound_scripted_condition_is_denied_even_under_a_cluster_grant() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns); + Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps)); + var ctx = Context(server, KeyIdentity()); + var orphan = nm.TryGetAlarmCondition("scripted-orphan")!; + + nm.ScriptedAlarmOwnerOf("scripted-orphan").ShouldBeNull(); + nm.ScriptedAlarmOwnerOf("scripted-never-existed").ShouldBeNull(); + var bound = nm.TryGetAlarmCondition(Alarm)!; + bound.OnAcknowledge!(ctx, bound, EventId(), new LocalizedText("positive control")).ShouldBe(ServiceResult.Good); + Denied(orphan.OnAcknowledge!(ctx, orphan, EventId(), new LocalizedText("orphan"))); + routed.Select(c => c.AlarmId).ShouldBe([Alarm]); + } + finally { await host.DisposeAsync(); } + } + + /// + /// The production population path end to end: a composition applied by + /// through the wrapper the actors inject (no new sink method — the + /// binding rides MaterialiseAlarmCondition's existing EquipmentId), then an Equipment grant + /// resolved from the SAME names authorizes the alarm while the sibling equipment's grant does not. + /// + [Fact] + public async Task The_applier_binds_each_scripted_alarm_to_its_composition_equipment() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + var deferred = new DeferredAddressSpaceSink(); + deferred.SetSink(new SdkAddressSpaceSink(nm)); + var applier = new AddressSpaceApplier(deferred, NullLogger.Instance); + var composition = new AddressSpaceComposition( + [new UnsAreaProjection(AreaId, "Area1")], + [new UnsLineProjection(LineId, AreaId, "Line1")], + [new EquipmentNode(Pump, "Pump", LineId), new EquipmentNode(Fan, "Fan", LineId)], + [], + []) + { + EquipmentScriptedAlarms = + [ + new EquipmentScriptedAlarmPlan(Alarm, Pump, "hi", "AlarmCondition", 500, "m", "s1", "return false;", [], false, true, true), + new EquipmentScriptedAlarmPlan(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, "m", "s2", "return false;", [], false, true, true), + ], + }; + + applier.MaterialiseHierarchy(composition).ShouldBe(0); + applier.MaterialiseScriptedAlarms(composition).ShouldBe(0); + + nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump); + nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBe(Fan); + nm.TryGetAlarmCondition(Alarm)!.NodeId.IdentifierAsString.ShouldBe(Alarm, "the opaque NodeId is unchanged"); + + var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); + var ctx = Context(server, KeyIdentity()); + var pump = nm.TryGetAlarmCondition(Alarm)!; + var fan = nm.TryGetAlarmCondition(FanAlarm)!; + pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("owner")).ShouldBe(ServiceResult.Good); + Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("sibling"))); + routed.Select(c => c.AlarmId).ShouldBe([Alarm]); + state.Current.GenerationId.ShouldBe(1); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── what must not change ───────────────────────────── + + /// + /// Native conditions (ConditionId = RawPath) keep their NodeId-scoped decision: a RawDevice grant + /// authorizes Acknowledge and AddComment, and an Equipment grant on a referencing equipment does not — + /// exactly as before #797. No owner binding is ever made for them. + /// + [Fact] + public async Task Native_conditions_keep_their_raw_path_scoped_decision() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var native = new List(); + nm.NativeAlarmAckRouter = native.Add; + var scripted = Route(nm); + Topology(nm); + nm.EnsureFolder("Plant/Modbus/dev1", parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp_hi", "Plant/Modbus/dev1", "temp_hi", "OffNormalAlarm", 700, + AddressSpaceRealm.Raw, isNative: true); + nm.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw, [Pump], AddressSpaceRealm.Uns); + var condition = nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw)!; + nm.ScriptedAlarmOwnerOf("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw).ShouldBeNull(); + var ctx = Context(server, KeyIdentity()); + + var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps)); + Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("equipment grant"))); + + var rawDevice = new NodeAcl + { + NodeAclId = "dev", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.RawDevice, + ScopeId = "dev1", PermissionFlags = AlarmOps, + }; + var scopes = new Dictionary> + { + [new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])], + }; + state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths()); + condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good); + condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good); + + native.Count.ShouldBe(1); + native[0].ConditionNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); + scripted.Select(c => (c.AlarmId, c.Operation)).ShouldBe([("Plant/Modbus/dev1/temp_hi", "AddComment")]); + } + finally { await host.DisposeAsync(); } + } + + /// + /// With Security:Acl:Enabled OFF nothing changes: the activation-time AlarmAck role decides, + /// bound or unbound, and a session without it is denied — the binding is consulted only by the ACL path. + /// + [Fact] + public async Task With_acl_off_the_alarm_ack_role_decides_bound_or_unbound() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var routed = Route(nm); + Topology(nm); + nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns); + Wire(nm, new FakeClock(Start), [Key()], enabled: false, EquipmentGrant(Fan, AlarmOps)); + var bound = nm.TryGetAlarmCondition(Alarm)!; + var orphan = nm.TryGetAlarmCondition("scripted-orphan")!; + + var withRole = Context(server, KeyIdentity(OpcUaDataPlaneRoles.AlarmAck)); + bound.OnAcknowledge!(withRole, bound, EventId(), new LocalizedText("1")).ShouldBe(ServiceResult.Good); + orphan.OnAcknowledge!(withRole, orphan, EventId(), new LocalizedText("2")).ShouldBe(ServiceResult.Good); + + var noRole = Context(server, KeyIdentity()); + Denied(bound.OnAcknowledge!(noRole, bound, EventId(), new LocalizedText("3"))); + routed.Select(c => c.AlarmId).ShouldBe([Alarm, "scripted-orphan"]); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── helpers ───────────────────────────── + + /// + /// Area1 / Line1 / {Pump, Fan}, every folder under its opaque entity id, with one scripted alarm under each + /// equipment — the shape AddressSpaceApplier materialises in production. + /// + private static void Topology(OtOpcUaNodeManager nm) + { + nm.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); + nm.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); + nm.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns); + nm.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + } + + private static Dictionary Areas() => new() { [AreaId] = "Area1" }; + + /// The production resolver over the topology's NAMES, as the deployment artifact builds it. + private static AclScopePathResolver Resolver(IReadOnlyDictionary? areas = null) => new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary + { + // A tag referenced by Pump, so a Tag grant resolves to a path BENEATH the owner. + ["TAG-1"] = ("dev-x", null, "Speed"), + }, + areas ?? Areas(), + new Dictionary { [LineId] = (AreaId, "Line1") }, + new Dictionary + { + [Pump] = (LineId, "Pump"), + [Fan] = (LineId, "Fan"), + }, + [(Pump, "TAG-1", "Speed")]); + + /// Every equipment's owner path, through the same resolver call the artifact makes. + private static Dictionary OwnerPaths() + { + var resolver = Resolver(); + return new Dictionary + { + [Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(), + [Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(), + }; + } + + private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( + OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) => + Wire(nm, clock, keys, enabled: true, Areas(), grants); + + private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( + OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, IReadOnlyDictionary areas, + params NodeAcl[] grants) => + Wire(nm, clock, keys, enabled: true, areas, grants); + + private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( + OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled, params NodeAcl[] grants) => + Wire(nm, clock, keys, enabled, Areas(), grants); + + private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire( + OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled, + IReadOnlyDictionary areas, NodeAcl[] grants) + { + var state = new DataPlaneAuthorityState(); + state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths()); + var authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock); + nm.DataPlaneAuthority = state; + nm.Authorizer = authorizer; + return (state, authorizer); + } + + /// A new deployment of the same topology carrying and . + private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) => + state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths()); + + private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) => new() + { + NodeAclId = $"acl-{equipmentId}", + ClusterId = Cluster, + Principal = Principal, + ScopeKind = NodeAclScopeKind.Equipment, + ScopeId = equipmentId, + PermissionFlags = flags, + }; + + private static NodeAcl ClusterGrant(NodePermissions flags) => new() + { + NodeAclId = "acl-cluster", + ClusterId = Cluster, + Principal = Principal, + ScopeKind = NodeAclScopeKind.Cluster, + PermissionFlags = flags, + }; + + private static ApiKey Key(DateTime? expiresUtc = null) => new() + { + ApiKeyId = KeyId, + Name = KeyId, + SecretHash = "hash", + Principal = Principal, + Enabled = true, + CreatedUtc = Start.AddDays(-1), + ExpiresUtc = expiresUtc, + }; + + /// A key-carrying session; no roles unless given — under enforcement the grant alone must decide. + private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) => + new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId); + + private static ServerSystemContext Context(OtOpcUaSdkServer server, RoleCarryingUserIdentity identity) => + new(server.CurrentInstance) { UserIdentity = identity }; + + private static List Route(OtOpcUaNodeManager nm) + { + var routed = new List(); + nm.AlarmCommandRouter = routed.Add; + return routed; + } + + private static ByteString EventId() => ByteString.From(Guid.NewGuid().ToByteArray()); + + private static void Denied(ServiceResult result) => + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + + /// A hand-advanced clock; the authorizer reads it on every decision. + private sealed class FakeClock(DateTime utcNow) : TimeProvider + { + private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); + + /// + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now = _now.Add(by); + + public void Reset(DateTime utcNow) => _now = new DateTimeOffset(utcNow, TimeSpan.Zero); + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() + { + var started = await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.ScriptedOwnerScopeTest", + ApplicationUri = $"urn:OtOpcUa.ScriptedOwnerScopeTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + return (started.Host, started.Server); + } + + /// + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch (IOException) { /* best effort */ } + } + } +} 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 f661c1221..b834dad78 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 @@ -180,4 +180,49 @@ public sealed class DeploymentArtifactScopePathTests projection.Grants.ShouldHaveSingleItem(); projection.ScopePaths.ShouldBeEmpty(); } + + /// + /// Gitea #797 — the artifact also yields every equipment's OWN path (the scripted-alarm owner lookup), and + /// it is identical to the path the same artifact plants an Equipment grant at. That equality is what makes + /// a narrow equipment grant authorize the equipment's scripted alarms: if the two were produced by + /// different rules, the owner binding would silently authorize at a node no grant can reach. + /// + [Fact] + public void Every_equipment_owner_path_equals_the_path_its_equipment_grant_is_planted_at() + { + var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); + + projection.EquipmentScopePaths.ShouldContainKey("EQ-mixer"); + var owner = projection.EquipmentScopePaths["EQ-mixer"]; + owner.Segments.ShouldBe(["uns", "West", "L1", "Mixer"]); + projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")] + .ShouldHaveSingleItem().Segments.ShouldBe(owner.Segments); + + // Keyed by identifier (OrdinalIgnoreCase), like every other id lookup on the authorization path. + projection.EquipmentScopePaths.ContainsKey("eq-MIXER").ShouldBeTrue(); + // A grant naming an equipment the artifact does not carry yields no owner path either. + projection.EquipmentScopePaths.ContainsKey("EQ-gone").ShouldBeFalse(); + + var snapshot = new DataPlaneAuthorityState().Apply( + "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths); + snapshot.TryGetEquipmentUnsSegments("EQ-mixer", out var segments).ShouldBeTrue(); + segments.ShouldBe(["West", "L1", "Mixer"]); + } + + /// An equipment whose UNS chain does not resolve (dangling line) has no owner path. + [Fact] + public void An_equipment_with_a_dangling_line_has_no_owner_path() + { + var projection = DeploymentArtifact.ParseDataPlaneAuthority( + Encoding.UTF8.GetBytes(""" + { + "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], + "Equipment": [{"EquipmentId":"EQ-orphan","UnsLineId":"UL-missing","Name":"Orphan"}], + "NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}] + } + """), + NodeId); + + projection.EquipmentScopePaths.ShouldBeEmpty(); + } } 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 bc161f71b..3c56ac2b3 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 @@ -664,10 +664,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public DataPlaneAuthoritySnapshot Apply( string? clusterId, string revision, IReadOnlyList keys, IReadOnlyList grants, - IReadOnlyDictionary> scopePaths) + IReadOnlyDictionary> scopePaths, + IReadOnlyDictionary? equipmentScopePaths = null) { Interlocked.Increment(ref _applies); - return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths); + return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths, equipmentScopePaths); } }