Skip to content

Commit 28bde32

Browse files
feat: add handler to remove roles when user is retired
1 parent 162adf0 commit 28bde32

6 files changed

Lines changed: 769 additions & 0 deletions

File tree

openedx_authz/api/roles.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"get_subject_role_assignments_for_role_in_scope",
4040
"get_all_subject_role_assignments_in_scope",
4141
"get_subject_role_assignments",
42+
"unassign_subjects_from_all_roles",
4243
]
4344

4445
# TODO: these are the concerns we still have to address:
@@ -424,3 +425,17 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
424425
enforcer = AuthzEnforcer.get_enforcer()
425426
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
426427
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]
428+
429+
430+
def unassign_subjects_from_all_roles(subject: SubjectData) -> bool:
431+
"""Unassign a subject from all roles across all scopes.
432+
433+
Args:
434+
subject: The SubjectData object representing the subject to unassign.
435+
436+
Returns:
437+
bool: True if any roles were removed, False otherwise.
438+
"""
439+
enforcer = AuthzEnforcer.get_enforcer()
440+
enforcer.load_policy()
441+
return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key)

openedx_authz/api/users.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
get_subject_role_assignments_in_scope,
2222
get_subjects_for_role,
2323
unassign_role_from_subject_in_scope,
24+
unassign_subjects_from_all_roles,
2425
)
2526

2627
__all__ = [
@@ -34,6 +35,7 @@
3435
"get_all_user_role_assignments_in_scope",
3536
"is_user_allowed",
3637
"get_users_for_role",
38+
"unassign_all_roles_from_user",
3739
]
3840

3941

