feat(server): inbound operator-write pipeline — OnWriteValue authz gate + node-write router

This commit is contained in:
Joseph Doherty
2026-06-13 12:02:34 -04:00
parent a23fb2b82e
commit bb5832e900
5 changed files with 266 additions and 3 deletions
@@ -70,6 +70,39 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </summary>
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
private volatile Action<string, object?>? _nodeWriteRouter;
/// <summary>
/// Reverse-path sink for inbound OPC UA operator writes to a writable equipment-tag variable node.
/// When a client writes such a node, the node's <see cref="BaseDataVariableState.OnWriteValue"/>
/// handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
/// variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
/// role and, when allowed, invokes this delegate with the node's string id + the written value to
/// route the write to the backing driver.
/// <para>
/// This is the write-side twin of <see cref="AlarmCommandRouter"/> — a plain
/// <see cref="Action{T1,T2}"/> (no Akka / <c>IActorRef</c> / DI handle) so this assembly stays
/// Akka-free. The handler delegates run under the node-manager <c>Lock</c> (the OPC UA SDK's
/// <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>), so this
/// dispatch MUST be non-blocking and fire-and-forget — exactly like the alarm router. The host
/// sets it at boot to a lambda that kicks off an unawaited bounded <c>Ask</c> of the local
/// <c>DriverHostActor</c> (<c>RouteNodeWrite</c> → <c>NodeWriteResult</c>) and logs failures from
/// a continuation; the write reaches the device asynchronously and the value self-corrects on the
/// next driver poll (standard OPC UA optimistic-write semantics). Null (the default) makes every
/// write resolve to a "writes unavailable" failure.
/// </para>
/// <para>
/// Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make the
/// startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
/// and the SDK reads it on Write request threads.
/// </para>
/// </summary>
public Action<string, object?>? NodeWriteRouter
{
get => _nodeWriteRouter;
set => _nodeWriteRouter = value;
}
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
@@ -551,6 +584,78 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
}
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role (<b>fails closed</b>: a missing identity or
/// missing role is denied), and on pass dispatches the value through <see cref="NodeWriteRouter"/>.
/// <para>
/// The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
/// manager <c>Lock</c> while invoking this handler, so a blocking driver round-trip here would
/// freeze every address-space operation (reads, subscription notifications, the publish path) for
/// the duration. The router only kicks off the asynchronous route. Returning
/// <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically; the next
/// driver poll republishes the confirmed register value over the optimistic one via the normal
/// <see cref="WriteValue"/> path.
/// </para>
/// </summary>
private ServiceResult OnEquipmentTagWrite(
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
// Capture the value into a local so the route thunk (a lambda) can close over it — a ref parameter
// can't be captured by a lambda. The handler does not mutate the ref value: returning Good lets the
// SDK apply the client's value as-is.
var writtenValue = value;
var nodeKey = node.NodeId.Identifier?.ToString() ?? string.Empty;
var router = _nodeWriteRouter;
Action? route = router is { } r ? () => r(nodeKey, writtenValue) : null;
return EvaluateEquipmentWrite(identity, route);
}
/// <summary>
/// Pure decision for an inbound equipment-tag write: the <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
/// role gate + the fire-and-forget dispatch, extracted off <see cref="OnEquipmentTagWrite"/> so it is
/// unit-testable without booting an SDK server. The gate fails closed (null identity or missing role
/// ⇒ <c>BadUserAccessDenied</c>, <paramref name="route"/> NOT invoked). When the gate passes but no
/// router is wired (<paramref name="route"/> is null), the write resolves to <c>BadNotWritable</c>
/// ("writes unavailable"). Otherwise it invokes <paramref name="route"/> exactly once (fire-and-forget
/// — the actual driver round-trip happens asynchronously off the router lambda) and returns
/// <see cref="ServiceResult.Good"/> so the SDK applies the client value optimistically. Role
/// comparison is case-insensitive (the role set is built with
/// <see cref="StringComparer.OrdinalIgnoreCase"/>), matching the alarm gate.
/// </summary>
/// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
/// session is anonymous / carries no role-carrying identity.</param>
/// <param name="route">A thunk that kicks off the asynchronous route of the write to the driver; invoked
/// only when the gate passes. Null when no router is wired (e.g. admin-only nodes).</param>
/// <returns><see cref="ServiceResult.Good"/> on an allowed write (dispatch started); <c>BadUserAccessDenied</c>
/// when the gate vetoes; <c>BadNotWritable</c> ("writes unavailable") when no router is wired.</returns>
internal static ServiceResult EvaluateEquipmentWrite(
RoleCarryingUserIdentity? identity, Action? route)
{
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
{
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// write and surfaces the status to the client; we never route.
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
if (route is null)
{
// Gate passed but no router wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
}
// Fire-and-forget: kick off the asynchronous route and return Good immediately. The SDK holds the
// node-manager Lock here, so we must NOT block on the driver round-trip.
route();
return ServiceResult.Good;
}
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
@@ -634,9 +739,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag); when false it stays <c>CurrentRead</c> (read-only). This task only sets
/// the access level — no OnWriteValue handler is attached here (the inbound-write handler is owned
/// by a later task).</param>
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
/// to its <c>OnWriteValue</c> (Task 11) so a client write gates on the <c>WriteOperate</c> role + routes
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
@@ -668,6 +773,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// Task 11: a writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteRouter. Read-only nodes leave it null (the default) so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
if (writable)
{
variable.OnWriteValue = OnEquipmentTagWrite;
}
parent.AddChild(variable);
AddPredefinedNode(SystemContext, variable);
_variables[variableNodeId] = variable;