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
110 lines
4.0 KiB
C#
110 lines
4.0 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.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
|
|
/// stage denied every UserName token.
|
|
/// </summary>
|
|
public sealed class ArtifactApiKeyStoreTests
|
|
{
|
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
|
|
{
|
|
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
|
|
|
|
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1",
|
|
[Key("abc", "scadabridge-dcl")],
|
|
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
|
|
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
|
|
|
found.ShouldNotBeNull();
|
|
found!.Key.Principal.ShouldBe("scadabridge-dcl");
|
|
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Only_cluster_scoped_grants_are_returned()
|
|
{
|
|
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
|
|
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
|
|
// promoting a scoped grant's flags to server-wide authority.
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1",
|
|
[Key("abc", "p")],
|
|
[
|
|
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
|
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
|
]);
|
|
|
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
|
|
|
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
|
|
{
|
|
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
|
|
// this is that deployment landing.
|
|
var state = new DataPlaneAuthorityState();
|
|
var sut = new ArtifactApiKeyStore(state);
|
|
|
|
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
|
|
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
|
|
|
var revoked = Key("abc", "p");
|
|
revoked.Enabled = false;
|
|
state.Apply("cluster-a", "rev-2", [revoked], []);
|
|
|
|
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Anonymous_grants_resolve_without_a_key()
|
|
{
|
|
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1", [],
|
|
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
|
|
|
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
|
|
|
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
|
|
}
|
|
|
|
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, NodeAclScopeKind scope, NodePermissions flags) => new()
|
|
{
|
|
NodeAclId = id,
|
|
ClusterId = "cluster-a",
|
|
Principal = principal,
|
|
ScopeKind = scope,
|
|
PermissionFlags = flags,
|
|
};
|
|
}
|