feat(security): carry LDAP roles onto session identity (T17)

Stop discarding the authenticator's resolved roles during impersonation.
HandleImpersonation now sets args.Identity to a RoleCarryingUserIdentity
(: UserIdentity) that carries result.Roles, so a downstream method handler
can read them off context.UserIdentity for the inbound AlarmAck gate (T18).

Verified via the decompiled SDK (1.5.378.106) that the instance we assign to
ImpersonateEventArgs.Identity is stored by reference onto Session.Identity /
EffectiveIdentity and surfaced unchanged on OperationContext.UserIdentity --
the custom subclass survives the round-trip. No auth-decision logic changes.
This commit is contained in:
Joseph Doherty
2026-06-11 05:42:27 -04:00
parent 2b890fa716
commit a6fed85ac9
3 changed files with 50 additions and 1 deletions
@@ -289,7 +289,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return;
}
args.Identity = new UserIdentity(token);
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
token.UserName, string.Join(",", result.Roles));
}
@@ -0,0 +1,31 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
/// A <see cref="UserIdentity"/> that additionally carries the LDAP-resolved roles
/// (<see cref="OpcUaUserAuthResult.Roles"/>) for the authenticated session. The SDK stores the
/// instance we assign to <c>ImpersonateEventArgs.Identity</c> by reference onto
/// <c>Session.Identity</c>/<c>Session.EffectiveIdentity</c> (no wrapping or copying), so a method
/// handler can later recover the roles via
/// <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c> — the
/// seam the inbound alarm ack/shelve <c>AlarmAck</c> gate (T18) reads. This type only carries the
/// roles; it makes no auth decision.
/// </summary>
public sealed class RoleCarryingUserIdentity : UserIdentity
{
/// <summary>
/// Initializes a new instance, building the base identity from the same UserName token the
/// SDK decrypted and attaching the authenticator's resolved roles.
/// </summary>
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
/// <param name="roles">The roles resolved by the authenticator (e.g. <c>ReadOnly</c>, <c>AlarmAck</c>).</param>
public RoleCarryingUserIdentity(UserIdentityToken token, IReadOnlyList<string> roles)
: base(token)
{
Roles = roles ?? throw new ArgumentNullException(nameof(roles));
}
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
public IReadOnlyList<string> Roles { get; }
}
@@ -35,6 +35,24 @@ public sealed class OpcUaApplicationHostImpersonationTests
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()