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()