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