@@ -198,3 +200,15 @@ def get_users_for_role(role_external_key: str) -> list[UserData]:
198200
"""
199201
users = get_subjects_for_role(RoleData(external_key=role_external_key))
200202
return [UserData(namespaced_key=user.namespaced_key) for user in users]
203+
204+
205+
def unassign_all_roles_from_user(user_external_key: str) -> bool:
206+
"""Unassign all roles from a user across all scopes.
207+
208+
Args:
209+
user_external_key (str): ID of the user (e.g., 'john_doe').
210+
211+
Returns:
212+
bool: True if any roles were removed, False otherwise.
213+
"""
214+
return unassign_subjects_from_all_roles(UserData(external_key=user_external_key))

openedx_authz/handlers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
from django.db.models.signals import post_delete
88
from django.dispatch import receiver
99

10+
from openedx_authz.api.users import unassign_all_roles_from_user
1011
from openedx_authz.models.core import ExtendedCasbinRule
1112

13+
try:
14+
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
15+
except ImportError:
16+
USER_RETIRE_LMS_CRITICAL = None
17+
1218
import logging
1319

1420
logger = logging.getLogger(__name__)
@@ -43,3 +49,30 @@ def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs):
4349
instance.casbin_rule_id,
4450
exc_info=exc,
4551
)
52+
53+
54+
def unassign_roles_on_user_retirement(sender, user, **kwargs):
55+
"""Unassign roles from a user when they are retired.
56+
57+
This handler is triggered when a user is retired in the LMS. It ensures that
58+
any roles assigned to the user are removed, maintaining the integrity of the
59+
authorization system.
60+
61+
Args:
62+
sender: The model class (User).
63+
user: The user instance being retired.
64+
**kwargs: Additional keyword arguments from the signal.
65+
"""
66+
try:
67+
unassign_all_roles_from_user(user.username)
68+
except Exception as exc:
69+
logger.exception(
70+
"Error unassigning roles from user %s during retirement",
71+
user.id,
72+
exc_info=exc,
73+
)
74+
75+
76+
# Only register the handler if the signal is available (i.e., running in Open edX)
77+
if USER_RETIRE_LMS_CRITICAL is not None:
78+
USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement)

openedx_authz/tests/api/test_roles.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
get_subject_role_assignments_for_role_in_scope,
2525
get_subject_role_assignments_in_scope,
2626
unassign_role_from_subject_in_scope,
27+
unassign_subjects_from_all_roles,
2728
)
2829
from openedx_authz.engine.enforcer import AuthzEnforcer
2930
from openedx_authz.engine.utils import migrate_policy_between_enforcers
@@ -796,3 +797,170 @@ def test_get_all_role_assignments_in_scope(self, scope_name, expected_assignment
796797
self.assertEqual(len(role_assignments), len(expected_assignments))
797798
for assignment in role_assignments:
798799
self.assertIn(assignment, expected_assignments)
800+
801+
@ddt_data(
802+
# Test user with single role in single scope
803+
("alice", ["lib:Org1:math_101"], {"library_admin"}),
804+
# Test user with multiple roles in different scopes
805+
("eve", ["lib:Org2:physics_401", "lib:Org2:chemistry_501", "lib:Org2:biology_601"],
806+
{"library_admin", "library_author", "library_user"}),
807+
# Test user with same role in multiple scopes
808+
("liam", ["lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"], {"library_author"}),
809+
# Test user with multiple different roles in multiple scopes
810+
("peter", ["lib:Org6:project_alpha", "lib:Org6:project_beta", "lib:Org6:project_gamma", "lib:Org6:project_delta"],
811+
{"library_admin", "library_author", "library_contributor", "library_user"}),
812+
)
813+
@unpack
814+
def test_unassign_subject_from_all_roles_removes_all_assignments(
815+
self, subject_name, scopes, expected_roles_before
816+
):
817+
"""Test that unassign_subjects_from_all_roles removes all role assignments.
818+
819+
Expected result:
820+
- Before unassignment: Subject has roles in specified scopes
821+
- Function returns True indicating roles were removed
822+
- After unassignment: Subject has no role assignments in any scope
823+
- Querying role assignments returns empty list
824+
"""
825+
subject = SubjectData(external_key=subject_name)
826+
827+
# Verify the subject has roles before unassignment
828+
assignments_before = get_subject_role_assignments(subject)
829+
self.assertGreater(len(assignments_before), 0)
830+
831+
# Verify roles are what we expect before removal
832+
roles_before = {
833+
r.external_key for assignment in assignments_before for r in assignment.roles
834+
}
835+
self.assertEqual(roles_before, expected_roles_before)
836+
837+
# Verify assignments exist in each expected scope
838+
for scope_name in scopes:
839+
scope_assignments = get_subject_role_assignments_in_scope(
840+
subject, ScopeData(external_key=scope_name)
841+
)
842+
self.assertGreater(len(scope_assignments), 0)
843+
844+
# Unassign all roles from the subject
845+
result = unassign_subjects_from_all_roles(subject)
846+
847+
# Verify the function returns True (indicating roles were removed)
848+
self.assertTrue(result)
849+
850+
# Verify the subject has no role assignments after unassignment
851+
assignments_after = get_subject_role_assignments(subject)
852+
self.assertEqual(len(assignments_after), 0)
853+
854+
# Verify no assignments in any of the previous scopes
855+
for scope_name in scopes:
856+
scope_assignments = get_subject_role_assignments_in_scope(
857+
subject, ScopeData(external_key=scope_name)
858+
)
859+
self.assertEqual(len(scope_assignments), 0)
860+
861+
def test_unassign_subject_with_no_roles_returns_false(self):
862+
"""Test that unassigning a subject with no roles returns False.
863+
864+
Expected result:
865+
- Function returns False when subject has no role assignments
866+
- No errors occur when trying to unassign from non-existent subject
867+
"""
868+
non_existent_subject = SubjectData(external_key="user_with_no_roles")
869+
870+
# Verify the subject has no roles
871+
assignments_before = get_subject_role_assignments(non_existent_subject)
872+
self.assertEqual(len(assignments_before), 0)
873+
874+
# Unassign all roles (should return False since there are none)
875+
result = unassign_subjects_from_all_roles(non_existent_subject)
876+
877+
# Verify the function returns False (no roles to remove)
878+
self.assertFalse(result)
879+
880+
# Verify still no assignments after the operation
881+
assignments_after = get_subject_role_assignments(non_existent_subject)
882+
self.assertEqual(len(assignments_after), 0)
883+
884+
def test_unassign_subject_does_not_affect_other_subjects(self):
885+
"""Test that unassigning one subject does not affect other subjects.
886+
887+
Expected result:
888+
- When unassigning roles from one subject, other subjects retain their roles
889+
- Other subjects with the same roles in the same scopes are unaffected
890+
"""
891+
# Use subjects that share the same scope
892+
subject_to_unassign = SubjectData(external_key="grace")
893+
other_subject = SubjectData(external_key="heidi")
894+
shared_scope = ScopeData(external_key="lib:Org1:math_advanced")
895+
896+
# Verify both subjects have roles in the shared scope before
897+
grace_assignments_before = get_subject_role_assignments_in_scope(
898+
subject_to_unassign, shared_scope
899+
)
900+
heidi_assignments_before = get_subject_role_assignments_in_scope(
901+
other_subject, shared_scope
902+
)
903+
904+
self.assertGreater(len(grace_assignments_before), 0)
905+
self.assertGreater(len(heidi_assignments_before), 0)
906+
907+
# Unassign all roles from grace
908+
result = unassign_subjects_from_all_roles(subject_to_unassign)
909+
self.assertTrue(result)
910+
911+
# Verify grace has no assignments after unassignment
912+
grace_assignments_after = get_subject_role_assignments(subject_to_unassign)
913+
self.assertEqual(len(grace_assignments_after), 0)
914+
915+
# Verify heidi still has her assignments
916+
heidi_assignments_after = get_subject_role_assignments_in_scope(
917+
other_subject, shared_scope
918+
)
919+
self.assertEqual(len(heidi_assignments_after), len(heidi_assignments_before))
920+
921+
# Verify heidi still has the library_contributor role
922+
heidi_roles = {
923+
r.external_key
924+
for assignment in heidi_assignments_after
925+
for r in assignment.roles
926+
}
927+
self.assertIn("library_contributor", heidi_roles)
928+
929+
def test_unassign_and_reassign_subject(self):
930+
"""Test that a subject can be reassigned roles after being unassigned.
931+
932+
Expected result:
933+
- Subject has roles initially
934+
- After unassignment, subject has no roles
935+
- Subject can be assigned new roles
936+
- Newly assigned roles work correctly
937+
"""
938+
subject = SubjectData(external_key="bob")
939+
original_scope = ScopeData(external_key="lib:Org1:history_201")
940+
new_scope = ScopeData(external_key="lib:Org1:new_library")
941+
new_role = RoleData(external_key="library_admin")
942+
943+
# Verify bob has roles initially
944+
assignments_before = get_subject_role_assignments(subject)
945+
self.assertGreater(len(assignments_before), 0)
946+
947+
# Unassign all roles
948+
result = unassign_subjects_from_all_roles(subject)
949+
self.assertTrue(result)
950+
951+
# Verify no roles after unassignment
952+
assignments_after_unassign = get_subject_role_assignments(subject)
953+
self.assertEqual(len(assignments_after_unassign), 0)
954+
955+
# Assign a new role in a new scope
956+
assign_result = assign_role_to_subject_in_scope(subject, new_role, new_scope)
957+
self.assertTrue(assign_result)
958+
959+
# Verify the new assignment works
960+
new_assignments = get_subject_role_assignments_in_scope(subject, new_scope)
961+
self.assertEqual(len(new_assignments), 1)
962+
963+
new_roles = {
964+
r.external_key for assignment in new_assignments for r in assignment.roles
965+
}
966+
self.assertIn("library_admin", new_roles)

0 commit comments

Comments
 (0)