diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 0109a56..bca3373 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -36,8 +36,8 @@ Remaining Stream C surfaces (hardening, not release-blocking): - ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired. - ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern). -- Alarm Acknowledge / Confirm / Shelve gating. -- Call (method invocation) gating. +- ~~Alarm Acknowledge / Confirm / Shelve gating.~~ **Partial, 2026-04-24.** Acknowledge + Confirm map to dedicated `OpcUaOperation.AlarmAcknowledge` / `AlarmConfirm` via `MapCallOperation`; Shelve falls through to generic `OpcUaOperation.Call` (needs per-instance method NodeId resolution to distinguish — follow-up). +- ~~Call (method invocation) gating.~~ **Closed 2026-04-24.** `DriverNodeManager.Call` override pre-gates each `CallMethodRequest` via `GateCallMethodRequests`. Denied calls return `BadUserAccessDenied` without running the method. Alarm methods map to alarm-specific operation kinds; everything else gates as generic `Call`. - Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12. - 3-user integration matrix covering every operation × allow/deny. diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index f3569a7..513fa56 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -382,6 +382,89 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder } } + /// + /// Phase 6.2 Stream C — method Call gating, covering the three Part 9 alarm methods + /// (Acknowledge / Confirm / Shelve) plus any driver-exposed method nodes. Pre-gates + /// each : denied calls return + /// without running the method. + /// + /// + /// + /// Operation kind per request is inferred from the MethodId — alarm + /// acknowledge / confirm / shelve map to the corresponding + /// values so operator-UI clients can have separate + /// "can acknowledge" vs "can shelve" grants. Everything else (non-alarm method + /// nodes) gates as generic . + /// + /// + /// Scope is resolved from the ObjectId (the owning node the method lives + /// on, e.g. the alarm condition). Methods on nodes outside the driver's + /// namespace (stack-synthesized standard-type methods with numeric NodeId + /// identifiers) bypass the gate. + /// + /// + public override void Call( + OperationContext context, + IList methodsToCall, + IList results, + IList errors) + { + GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver); + base.Call(context, methodsToCall, results, errors); + } + + /// + /// Pure-function gate for a batch of . Pre-populates + /// slots with + /// for calls the session isn't allowed to make. Extracted for unit-testability. + /// + internal static void GateCallMethodRequests( + IList methodsToCall, + IList errors, + IUserIdentity? userIdentity, + AuthorizationGate? gate, + NodeScopeResolver? scopeResolver) + { + if (gate is null || scopeResolver is null) return; + if (methodsToCall.Count == 0) return; + + for (var i = 0; i < methodsToCall.Count; i++) + { + if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue; + + var request = methodsToCall[i]; + if (request.ObjectId.Identifier is not string fullRef) continue; + + var scope = scopeResolver.Resolve(fullRef); + var operation = MapCallOperation(request.MethodId); + if (!gate.IsAllowed(userIdentity, operation, scope)) + errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); + } + } + + /// + /// Maps a method's to the the gate + /// should check. Alarm methods resolve to their specific operation kinds so + /// operator-UI grants can distinguish acknowledge/confirm/shelve; everything else + /// falls through to generic . + /// + internal static OpcUaOperation MapCallOperation(NodeId methodId) + { + // Standard Part 9 method ids on AcknowledgeableConditionType. The stack models these + // as ns=0 numeric ids; comparisons are value-based. Shelve is dispatched on the + // ShelvedStateMachine instance's methods — those arrive with per-instance NodeIds + // rather than well-known type NodeIds, so we can't reliably constant-match them + // here. Shelve falls through to OpcUaOperation.Call; the caller can still set a + // permissive Call grant for operators who are allowed to shelve alarms, and + // finer-grained AlarmShelve gating is a follow-up when the method-invocation path + // also carries a "method-role" annotation. + if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge) + return OpcUaOperation.AlarmAcknowledge; + if (methodId == MethodIds.AcknowledgeableConditionType_Confirm) + return OpcUaOperation.AlarmConfirm; + return OpcUaOperation.Call; + } + /// /// Pure-function filter over a list. Extracted so /// the Browse-gate policy is unit-testable without standing up the OPC UA server diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs new file mode 100644 index 0000000..2b9ae9b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs @@ -0,0 +1,156 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +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.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Unit tests for and +/// — Phase 6.2 Stream C method-Call +/// gating covering the Part 9 alarm Acknowledge / Confirm methods plus generic +/// driver-exposed method nodes. +/// +[Trait("Category", "Unit")] +public sealed class CallGatingTests +{ + [Fact] + public void MapCallOperation_Acknowledge_maps_to_AlarmAcknowledge() + { + DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Acknowledge) + .ShouldBe(OpcUaOperation.AlarmAcknowledge); + } + + [Fact] + public void MapCallOperation_Confirm_maps_to_AlarmConfirm() + { + DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Confirm) + .ShouldBe(OpcUaOperation.AlarmConfirm); + } + + [Fact] + public void MapCallOperation_generic_method_maps_to_Call() + { + // Arbitrary driver-exposed method NodeId — falls through to generic Call. + DriverNodeManager.MapCallOperation(new NodeId("driver-method", 2)) + .ShouldBe(OpcUaOperation.Call); + } + + [Fact] + public void Gate_null_leaves_errors_untouched() + { + var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge) }; + var errors = new List { (ServiceResult)null! }; + + DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate: null, scopeResolver: null); + + errors[0].ShouldBeNull(); + } + + [Fact] + public void Denied_Acknowledge_call_gets_BadUserAccessDenied() + { + var calls = new List + { + NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), + }; + var errors = new List { (ServiceResult)null! }; + var gate = MakeGate(strict: true, rows: []); // no grants → deny + + DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1")); + + ServiceResult.IsBad(errors[0]).ShouldBeTrue(); + errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied); + } + + [Fact] + public void Allowed_Acknowledge_passes_through() + { + var calls = new List + { + NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), + }; + var errors = new List { (ServiceResult)null! }; + var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]); + + DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1")); + + errors[0].ShouldBeNull(); + } + + [Fact] + public void Mixed_batch_gates_per_item() + { + var calls = new List + { + NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), + NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Confirm), + }; + var errors = new List { (ServiceResult)null!, (ServiceResult)null! }; + // Grant Acknowledge but not Confirm — mixed outcome per item. + var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]); + + DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1")); + + errors[0].ShouldBeNull("Acknowledge granted"); + ServiceResult.IsBad(errors[1]).ShouldBeTrue("Confirm not granted"); + } + + [Fact] + public void Pre_populated_error_is_preserved() + { + var calls = new List { NewCall("c1/area/line/eq/alarm1", NodeId.Null) }; + var errors = new List { new(StatusCodes.BadMethodInvalid) }; + var gate = MakeGate(strict: true, rows: []); + + DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1")); + + errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadMethodInvalid); + } + + // ---- helpers ----------------------------------------------------------- + + private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new() + { + ObjectId = new NodeId(objectFullRef, 2), + MethodId = methodId, + }; + + private static NodeAcl Row(string group, NodePermissions flags) => new() + { + NodeAclRowId = Guid.NewGuid(), + NodeAclId = Guid.NewGuid().ToString(), + GenerationId = 1, + ClusterId = "c1", + LdapGroup = group, + ScopeKind = NodeAclScopeKind.Cluster, + ScopeId = null, + PermissionFlags = flags, + }; + + private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows) + { + var cache = new PermissionTrieCache(); + cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); + var evaluator = new TriePermissionEvaluator(cache); + return new AuthorizationGate(evaluator, strictMode: strict); + } + + private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); + + private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer + { + public FakeIdentity(string name, IReadOnlyList groups) + { + DisplayName = name; + LdapGroups = groups; + } + public new string DisplayName { get; } + public IReadOnlyList LdapGroups { get; } + } +}