3a6b2a1daf
Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.
The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
but v3 has no Namespace entity — the snapshot was retired when the address
space went dual-namespace, and the identifier appears nowhere in production
outside NodeScope. So the level could only ever fail to match, which silently
made every sub-cluster grant unreachable while cluster grants kept working.
It is now a realm token derived from the scope's Kind, not carried on it, so
the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
at build time. Depth-derived provenance could not name a raw level at all,
since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
uniqueness is ordinal, so Speed and speed can both exist under one device and
a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
unresolvable rows at a direct child of the trie root, a position the walker
cannot reach, so the grant looked placed and could never match — and every
test using that mode proved nothing about production placement. scopePaths is
now required, and a row that does not resolve is dropped with a diagnostic.
A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.
Also closed here, all found by review rather than asked for:
- NodeAcl rows had no deploy-time validation of any kind and were not reachable
from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
cluster, because it filtered on scope kind with no ClusterId predicate while
ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
test has been red since the key-minting commit merged — I did not run
AdminUI.Tests before merging stage 4.
Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.
Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
119 lines
4.6 KiB
C#
119 lines
4.6 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);
|
|
|
|
/// <summary>
|
|
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
|
|
/// </summary>
|
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
|
|
|
[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)], NoScopePaths);
|
|
|
|
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()
|
|
{
|
|
// 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. Scope kind
|
|
// is the only narrowing this store performs — cluster confinement already happened in
|
|
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
|
|
// on cluster itself; see ConfigDbApiKeyStoreTests).
|
|
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),
|
|
], NoScopePaths);
|
|
|
|
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")], [], NoScopePaths);
|
|
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
|
|
|
var revoked = Key("abc", "p");
|
|
revoked.Enabled = false;
|
|
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
|
|
|
|
(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)],
|
|
NoScopePaths);
|
|
|
|
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,
|
|
};
|
|
}
|