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
@@ -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