diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 7245fe42..20e3e153 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -1545,6 +1545,22 @@ def __repr__(self): role_keys = ", ".join(role.namespaced_key for role in self.roles) return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}" + @staticmethod + def filter_platform_glob_assignments( + assignments: list["RoleAssignmentData"], + ) -> list["RoleAssignmentData"]: + """Filter a list of role assignments down to those in a platform-level glob scope. + + Args: + assignments: Role assignments to filter. + + Returns: + list[RoleAssignmentData]: The assignments whose scope is a platform-level glob + (e.g., PlatformCourseOverviewGlobData, PlatformContentLibraryGlobData). + """ + platform_glob_scopes = tuple(ScopeData.get_all_platform_glob_namespaces().values()) + return [assignment for assignment in assignments if isinstance(assignment.scope, platform_glob_scopes)] + @define class SuperAdminAssignmentData: diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 96686338..a9db1441 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -30,12 +30,14 @@ OrgCourseOverviewGlobData, PlatformGlobData, RoleAssignmentData, + ScopeData, SuperAdminAssignmentData, UserAssignmentData, ) from openedx_authz.api.users import ( get_scopes_for_user_and_permission, get_superadmin_assignments, + get_user_role_assignments_per_scope_type, get_visible_user_role_assignments_filtered_by_current_user, ) from openedx_authz.api.utils import get_user_map @@ -565,9 +567,12 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView): - name: The organization's name - short_name: The organization's short name + Results are limited to the orgs of the courses and libraries the requesting user has a + role assignment in. Staff and superusers see every active org. + **Authentication and Permissions** - - Requires authenticated user. + - Requires authenticated user with either a content library or course view team permission. **Example Request** @@ -601,8 +606,29 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView): permission_classes = [AnyScopePermission] def get_queryset(self) -> QuerySet: - """Return active organizations ordered by name.""" - return Organization.objects.filter(active=True).order_by("name") + """Return the active organizations visible to the requesting user, ordered by name.""" + user = self.request.user + + orgs = Organization.objects.filter(active=True).order_by("name") + + # Staff/superusers bypass assignment-based filtering, same as elsewhere in the codebase + # (see _filter_allowed_assignments and the Casbin matcher). TODO: this check is duplicated + # at each call site that needs it; it should live once in the api layer instead of being + # re-implemented in the REST API layer. + # See https://github.com/openedx/openedx-authz/issues/347 to standardize this further. + if user.is_staff or user.is_superuser: + return orgs + + assignments = get_user_role_assignments_per_scope_type( + user.username, + tuple(ScopeData.get_all_registered_scopes()), + ) + + if RoleAssignmentData.filter_platform_glob_assignments(assignments): + return orgs + + short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))} + return orgs.filter(short_name__in=short_names) @view_auth_classes() diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index 7abe4436..56823076 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -855,6 +855,73 @@ def test_role_assignment_data_repr(self): expected_repr = "user^john_doe => [role^instructor, role^library_admin] @ lib^lib:DemoX:CSPROB" self.assertEqual(actual_repr, expected_repr) + def test_filter_platform_glob_assignments_keeps_only_platform_glob_scopes(self): + """Test that filter_platform_glob_assignments keeps only platform-level glob assignments. + + Expected Result: + - Assignments scoped to PlatformCourseOverviewGlobData and PlatformContentLibraryGlobData + are kept + - Assignments scoped to a specific library, a specific course, and an org-level glob + are dropped + """ + user = UserData(external_key="john_doe") + role = RoleData(external_key="instructor") + platform_course_assignment = RoleAssignmentData( + subject=user, + roles=[role], + scope=PlatformCourseOverviewGlobData(external_key=PlatformCourseOverviewGlobData.build_external_key()), + ) + platform_library_assignment = RoleAssignmentData( + subject=user, + roles=[role], + scope=PlatformContentLibraryGlobData(external_key=PlatformContentLibraryGlobData.build_external_key()), + ) + specific_library_assignment = RoleAssignmentData( + subject=user, roles=[role], scope=ContentLibraryData(external_key="lib:DemoX:CSPROB") + ) + specific_course_assignment = RoleAssignmentData( + subject=user, roles=[role], scope=CourseOverviewData(external_key="course-v1:DemoX+CS101+2024") + ) + org_glob_assignment = RoleAssignmentData( + subject=user, roles=[role], scope=OrgContentLibraryGlobData(external_key="lib:DemoX:*") + ) + + result = RoleAssignmentData.filter_platform_glob_assignments( + [ + platform_course_assignment, + platform_library_assignment, + specific_library_assignment, + specific_course_assignment, + org_glob_assignment, + ] + ) + + self.assertEqual(result, [platform_course_assignment, platform_library_assignment]) + + def test_filter_platform_glob_assignments_empty_list(self): + """Test that filter_platform_glob_assignments returns an empty list when given no assignments. + + Expected Result: + - Returns an empty list + """ + self.assertEqual(RoleAssignmentData.filter_platform_glob_assignments([]), []) + + def test_filter_platform_glob_assignments_no_matches(self): + """Test that filter_platform_glob_assignments returns an empty list when none match. + + Expected Result: + - Returns an empty list when no assignment is scoped to a platform-level glob + """ + user = UserData(external_key="john_doe") + role = RoleData(external_key="instructor") + specific_library_assignment = RoleAssignmentData( + subject=user, roles=[role], scope=ContentLibraryData(external_key="lib:DemoX:CSPROB") + ) + + result = RoleAssignmentData.filter_platform_glob_assignments([specific_library_assignment]) + + self.assertEqual(result, []) + @ddt class TestContentLibraryData(TestCase): diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index e7632817..9ce75ebe 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -2171,6 +2171,129 @@ def test_get_orgs_unauthenticated(self): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_django_staff_sees_all_orgs_regardless_of_assignments(self): + """Test that a Django staff/superuser sees every active org, not just the ones from their assignments. + + admin_2's only role assignment is in scope "lib:Org2:LIB2" (org "Org2"), which doesn't match + any of the AlphaU/BetaI/GammaC fixtures, yet all 3 must still be returned. + + Expected result: + - Returns 200 OK status + - Returns all 3 orgs + """ + user = User.objects.get(username="admin_2") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + + @data( + # Specific (non-glob) scopes + ([("lib:AlphaU:LIB_ADMIN_TEST", roles.LIBRARY_USER.external_key)], {"AlphaU"}), + ([("course-v1:BetaI+COURSE1+2024", roles.COURSE_STAFF.external_key)], {"BetaI"}), + # Org-level glob scopes + ([(OrgContentLibraryGlobData.build_external_key("AlphaU"), roles.LIBRARY_ADMIN.external_key)], {"AlphaU"}), + ([(OrgCourseOverviewGlobData.build_external_key("BetaI"), roles.COURSE_STAFF.external_key)], {"BetaI"}), + # Multiple assignments across different orgs and scope types + ( + [ + ("course-v1:AlphaU+COURSE1+2024", roles.COURSE_STAFF.external_key), + (OrgContentLibraryGlobData.build_external_key("BetaI"), roles.LIBRARY_ADMIN.external_key), + ], + {"AlphaU", "BetaI"}, + ), + # Two specific course scopes, different orgs + ( + [ + ("course-v1:AlphaU+COURSE1+2024", roles.COURSE_STAFF.external_key), + ("course-v1:BetaI+COURSE2+2024", roles.COURSE_STAFF.external_key), + ], + {"AlphaU", "BetaI"}, + ), + # Two org-level glob scopes, different orgs + ( + [ + (OrgContentLibraryGlobData.build_external_key("AlphaU"), roles.LIBRARY_ADMIN.external_key), + (OrgCourseOverviewGlobData.build_external_key("BetaI"), roles.COURSE_STAFF.external_key), + ], + {"AlphaU", "BetaI"}, + ), + ) + @unpack + def test_non_staff_sees_only_assigned_orgs(self, assignments: list[tuple[str, str]], expected_orgs: set[str]): + """Test that a non-staff user only sees the orgs covered by their role assignments. + + Test cases: + - Specific ContentLibraryData scope (a single library) + - Specific CourseOverviewData scope (a single course) + - Org-level OrgContentLibraryGlobData scope (all libraries in an org) + - Org-level OrgCourseOverviewGlobData scope (all courses in an org) + - A specific CourseOverviewData scope and an OrgContentLibraryGlobData scope together, + in different orgs + - Two specific CourseOverviewData scopes, in different orgs + - Two org-level glob scopes (OrgContentLibraryGlobData and OrgCourseOverviewGlobData), + in different orgs + + Expected result: + - Returns 200 OK status + - Returns only the orgs covered by the assignments + - GammaC, where the user has no assignment, is always excluded + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_1", + "role_name": role_name, + "scope_name": scope_name, + } + for scope_name, role_name in assignments + ] + ) + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(expected_orgs)) + result_short_names = {org["short_name"] for org in response.data["results"]} + self.assertEqual(result_short_names, expected_orgs) + + @data( + (PLATFORM_COURSE_GLOB, roles.COURSE_STAFF.external_key), + (PLATFORM_LIBRARY_GLOB, roles.LIBRARY_ADMIN.external_key), + ) + @unpack + def test_non_staff_with_platform_glob_sees_all_orgs(self, scope_name: str, role_name: str): + """Test that a platform-level glob assignment grants visibility into every active org. + + Platform-level scopes (e.g. PlatformCourseOverviewGlobData) cover every org, so a user + with a role there is treated like staff/superuser for this endpoint, regardless of which + orgs they'd otherwise be able to derive from `.org` on their other assignments. + + Expected result: + - Returns 200 OK status + - Returns all 3 orgs + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_1", + "role_name": role_name, + "scope_name": scope_name, + }, + ] + ) + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + @ddt class TestTeamMembersAPIView(ViewTestMixin):