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:
Joseph Doherty
2026-07-31 14:37:18 -04:00
parent c1771dc271
commit a438ac1640
21 changed files with 1505 additions and 48 deletions
@@ -49,6 +49,13 @@ public static class ConfigComposer
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
// #520 stage 4: the data-plane credentials travel with the config, because a driver-only node
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
// still cannot be turned into a working credential. This is also why revoking a key reaches a
// driver-only node only on the next deployment; see the ApiKey entity's remarks.
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
@@ -0,0 +1,80 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
// Fully-qualified rather than relative: this file's own namespace root contains a `Configuration`
// namespace (ZB.MOM.WW.OtOpcUa.Host.Configuration), so a relative `Configuration.Enums` binds there and
// fails to compile.
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// <see cref="IApiKeyStore"/> served from the deployment artifact (#520, stage 4). This is the key
/// source for a <b>driver-only</b> node, which has had no ConfigDb since per-cluster mesh Phase 4 and
/// until now denied every UserName token.
/// </summary>
/// <remarks>
/// <para>
/// Purely in-memory: <see cref="OpcUaPublishActor"/> decodes the keys and grants once per applied
/// artifact and installs them on <see cref="IDataPlaneAuthoritySource"/>. That matters because the
/// SDK raises the impersonation callback <b>synchronously, under a <c>SessionManager</c>-wide
/// lock</b> — an I/O-bound lookup here would serialize every session activation in the server
/// behind one disk or network round trip.
/// </para>
/// <para>
/// ⚠️ <b>Consequence: revocation lands on the next deployment, not immediately.</b> A node reading
/// the ConfigDb sees <c>Enabled = 0</c> on the next request; this one sees it only once a new
/// artifact arrives. That asymmetry is inherent to a node with no config database and is
/// documented for operators on the AdminUI key page.
/// </para>
/// <para>
/// Before the first artifact is applied the snapshot is empty, so every key is denied. That is the
/// safe direction, and it is also honest — a node that has not been deployed to has no configured
/// keys.
/// </para>
/// </remarks>
public sealed class ArtifactApiKeyStore : IApiKeyStore
{
private readonly IDataPlaneAuthoritySource _authority;
/// <summary>Initializes a new instance of the <see cref="ArtifactApiKeyStore"/> class.</summary>
/// <param name="authority">The node's current data-plane authorization inputs.</param>
public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority)
{
ArgumentNullException.ThrowIfNull(authority);
_authority = authority;
}
/// <inheritdoc />
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
// Read the snapshot ONCE. Re-reading Current for the grants would let a deploy land between the
// two reads and pair a key from one generation with grants from another.
var snapshot = _authority.Current;
var key = snapshot.FindUsableKey(apiKeyId, utcNow);
return Task.FromResult(key is null
? null
: new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal)));
}
/// <inheritdoc />
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
/// both stores to the full grant set once scope is actually enforced.
/// </summary>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
if (all.Count == 0) return [];
var scoped = new List<NodeAcl>(all.Count);
foreach (var grant in all)
{
if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant);
}
return scoped;
}
}
@@ -10,12 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
/// </summary>
/// <remarks>
/// ⚠️ <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot
/// be registered there and such a node has no key source until the artifact-backed store lands in
/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to
/// <see cref="OpcUaServer.Security.NullOpcUaUserAuthenticator"/>, which denies every attempt. That is
/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until
/// enforcement lands in stages 5–6) are unaffected.
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
/// <para>
/// Reading per request is what makes revocation immediate here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
@@ -7,9 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// </summary>
/// <remarks>
/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused
/// admin+driver node reads the config database directly; a <b>driver-only</b> node has no ConfigDb
/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact.
/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4.
/// admin+driver node reads the config database directly (<see cref="ConfigDbApiKeyStore"/>); a
/// <b>driver-only</b> node has no ConfigDb at all since per-cluster mesh Phase 4, so its keys come out
/// of the deployment artifact (<see cref="ArtifactApiKeyStore"/>).
/// <para>
/// The two differ in one operator-visible way: the ConfigDb store sees a revocation on the next
/// request, the artifact store only once a new deployment reaches the node.
/// </para>
/// </remarks>
public interface IApiKeyStore
{
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
@@ -34,6 +35,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
private readonly ServerHistorianOptions _serverHistorianOptions;
private readonly Func<ActorSystem> _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
@@ -55,6 +57,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// node, the same source the address-space sink + node-write gateway come from), or the configured
/// HistorianGateway read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
/// <see cref="StartAsync"/>.</param>
/// <param name="dataPlaneAuthority">#520 stage 4 — the node's data-plane authorization inputs (its
/// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment
/// artifact. Wired onto the node manager in <see cref="StartAsync"/> so the stage 5/6 enforcement seams
/// have a cluster identity to build a <c>NodeScope</c> with. Resolves to the empty source on a node with
/// no driver role.</param>
/// <param name="configuration">App configuration; the <c>ServerHistorian</c> section is bound here to
/// read <see cref="ServerHistorianOptions.MaxTieClusterOverfetch"/> for the node manager. Bound directly
/// (not via <c>IOptions</c>) so the wiring is self-contained in this hosted service — the option's
@@ -73,6 +80,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
IHistorianDataSource historianDataSource,
IDataPlaneAuthoritySource dataPlaneAuthority,
IConfiguration configuration,
Func<ActorSystem> actorSystemAccessor,
ActorRegistry actorRegistry,
@@ -83,6 +91,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_historianDataSource = historianDataSource;
_dataPlaneAuthority = dataPlaneAuthority;
// Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need
// MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file.
_serverHistorianOptions =
@@ -216,6 +225,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads
// them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists
// because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the
// evaluator fails closed on a mismatch — every scope it could have built would have been denied.
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
@@ -257,6 +272,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server?.SetNativeAlarmAckRouter(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
_server?.SetDataPlaneAuthority(null);
return Task.CompletedTask;
}
+34 -7
View File
@@ -351,12 +351,31 @@ if (hasDriver)
// (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
// registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
// Only IOpcUaUserAuthenticator moves.
// #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth
// generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any
// driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to
// the SAME instance, since the writer and the readers must see one snapshot.
if (hasDriver)
{
builder.Services.AddSingleton<DataPlaneAuthorityState>();
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
}
else
{
// The SOURCE half must always resolve — OtOpcUaServerHostedService takes it as a required ctor
// parameter, and the built-in DI container does not fall back to a default value for a parameter
// whose service is unregistered; it throws and the node fails to start. Same trap, and the same
// fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional
// (nothing publishes artifacts on a node with no driver role).
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
}
if (hasAdmin)
{
// ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin.
// A driver-only node therefore has no key source until the artifact-backed store lands in
// stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token.
// Safe direction, and anonymous sessions are unaffected.
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
// on the very next request rather than on the next deployment. Preferred wherever it is available,
// including on a fused admin+driver node.
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
@@ -365,11 +384,19 @@ if (hasDriver)
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
}
else if (hasDriver)
{
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
// token — safe, but it meant no machine client could authenticate against a site node at all.
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
}
else
{
// MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes
// IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a
// quiet fallback to deny-all — it fails DI and the node does not start at all.
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
}
@@ -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)
@@ -37,6 +37,24 @@ public enum ClusterFilterMode
Suppress,
}
/// <summary>
/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520).
/// </summary>
/// <param name="ClusterId">The node's cluster per its <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.</param>
/// <param name="Keys">Every <c>ApiKey</c> the artifact carries, revoked and expired included — the
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
/// <param name="Mode">
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
@@ -534,6 +552,170 @@ public static class DeploymentArtifact
: all;
}
/// <summary>
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
/// permissions (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY key source a <b>driver-only</b> node has — per-cluster mesh Phase 4 left
/// it with no ConfigDb, so without this it denies every UserName token.
/// </para>
/// <para>
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope"/>); keys are
/// not, because <c>ApiKey</c> carries no ClusterId — a key is a fleet-wide credential and it
/// is the ACL rows that bind it to a cluster's nodes. A <see cref="ClusterFilterMode.Suppress"/>
/// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching
/// the driver-spawn path's reading that an unlisted node was not deployed to.
/// </para>
/// <para>
/// Lenient like every other parser here: an empty or malformed blob yields empty lists. The
/// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the
/// apply paths only reach this with bytes that already passed their own emptiness guard.
/// </para>
/// </remarks>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> blob, string nodeId)
{
if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
var scope = ResolveClusterScope(blob, nodeId);
try
{
using var doc = JsonDocument.Parse(blob.ToArray());
var root = doc.RootElement;
var keys = new List<ApiKey>();
foreach (var el in EnumerateArray(root, "ApiKeys"))
{
var key = TryReadApiKey(el);
if (key is not null) keys.Add(key);
}
var grants = new List<NodeAcl>();
foreach (var el in EnumerateArray(root, "NodeAcls"))
{
var acl = TryReadNodeAcl(el);
if (acl is null) continue;
if (scope.Mode == ClusterFilterMode.Suppress) continue;
if (scope.Mode == ClusterFilterMode.ScopeTo
&& !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants);
}
catch (JsonException)
{
return DataPlaneAuthorityProjection.Empty;
}
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
private static ApiKey? TryReadApiKey(JsonElement el)
{
var apiKeyId = ReadString(el, "ApiKeyId");
var secretHash = ReadString(el, "SecretHash");
var principal = ReadString(el, "Principal");
if (string.IsNullOrWhiteSpace(apiKeyId)
|| string.IsNullOrWhiteSpace(secretHash)
|| string.IsNullOrWhiteSpace(principal))
{
return null;
}
return new ApiKey
{
ApiKeyId = apiKeyId,
Name = ReadString(el, "Name") ?? apiKeyId,
SecretHash = secretHash,
Principal = principal,
// A row whose Enabled flag did not survive serialisation is treated as REVOKED. The other
// direction would resurrect a revoked key out of a malformed artifact.
Enabled = ReadBool(el, "Enabled") ?? false,
CreatedUtc = ReadDateTime(el, "CreatedUtc") ?? default,
ExpiresUtc = ReadDateTime(el, "ExpiresUtc"),
Notes = ReadNullableString(el, "Notes"),
};
}
/// <summary>Reads one <c>NodeAcls</c> element; null when identity, cluster or scope kind is unreadable.</summary>
private static NodeAcl? TryReadNodeAcl(JsonElement el)
{
var nodeAclId = ReadString(el, "NodeAclId");
var clusterId = ReadString(el, "ClusterId");
var principal = ReadString(el, "Principal");
if (string.IsNullOrWhiteSpace(nodeAclId)
|| string.IsNullOrWhiteSpace(clusterId)
|| string.IsNullOrWhiteSpace(principal))
{
return null;
}
// ConfigComposer serialises enums as NUMBERS (its JsonSerializerOptions carries no
// JsonStringEnumConverter), so these read as int. Keep the two sides in step — a string-enum
// converter added to the composer would silently drop every grant here.
var scopeKind = ReadInt32(el, "ScopeKind");
if (scopeKind is null || !Enum.IsDefined(typeof(NodeAclScopeKind), scopeKind.Value)) return null;
return new NodeAcl
{
NodeAclId = nodeAclId,
ClusterId = clusterId,
Principal = principal,
ScopeKind = (NodeAclScopeKind)scopeKind.Value,
ScopeId = ReadNullableString(el, "ScopeId"),
// An unreadable bitmask grants nothing rather than defaulting to something permissive.
PermissionFlags = (NodePermissions)(ReadInt32(el, "PermissionFlags") ?? 0),
Notes = ReadNullableString(el, "Notes"),
};
}
/// <summary>Reads a boolean property; null when absent or not a JSON boolean.</summary>
private static bool? ReadBool(JsonElement el, string property) =>
el.TryGetProperty(property, out var p)
? p.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null,
}
: null;
/// <summary>Reads an int property; null when absent, not a number, or out of int range.</summary>
private static int? ReadInt32(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.Number
&& p.TryGetInt32(out var v)
? v
: null;
/// <summary>Reads a UTC DateTime property; null when absent, JSON null, or unparseable.</summary>
private static DateTime? ReadDateTime(JsonElement el, string property)
{
if (!el.TryGetProperty(property, out var p) || p.ValueKind != JsonValueKind.String) return null;
if (!p.TryGetDateTime(out var v)) return null;
// Normalise to UTC, because every consumer compares against DateTime.UtcNow and an expiry that
// silently shifted by the node's timezone is a key that outlives (or predeceases) its expiry.
// Unspecified must be SpecifyKind, NOT ToUniversalTime: EF materialises datetime2 as Unspecified,
// so the composer writes these UTC instants without an offset and ToUniversalTime would subtract
// the node's local offset from a value that was already UTC.
return v.Kind switch
{
DateTimeKind.Utc => v,
DateTimeKind.Local => v.ToUniversalTime(),
_ => DateTime.SpecifyKind(v, DateTimeKind.Utc),
};
}
/// <summary>Reads a string property, tolerating a wrong JSON type: a property that is present but
/// NOT a JSON string (number / bool / object / array) yields null rather than throwing
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. Keeps the artifact-decode lenient
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
@@ -95,6 +96,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private readonly NodeId? _localNode;
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier;
/// <summary>#520 stage 4 — receives the API keys + ACL grants carried by each applied artifact, so
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
/// that is not serving an OPC UA data plane).</summary>
private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority;
private readonly IActorRef? _dbHealthProbe;
/// <summary>Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its
/// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired —
@@ -166,7 +171,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -178,7 +184,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess)).WithDispatcher(DispatcherId);
dbLess,
dataPlaneAuthority)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -214,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -226,7 +234,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess));
dbLess,
dataPlaneAuthority));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -259,9 +268,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false)
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null)
{
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
_serviceLevel = serviceLevel;
_subscribeRedundancyTopic = subscribeRedundancyTopic;
_localNode = localNode;
@@ -412,6 +423,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return;
}
// #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
// key or edits a grant without touching a single node produces an EMPTY address-space plan, so
// feeding this after that return would leave a revoked key authenticating until some unrelated
// structural change happened to redeploy. Authorization and topology change on their own
// schedules.
FeedDataPlaneAuthority(artifact);
var composition = _localNode is { } ln
? DeploymentArtifact.ParseComposition(artifact, ln.Value,
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
@@ -491,6 +510,43 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants)
/// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes
/// them.
/// </summary>
/// <remarks>
/// Wrapped so a decode failure can never break a deploy — the same discipline as the historised-ref
/// feed. The cost of that choice is explicit: the node keeps serving the PREVIOUS key set, which is
/// the safe direction for availability but means a revocation carried by an artifact this node
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
/// </remarks>
private void FeedDataPlaneAuthority(byte[] artifact)
{
if (_dataPlaneAuthority is null) return;
try
{
var nodeId = _localNode?.Value ?? string.Empty;
var projection = DeploymentArtifact.ParseDataPlaneAuthority(artifact, nodeId);
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count);
}
catch (Exception ex)
{
_log.Error(ex,
"OpcUaPublish: failed to install data-plane authority from the applied artifact — the node keeps the PREVIOUS keys and grants, so any revocation in this deployment has NOT taken effect");
}
}
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
/// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485).</summary>
@@ -17,6 +17,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
@@ -467,6 +468,11 @@ public static class ServiceCollectionExtensions
loggerFactory.CreateLogger<AddressSpaceApplier>(),
provisioning: provisioning,
historizedSubscriptions: historizedSubscriptions);
// #520 stage 4 — the sink that receives each applied artifact's API keys + ACL grants, so OPC
// UA session activation can authenticate. Optional: GetService (not GetRequiredService), because
// a node with no driver role never registers it and test harnesses build this graph without it;
// the actor treats null as "nothing consumes authorization inputs" and skips the decode.
var dataPlaneAuthority = resolver.GetService<IDataPlaneAuthoritySink>();
var publishActor = system.ActorOf(
OpcUaPublishActor.Props(
sink: addressSpaceSink,
@@ -475,7 +481,8 @@ public static class ServiceCollectionExtensions
dbFactory: dbFactory,
applier: applier,
dbHealthProbe: dbHealth,
dbLess: dbFactory is null),
dbLess: dbFactory is null,
dataPlaneAuthority: dataPlaneAuthority),
OpcUaPublishActorName);
registry.Register<OpcUaPublishActorKey>(publishActor);
@@ -21,6 +21,10 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
<!-- #520 stage 4: the artifact-apply path feeds IDataPlaneAuthoritySink. Core already arrives
transitively via OpcUaServer, but a transitive reference is not a stated dependency — make it
explicit so removing it from OpcUaServer does not silently break this one. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!--
Concrete OtOpcUa.Driver.* assemblies are loaded reflectively at runtime (Phase 6,
Task 41+) and intentionally NOT project-referenced here. The driver contracts live in