Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDataPlaneAuthorityTests.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

130 lines
5.5 KiB
C#

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