From a6fed85ac92c143aa769c7e0ed20a95982006aa4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 05:42:27 -0400 Subject: [PATCH] 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. --- .../OpcUaApplicationHost.cs | 2 +- .../Security/RoleCarryingUserIdentity.cs | 31 +++++++++++++++++++ .../OpcUaApplicationHostImpersonationTests.cs | 18 +++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 77ca866b..93b5230c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -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)); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs new file mode 100644 index 00000000..c46b69f5 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs @@ -0,0 +1,31 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +/// +/// A that additionally carries the LDAP-resolved roles +/// () for the authenticated session. The SDK stores the +/// instance we assign to ImpersonateEventArgs.Identity by reference onto +/// Session.Identity/Session.EffectiveIdentity (no wrapping or copying), so a method +/// handler can later recover the roles via +/// (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity — the +/// seam the inbound alarm ack/shelve AlarmAck gate (T18) reads. This type only carries the +/// roles; it makes no auth decision. +/// +public sealed class RoleCarryingUserIdentity : UserIdentity +{ + /// + /// Initializes a new instance, building the base identity from the same UserName token the + /// SDK decrypted and attaching the authenticator's resolved roles. + /// + /// The user identity token the SDK handed to the impersonation handler. + /// The roles resolved by the authenticator (e.g. ReadOnly, AlarmAck). + public RoleCarryingUserIdentity(UserIdentityToken token, IReadOnlyList roles) + : base(token) + { + Roles = roles ?? throw new ArgumentNullException(nameof(roles)); + } + + /// The roles the authenticator granted this session, used by downstream permission gates. + public IReadOnlyList Roles { get; } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs index a7bb5d9e..81e97547 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs @@ -35,6 +35,24 @@ public sealed class OpcUaApplicationHostImpersonationTests authenticator.LastPassword.ShouldBe("secret"); } + /// T17 — verifies the granted identity carries the authenticator's resolved roles + /// (so a downstream method handler can read them off context.UserIdentity for the + /// AlarmAck gate). The identity must be a whose + /// equals result.Roles. + [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.Instance); + + var identity = args.Identity.ShouldBeOfType(); + identity.Roles.ShouldBe(roles); + } + /// Verifies failed UserName token impersonation sets validation error and clears identity. [Fact] public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()