Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs
T
Joseph Doherty 7482a88888 fix(opcua): deferred condition events never outlive their condition; per-condition bounded deferral; canonical lockout identity (#838, #844 review)
#838 review of 91e28e87:
1. Ghost events. The deferred report ran the SDK walk off Lock with no
   liveness check, so an event waiting behind a full channel when a deploy
   removed its condition still reached i=2253 subscribers after the NodeDeleted
   (a ghost Retain alarm for ScadaBridge) and folder items. Delivery now walks
   the SDK path itself (OnReportEvent, OnReportEventAsync, inverse notifiers,
   then the Parent as BaseInstanceState does) re-checking before EVERY hop that
   the condition is the live instance; OnConditionReportEvent checks the same,
   which also stops a non-live scripted condition being admitted through its
   old equipment folder (#808). A guard test pins that only NodeState and
   BaseInstanceState define the walk. New wire test removes the condition in
   the same Lock hold as a flood; the flag-off variant fails without the checks.
2. Back-pressure. Deferral is per condition (one condition's events stay
   ordered; others are not held up). Overflow policy: coalesce, newest wins --
   past 1024 waiting per condition or 65,536 total, the oldest not-yet-started
   event is discarded (a condition event carries full state). Warning on
   entering deferral (<=1/min), Information on draining, meters
   otopcua.opcua.condition_events.deferred / .discarded{reason}.
3. Verified by decompiling SDK 2.0.1: the notifier list is snapshotted under the
   node's own lock, MonitoredNode2.Dispose completes its writer (pending writes
   fail with ChannelClosedException, swallowed), and the consumer catches
   per-event exceptions -- no fix needed; documented.
4. ConditionRefresh does not flush deferrals; documented as a residual.

#844 review: DescribeCredential canonicalises the key id (trim, lowercase;
non-hex ids fold into one identity) and the username, since ConfigDbApiKeyStore
matches with SQL Server's case-insensitive, trailing-space-blind `=`.
2026-11-11 04:35:30 -05:00

