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
130 lines
5.5 KiB
C#
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);
|
|
}
|