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:
+190
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user