Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
T
Joseph Doherty a438ac1640 feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4)
A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it
registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys
now ride the deployment artifact instead.

- ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried.
  Only the peppered HMAC ships, never a secret, and the pepper is node config
  that is deliberately not in the artifact.
- DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are
  scoped to the node's own cluster; keys are not, because ApiKey carries no
  ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it.
- OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply,
  BEFORE its plan.IsEmpty early-return: an artifact that only revokes a key
  produces an empty address-space plan, so feeding after that return would leave
  the revoked key working until an unrelated structural change redeployed.
- ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb
  keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation
  immediate there.
- OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4
  named. NodeScope.ClusterId is required and the evaluator fails closed on a
  mismatch, so until now no scope it built could have evaluated. Nothing reads it
  until stages 5/6.

The generation is a node-local COUNTER driven by the RevisionHash, not derived
from its bits. PermissionTrieCache treats the highest GenerationId as current, so
a generation taken from hash bytes would install a lower number about half the
time and the cache would go on serving the previous deployment's ACLs, silently.

Sessions are not stamped with it yet — that belongs with the first seam that
reads a UserAuthorizationState, in stage 5, rather than being built now and
consulted by nothing.

Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key
authenticates, wrong secret denied, both halves of the revocation asymmetry
observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an
independent Python HMAC so the verifier was checked against something other than
itself. Rig left clean.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 14:37:18 -04:00

137 lines
8.0 KiB
C#

using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// <see cref="StandardServer"/> subclass that wires in the v2 <see cref="OtOpcUaNodeManager"/>.
/// Exposes the live node manager after start so callers (<see cref="OpcUaApplicationHost"/>,
/// the fused Host's DI binding) can wrap it in a <see cref="SdkAddressSpaceSink"/> and hand
/// it to <c>OpcUaPublishActor</c>.
/// </summary>
public sealed class OtOpcUaSdkServer : StandardServer
{
private OtOpcUaNodeManager? _otOpcUaNodeManager;
/// <summary>The custom node manager once <c>StartAsync</c> has called
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
/// <summary>
/// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a non-blocking
/// <c>mediator.Tell</c> that publishes each <see cref="AlarmCommand"/> onto the
/// <c>alarm-commands</c> DistributedPubSub topic. No-op (returns <c>false</c>) when the node
/// manager has not been created yet, so the caller can detect a too-early call.
/// </summary>
/// <param name="router">The router invoked by the condition handlers once the <c>AlarmAck</c>
/// gate passes; may be <c>null</c> to clear it.</param>
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetAlarmCommandRouter(Action<AlarmCommand>? router)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.AlarmCommandRouter = router;
return true;
}
/// <summary>
/// Wire the reverse-path router for inbound Part 9 Acknowledge of a NATIVE (driver-fed, e.g. Galaxy)
/// condition onto the created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a
/// non-blocking router that Tells a <c>DriverHostActor.RouteNativeAlarmAck</c> into the local
/// <c>DriverHostActor</c>, which (Primary-gated) routes the acknowledge to the owning driver child.
/// Native conditions are not owned by the scripted-alarm engine, so the node manager branches their
/// inbound Acknowledge to this seam instead of the scripted <see cref="SetAlarmCommandRouter"/> path.
/// Passing <c>null</c> clears it. No-op (returns <c>false</c>) when the node manager has not been
/// created yet, so the caller can detect a too-early call (mirrors <see cref="SetAlarmCommandRouter"/>).
/// </summary>
/// <param name="router">The router invoked by the native condition's Acknowledge handler once the
/// <c>AlarmAck</c> gate passes; may be <c>null</c> to clear it.</param>
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetNativeAlarmAckRouter(Action<NativeAlarmAck>? router)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.NativeAlarmAckRouter = router;
return true;
}
/// <summary>
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
/// created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with an
/// <c>ActorNodeWriteGateway</c> whose <c>WriteAsync</c> kicks off a bounded <c>Ask</c> of the local
/// <c>DriverHostActor</c> (<c>RouteNodeWrite</c>); the node manager calls it fire-and-forget and uses
/// the resolved <c>NodeWriteOutcome</c> to self-correct the node on a failed device write (mirrors
/// <see cref="SetAlarmCommandRouter"/>). Passing <c>null</c> restores the
/// <c>NullOpcUaNodeWriteGateway</c> default (writes unavailable). No-op (returns <c>false</c>) when the
/// node manager has not been created yet, so the caller can detect a too-early call.
/// </summary>
/// <param name="gateway">The gateway invoked by the writable node's <c>OnWriteValue</c> handler once the
/// <c>WriteOperate</c> gate passes; may be <c>null</c> to restore the Null default.</param>
/// <returns><c>true</c> when the gateway was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetNodeWriteGateway(IOpcUaNodeWriteGateway? gateway)
{
if (_otOpcUaNodeManager is null) return false;
// The NodeWriteGateway setter null-coalesces to the Null default, so a null gateway is intentional
// (restores "writes unavailable"); forgive the nullable-in here.
_otOpcUaNodeManager.NodeWriteGateway = gateway!;
return true;
}
/// <summary>
/// Wire the server-side HistoryRead backend (the <see cref="IHistorianDataSource"/> the node
/// manager's HistoryRead overrides block-bridge to) onto the created
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with the DI-resolved source —
/// the <c>NullHistorianDataSource</c> default (GoodNoData-empty reads) or the configured HistorianGateway
/// read client. Passing <c>null</c> restores the Null default (the property setter null-coalesces),
/// i.e. "no historian". No-op (returns <c>false</c>) when the node manager has not been created yet,
/// so the caller can detect a too-early call (mirrors <see cref="SetNodeWriteGateway"/>).
/// </summary>
/// <param name="source">The read backend invoked by the node manager's HistoryRead overrides; may be
/// <c>null</c> to restore the Null default (GoodNoData-empty reads).</param>
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetHistorianDataSource(IHistorianDataSource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The HistorianDataSource setter null-coalesces to the Null default, so a null source is intentional
// (restores GoodNoData-empty reads); forgive the nullable-in here.
_otOpcUaNodeManager.HistorianDataSource = source!;
return true;
}
/// <summary>
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
/// ACL grants. The host calls this after start with the DI-resolved
/// <see cref="IDataPlaneAuthoritySource"/>, which the artifact-apply path refreshes on every
/// deploy. Passing <c>null</c> restores the empty source (authorizes nothing). No-op (returns
/// <c>false</c>) when the node manager has not been created yet, so the caller can detect a
/// too-early call (mirrors <see cref="SetHistorianDataSource"/>).
/// </summary>
/// <param name="source">The node's data-plane authorization inputs; may be <c>null</c> to restore the
/// empty default.</param>
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetDataPlaneAuthority(IDataPlaneAuthoritySource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The DataPlaneAuthority setter null-coalesces to the empty source, so a null is intentional;
// forgive the nullable-in here.
_otOpcUaNodeManager.DataPlaneAuthority = source!;
return true;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
{
_otOpcUaNodeManager = new OtOpcUaNodeManager(server, configuration);
return new MasterNodeManager(server, configuration, dynamicNamespaceUri: null, _otOpcUaNodeManager);
}
}