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,272 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// End-to-end contract between the two halves of #520 stage 4: <see cref="ConfigComposer"/> writes the
/// API keys + ACL grants into the artifact, and <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/>
/// reads them back out on the node.
/// </summary>
/// <remarks>
/// These deliberately compose a REAL artifact rather than hand-writing the JSON. A hand-written fixture
/// pins the parser against my own idea of the format, not against the producer — the failure it would
/// miss is exactly the one that matters here: someone changes how the composer serialises (a naming
/// policy, a <c>JsonStringEnumConverter</c>) and every key and grant silently stops arriving at the
/// node, with no error anywhere and no test going red.
/// </remarks>
public sealed class DataPlaneAuthorityArtifactTests : ControlPlaneActorTestBase
{
private const string NodeA = "node-a:4840";
private const string NodeB = "node-b:4840";
[Fact]
public async Task A_key_survives_the_round_trip_through_a_composed_artifact()
{
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ApiKeys.Add(NewKey("abc123", "scadabridge-dcl"));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var key = parsed.Keys.ShouldHaveSingleItem();
key.ApiKeyId.ShouldBe("abc123");
key.Principal.ShouldBe("scadabridge-dcl");
key.SecretHash.ShouldBe("hash-of-abc123");
key.Enabled.ShouldBeTrue();
}
[Fact]
public async Task A_grant_survives_the_round_trip_with_its_scope_kind_and_permission_flags()
{
// The enum fields are the fragile part: ConfigComposer writes them as NUMBERS, so the parser reads
// int. If that ever diverges the grant is dropped and a key silently authenticates with no
// permissions — which looks exactly like a correctly-unprivileged key.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.NodeAcls.Add(NewAcl("acl-1", "cluster-a", "scadabridge-dcl",
NodeAclScopeKind.Cluster, NodePermissions.Read | NodePermissions.WriteOperate));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.Principal.ShouldBe("scadabridge-dcl");
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
grant.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
}
[Fact]
public async Task A_non_cluster_scope_kind_round_trips_as_itself()
{
// Falsifies "the enum happens to work because Cluster is 0". Equipment is 4, so a broken
// enum read would surface as Cluster here rather than as a dropped row.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var acl = NewAcl("acl-1", "cluster-a", "p", NodeAclScopeKind.Equipment, NodePermissions.Read);
acl.ScopeId = "EQ-1";
db.NodeAcls.Add(acl);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment);
grant.ScopeId.ShouldBe("EQ-1");
}
[Fact]
public async Task An_expiry_round_trips_as_the_same_UTC_instant()
{
// Covers the round trip only. It does NOT cover the timezone hazard: the in-memory EF provider
// preserves DateTimeKind.Utc, so the composer writes a "Z" here and the parser's Unspecified
// branch never runs — verified by breaking that branch and watching this test still pass. SQL
// Server's datetime2 DOES materialise as Unspecified, so the branch is real in production and is
// pinned directly at the parser by
// DeploymentArtifactDataPlaneAuthorityTests.An_expiry_written_without_an_offset_is_read_as_UTC.
var expires = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.ExpiresUtc = expires;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var parsedKey = parsed.Keys.ShouldHaveSingleItem();
parsedKey.ExpiresUtc.ShouldNotBeNull();
parsedKey.ExpiresUtc!.Value.ShouldBe(expires);
parsedKey.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public async Task Grants_are_scoped_to_the_nodes_own_cluster_but_keys_are_not()
{
// Multi-cluster artifact: node-a belongs to cluster-a, so it must not receive cluster-b's grants.
// Keys carry no ClusterId — a key is a fleet-wide credential, and it is the ACL rows that decide
// what it can reach — so BOTH keys arrive.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeA, "cluster-a"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.ApiKeys.Add(NewKey("key-a", "principal-a"));
db.ApiKeys.Add(NewKey("key-b", "principal-b"));
db.NodeAcls.Add(NewAcl("acl-a", "cluster-a", "principal-a", NodeAclScopeKind.Cluster, NodePermissions.Read));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.ClusterId.ShouldBe("cluster-a");
parsed.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-a");
parsed.Keys.Select(k => k.ApiKeyId).OrderBy(x => x).ShouldBe(["key-a", "key-b"]);
}
[Fact]
public async Task A_node_absent_from_a_multi_cluster_artifact_receives_no_grants()
{
// ResolveClusterScope reads an unlisted node as Suppress — it was not deployed to. Handing it
// another cluster's grants would be the worst possible reading of that.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, "node-nowhere:4840");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public async Task A_revoked_key_still_ships_so_the_node_can_see_the_revocation()
{
// Filtering revoked keys out of the artifact would make a revocation indistinguishable from a key
// that was never deployed — and worse, would leave the node's previous snapshot as the last word
// on that id. The key ships with Enabled=false and the lookup denies it.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.Enabled = false;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_empty_or_malformed_blob_yields_nothing()
{
DeploymentArtifact.ParseDataPlaneAuthority(ReadOnlySpan<byte>.Empty, NodeA)
.Keys.ShouldBeEmpty();
DeploymentArtifact.ParseDataPlaneAuthority("not json at all"u8, NodeA)
.Grants.ShouldBeEmpty();
}
[Fact]
public async Task Adding_a_key_changes_the_revision_hash()
{
// The node's generation stamp is driven by this hash, so a key change that did not move it would
// leave the node believing it had already applied the new authorization inputs.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
await db.SaveChangesAsync();
}
var before = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
await using (var db = f.CreateDbContext())
{
db.ApiKeys.Add(NewKey("abc123", "p"));
await db.SaveChangesAsync();
}
var after = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
after.ShouldNotBe(before);
}
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new()
{
ClusterId = id,
Name = id,
Enterprise = "ent",
Site = "site",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ClusterNode NewNode(string nodeId, string clusterId) => new()
{
NodeId = nodeId,
ClusterId = clusterId,
Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:{nodeId}",
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ApiKey NewKey(string apiKeyId, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = apiKeyId,
Name = $"key {apiKeyId}",
SecretHash = $"hash-of-{apiKeyId}",
Principal = principal,
Enabled = true,
CreatedUtc = FixedTimestamp,
};
private static NodeAcl NewAcl(
string id, string clusterId, string principal, NodeAclScopeKind scopeKind, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scopeKind,
PermissionFlags = flags,
};
}
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
/// stage denied every UserName token.
/// </summary>
public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found.ShouldNotBeNull();
found!.Key.Principal.ShouldBe("scadabridge-dcl");
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
[Fact]
public async Task Only_cluster_scoped_grants_are_returned()
{
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
// promoting a scoped grant's flags to server-wide authority.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
[Fact]
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
{
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
// this is that deployment landing.
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], []);
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task Anonymous_grants_resolve_without_a_key()
{
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
}
@@ -0,0 +1,129 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Parser-level coverage for <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> (#520 stage 4)
/// over hand-written JSON — the shapes a composed artifact cannot produce from the in-memory EF
/// provider, plus the malformed rows the lenient-decode contract has to survive.
/// </summary>
/// <remarks>
/// The happy path is deliberately NOT tested here. It is tested in
/// <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c> against an artifact a real
/// <c>ConfigComposer</c> produced, because a hand-written fixture pins the parser against this file's
/// idea of the format rather than against the producer.
/// </remarks>
public sealed class DeploymentArtifactDataPlaneAuthorityTests
{
private const string NodeId = "node-a:4840";
[Fact]
public void An_expiry_written_without_an_offset_is_read_as_UTC()
{
// SQL Server's datetime2 carries no offset, so EF materialises it as DateTimeKind.Unspecified and
// the composer writes "2030-06-15T12:00:00" with no Z. Calling ToUniversalTime() on that would
// SUBTRACT the node's local offset from a value that is already UTC, moving every key's expiry by
// hours — earlier in the Americas, so keys would appear to expire before they should. The
// in-memory provider preserves Kind and cannot reproduce this, which is why it is pinned here.
var parsed = Parse($$"""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"}]}
""");
var key = parsed.Keys.ShouldHaveSingleItem();
key.ExpiresUtc.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
key.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public void An_expiry_written_with_a_Z_is_also_read_as_the_same_instant()
{
// The positive control for the test above: without it, a parser that ignored ExpiresUtc entirely
// and returned a hardcoded value would satisfy neither, but one that mangled only the Z-suffixed
// form would slip through.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"ExpiresUtc":"2030-06-15T12:00:00Z"}]}
""");
parsed.Keys.ShouldHaveSingleItem().ExpiresUtc
.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void A_key_row_missing_its_secret_hash_is_dropped_rather_than_admitted()
{
// A half-written row must never become a credential. Dropping it denies that key id; keeping it
// with an empty hash would hand the verifier something to compare against.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","Principal":"p","Enabled":true}]}
""");
parsed.Keys.ShouldBeEmpty();
}
[Fact]
public void A_key_row_with_no_Enabled_field_is_treated_as_revoked()
{
// The other direction would resurrect a revoked key out of a malformed artifact.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p"}]}
""");
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_out_of_range_scope_kind_drops_the_grant()
{
// Casting an undefined int straight to the enum would produce a scope the trie walker can never
// match, i.e. a grant that is silently unreachable rather than visibly absent.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":99,"PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void A_grant_with_an_unreadable_permission_mask_grants_nothing()
{
// Defaulting a missing bitmask to anything other than None would invent authority.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":0}]}
""");
parsed.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.None);
}
[Fact]
public void A_string_valued_scope_kind_drops_the_grant()
{
// Guards the enum-serialization drift this repo has been bitten by before: if the composer ever
// gained a JsonStringEnumConverter, grants would arrive as "Cluster" instead of 0. That must fail
// loudly in tests rather than silently drop every grant in production — the ControlPlane
// round-trip test is what goes red; this one documents the shape the parser refuses.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":"Cluster","PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void An_artifact_with_neither_section_yields_empty_lists_not_a_throw()
{
var parsed = Parse("""{"DriverInstances":[]}""");
parsed.Keys.ShouldBeEmpty();
parsed.Grants.ShouldBeEmpty();
parsed.ClusterId.ShouldBeNull();
}
private static DataPlaneAuthorityProjection Parse(string json) =>
DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(json), NodeId);
}