/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Text; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Opc.Ua.Identity; using Opc.Ua.Server.UserManagement; using Opc.Ua.Tests; namespace Opc.Ua.Server.Tests { /// /// Tests for covering the /// integration with /// (Part 18 §4.4.4) and the /// gate (Part 18 §5.2.8). /// [TestFixture] [Category("Session")] [Parallelizable] public class SessionManagerRoleResolutionTests { private Mock m_serverMock = null!; private ITelemetryContext m_telemetry = null!; private ApplicationConfiguration m_config = null!; [SetUp] public void SetUp() { m_telemetry = NUnitTelemetryContext.Create(); m_serverMock = new Mock(); m_serverMock.Setup(s => s.Telemetry).Returns(m_telemetry); m_serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); m_config = new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration { MinSessionTimeout = 1000, MaxSessionTimeout = 3_600_000, MaxSessionCount = 100, MaxRequestAge = 60_000, MaxBrowseContinuationPoints = 10, MaxHistoryContinuationPoints = 10 } }; } private static OperationContext CreateOperationContext(MessageSecurityMode securityMode) { var endpoint = new EndpointDescription { SecurityMode = securityMode }; var channelContext = new SecureChannelContext("test-channel", endpoint, RequestEncoding.Binary); var requestHeader = new RequestHeader(); return new OperationContext(requestHeader, channelContext, RequestType.ActivateSession, RequestLifetime.None); } private static Mock CreateSessionMock() { var session = new Mock(); session.Setup(s => s.ClientCertificate) .Returns((Security.Certificates.Certificate)null!); return session; } private static IUserIdentity CreateUserNameIdentity(string userName) { var identity = new Mock(); identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName); identity.Setup(i => i.DisplayName).Returns(userName); identity.Setup(i => i.GrantedRoleIds).Returns([]); return identity.Object; } [Test] public void AddMandatoryRoles_RoleManagerGrantsRole_AddsToEffectiveIdentity() { using var roleManager = new RoleManager(); // Configure the Observer role to grant any AuthenticatedUser. Assert.That(ServiceResult.IsGood( roleManager.AddIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser })), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.True, "RoleManager.ResolveGrantedRoles should have layered the Observer role onto the session."); } [Test] public void AddMandatoryRoles_NoMatchingRule_DoesNotAddDynamicRoles() { using var roleManager = new RoleManager(); // No identity rules added — Anonymous role still matches anonymous tokens by default. m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); // AuthenticatedUser default rule on the AuthenticatedUser role matches any non-anonymous session. Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser), Is.True, "Default AuthenticatedUser rule should grant the AuthenticatedUser role to USERNAME sessions."); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.False); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Operator), Is.False); } [Test] public void AddMandatoryRoles_MustChangePasswordSet_RestrictsSessionToAnonymousRole() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword("alice")).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Anonymous), Is.True, "MustChangePassword users must only get the Anonymous role."); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.False, "MustChangePassword must short-circuit the role-manager layer."); } [Test] public void AddMandatoryRoles_MustChangePasswordSet_DiscardsAlreadyGrantedRoles() { // Regression: an identity that already carries elevated roles (for // example granted by an ImpersonateUser callback) must be reduced to // the Anonymous role, not merged with it. OPC 10000-18 5.2.8 requires // the activated Session to have "only the Role Anonymous". var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword("alice")).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); var elevated = new RoleBasedIdentity( CreateUserNameIdentity("alice"), [Role.SecurityAdmin, Role.ConfigureAdmin], m_serverMock.Object.NamespaceUris); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); Assert.That( elevated.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin), Is.True, "precondition: the identity starts out with an elevated role"); IUserIdentity result = manager.PublicAddMandatoryRoles( sessionMock.Object, context, elevated); Assert.Multiple(() => { Assert.That( result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Anonymous), Is.True, "the restricted Session must hold the Anonymous role"); Assert.That( result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin), Is.False, "the previously granted SecurityAdmin role must be discarded"); Assert.That( result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_ConfigureAdmin), Is.False, "the previously granted ConfigureAdmin role must be discarded"); Assert.That( result.GrantedRoleIds.Count, Is.EqualTo(1), "no role other than Anonymous may remain"); Assert.That( ((RoleBasedIdentity)result).Roles, Is.EquivalentTo([Role.Anonymous]), "the Roles collection must be reduced to Anonymous as well"); // ChangePassword still has to identify the caller. Assert.That(result.TokenType, Is.EqualTo(UserTokenType.UserName)); Assert.That(result.DisplayName, Is.EqualTo("alice")); }); } [Test] public void AddMandatoryRoles_MustChangePasswordClear_GrantsNormalRoles() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword(It.IsAny())).Returns(false); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using var roleManager = new RoleManager(); Assert.That(ServiceResult.IsGood( roleManager.AddIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.UserName, Criteria = "alice" })), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.True, "When MustChangePassword is false, the role manager should grant the configured role."); } [Test] public void AddMandatoryRoles_NoUserManagement_SkipsMustChangePasswordCheck() { // UserManagement is null - MustChangePassword check short-circuits. using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); // The identity should NOT be wrapped in an Anonymous-only restriction. Assert.That(result.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Anonymous), Is.False, "Without UserManagement injected, MustChangePassword path must not activate."); } [Test] public void AddMandatoryRoles_MustChangePasswordOnAnonymousToken_NoEffect() { // Anonymous identity should never be restricted by MustChangePassword. var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword(It.IsAny())).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); var identity = new UserIdentity(); Mock sessionMock = CreateSessionMock(); OperationContext context = CreateOperationContext(MessageSecurityMode.SignAndEncrypt); IUserIdentity result = manager.PublicAddMandatoryRoles(sessionMock.Object, context, identity); // Anonymous identity should not trigger the MustChangePassword path // (which is USERNAME-only per the spec). userManagement.Verify(u => u.MustChangePassword(It.IsAny()), Times.Never, "MustChangePassword should only be consulted for USERNAME tokens."); } [Test] public void ComputeActivationStatus_MustChangePasswordSet_ReturnsGoodPasswordChangeRequired() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword("alice")).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); ServiceResult status = manager.PublicComputeActivationStatus(identity); Assert.That(status.Code, Is.EqualTo((uint)StatusCodes.GoodPasswordChangeRequired), "Part 18 §5.2.8 — MustChangePassword users must see Good_PasswordChangeRequired on activation."); } [Test] public void ComputeActivationStatus_MustChangePasswordClear_ReturnsGood() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword(It.IsAny())).Returns(false); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); ServiceResult status = manager.PublicComputeActivationStatus(identity); Assert.That(status, Is.EqualTo(ServiceResult.Good), "Normal users must see Good on activation."); } [Test] public void ComputeActivationStatus_AnonymousIdentity_ReturnsGoodAndSkipsUserManagement() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword(It.IsAny())).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); var identity = new UserIdentity(); ServiceResult status = manager.PublicComputeActivationStatus(identity); Assert.That(status, Is.EqualTo(ServiceResult.Good), "Anonymous identities must never carry Good_PasswordChangeRequired."); userManagement.Verify(u => u.MustChangePassword(It.IsAny()), Times.Never, "MustChangePassword must only be consulted for USERNAME tokens."); } [Test] public void ComputeActivationStatus_NoUserManagement_ReturnsGood() { // UserManagement is null — no MustChangePassword data available. using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity("alice"); ServiceResult status = manager.PublicComputeActivationStatus(identity); Assert.That(status, Is.EqualTo(ServiceResult.Good), "Without UserManagement injected, activation must default to Good."); } [Test] public void ComputeActivationStatus_EmptyUserName_ReturnsGood() { var userManagement = new Mock(); userManagement.Setup(u => u.MustChangePassword(It.IsAny())).Returns(true); m_serverMock.Setup(s => s.UserManagement).Returns(userManagement.Object); using TestableSessionManager manager = CreateManager(); IUserIdentity identity = CreateUserNameIdentity(string.Empty); ServiceResult status = manager.PublicComputeActivationStatus(identity); Assert.That(status, Is.EqualTo(ServiceResult.Good), "USERNAME identities without a DisplayName cannot be looked up — must return Good."); userManagement.Verify(u => u.MustChangePassword(It.IsAny()), Times.Never); } [Test] public async Task AuthenticateUserIdentityAsyncRegistryAcceptedSkipsLegacyAndRoleManagerGrants() { using var roleManager = new RoleManager(); Assert.That(ServiceResult.IsGood( roleManager.AddIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.UserName, Criteria = "alice" })), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); IUserIdentity registryIdentity = CreateUserNameIdentity("alice"); var authenticator = new Mock(); authenticator.Setup(a => a.TokenType).Returns(UserTokenType.UserName); authenticator.Setup(a => a.IssuedTokenProfileUri).Returns((string?)null); authenticator.Setup(a => a.AuthenticateAsync( It.IsAny(), It.IsAny())) .Returns(new ValueTask( AuthenticationResult.Accept(registryIdentity))); m_serverMock.Setup(s => s.IdentityRegistry).Returns(new ServerIdentityRegistry(authenticator.Object)); using TestableSessionManager manager = CreateManager(); int legacyCalls = 0; #pragma warning disable CS0618 // Obsolete by P5: verifies registry-authenticated sessions skip the legacy event. manager.ImpersonateUser += (_, _) => legacyCalls++; #pragma warning restore CS0618 Mock sessionMock = CreateSessionMock(); (IUserIdentity? identity, IUserIdentity? effectiveIdentity, ServiceResult? error) = await manager.PublicAuthenticateUserIdentityAsync( sessionMock.Object, new UserNameIdentityTokenHandler("alice", Encoding.UTF8.GetBytes("password")), new UserTokenPolicy { TokenType = UserTokenType.UserName, PolicyId = "user" }, CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt).EndpointDescription!) .ConfigureAwait(false); Assert.That(error, Is.Null); Assert.That(identity, Is.SameAs(registryIdentity)); Assert.That(effectiveIdentity, Is.SameAs(registryIdentity)); Assert.That(legacyCalls, Is.Zero); IUserIdentity grantedIdentity = manager.PublicAddMandatoryRoles( sessionMock.Object, CreateOperationContext(MessageSecurityMode.SignAndEncrypt), effectiveIdentity!); Assert.That(grantedIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.True); } [Test] public async Task AuthenticateUserIdentityAsyncRegistryRejectedReturnsErrorAndSkipsLegacy() { var authenticator = new Mock(); authenticator.Setup(a => a.TokenType).Returns(UserTokenType.UserName); authenticator.Setup(a => a.IssuedTokenProfileUri).Returns((string?)null); authenticator.Setup(a => a.AuthenticateAsync( It.IsAny(), It.IsAny())) .Returns(new ValueTask( AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadUserAccessDenied)))); m_serverMock.Setup(s => s.IdentityRegistry).Returns(new ServerIdentityRegistry(authenticator.Object)); using TestableSessionManager manager = CreateManager(); int legacyCalls = 0; #pragma warning disable CS0618 // Obsolete by P5: verifies registry rejections skip the legacy event. manager.ImpersonateUser += (_, _) => legacyCalls++; #pragma warning restore CS0618 (IUserIdentity? identity, IUserIdentity? effectiveIdentity, ServiceResult? error) = await manager.PublicAuthenticateUserIdentityAsync( CreateSessionMock().Object, new UserNameIdentityTokenHandler("alice", Encoding.UTF8.GetBytes("password")), new UserTokenPolicy { TokenType = UserTokenType.UserName, PolicyId = "user" }, CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt).EndpointDescription!) .ConfigureAwait(false); Assert.That(identity, Is.Null); Assert.That(effectiveIdentity, Is.Null); Assert.That(error, Is.Not.Null); Assert.That(error!.Code, Is.EqualTo((uint)StatusCodes.BadUserAccessDenied)); Assert.That(legacyCalls, Is.Zero); } [Test] public async Task AuthenticateUserIdentityAsyncRegistryNotHandledFiresLegacyEvent() { m_serverMock.Setup(s => s.IdentityRegistry).Returns(new ServerIdentityRegistry()); using TestableSessionManager manager = CreateManager(); IUserIdentity legacyIdentity = CreateUserNameIdentity("legacy"); int legacyCalls = 0; #pragma warning disable CS0618 // Obsolete by P5: verifies the legacy fallback remains functional. manager.ImpersonateUser += (_, args) => { legacyCalls++; args.Identity = legacyIdentity; args.EffectiveIdentity = legacyIdentity; }; #pragma warning restore CS0618 (IUserIdentity? identity, IUserIdentity? effectiveIdentity, ServiceResult? error) = await manager.PublicAuthenticateUserIdentityAsync( CreateSessionMock().Object, new UserNameIdentityTokenHandler("alice", Encoding.UTF8.GetBytes("password")), new UserTokenPolicy { TokenType = UserTokenType.UserName, PolicyId = "user" }, CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt).EndpointDescription!) .ConfigureAwait(false); Assert.That(error, Is.Null); Assert.That(identity, Is.SameAs(legacyIdentity)); Assert.That(effectiveIdentity, Is.SameAs(legacyIdentity)); Assert.That(legacyCalls, Is.EqualTo(1)); } [Test] public void OnRoleConfigurationChanged_MarksAllSessionsStale() { using TestableSessionManager manager = CreateManager(); var sessionA = new Mock(); var sessionB = new Mock(); manager.InjectedSessions = [sessionA.Object, sessionB.Object]; manager.PublicOnRoleConfigurationChanged( this, new RoleConfigurationChangedEventArgs( ObjectIds.WellKnownRole_Observer, RoleConfigurationChangeKind.IdentityAdded)); sessionA.Verify(s => s.MarkIdentityStale(), Times.Once); sessionB.Verify(s => s.MarkIdentityStale(), Times.Once); } [Test] public void OnRoleConfigurationChanged_SessionThrows_OtherSessionsStillMarked() { using TestableSessionManager manager = CreateManager(); var sessionA = new Mock(); sessionA.Setup(s => s.MarkIdentityStale()).Throws(new InvalidOperationException("boom")); var sessionB = new Mock(); // Order in the list determines walk order; the throwing session // is first so we need the handler to swallow the error and still // raise on the second session. The current implementation logs // the error and exits the loop, so this test documents the // observed behaviour. If the contract changes to per-session // isolation, update this assertion. manager.InjectedSessions = [sessionA.Object, sessionB.Object]; Assert.DoesNotThrow(() => manager.PublicOnRoleConfigurationChanged( this, new RoleConfigurationChangedEventArgs( ObjectIds.WellKnownRole_Observer, RoleConfigurationChangeKind.IdentityAdded)), "The event handler must never throw — it would tear down the RoleManager event source."); sessionA.Verify(s => s.MarkIdentityStale(), Times.Once); } [Test] public void ReevaluateIdentityIfStaleNotStaleDoesNotInvokeRefresh() { using TestableSessionManager manager = CreateManager(); var session = new Mock(); session.Setup(s => s.IsIdentityStale).Returns(false); SecureChannelContext channelContext = CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt); manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); session.Verify(s => s.CaptureIdentityRefreshSnapshot(), Times.Never); session.Verify(s => s.TryRefreshEffectiveIdentity( It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, "Fresh sessions must not be refreshed."); } [Test] public void ReevaluateIdentityIfStaleStaleSessionRefreshesWithLayeredIdentity() { // The RoleManager grants Observer to any AuthenticatedUser; the // re-evaluation should apply this on top of the original Identity // and commit it using the captured identity generation. using var roleManager = new RoleManager(); Assert.That(ServiceResult.IsGood( roleManager.AddIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser })), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity originalIdentity = new RoleBasedIdentity( CreateUserNameIdentity("alice"), [Role.Operator], m_serverMock.Object.NamespaceUris); const long generation = 17; IUserIdentity? refreshed = null; var session = new Mock(); session.Setup(s => s.IsIdentityStale).Returns(true); session.Setup(s => s.Identity).Returns(originalIdentity); session.Setup(s => s.EffectiveIdentity).Returns(originalIdentity); session.Setup(s => s.CaptureIdentityRefreshSnapshot()) .Returns(new IdentityRefreshSnapshot(originalIdentity, generation)); session.Setup(s => s.ClientCertificate) .Returns((Security.Certificates.Certificate)null!); session.Setup(s => s.TryRefreshEffectiveIdentity( originalIdentity, generation, It.IsAny())) .Callback((_, _, id) => refreshed = id) .Returns(true); SecureChannelContext channelContext = CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt); manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); Assert.That(refreshed, Is.Not.Null, "Stale session must be refreshed."); Assert.That(refreshed!.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.True, "Refreshed identity must reflect the current RoleManager grants."); Assert.That(refreshed.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Operator), Is.True, "Refreshing dynamic roles must preserve roles from the original identity."); session.Verify(s => s.CaptureIdentityRefreshSnapshot(), Times.Once); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, generation, refreshed), Times.Once); } [Test] public void ReevaluateIdentityIfStaleRefreshThrowsDoesNotPropagate() { using TestableSessionManager manager = CreateManager(); IUserIdentity originalIdentity = CreateUserNameIdentity("alice"); const long generation = 23; var failure = new InvalidOperationException("session disposed"); var session = new Mock(); session.Setup(s => s.IsIdentityStale).Returns(true); session.Setup(s => s.Identity).Returns(originalIdentity); session.Setup(s => s.EffectiveIdentity).Returns(originalIdentity); session.Setup(s => s.CaptureIdentityRefreshSnapshot()) .Returns(new IdentityRefreshSnapshot(originalIdentity, generation)); session.Setup(s => s.ClientCertificate) .Returns((Security.Certificates.Certificate)null!); session.Setup(s => s.TryRefreshEffectiveIdentity( originalIdentity, generation, It.IsAny())) .Throws(failure); SecureChannelContext channelContext = CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt); // The hook is on the request hot path — a per-session failure // (e.g. mid-disposal race) must not poison the request pipeline. Assert.DoesNotThrow( () => manager.PublicReevaluateIdentityIfStale(session.Object, channelContext)); session.Verify(s => s.CaptureIdentityRefreshSnapshot(), Times.Once); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, generation, originalIdentity), Times.Once); } [Test] public void ReevaluateIdentityIfStaleRuleRemovedDropsPreviouslyGrantedRole() { // Regression for PR #3778 review comment (romanett): when a role // grant is REMOVED from the role manager (e.g. RemoveIdentity), // the live re-evaluation must drop that role from the session's // EffectiveIdentity on the next request. // // The implementation re-runs AddMandatoryRoles starting from // session.Identity (the original impersonated identity), not from // the accumulated EffectiveIdentity, so RoleManager-derived role // grants are recomputed fresh from scratch. using var roleManager = new RoleManager(); ServiceResult addRule = roleManager.AddIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.UserName, Criteria = "alice" }); Assert.That(ServiceResult.IsGood(addRule), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity originalIdentity = CreateUserNameIdentity("alice"); long generation = 31; IUserIdentity? refreshed = null; var session = new Mock(); session.Setup(s => s.IsIdentityStale).Returns(true); session.Setup(s => s.Identity).Returns(originalIdentity); session.Setup(s => s.EffectiveIdentity).Returns(() => refreshed ?? originalIdentity); session.Setup(s => s.CaptureIdentityRefreshSnapshot()) .Returns(() => new IdentityRefreshSnapshot(originalIdentity, generation)); session.Setup(s => s.ClientCertificate) .Returns((Security.Certificates.Certificate)null!); session.Setup(s => s.TryRefreshEffectiveIdentity( originalIdentity, It.Is(value => value == generation), It.IsAny())) .Callback((_, _, id) => refreshed = id) .Returns(true); SecureChannelContext channelContext = CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt); // First re-evaluation: rule is present → Observer must be granted. manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); Assert.That(refreshed, Is.Not.Null); Assert.That(refreshed!.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.True, "Before the rule is removed, alice must have the Observer role."); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, 31, refreshed), Times.Once); // Admin removes the identity rule that granted Observer to alice. ServiceResult removeRule = roleManager.RemoveIdentity( ObjectIds.WellKnownRole_Observer, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.UserName, Criteria = "alice" }); Assert.That(ServiceResult.IsGood(removeRule), Is.True); // Second re-evaluation: rule is gone → Observer must NOT be granted. generation++; manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); Assert.That(refreshed, Is.Not.Null); Assert.That(refreshed!.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Observer), Is.False, "After RemoveIdentity, the live re-evaluation must drop the previously granted Observer role."); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, 32, refreshed), Times.Once); session.Verify(s => s.CaptureIdentityRefreshSnapshot(), Times.Exactly(2)); } [Test] public void ReevaluateIdentityIfStaleRoleRemovedDropsPreviouslyGrantedRole() { // Stronger regression: removing the entire role from the manager // (RemoveRole) must also drop the role from active sessions on // the next re-evaluation. This is the case where the role NodeId // simply ceases to exist. using var roleManager = new RoleManager(); // Use a custom role so it is not reserved and can be removed. var namespaces = new NamespaceTable(); namespaces.GetIndexOrAppend("http://example.org/custom"); ServiceResult addRole = roleManager.AddRole("CustomRole", "http://example.org/custom", namespaces, defaultNamespaceIndex: 1, out NodeId customRoleId); Assert.That(ServiceResult.IsGood(addRole), Is.True); Assert.That(ServiceResult.IsGood( roleManager.AddIdentity(customRoleId, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.UserName, Criteria = "alice" })), Is.True); m_serverMock.Setup(s => s.RoleManager).Returns(roleManager); using TestableSessionManager manager = CreateManager(); IUserIdentity originalIdentity = CreateUserNameIdentity("alice"); long generation = 41; IUserIdentity? refreshed = null; var session = new Mock(); session.Setup(s => s.IsIdentityStale).Returns(true); session.Setup(s => s.Identity).Returns(originalIdentity); session.Setup(s => s.EffectiveIdentity).Returns(() => refreshed ?? originalIdentity); session.Setup(s => s.CaptureIdentityRefreshSnapshot()) .Returns(() => new IdentityRefreshSnapshot(originalIdentity, generation)); session.Setup(s => s.ClientCertificate) .Returns((Security.Certificates.Certificate)null!); session.Setup(s => s.TryRefreshEffectiveIdentity( originalIdentity, It.Is(value => value == generation), It.IsAny())) .Callback((_, _, id) => refreshed = id) .Returns(true); SecureChannelContext channelContext = CreateSecureChannelContext(MessageSecurityMode.SignAndEncrypt); manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); Assert.That(refreshed, Is.Not.Null); Assert.That(refreshed!.GrantedRoleIds.Contains(customRoleId), Is.True, "Before RemoveRole, alice must have the custom role."); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, 41, refreshed), Times.Once); Assert.That(ServiceResult.IsGood(roleManager.RemoveRole(customRoleId)), Is.True); generation++; manager.PublicReevaluateIdentityIfStale(session.Object, channelContext); Assert.That(refreshed, Is.Not.Null); Assert.That(refreshed!.GrantedRoleIds.Contains(customRoleId), Is.False, "After RemoveRole, the live re-evaluation must drop the removed role."); session.Verify(s => s.TryRefreshEffectiveIdentity( originalIdentity, 42, refreshed), Times.Once); session.Verify(s => s.CaptureIdentityRefreshSnapshot(), Times.Exactly(2)); } private static SecureChannelContext CreateSecureChannelContext(MessageSecurityMode securityMode) { var endpoint = new EndpointDescription { SecurityMode = securityMode }; return new SecureChannelContext("test-channel", endpoint, RequestEncoding.Binary); } private TestableSessionManager CreateManager() { return new TestableSessionManager(m_serverMock.Object, m_config); } } }