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
This commit is contained in:
@@ -3,6 +3,7 @@ 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;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
|
||||
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
|
||||
@@ -243,6 +244,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves,
|
||||
/// the generation those were resolved at, and the ACL grants themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This closes the fourth #520 blocker: <c>NodeScope.ClusterId</c> is <c>required</c> and
|
||||
/// <c>TriePermissionEvaluator</c> fails closed when it disagrees with the session's, but the
|
||||
/// node manager had no idea what cluster it was serving — so no <c>NodeScope</c> it built
|
||||
/// could ever have evaluated. It is fed here rather than at construction because the SDK
|
||||
/// constructs this manager during <c>StartAsync</c>, before any artifact has been applied,
|
||||
/// and the value changes on every deploy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm
|
||||
/// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so
|
||||
/// nothing reads this yet and authorization behaviour is unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Backed by a <c>volatile</c> field for the same reason as <see cref="HistorianDataSource"/>:
|
||||
/// written on the deploy path, read on SDK request threads. Assigning <c>null</c> restores the
|
||||
/// empty source, which authenticates and authorizes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IDataPlaneAuthoritySource DataPlaneAuthority
|
||||
{
|
||||
get => _dataPlaneAuthority;
|
||||
set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance;
|
||||
}
|
||||
|
||||
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
|
||||
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
|
||||
@@ -103,6 +104,28 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user