Phase 6.2 Stream C — Call + Alarm Acknowledge/Confirm gating

Closes task #122 (Acknowledge + Confirm + generic Call — Shelve stays as
a follow-up pending per-instance method-NodeId resolution).

Before this commit any session with a connected channel could invoke
method nodes on driver-materialized equipment — including alarm
Acknowledge / Confirm. Combined with the Browse + CreateMonitoredItems
gates that landed earlier in Stream C, this was the last service-layer
entry point where a session could still affect state without passing
the authz trie.

Implementation on DriverNodeManager:
- `Call` override — pre-iterates methodsToCall, gates each through
  AuthorizationGate with the operation kind returned by
  MapCallOperation. Denied calls get errors[i] = BadUserAccessDenied
  before delegating to base.Call.
- `MapCallOperation(NodeId methodId)` — maps well-known Part 9 method
  NodeIds to dedicated operation kinds:
    MethodIds.AcknowledgeableConditionType_Acknowledge →
        OpcUaOperation.AlarmAcknowledge
    MethodIds.AcknowledgeableConditionType_Confirm →
        OpcUaOperation.AlarmConfirm
    everything else → OpcUaOperation.Call
  Lets the ACL distinguish "can acknowledge alarms" from "can invoke
  arbitrary methods" without conflating the two roles.
- Shelve dispatch paths through per-instance ShelvedStateMachine methods
  with dynamic NodeIds that can't be constant-matched — falls through
  to generic Call. Fine-grained OpcUaOperation.AlarmShelve is a follow-
  up when the method-invocation path grows a "method-role" annotation.

Extracted GateCallMethodRequests + MapCallOperation as static internal
for unit-testability. 8 new tests (MapCallOperation Acknowledge /
Confirm / generic; gate-null no-op, denied-Acknowledge, allowed-
Acknowledge, mixed-batch, pre-populated-error-preserved).
Server.Tests 269 → 277.

Known follow-ups:
- Shelve per-operation gating (see above).
- TranslateBrowsePathsToNodeIds gating (Browse follow-up from #120).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-24 15:22:19 -04:00
parent 6a6b0f56f2
commit ded292ecd7
3 changed files with 241 additions and 2 deletions

View File

@@ -382,6 +382,89 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
}
}
/// <summary>
/// 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 <see cref="CallMethodRequest"/>: denied calls return
/// <see cref="StatusCodes.BadUserAccessDenied"/> without running the method.
/// </summary>
/// <remarks>
/// <para>
/// Operation kind per request is inferred from the <c>MethodId</c> — alarm
/// acknowledge / confirm / shelve map to the corresponding
/// <see cref="OpcUaOperation"/> values so operator-UI clients can have separate
/// "can acknowledge" vs "can shelve" grants. Everything else (non-alarm method
/// nodes) gates as generic <see cref="OpcUaOperation.Call"/>.
/// </para>
/// <para>
/// Scope is resolved from the <c>ObjectId</c> (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.
/// </para>
/// </remarks>
public override void Call(
OperationContext context,
IList<CallMethodRequest> methodsToCall,
IList<CallMethodResult> results,
IList<ServiceResult> errors)
{
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
base.Call(context, methodsToCall, results, errors);
}
/// <summary>
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
/// for calls the session isn't allowed to make. Extracted for unit-testability.
/// </summary>
internal static void GateCallMethodRequests(
IList<CallMethodRequest> methodsToCall,
IList<ServiceResult> 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);
}
}
/// <summary>
/// Maps a method's <see cref="NodeId"/> to the <see cref="OpcUaOperation"/> 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 <see cref="OpcUaOperation.Call"/>.
/// </summary>
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;
}
/// <summary>
/// Pure-function filter over a <see cref="ReferenceDescription"/> list. Extracted so
/// the Browse-gate policy is unit-testable without standing up the OPC UA server