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; /// /// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role /// mapping it feeds the two live authorization gates from. /// 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, default); 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, default); 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", default); var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default); var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default); var empty = await auth.AuthenticateUserNameAsync("apikey", "", default); 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, default)).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", default); result.Success.ShouldBeFalse(); result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); } [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.Instance); (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse(); } // ---------- 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(default); 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(default); 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(default); 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, default); // 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(default); 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.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 { Grant(scope, flags) }; return (presented, new FakeApiKeyStore(key, grants)); } private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList grants) : IApiKeyStore { public string KeyId => key.ApiKeyId; /// Grants returned for a bare principal lookup (the anonymous path). public List PrincipalGrants { get; } = []; public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) ? new ApiKeyLookup(key, grants) : null); public Task> FindGrantsAsync(string principal, CancellationToken ct) => Task.FromResult>( [.. PrincipalGrants.Where(g => string.Equals(g.Principal, principal, StringComparison.OrdinalIgnoreCase))]); } private sealed class ThrowingApiKeyStore : IApiKeyStore { public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => throw new InvalidOperationException("store is down"); public Task> FindGrantsAsync(string principal, CancellationToken ct) => throw new InvalidOperationException("store is down"); } }