Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs
T
Joseph Doherty dd939d3c97 feat(acl): anonymous becomes a normal grantable principal (#520 D3)
Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's
default validation stand" — so they always connected and always carried no roles.
Now admission and authority are separated:

  admission  -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies)
  authority  -> the authenticator, resolving the reserved principal "anonymous"
                through the SAME grant path a key's principal goes through

So anonymous can be granted WRITE, not merely tolerated as read-only, and it
arrives at the downstream gates as the same RoleCarryingUserIdentity a key
produces — which is the real payoff: no gate carries an anonymous-shaped branch,
so there is no bypass path to get wrong.

AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently
deny every client without a key the moment this deploys, including ScadaBridge's
Data Connection Layer in another repo. Making keys mandatory is a rollout
decision, not an upgrade side effect. When false, the Anonymous token policy is
withheld so a client is refused at DISCOVERY rather than only at activation, and
activation is rejected independently in case a client attempts a token type the
endpoint never advertised.

Two fail-closed choices, both falsified:
- A failure to read anonymous's grants DENIES rather than admitting with no
  roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when
  the server cannot determine what anonymous may do stops being harmless once
  reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read.
- A key may not authenticate as the reserved "anonymous" principal, or a
  credentialled client silently inherits whatever anonymous was granted.

AuthenticateAnonymousAsync is a DEFAULT interface implementation returning
admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so
NullOpcUaUserAuthenticator and every test double keep working rather than being
forced to reimplement it — important because driver-only nodes run the Null one
and would otherwise black out. Note a default interface method is only reachable
through the interface-typed reference, which is how OpcUaApplicationHost holds it.

An existing test asserted the old fall-through and correctly went red; it is
rewritten to pin the new contract rather than deleted, with the reason recorded.

OpcUaServer 366, Host 42 green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 13:20:14 -04:00

182 lines
9.3 KiB
C#

using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// F13c — verifies the impersonation handler routes UserName tokens through
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
/// </summary>
public sealed class OpcUaApplicationHostImpersonationTests
{
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
/// <summary>Verifies successful UserName token impersonation sets identity and clears validation error.</summary>
[Fact]
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
{
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldNotBeNull();
args.IdentityValidationError.ShouldBeNull();
authenticator.LastUsername.ShouldBe("alice");
authenticator.LastPassword.ShouldBe("secret");
}
/// <summary>T17 — verifies the granted identity carries the authenticator's resolved roles
/// (so a downstream method handler can read them off <c>context.UserIdentity</c> for the
/// AlarmAck gate). The identity must be a <see cref="RoleCarryingUserIdentity"/> whose
/// <see cref="RoleCarryingUserIdentity.Roles"/> equals <c>result.Roles</c>.</summary>
[Fact]
public void HandleImpersonation_username_success_carries_roles_on_identity()
{
var roles = new[] { "ReadOnly", "WriteOperate", "AlarmAck" };
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("Alice", roles));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
var identity = args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
identity.Roles.ShouldBe(roles);
}
/// <summary>Verifies failed UserName token impersonation sets validation error and clears identity.</summary>
[Fact]
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
{
var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = Encoding.UTF8.GetBytes("wrong") };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
}
/// <summary>
/// An admitted anonymous session now carries a role-bearing identity (#520).
/// </summary>
/// <remarks>
/// This test previously asserted the opposite — that anonymous fell through untouched to the SDK
/// default identity. That was the pre-#520 behaviour and is exactly what changed: anonymous is a
/// normal principal whose grants come from ACL rows, so it must arrive at the downstream gates as
/// the same <see cref="RoleCarryingUserIdentity"/> a key produces, or every gate would need an
/// anonymous-shaped branch.
/// </remarks>
[Fact]
public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity()
{
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.IdentityValidationError.ShouldBeNull();
args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
// Still no UserName authentication — the anonymous path must not consult the credential seam.
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the UserName authenticator");
}
/// <summary>
/// With anonymous disallowed the session is rejected outright rather than admitted with no roles.
/// </summary>
[Fact]
public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed()
{
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
OpcUaApplicationHost.HandleImpersonation(
authenticator, args, NullLogger<object>.Instance, allowAnonymous: false);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
[Fact]
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
{
var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = Encoding.UTF8.GetBytes("x") };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
[Fact]
public void HandleImpersonation_null_username_treated_as_empty_string()
{
var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = Encoding.UTF8.GetBytes("abc") };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
authenticator.LastUsername.ShouldBe(string.Empty);
}
/// <summary>Verifies NullOpcUaUserAuthenticator always returns denial result.</summary>
[Fact]
public async Task NullOpcUaUserAuthenticator_always_denies()
{
var result = await NullOpcUaUserAuthenticator.Instance
.AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Roles.ShouldBeEmpty();
}
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
{
/// <summary>Gets the username passed to the last authentication call.</summary>
public string? LastUsername { get; private set; }
/// <summary>Gets the password passed to the last authentication call.</summary>
public string? LastPassword { get; private set; }
/// <summary>Authenticates a username and password, recording them for inspection by tests.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The predefined authentication result.</returns>
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
LastUsername = username;
LastPassword = password;
return Task.FromResult(outcome);
}
}
/// <summary>Test authenticator that throws an exception during authentication.</summary>
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
{
/// <summary>Authenticates by throwing the configured exception.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; always throws the configured exception.</returns>
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
=> Task.FromException<OpcUaUserAuthResult>(ex);
}
}