Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ConfigDbApiKeyStoreTests.cs
T
Joseph Doherty 3a6b2a1daf feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)
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
2026-08-01 01:07:59 -04:00

228 lines
9.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
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 node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
/// admin+driver one.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
/// central node.
/// </para>
/// <para>
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStoreTests
{
private const string ThisCluster = "SITE-A";
private const string OtherCluster = "SITE-B";
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
found.ShouldNotBeNull();
found!.Grants.ShouldBeEmpty();
}
/// <summary>
/// The positive control for the test above: without it, a "fix" that returned no grants at all
/// would pass.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls:
[
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
/// Failing closed here would silently strip every grant on an existing deployment.
/// </summary>
[Fact]
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default);
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
}
/// <summary>
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
/// authored independently. A case-sensitive match would therefore strip every grant on a node
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
/// </summary>
[Fact]
public async Task A_ClusterId_matches_case_insensitively()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
/// </summary>
[Fact]
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [],
acls:
[
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
}
/// <summary>
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
/// </summary>
[Fact]
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
/// <summary>
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
/// </summary>
[Fact]
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
{
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
.ShouldBe("SITE-A");
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
.ShouldBeNull();
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
.ShouldBeNull();
}
private static void Seed(
IDbContextFactory<OtOpcUaConfigDbContext> factory,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> acls)
{
using var db = factory.CreateDbContext();
db.ApiKeys.AddRange(keys);
db.NodeAcls.AddRange(acls);
db.SaveChanges();
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a context over this test's own in-memory database.</summary>
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
}
}