Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/DataPlaneAuthorityArtifactTests.cs
T
Joseph Doherty a438ac1640 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
2026-07-31 14:37:18 -04:00

273 lines
12 KiB
C#

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,
};
}