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
@@ -0,0 +1,235 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// The data-plane authorization inputs a node is currently serving: the API keys that may
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
/// generation those were resolved at (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// Immutable. A new deployment produces a whole new snapshot which is swapped in atomically, so
/// a request that started against the previous generation finishes against a coherent set
/// rather than a half-updated one.
/// </para>
/// <para>
/// The lookups are precomputed because the consumer is OPC UA session activation, which the SDK
/// raises <b>synchronously under a <c>SessionManager</c>-wide lock</b> — every session in the
/// server serializes behind it. A dictionary probe is the whole cost.
/// </para>
/// </remarks>
public sealed class DataPlaneAuthoritySnapshot
{
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
private DataPlaneAuthoritySnapshot(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
GenerationId = generationId;
Keys = keys;
Grants = grants;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
private readonly IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> _grantsByPrincipal;
/// <summary>
/// The cluster this node belongs to per the artifact's <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.
/// </summary>
public string? ClusterId { get; }
/// <summary>SHA-256 of the artifact these inputs came from; empty before the first apply.</summary>
public string RevisionHash { get; }
/// <summary>
/// Monotonic, node-local generation stamp. Zero means "nothing applied yet"; it increases by one
/// each time an artifact with a <em>different</em> <see cref="RevisionHash"/> is applied.
/// </summary>
public long GenerationId { get; }
/// <summary>Every key in the artifact, revoked and expired ones included — filtering is the lookup's job.</summary>
public IReadOnlyList<ApiKey> Keys { get; }
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
public IReadOnlyList<NodeAcl> Grants { get; }
/// <summary>True once a real artifact has been applied.</summary>
public bool HasArtifact => GenerationId > 0;
/// <summary>
/// Finds a key that is enabled and unexpired at <paramref name="utcNow"/>. A revoked or expired
/// key returns null rather than a "found but unusable" result, mirroring
/// <c>ConfigDbApiKeyStore</c>, which filters both in SQL for the same reason: a key that must
/// not authenticate should never reach a caller that might treat a non-null return as a match.
/// </summary>
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
/// <returns>The usable key, or null.</returns>
public ApiKey? FindUsableKey(string apiKeyId, DateTime utcNow)
{
if (string.IsNullOrEmpty(apiKeyId)) return null;
if (!_keysById.TryGetValue(apiKeyId, out var key)) return null;
if (!key.Enabled) return null;
if (key.ExpiresUtc is { } expires && expires <= utcNow) return null;
return key;
}
/// <summary>Returns the grants naming a principal; empty when none.</summary>
/// <param name="principal">The principal to look up.</param>
/// <returns>The matching grants.</returns>
public IReadOnlyList<NodeAcl> GrantsFor(string principal) =>
!string.IsNullOrEmpty(principal) && _grantsByPrincipal.TryGetValue(principal, out var g)
? g
: [];
/// <summary>Builds a snapshot, precomputing the key and grant lookups.</summary>
/// <param name="clusterId">The node's cluster, or null when the artifact is single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the source artifact.</param>
/// <param name="generationId">The node-local generation stamp.</param>
/// <param name="keys">The API keys carried by the artifact.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(grants);
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
// evaluator's (TriePermissionEvaluator matches principals OrdinalIgnoreCase).
var byId = new Dictionary<string, ApiKey>(keys.Count, StringComparer.OrdinalIgnoreCase);
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key.ApiKeyId)) continue;
// A duplicate id cannot come from the database (UX_ApiKey_ApiKeyId is unique), so this only
// fires on a corrupt artifact. Keep the FIRST and drop the rest rather than throwing: the
// alternative is a node that refuses to authenticate anything because one row is malformed.
byId.TryAdd(key.ApiKeyId, key);
}
var byPrincipal = new Dictionary<string, List<NodeAcl>>(StringComparer.OrdinalIgnoreCase);
foreach (var grant in grants)
{
if (string.IsNullOrWhiteSpace(grant.Principal)) continue;
if (!byPrincipal.TryGetValue(grant.Principal, out var list))
byPrincipal[grant.Principal] = list = [];
list.Add(grant);
}
var frozen = byPrincipal.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
}
}
/// <summary>Read side of the node's current data-plane authorization inputs.</summary>
public interface IDataPlaneAuthoritySource
{
/// <summary>The snapshot in force right now. Never null; <see cref="DataPlaneAuthoritySnapshot.Empty"/> before the first apply.</summary>
DataPlaneAuthoritySnapshot Current { get; }
}
/// <summary>
/// The source bound when nothing has been wired: permanently empty, so it authenticates no key and
/// grants no permission. The safe default for a node that is not serving a data plane.
/// </summary>
public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
{
/// <summary>The shared instance.</summary>
public static readonly EmptyDataPlaneAuthoritySource Instance = new();
private EmptyDataPlaneAuthoritySource() { }
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
}
/// <summary>
/// Write side, fed once per deployment from the node's artifact-apply path.
/// </summary>
public interface IDataPlaneAuthoritySink
{
/// <summary>
/// Installs the authorization inputs carried by a newly applied artifact. Idempotent: re-applying
/// the same <paramref name="revisionHash"/> leaves the generation stamp untouched.
/// </summary>
/// <param name="clusterId">The node's cluster per the artifact, or null when single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants);
}
/// <summary>
/// Process-singleton holder of <see cref="DataPlaneAuthoritySnapshot"/>. Registered on every
/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it.
/// </summary>
public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink
{
private readonly Lock _gate = new();
private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty;
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
{
ArgumentNullException.ThrowIfNull(revisionHash);
lock (_gate)
{
var previous = _current;
// The generation is a node-local counter driven BY the revision hash, not derived FROM it.
// Deriving a long from hash bytes was the obvious reading of "stamp the generation from the
// RevisionHash", and it is wrong: PermissionTrieCache treats the highest GenerationId as the
// current one (ClusterEntry.WithAdditional) and Prune keeps the highest. Hash fragments are
// unordered, so roughly half of all deploys would install a LOWER generation and the cache
// would keep serving the previous deployment's ACLs — a silent stale-authorization bug with
// no error anywhere. A counter is monotonic by construction.
//
// It resets to zero on restart, which is safe: sessions do not survive a restart and the trie
// cache is in-process, so there is no older generation left to be out-ordered by.
var generation = string.Equals(previous.RevisionHash, revisionHash, StringComparison.Ordinal)
&& previous.HasArtifact
? previous.GenerationId
: previous.GenerationId + 1;
var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants);
Volatile.Write(ref _current, next);
return next;
}
}
}
@@ -13,10 +13,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
/// thing that can change a session's grants is a new deploy — which already bumps
/// <see cref="AuthGenerationId"/>. The generation stamp, not the freshness timer, is the
/// real invalidation signal. Revisit whether
/// <see cref="MembershipFreshnessInterval"/> / <see cref="NeedsRefresh"/> earn their keep
/// when the session lifecycle lands (design doc stage 4); they are retained unchanged here
/// so this rename stays behaviour-neutral.
/// real invalidation signal. Stage 4 built that signal — <see cref="DataPlaneAuthorityState"/>
/// advances a monotonic generation on every artifact carrying a new <c>RevisionHash</c> — but
/// deliberately did NOT construct this type per session: nothing reads a
/// <see cref="UserAuthorizationState"/> until the stage 5 enforcement seams exist, and a
/// per-session object that nothing consults is dormant wiring. So the question of whether
/// <see cref="MembershipFreshnessInterval"/> / <see cref="NeedsRefresh"/> earn their keep moves
/// to stage 5, where sessions are actually stamped.
///
/// The membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 5 min). After that, the next hot-path authz call re-resolves the principal