a438ac1640
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
273 lines
12 KiB
C#
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,
|
|
};
|
|
}
|