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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user