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