Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ConfigDbApiKeyStoreTests.cs
T
Joseph Doherty f692f6afe4 feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide
data-plane authorization at the three seams that have one, and gives HistoryRead
a check it never had. Read, Browse and Subscribe stay ungated — stage 6.

The ACL decision REPLACES the server-wide role check rather than chaining with
it. Chained, a grant would have to confer the role as well as pass the scope
check, and only Cluster-scoped grants confer a role — so every narrow grant
would have stayed inert and the flag would have looked inert too. The
load-bearing test is an ALLOW: a session carrying no roles at all writing
successfully on the strength of its grant. A deny-only test passes against a
build where the ACL is never consulted, because the role gate denies too.

Found en route: the principal never reached the session. The authenticator
passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity
derives DisplayName from the presented token — for a key-bearing client that is
the static username `apikey`, identical fleet-wide. So the trie's matching key
was dropped at the identity boundary, and the audit ClientUserId on a rejected
write recorded `apikey` for every consuming system. Principal and ApiKeyId are
now carried explicitly.

Beyond the stage as sketched:

- Revocation reaches an OPEN session. Every decision re-checks the
  authenticating key against the current snapshot; without it a key revoked by a
  deployment kept its authority until the client chose to reconnect, which for a
  machine-to-machine consumer can be indefinitely.
- UserAuthorizationState is built per CALL, not per session. The per-session
  shape exists so an LDAP session can re-resolve membership that changed out of
  band; nothing can change out of band under API keys, so the generation stamp is
  the whole invalidation signal and a per-call record picks it up for free.
  IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial.
- The trie is built inside DataPlaneAuthorityState.Apply, under the same
  generation as the snapshot, rather than by a second wiring at the publish
  actor. An uninstalled trie denies everything — indistinguishable from working
  enforcement. Deleting the install turns 13 of the 22 new tests red.
- The deploy log reports what the BUILD dropped instead of re-deriving it. The
  old count agreed with the builder on one of its four drop reasons; a rule with
  a blank ScopeId, a retired scope kind, or a path not starting at a realm
  vanished with the log reading zero.

Both IApiKeyStore implementations widened to the full grant set. The narrowing
was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and
became a trap once scope was enforced. Pinned by asserting the roles are
unchanged, with the two grants carrying different flags so it cannot pass by
accident.

Falsified by mutation: enforcement never engaging, the write seam ignoring the
ACL, the realm dropped from the scope, the revocation check removed, and the
trie never installed each turn the intended tests red and no others.

Full solution suite green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 05:00:28 -04:00

240 lines
11 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.OpcUaServer.Security;
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 returned (#520 stage 5 widened this) — the cluster filter is
/// now the only narrowing this store performs.
/// </summary>
/// <remarks>
/// The scope-kind filter that used to sit alongside it was redundant: <c>ApiKeyInterimRoleMapper</c>
/// applies exactly that filter itself, so removing it changes no role — pinned below — while a
/// truncated <c>Grants</c> collection would have quietly misled anything that read it once scope
/// started being enforced.
/// </remarks>
[Fact]
public async Task A_narrower_scope_in_this_cluster_is_returned()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
// Both grants now reach the mapper, and it still takes flags from the CLUSTER-scoped one only:
// AlarmAck is conferred, WriteOperate is not. The two grants carry different flags precisely so
// this cannot pass by accident — which is why widening the store is safe with enforcement off.
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBe([OpcUaDataPlaneRoles.AlarmAck]);
}
/// <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);
}
}