diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3d2045ad..a37f898b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,14 @@ Unreleased * +0.17.0 - 2025-11-14 +******************** + +Added +===== + +* Signal to clear policies associated to a user when they are retired. + 0.16.0 - 2025-11-13 ******************** diff --git a/docs/conf.py b/docs/conf.py index c64d714c..75e02d1a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -557,6 +557,12 @@ def on_init(app): # pylint: disable=unused-argument # If we are, assemble the path manually bin_path = os.path.abspath(os.path.join(sys.prefix, "bin")) apidoc_path = os.path.join(bin_path, apidoc_path) + + # Set SPHINX_APIDOC_OPTIONS to add :no-index: to generated automodule directives + # This prevents duplicate object warnings for re-exported API members + env = os.environ.copy() + env['SPHINX_APIDOC_OPTIONS'] = 'members,show-inheritance,undoc-members,no-index' + check_call( [ apidoc_path, @@ -565,7 +571,8 @@ def on_init(app): # pylint: disable=unused-argument os.path.join(root_path, "openedx_authz"), os.path.join(root_path, "openedx_authz/migrations"), os.path.join(root_path, "openedx_authz/tests"), - ] + ], + env=env ) diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 5c417147..4e10cf73 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "0.16.0" +__version__ = "0.17.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index fa9385b1..e5da5abc 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -41,6 +41,7 @@ "get_all_subject_role_assignments_in_scope", "get_subject_role_assignments", "get_scopes_for_subject_and_permission", + "unassign_subject_from_all_roles", ] # TODO: these are the concerns we still have to address: @@ -418,3 +419,16 @@ def get_scopes_for_subject_and_permission( if permission in role.permissions and role_assignment.scope not in scopes: scopes.append(role_assignment.scope) return scopes + + +def unassign_subject_from_all_roles(subject: SubjectData) -> bool: + """Unassign a subject from all roles across all scopes. + + Args: + subject: The SubjectData object representing the subject to unassign. + + Returns: + bool: True if any roles were removed, False otherwise. + """ + enforcer = AuthzEnforcer.get_enforcer() + return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key) diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py index 7b1f58a7..e07df678 100644 --- a/openedx_authz/api/users.py +++ b/openedx_authz/api/users.py @@ -22,6 +22,7 @@ get_subject_role_assignments_in_scope, get_subjects_for_role_in_scope, unassign_role_from_subject_in_scope, + unassign_subject_from_all_roles, ) __all__ = [ @@ -36,6 +37,7 @@ "is_user_allowed", "get_scopes_for_user_and_permission", "get_users_for_role_in_scope", + "unassign_all_roles_from_user", ] @@ -223,3 +225,15 @@ def get_scopes_for_user_and_permission( UserData(external_key=user_external_key), PermissionData(action=ActionData(external_key=action_external_key)), ) + + +def unassign_all_roles_from_user(user_external_key: str) -> bool: + """Unassign all roles from a user across all scopes. + + Args: + user_external_key (str): ID of the user (e.g., 'john_doe'). + + Returns: + bool: True if any roles were removed, False otherwise. + """ + return unassign_subject_from_all_roles(UserData(external_key=user_external_key)) diff --git a/openedx_authz/handlers.py b/openedx_authz/handlers.py index 45541b83..7701123e 100644 --- a/openedx_authz/handlers.py +++ b/openedx_authz/handlers.py @@ -10,8 +10,14 @@ from django.db.models.signals import post_delete from django.dispatch import receiver +from openedx_authz.api.users import unassign_all_roles_from_user from openedx_authz.models.core import ExtendedCasbinRule +try: + from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL +except ImportError: + USER_RETIRE_LMS_CRITICAL = None + logger = logging.getLogger(__name__) @@ -48,3 +54,31 @@ def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs): # instance.casbin_rule_id, exc_info=exc, ) + + +def unassign_roles_on_user_retirement(sender, user, **kwargs): # pylint: disable=unused-argument + """ + Unassign roles from a user when they are retired. + + This handler is triggered when a user is retired in the LMS. It ensures that + any roles assigned to the user are removed, maintaining the integrity of the + authorization system. + + Args: + sender: The model class (User). + user: The user instance being retired. + **kwargs: Additional keyword arguments from the signal. + """ + try: + unassign_all_roles_from_user(user.username) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.exception( + "Error unassigning roles from user %s during retirement", + user.id, + exc_info=exc, + ) + + +# Only register the handler if the signal is available (i.e., running in Open edX) +if USER_RETIRE_LMS_CRITICAL is not None: + USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement) diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 431ca9ab..83163c51 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -35,6 +35,7 @@ get_subject_role_assignments_in_scope, get_subjects_for_role_in_scope, unassign_role_from_subject_in_scope, + unassign_subject_from_all_roles, ) from openedx_authz.constants import permissions, roles from openedx_authz.constants.roles import ( @@ -976,3 +977,156 @@ def test_assign_role_creates_extended_casbin_rule(self): self.assertIn(role_data.namespaced_key, extended_rule.casbin_rule_key) self.assertIn(subject_data.namespaced_key, extended_rule.casbin_rule_key) self.assertIn(scope_data.namespaced_key, extended_rule.casbin_rule_key) + + + @ddt_data( + # Test user with single role in single scope + ("alice", ["lib:Org1:math_101"], {"library_admin"}), + # Test user with multiple roles in different scopes + ( + "eve", + ["lib:Org2:physics_401", "lib:Org2:chemistry_501", "lib:Org2:biology_601"], + {"library_admin", "library_author", "library_user"}, + ), + # Test user with same role in multiple scopes + ("liam", ["lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"], {"library_author"}), + # Test user with multiple different roles in multiple scopes + ( + "peter", + ["lib:Org6:project_alpha", "lib:Org6:project_beta", "lib:Org6:project_gamma", "lib:Org6:project_delta"], + {"library_admin", "library_author", "library_contributor", "library_user"}, + ), + ) + @unpack + def test_unassign_subject_from_all_roles_removes_all_assignments(self, subject_name, scopes, expected_roles_before): + """Test that unassign_subject_from_all_roles removes all role assignments. + + Expected result: + - Before unassignment: Subject has roles in specified scopes + - Function returns True indicating roles were removed + - After unassignment: Subject has no role assignments in any scope + - Querying role assignments returns empty list + """ + subject = SubjectData(external_key=subject_name) + + # Verify the subject has roles before unassignment + assignments_before = get_subject_role_assignments(subject) + self.assertGreater(len(assignments_before), 0) + + # Verify roles are what we expect before removal + roles_before = {r.external_key for assignment in assignments_before for r in assignment.roles} + self.assertEqual(roles_before, expected_roles_before) + + # Verify assignments exist in each expected scope + for scope_name in scopes: + scope_assignments = get_subject_role_assignments_in_scope(subject, ScopeData(external_key=scope_name)) + self.assertGreater(len(scope_assignments), 0) + + # Unassign all roles from the subject + result = unassign_subject_from_all_roles(subject) + + # Verify the function returns True (indicating roles were removed) + self.assertTrue(result) + + # Verify the subject has no role assignments after unassignment + assignments_after = get_subject_role_assignments(subject) + self.assertEqual(len(assignments_after), 0) + + # Verify no assignments in any of the previous scopes + for scope_name in scopes: + scope_assignments = get_subject_role_assignments_in_scope(subject, ScopeData(external_key=scope_name)) + self.assertEqual(len(scope_assignments), 0) + + def test_unassign_subject_with_no_roles_returns_false(self): + """Test that unassigning a subject with no roles returns False. + + Expected result: + - Function returns False when subject has no role assignments + - No errors occur when trying to unassign from non-existent subject + """ + non_existent_subject = SubjectData(external_key="user_with_no_roles") + + # Verify the subject has no roles + assignments_before = get_subject_role_assignments(non_existent_subject) + self.assertEqual(len(assignments_before), 0) + + # Unassign all roles (should return False since there are none) + result = unassign_subject_from_all_roles(non_existent_subject) + + # Verify the function returns False (no roles to remove) + self.assertFalse(result) + + # Verify still no assignments after the operation + assignments_after = get_subject_role_assignments(non_existent_subject) + self.assertEqual(len(assignments_after), 0) + + def test_unassign_subject_does_not_affect_other_subjects(self): + """Test that unassigning one subject does not affect other subjects. + + Expected result: + - When unassigning roles from one subject, other subjects retain their roles + - Other subjects with the same roles in the same scopes are unaffected + """ + # Use subjects that share the same scope + subject_to_unassign = SubjectData(external_key="grace") + other_subject = SubjectData(external_key="heidi") + shared_scope = ScopeData(external_key="lib:Org1:math_advanced") + + # Verify both subjects have roles in the shared scope before + grace_assignments_before = get_subject_role_assignments_in_scope(subject_to_unassign, shared_scope) + heidi_assignments_before = get_subject_role_assignments_in_scope(other_subject, shared_scope) + + self.assertGreater(len(grace_assignments_before), 0) + self.assertGreater(len(heidi_assignments_before), 0) + + # Unassign all roles from grace + result = unassign_subject_from_all_roles(subject_to_unassign) + self.assertTrue(result) + + # Verify grace has no assignments after unassignment + grace_assignments_after = get_subject_role_assignments(subject_to_unassign) + self.assertEqual(len(grace_assignments_after), 0) + + # Verify heidi still has her assignments + heidi_assignments_after = get_subject_role_assignments_in_scope(other_subject, shared_scope) + self.assertEqual(len(heidi_assignments_after), len(heidi_assignments_before)) + + # Verify heidi still has the library_contributor role + heidi_roles = {r.external_key for assignment in heidi_assignments_after for r in assignment.roles} + self.assertIn("library_contributor", heidi_roles) + + def test_unassign_and_reassign_subject(self): + """Test that a subject can be reassigned roles after being unassigned. + + Expected result: + - Subject has roles initially + - After unassignment, subject has no roles + - Subject can be assigned new roles + - Newly assigned roles work correctly + """ + subject = SubjectData(external_key="bob") + new_scope = ScopeData(external_key="lib:Org1:new_library") + new_role = RoleData(external_key="library_admin") + + # Verify bob has roles initially + assignments_before = get_subject_role_assignments(subject) + self.assertGreater(len(assignments_before), 0) + + # Unassign all roles + result = unassign_subject_from_all_roles(subject) + self.assertTrue(result) + + # Verify no roles after unassignment + assignments_after_unassign = get_subject_role_assignments(subject) + self.assertEqual(len(assignments_after_unassign), 0) + + # Assign a new role in a new scope + assign_result = assign_role_to_subject_in_scope(subject, new_role, new_scope) + self.assertTrue(assign_result) + + # Verify the new assignment works + new_assignments = get_subject_role_assignments_in_scope(subject, new_scope) + self.assertEqual(len(new_assignments), 1) + + new_roles = {r.external_key for assignment in new_assignments for r in assignment.roles} + self.assertIn("library_admin", new_roles) diff --git a/openedx_authz/tests/api/test_users.py b/openedx_authz/tests/api/test_users.py index 369d740b..64f3848c 100644 --- a/openedx_authz/tests/api/test_users.py +++ b/openedx_authz/tests/api/test_users.py @@ -12,6 +12,7 @@ get_user_role_assignments_for_role_in_scope, get_user_role_assignments_in_scope, is_user_allowed, + unassign_all_roles_from_user, unassign_role_from_user, ) from openedx_authz.constants import permissions, roles @@ -229,6 +230,200 @@ def test_get_all_user_role_assignments_in_scope(self, scope_name, expected_assig for assignment in role_assignments: self.assertIn(assignment, expected_assignments) + @data( + # Test user with single role in single scope + ("alice", ["lib:Org1:math_101"], {"library_admin"}), + # Test user with multiple roles in different scopes + ( + "eve", + ["lib:Org2:physics_401", "lib:Org2:chemistry_501", "lib:Org2:biology_601"], + {"library_admin", "library_author", "library_user"}, + ), + # Test user with same role in multiple scopes + ("liam", ["lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"], {"library_author"}), + # Test user with multiple different roles in multiple scopes + ( + "peter", + ["lib:Org6:project_alpha", "lib:Org6:project_beta", "lib:Org6:project_gamma", "lib:Org6:project_delta"], + {"library_admin", "library_author", "library_contributor", "library_user"}, + ), + ) + @unpack + def test_unassign_all_roles_from_user_removes_all_assignments(self, username, scopes, expected_roles_before): + """Test that unassign_all_roles_from_user removes all role assignments. + + Expected result: + - Before unassignment: User has roles in specified scopes + - Function returns True indicating roles were removed + - After unassignment: User has no role assignments in any scope + - Querying role assignments returns empty list + """ + # Verify the user has roles before unassignment + assignments_before = get_user_role_assignments(user_external_key=username) + self.assertGreater(len(assignments_before), 0) + + # Verify roles are what we expect before removal + roles_before = {r.external_key for assignment in assignments_before for r in assignment.roles} + self.assertEqual(roles_before, expected_roles_before) + + # Verify assignments exist in each expected scope + for scope_name in scopes: + scope_assignments = get_user_role_assignments_in_scope( + user_external_key=username, scope_external_key=scope_name + ) + self.assertGreater(len(scope_assignments), 0) + + # Unassign all roles from the user + result = unassign_all_roles_from_user(user_external_key=username) + + # Verify the function returns True (indicating roles were removed) + self.assertTrue(result) + + # Verify the user has no role assignments after unassignment + assignments_after = get_user_role_assignments(user_external_key=username) + self.assertEqual(len(assignments_after), 0) + + # Verify no assignments in any of the previous scopes + for scope_name in scopes: + scope_assignments = get_user_role_assignments_in_scope( + user_external_key=username, scope_external_key=scope_name + ) + self.assertEqual(len(scope_assignments), 0) + + def test_unassign_all_roles_from_user_with_no_roles_returns_false(self): + """Test that unassigning a user with no roles returns False. + + Expected result: + - Function returns False when user has no role assignments + - No errors occur when trying to unassign from non-existent user + """ + non_existent_user = "user_with_no_roles" + + # Verify the user has no roles + assignments_before = get_user_role_assignments(user_external_key=non_existent_user) + self.assertEqual(len(assignments_before), 0) + + # Unassign all roles (should return False since there are none) + result = unassign_all_roles_from_user(user_external_key=non_existent_user) + + # Verify the function returns False (no roles to remove) + self.assertFalse(result) + + # Verify still no assignments after the operation + assignments_after = get_user_role_assignments(user_external_key=non_existent_user) + self.assertEqual(len(assignments_after), 0) + + def test_unassign_all_roles_does_not_affect_other_users(self): + """Test that unassigning one user does not affect other users. + + Expected result: + - When unassigning roles from one user, other users retain their roles + - Other users with the same roles in the same scopes are unaffected + """ + # Use users that share the same scope + user_to_unassign = "grace" + other_user = "heidi" + shared_scope = "lib:Org1:math_advanced" + + # Verify both users have roles in the shared scope before + grace_assignments_before = get_user_role_assignments_in_scope( + user_external_key=user_to_unassign, scope_external_key=shared_scope + ) + heidi_assignments_before = get_user_role_assignments_in_scope( + user_external_key=other_user, scope_external_key=shared_scope + ) + + self.assertGreater(len(grace_assignments_before), 0) + self.assertGreater(len(heidi_assignments_before), 0) + + # Unassign all roles from grace + result = unassign_all_roles_from_user(user_external_key=user_to_unassign) + self.assertTrue(result) + + # Verify grace has no assignments after unassignment + grace_assignments_after = get_user_role_assignments(user_external_key=user_to_unassign) + self.assertEqual(len(grace_assignments_after), 0) + + # Verify heidi still has her assignments + heidi_assignments_after = get_user_role_assignments_in_scope( + user_external_key=other_user, scope_external_key=shared_scope + ) + self.assertEqual(len(heidi_assignments_after), len(heidi_assignments_before)) + + # Verify heidi still has the library_contributor role + heidi_roles = {r.external_key for assignment in heidi_assignments_after for r in assignment.roles} + self.assertIn("library_contributor", heidi_roles) + + def test_unassign_and_reassign_user(self): + """Test that a user can be reassigned roles after being unassigned. + + Expected result: + - User has roles initially + - After unassignment, user has no roles + - User can be assigned new roles + - Newly assigned roles work correctly + """ + username = "bob" + new_scope = "lib:Org1:new_library" + new_role = "library_admin" + + # Verify bob has roles initially + assignments_before = get_user_role_assignments(user_external_key=username) + self.assertGreater(len(assignments_before), 0) + + # Unassign all roles + result = unassign_all_roles_from_user(user_external_key=username) + self.assertTrue(result) + + # Verify no roles after unassignment + assignments_after_unassign = get_user_role_assignments(user_external_key=username) + self.assertEqual(len(assignments_after_unassign), 0) + + # Assign a new role in a new scope + assign_result = assign_role_to_user_in_scope( + user_external_key=username, role_external_key=new_role, scope_external_key=new_scope + ) + self.assertTrue(assign_result) + + # Verify the new assignment works + new_assignments = get_user_role_assignments_in_scope(user_external_key=username, scope_external_key=new_scope) + self.assertEqual(len(new_assignments), 1) + + new_roles = {r.external_key for assignment in new_assignments for r in assignment.roles} + self.assertIn(new_role, new_roles) + + def test_unassign_all_roles_impacts_permissions(self): + """Test that unassigning all roles removes the user's permissions. + + Expected result: + - User has permissions before unassignment + - After unassignment, user no longer has those permissions + - Permission checks return False after unassignment + """ + username = "alice" + scope = "lib:Org1:math_101" + action = permissions.DELETE_LIBRARY.identifier + + # Verify alice has the permission before unassignment + has_permission_before = is_user_allowed( + user_external_key=username, + action_external_key=action, + scope_external_key=scope, + ) + self.assertTrue(has_permission_before) + + # Unassign all roles + result = unassign_all_roles_from_user(user_external_key=username) + self.assertTrue(result) + + # Verify alice no longer has the permission + has_permission_after = is_user_allowed( + user_external_key=username, + action_external_key=action, + scope_external_key=scope, + ) + self.assertFalse(has_permission_after) + @ddt class TestUserPermissions(UserAssignmentsSetupMixin): diff --git a/openedx_authz/tests/integration/test_handlers.py b/openedx_authz/tests/integration/test_handlers.py new file mode 100644 index 00000000..31642c90 --- /dev/null +++ b/openedx_authz/tests/integration/test_handlers.py @@ -0,0 +1,250 @@ +"""Integration tests for signal handlers in Open edX environment. + +These tests verify that signal handlers work correctly when integrated with +the real Open edX platform, particularly testing the user retirement flow. + +Run these tests in an edx-platform environment where the USER_RETIRE_LMS_CRITICAL +signal is available. +""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL # pylint: disable=import-error + +from openedx_authz.api.data import UserData +from openedx_authz.api.users import ( + assign_role_to_user_in_scope, + get_user_role_assignments, + get_user_role_assignments_in_scope, +) +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.models import ExtendedCasbinRule, Subject + +User = get_user_model() + + +class TestUserRetirementSignalIntegration(TestCase): + """Integration tests for the USER_RETIRE_LMS_CRITICAL signal handler. + + These tests verify that when a user retirement signal is sent in a real Open edX + environment, all role assignments for the user are properly cleaned up across all scopes. + """ + + def setUp(self): + """Set up test data with real users and role assignments.""" + # Create real Django users for testing + self.retiring_user = User.objects.create_user( + username="retiring_user", email="retiring@example.com", password="testpass123" + ) + self.other_user = User.objects.create_user( + username="other_user", email="other@example.com", password="testpass123" + ) + + # Load enforcer policy + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + + def tearDown(self): + """Clean up test data.""" + # Clean up users + User.objects.filter(username__in=["retiring_user", "other_user"]).delete() + + # Clear enforcer policy + enforcer = AuthzEnforcer.get_enforcer() + enforcer.clear_policy() + + def test_user_retirement_signal_removes_all_role_assignments(self): + """Test that sending USER_RETIRE_LMS_CRITICAL removes all roles for a user. + + Expected Result: + - User has multiple role assignments before signal is sent + - After signal is sent, user has no role assignments + - Other users' role assignments are unaffected + """ + # Assign roles to retiring user in multiple scopes + assign_role_to_user_in_scope(self.retiring_user.username, "library_admin", "lib:TestOrg:lib1") + assign_role_to_user_in_scope(self.retiring_user.username, "library_author", "lib:TestOrg:lib2") + assign_role_to_user_in_scope(self.retiring_user.username, "library_user", "lib:TestOrg:lib3") + + # Assign role to other user + assign_role_to_user_in_scope(self.other_user.username, "library_admin", "lib:TestOrg:lib4") + + # Verify users have roles before retirement + retiring_user_roles_before = get_user_role_assignments(self.retiring_user.username) + other_user_roles_before = get_user_role_assignments(self.other_user.username) + + self.assertEqual(len(retiring_user_roles_before), 3) + self.assertEqual(len(other_user_roles_before), 1) + + # Send the retirement signal + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=self.retiring_user) + + # Verify roles are removed for retiring user but not other user + retiring_user_roles_after = get_user_role_assignments(self.retiring_user.username) + other_user_roles_after = get_user_role_assignments(self.other_user.username) + + self.assertEqual(len(retiring_user_roles_after), 0) + self.assertEqual(len(other_user_roles_after), 1) + + def test_user_retirement_signal_with_no_roles(self): + """Test that retirement signal handles users with no roles gracefully. + + Expected Result: + - User has no roles before signal + - Signal completes without error + - User still has no roles after signal + """ + # Create user with no role assignments + user_no_roles = User.objects.create_user( + username="user_no_roles", email="noroles@example.com", password="testpass123" + ) + + # Verify user has no roles + user_roles_before = get_user_role_assignments(user_no_roles.username) + self.assertEqual(len(user_roles_before), 0) + + # Send retirement signal - should not raise error + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=user_no_roles) + + # Verify still no roles + user_roles_after = get_user_role_assignments(user_no_roles.username) + self.assertEqual(len(user_roles_after), 0) + + # Cleanup + user_no_roles.delete() + + def test_user_retirement_removes_extended_casbin_rules(self): + """Test that user retirement also cleans up ExtendedCasbinRule records. + + Expected Result: + - User has ExtendedCasbinRule records linked to their assignments + - After retirement signal, ExtendedCasbinRule records are removed + - This ensures complete cleanup including database integrity + """ + # Assign roles which should create ExtendedCasbinRule records + assign_role_to_user_in_scope(self.retiring_user.username, "library_admin", "lib:TestOrg:cleanup1") + assign_role_to_user_in_scope(self.retiring_user.username, "library_author", "lib:TestOrg:cleanup2") + + # Get the subject to check ExtendedCasbinRule records + user_data = UserData(external_key=self.retiring_user.username) + subject = Subject.objects.get_or_create_for_external_key(user_data) + + # Verify ExtendedCasbinRule records exist + extended_rules_before = ExtendedCasbinRule.objects.filter(subject=subject) + self.assertGreater(extended_rules_before.count(), 0) + + # Send retirement signal + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=self.retiring_user) + + # Verify ExtendedCasbinRule records are cleaned up + extended_rules_after = ExtendedCasbinRule.objects.filter(subject=subject) + self.assertEqual(extended_rules_after.count(), 0) + + def test_user_retirement_with_multiple_scopes_same_role(self): + """Test retirement for user with same role in multiple scopes. + + Expected Result: + - User has same role assigned in multiple different scopes + - After retirement, all assignments across all scopes are removed + - Role assignments are completely cleared + """ + scopes = ["lib:Org1:scope1", "lib:Org2:scope2", "lib:Org3:scope3"] + + # Assign same role in multiple scopes + for scope in scopes: + assign_role_to_user_in_scope(self.retiring_user.username, "library_admin", scope) + + # Verify assignments in each scope before retirement + for scope in scopes: + assignments = get_user_role_assignments_in_scope(self.retiring_user.username, scope) + self.assertEqual(len(assignments), 1) + + total_assignments_before = get_user_role_assignments(self.retiring_user.username) + self.assertEqual(len(total_assignments_before), 3) + + # Send retirement signal + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=self.retiring_user) + + # Verify all assignments removed from all scopes + for scope in scopes: + assignments = get_user_role_assignments_in_scope(self.retiring_user.username, scope) + self.assertEqual(len(assignments), 0) + + total_assignments_after = get_user_role_assignments(self.retiring_user.username) + self.assertEqual(len(total_assignments_after), 0) + + def test_user_retirement_with_mixed_role_types(self): + """Test retirement for user with different roles across scopes. + + Expected Result: + - User has different roles (admin, author, contributor, user) in different scopes + - After retirement, all roles are removed regardless of type + - Comprehensive cleanup across all role types + """ + role_scope_pairs = [ + ("library_admin", "lib:TestOrg:admin_scope"), + ("library_author", "lib:TestOrg:author_scope"), + ("library_contributor", "lib:TestOrg:contrib_scope"), + ("library_user", "lib:TestOrg:user_scope"), + ] + + # Assign different roles in different scopes + for role, scope in role_scope_pairs: + assign_role_to_user_in_scope(self.retiring_user.username, role, scope) + + # Verify all assignments exist + total_assignments_before = get_user_role_assignments(self.retiring_user.username) + self.assertEqual(len(total_assignments_before), 4) + + # Extract role types to verify diversity + roles_before = {r.external_key for assignment in total_assignments_before for r in assignment.roles} + self.assertEqual(roles_before, {"library_admin", "library_author", "library_contributor", "library_user"}) + + # Send retirement signal + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=self.retiring_user) + + # Verify all roles removed + total_assignments_after = get_user_role_assignments(self.retiring_user.username) + self.assertEqual(len(total_assignments_after), 0) + + def test_multiple_user_retirements_do_not_interfere(self): + """Test that retiring multiple users doesn't affect each other. + + Expected Result: + - Multiple users each have their own role assignments + - Retiring one user removes only their assignments + - Retiring another user removes only their assignments + - No cross-contamination between user retirements + """ + # Create additional users + user1 = User.objects.create_user(username="retire_test_1", email="retire1@example.com", password="testpass123") + user2 = User.objects.create_user(username="retire_test_2", email="retire2@example.com", password="testpass123") + user3 = User.objects.create_user(username="retire_test_3", email="retire3@example.com", password="testpass123") + + # Assign roles to each user + for user in [user1, user2, user3]: + assign_role_to_user_in_scope(user.username, "library_admin", f"lib:TestOrg:{user.username}_scope") + + # Verify all users have assignments + for user in [user1, user2, user3]: + assignments = get_user_role_assignments(user.username) + self.assertEqual(len(assignments), 1) + + # Retire user1 + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=user1) + + # Verify user1 has no assignments, but user2 and user3 still do + self.assertEqual(len(get_user_role_assignments(user1.username)), 0) + self.assertEqual(len(get_user_role_assignments(user2.username)), 1) + self.assertEqual(len(get_user_role_assignments(user3.username)), 1) + + # Retire user2 + USER_RETIRE_LMS_CRITICAL.send(sender=User, user=user2) + + # Verify user2 has no assignments, but user3 still does + self.assertEqual(len(get_user_role_assignments(user2.username)), 0) + self.assertEqual(len(get_user_role_assignments(user3.username)), 1) + + # Cleanup + for user in [user1, user2, user3]: + user.delete()