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