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,190 @@
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;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// The node-local holder of the data-plane authorization inputs (#520 stage 4): generation stamping,
/// and the key/grant lookups session activation runs against.
/// </summary>
public sealed class DataPlaneAuthorityStateTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Before_any_artifact_the_node_authenticates_nothing()
{
var sut = new DataPlaneAuthorityState();
sut.Current.HasArtifact.ShouldBeFalse();
sut.Current.GenerationId.ShouldBe(0);
sut.Current.FindUsableKey("anything", Now).ShouldBeNull();
sut.Current.GrantsFor("anyone").ShouldBeEmpty();
}
[Fact]
public void Each_new_revision_advances_the_generation()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
}
[Fact]
public void Re_applying_the_same_revision_does_not_advance_the_generation()
{
// A node re-applies the same artifact on restart and on a redundant redeploy. Bumping the
// generation there would invalidate every cached authorization decision for no reason.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
}
[Fact]
public void The_generation_only_ever_increases_even_when_the_hash_decreases()
{
// This is the whole reason the generation is a COUNTER rather than bits taken from the revision
// hash. PermissionTrieCache treats the highest GenerationId as current, so a generation derived
// from an unordered hash would, about half the time, install a LOWER number than the deployment
// it replaces — and the cache would go on serving the PREVIOUS deployment's ACLs with nothing
// logged. Applying hashes in descending order must still produce ascending generations.
var sut = new DataPlaneAuthorityState();
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
.ToArray();
generations.ShouldBe([1, 2, 3, 4]);
}
[Fact]
public void A_usable_key_is_found_by_id()
{
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
}
[Fact]
public void A_key_id_matches_case_insensitively()
{
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
// it. A case-sensitive miss would read to them as "the key was revoked".
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
}
[Fact]
public void A_revoked_key_is_not_found()
{
var revoked = Key("abc", "p");
revoked.Enabled = false;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [revoked], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
}
[Fact]
public void An_expired_key_is_not_found_but_the_same_key_is_found_before_its_expiry()
{
// The pair matters: asserting only the expired case passes just as well against a lookup that
// rejects every key with an ExpiresUtc set at all.
var expiring = Key("abc", "p");
expiring.ExpiresUtc = Now;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [expiring], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
}
[Fact]
public void Grants_are_returned_for_the_named_principal_only()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "bob", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
}
[Fact]
public void Several_grants_for_one_principal_are_all_returned()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "alice", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
}
[Fact]
public void Applying_a_new_revision_replaces_the_previous_keys_rather_than_merging_them()
{
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
// node's memory forever.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("old", "p")], []);
sut.Apply(null, "rev-2", [Key("new", "p")], []);
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
}
[Fact]
public void A_duplicate_key_id_does_not_throw()
{
// Cannot happen from the database (the id is uniquely indexed), so this only guards a corrupt
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
var sut = new DataPlaneAuthorityState();
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
}
[Fact]
public void The_cluster_id_is_carried_through()
{
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
// evaluator fails closed when it disagrees with the session's.
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [], []);
sut.Current.ClusterId.ShouldBe("cluster-a");
}
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, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
}