From 60a1a9e77c0627313fa6825cc0a395d457c42d2c Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 13 Apr 2026 15:49:48 -0600 Subject: [PATCH] feat: add scope filter to get_scopes_for_user_and_permission --- openedx_authz/api/users.py | 10 ++-- openedx_authz/tests/api/test_roles.py | 16 ++++++ openedx_authz/tests/api/test_users.py | 83 ++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py index b2dd2654..de8906c9 100644 --- a/openedx_authz/api/users.py +++ b/openedx_authz/api/users.py @@ -311,22 +311,24 @@ def get_users_for_role_in_scope(role_external_key: str, scope_external_key: str) def get_scopes_for_user_and_permission( - user_external_key: str, - action_external_key: str, + user_external_key: str, action_external_key: str, scope_classes_filter: tuple[type[ScopeData], ...] | None = None ) -> list[ScopeData]: """Get all scopes where a specific user is assigned a specific permission. Args: user_external_key (str): ID of the user (e.g., 'john_doe'). action_external_key (str): The action to filter scopes (e.g., 'view', 'edit'). - + scope_classes_filter (tuple[type[ScopeData], ...] | None): Optional tuple of scope types to filter by. Returns: list[ScopeData]: A list of scopes where the user is assigned the specified permission. """ - return get_scopes_for_subject_and_permission( + scopes_list = get_scopes_for_subject_and_permission( UserData(external_key=user_external_key), PermissionData(action=ActionData(external_key=action_external_key)), ) + if scope_classes_filter: + scopes_list = [scope for scope in scopes_list if isinstance(scope, scope_classes_filter)] + return scopes_list def unassign_all_roles_from_user(user_external_key: str) -> bool: diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index ecdb80e2..08d066a1 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -296,6 +296,22 @@ def setUpClass(cls): "role_name": roles.COURSE_STAFF.external_key, "scope_name": "course-v1:TestOrg+TestCourse+2024_T3", }, + # Edge case: same user, different role, different scopes using Org instead of course scope + { + "subject_name": "eduardo", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:TestOrg+TestCourse+2024_T1", + }, + { + "subject_name": "eduardo", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:TestOrg+*", + }, + { + "subject_name": "eduardo", + "role_name": roles.LIBRARY_AUTHOR.external_key, + "scope_name": "lib:Org4:art_301", + }, # Mixed permission levels across libraries for comprehensive testing { "subject_name": "maya", diff --git a/openedx_authz/tests/api/test_users.py b/openedx_authz/tests/api/test_users.py index 2da05e21..dca32aca 100644 --- a/openedx_authz/tests/api/test_users.py +++ b/openedx_authz/tests/api/test_users.py @@ -2,12 +2,20 @@ from ddt import data, ddt, unpack -from openedx_authz.api.data import ContentLibraryData, RoleAssignmentData, RoleData, UserData +from openedx_authz.api.data import ( + ContentLibraryData, + CourseOverviewData, + OrgCourseOverviewGlobData, + RoleAssignmentData, + RoleData, + UserData, +) from openedx_authz.api.users import ( assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, batch_unassign_role_from_users, get_all_user_role_assignments_in_scope, + get_scopes_for_user_and_permission, get_user_role_assignments, get_user_role_assignments_for_role_in_scope, get_user_role_assignments_in_scope, @@ -424,6 +432,79 @@ def test_unassign_all_roles_impacts_permissions(self): ) self.assertFalse(has_permission_after) + @data( + # No filter → should return all scopes where user has permission + ( + "alice", + permissions.DELETE_LIBRARY.identifier, + None, + {"lib:Org1:math_101"}, + ), + # Filter only ContentLibraryData → should include library scopes only + ( + "alice", + permissions.DELETE_LIBRARY.identifier, + (ContentLibraryData,), + {"lib:Org1:math_101"}, + ), + # Filter excludes the scope type → should return empty + ( + "alice", + permissions.COURSES_VIEW_COURSE.identifier, + (CourseOverviewData,), + set(), + ), + # Multiple scopes (same type) + ( + "eve", + permissions.MANAGE_LIBRARY_TEAM.identifier, + (ContentLibraryData,), + {"lib:Org2:physics_401"}, + ), + # Multiple scopes (different types) - filter to only one type + ( + "eduardo", + permissions.COURSES_VIEW_COURSE.identifier, + (CourseOverviewData,), + {"course-v1:TestOrg+TestCourse+2024_T1"}, + ), + ( + "eduardo", + permissions.COURSES_VIEW_COURSE.identifier, + None, + {"course-v1:TestOrg+TestCourse+2024_T1", "course-v1:TestOrg+*"}, + ), + ( + "eduardo", + permissions.COURSES_VIEW_COURSE.identifier, + (OrgCourseOverviewGlobData,), + {"course-v1:TestOrg+*"}, + ), + ) + @unpack + def test_get_scopes_for_user_and_permission_with_filter( + self, + username, + action, + scope_filter, + expected_scopes, + ): + """Test filtering scopes by scope_classes_filter. + + Expected result: + - When no filter is provided, all scopes are returned + - When a filter is provided, only matching scope types are returned + - When filter excludes scope types, result is empty + """ + scopes = get_scopes_for_user_and_permission( + user_external_key=username, + action_external_key=action, + scope_classes_filter=scope_filter, + ) + + scope_keys = {scope.external_key for scope in scopes} + self.assertEqual(scope_keys, expected_scopes) + @ddt class TestUserPermissions(UserAssignmentsSetupMixin):