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
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user