371 lines
16 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role
/// mapping it feeds the two live authorization gates from.
/// </summary>
public sealed class ApiKeyAuthenticationTests
{
private const string Pepper = "unit-test-pepper";
private const string Principal = "scadabridge-dcl";
// ---------- interim role mapping ----------
[Fact]
public void A_cluster_scoped_write_grant_confers_the_write_role()
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public void A_narrower_scoped_write_grant_confers_nothing_yet()
{
// THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until
// stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into
// server-wide write authority — the operator's narrow intent silently becoming broad. Under-
// granting is the only safe reading while scope is unenforceable.
foreach (var narrower in new[]
{
NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
})
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(narrower, NodePermissions.WriteOperate)], Principal);
roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
}
}
[Fact]
public void A_grant_for_a_different_principal_is_ignored()
{
var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin);
other.Principal = "someone-else";
ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty();
}
[Fact]
public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal)
.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
// ReadOnly carries no gated permission — reads are ungated today, so it must confer no role
// rather than quietly granting one.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal)
.ShouldBeEmpty();
}
[Fact]
public void Flags_union_across_several_cluster_grants()
{
var roles = ApiKeyInterimRoleMapper.Map(
[
Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
}
[Fact]
public void Principal_matching_is_case_insensitive_but_not_prefix_based()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant())
.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
// A principal that merely starts with the granted name must not inherit it.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly")
.ShouldBeEmpty();
}
// ---------- authenticator ----------
[Fact]
public async Task A_valid_key_authenticates_as_its_principal()
{
var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster);
var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe(Principal);
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public async Task The_wrong_username_is_denied()
{
var (presented, store) = SetUp();
var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically()
{
var (presented, store) = SetUp();
var auth = Auth(store);
var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", TestContext.Current.CancellationToken);
var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", TestContext.Current.CancellationToken);
var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", TestContext.Current.CancellationToken);
var empty = await auth.AuthenticateUserNameAsync("apikey", "", TestContext.Current.CancellationToken);
foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty })
{
r.Success.ShouldBeFalse();
// Identical message for every failure mode: the server must not be usable as an oracle for
// which half of the credential was wrong, or whether a key id exists at all.
r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
// ...and the valid one still works, so the above is not passing because everything is denied.
(await auth.AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken)).Success.ShouldBeTrue();
}
[Fact]
public async Task A_store_that_throws_denies_rather_than_propagating()
{
// The interface contract forbids throwing — an exception escapes into the SDK session-activation
// path and surfaces as BadInternalError instead of a clean rejection.
var store = new ThrowingApiKeyStore();
var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
result.Transient.ShouldBeTrue("#845: a store outage / timeout / miss-budget refusal is not a bad credential");
}
/// <summary>
/// #845 — only a definite verdict on the credential is non-transient: a wrong secret, an unknown key id, a
/// malformed credential and the wrong username all count toward the failed-credential lockout.
/// </summary>
[Fact]
public async Task Definite_credential_failures_are_not_transient()
{
var (presented, store) = SetUp();
var auth = Auth(store);
var ct = TestContext.Current.CancellationToken;
OpcUaUserAuthResult[] results =
[
await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", ct),
await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", ct),
await auth.AuthenticateUserNameAsync("apikey", "not-a-key", ct),
await auth.AuthenticateUserNameAsync("alice", presented, ct),
];
results.ShouldAllBe(r => !r.Success && !r.Transient);
}
/// <summary>
/// #844 — the lockout counts per credential identity: the key id (public — the lookup index, never the
/// secret), else the username. The secret must never appear in it (it is logged).
/// </summary>
[Fact]
public void The_credential_identity_is_the_key_id_and_never_the_secret()
{
var (presented, store) = SetUp();
var auth = Auth(store);
var identity = auth.DescribeCredential("apikey", presented);
identity.ShouldBe($"key:{store.KeyId}");
identity.ShouldNotContain(presented.Split('_')[^1]);
auth.DescribeCredential("apikey", "not-a-key").ShouldBe("user:apikey");
}
/// <summary>
/// #844 review — ConfigDbApiKeyStore matches the key id with SQL Server <c>=</c> (case-insensitive, trailing
/// spaces ignored), so case / trailing-space variants of one key id reach the same key and must be ONE lockout
/// identity; ids outside the minted lowercase-hex charset (no real key carries one) share a single identity.
/// </summary>
[Fact]
public void Credential_identities_are_canonical_so_variants_of_one_key_id_share_a_count()
{
var auth = Auth(SetUp().Store);
const string id = "0123456789abcdef";
auth.DescribeCredential("apikey", $"otua_{id}_s1").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_{id.ToUpperInvariant()}_s2").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_{id} _s3").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_ {id.ToUpperInvariant()} _s4").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", "otua_not-hex_s").ShouldBe("key:<not-a-minted-id>");
auth.DescribeCredential("apikey", "otua_zzzz_s").ShouldBe(auth.DescribeCredential("apikey", "otua_yyyy_s"));
auth.DescribeCredential("APIKEY ", "garbage").ShouldBe("user:apikey");
}
[Fact]
public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
{
var (presented, store) = SetUp();
var auth = new ApiKeyOpcUaUserAuthenticator(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
var result = await auth.AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Transient.ShouldBeTrue("#845: a deployment error, not a bad credential");
}
// ---------- anonymous as a normal principal (D3) ----------
[Fact]
public async Task Anonymous_resolves_through_the_same_grant_path_as_a_key()
{
var (_, store) = SetUp();
store.PrincipalGrants.Add(new NodeAcl
{
NodeAclId = "ACL-anon",
ClusterId = "MAIN",
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = NodePermissions.WriteOperate,
});
var result = await Auth(store).AuthenticateAnonymousAsync(TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe(OpcUaPrincipals.Anonymous);
// The point of D3: anonymous can be granted WRITE, not merely tolerated as read-only.
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public async Task Anonymous_with_no_grants_is_admitted_with_no_roles()
{
// Today's behaviour preserved: it connects, and every role-gated operation denies.
var (_, store) = SetUp();
var result = await Auth(store).AuthenticateAnonymousAsync(TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue();
result.Roles.ShouldBeEmpty();
}
[Fact]
public async Task Anonymous_denies_when_grants_cannot_be_read()
{
// Deliberately NOT admit-with-no-roles. That is the pre-#520 behaviour, and defaulting to it
// exactly when we cannot read what anonymous may do stops being harmless once reads are gated.
var result = await Auth(new ThrowingApiKeyStore()).AuthenticateAnonymousAsync(TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_key_may_not_authenticate_as_the_reserved_anonymous_principal()
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "sneaky",
SecretHash = hash,
Principal = OpcUaPrincipals.Anonymous,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
var result = await Auth(new FakeApiKeyStore(key, [])).AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken);
// Otherwise a credentialled client silently inherits whatever anonymous was granted.
result.Success.ShouldBeFalse();
}
[Fact]
public async Task The_default_interface_implementation_admits_anonymous_with_no_roles()
{
// NullOpcUaUserAuthenticator (driver-only nodes, pre-boot) must keep the pre-#520 behaviour
// rather than denying anonymous outright, or turning this on would black out those nodes.
// Interface-typed on purpose: a default interface implementation is only reachable through the
// interface, which is how OpcUaApplicationHost holds it.
IOpcUaUserAuthenticator sut = NullOpcUaUserAuthenticator.Instance;
var result = await sut.AuthenticateAnonymousAsync(TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue();
result.Roles.ShouldBeEmpty();
}
// ---------- helpers ----------
private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = $"ACL-{scope}-{flags}",
ClusterId = "MAIN",
Principal = Principal,
ScopeKind = scope,
ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
PermissionFlags = flags,
};
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
private static (string Presented, FakeApiKeyStore Store) SetUp(
NodePermissions flags = NodePermissions.None,
NodeAclScopeKind scope = NodeAclScopeKind.Cluster)
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "test key",
SecretHash = hash,
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
var grants = flags == NodePermissions.None ? [] : new List<NodeAcl> { Grant(scope, flags) };
return (presented, new FakeApiKeyStore(key, grants));
}
private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList<NodeAcl> grants) : IApiKeyStore
{
public string KeyId => key.ApiKeyId;
/// <summary>Grants returned for a bare principal lookup (the anonymous path).</summary>
public List<NodeAcl> PrincipalGrants { get; } = [];
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
? new ApiKeyLookup(key, grants)
: null);
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>(
[.. PrincipalGrants.Where(g => string.Equals(g.Principal, principal, StringComparison.OrdinalIgnoreCase))]);
}
private sealed class ThrowingApiKeyStore : IApiKeyStore
{
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
throw new InvalidOperationException("store is down");
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
throw new InvalidOperationException("store is down");
}
}