From 0dce38b18ed4ca7e03cb1553fa91752d1915a8cf Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 9 Jul 2026 10:33:46 +0200 Subject: [PATCH 1/4] fix: scope AdminConsoleOrgsAPIView to the user's assigned orgs The org filter endpoint returned every active org regardless of the requesting user's actual course/library role assignments. Now it's limited to orgs the user has a role in (via specific or org-level glob scopes), with staff/superusers still seeing everything. Co-Authored-By: Claude Sonnet 5 --- openedx_authz/rest_api/v1/views.py | 23 +++- openedx_authz/tests/rest_api/test_views.py | 123 +++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 96686338..bba7e1ef 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,7 +606,21 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView): permission_classes = [AnyScopePermission] def get_queryset(self) -> QuerySet: - """Return active organizations ordered by name.""" + """Return the active organizations visible to the requesting user, ordered by name.""" + user = self.request.user + + # 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. + if not (user.is_staff or user.is_superuser): + assignments = get_user_role_assignments_per_scope_type( + user.username, + tuple(ScopeData.get_all_registered_scopes()), + ) + short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))} + return Organization.objects.filter(active=True, short_name__in=short_names).order_by("name") + return Organization.objects.filter(active=True).order_by("name") diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index e7632817..ada84a2a 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_no_orgs(self, scope_name: str, role_name: str): + """Test that a platform-level glob assignment alone does not resolve to any org today. + + Platform-level scopes (e.g. PlatformCourseOverviewGlobData) cover every org, so they have + no single `.org` to extract, and this endpoint doesn't special-case them like it does + staff/superusers. + + Expected result: + - Returns 200 OK status + - Returns no 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"], 0) + @ddt class TestTeamMembersAPIView(ViewTestMixin): From 3d94a1270f16aaf1d56834c0f6a84943d736ff3d Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 28 Jul 2026 10:35:14 +0200 Subject: [PATCH 2/4] fix: grant platform-glob role holders full org visibility in AdminConsoleOrgsAPIView Users with a role at a platform-level glob scope (all courses or all libraries) have no single org to derive, so they previously saw zero orgs instead of every org they can actually manage. Adds RoleAssignmentData.filter_platform_glob_assignments to check this without duplicating scope-type logic in the REST API layer. See https://github.com/openedx/openedx-authz/issues/347 for standardizing the staff/superuser and platform-access checks duplicated across call sites. Co-Authored-By: Claude Sonnet 5 --- openedx_authz/api/data.py | 16 +++++++++++++++ openedx_authz/rest_api/v1/views.py | 23 ++++++++++++++-------- openedx_authz/tests/rest_api/test_views.py | 14 ++++++------- 3 files changed, 38 insertions(+), 15 deletions(-) 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 bba7e1ef..404f95e2 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -609,19 +609,26 @@ def get_queryset(self) -> QuerySet: """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. - if not (user.is_staff or user.is_superuser): - assignments = get_user_role_assignments_per_scope_type( - user.username, - tuple(ScopeData.get_all_registered_scopes()), - ) - short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))} - return Organization.objects.filter(active=True, short_name__in=short_names).order_by("name") + # 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 - return Organization.objects.filter(active=True).order_by("name") + short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))} + return Organization.objects.filter(active=True, short_name__in=short_names).order_by("name") @view_auth_classes() diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index ada84a2a..9ce75ebe 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -2266,16 +2266,16 @@ def test_non_staff_sees_only_assigned_orgs(self, assignments: list[tuple[str, st (PLATFORM_LIBRARY_GLOB, roles.LIBRARY_ADMIN.external_key), ) @unpack - def test_non_staff_with_platform_glob_sees_no_orgs(self, scope_name: str, role_name: str): - """Test that a platform-level glob assignment alone does not resolve to any org today. + 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 they have - no single `.org` to extract, and this endpoint doesn't special-case them like it does - staff/superusers. + 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 no orgs + - Returns all 3 orgs """ self._assign_roles_to_users( [ @@ -2292,7 +2292,7 @@ def test_non_staff_with_platform_glob_sees_no_orgs(self, scope_name: str, role_n response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["count"], 3) @ddt From e1d593a4b6372f44c39ee87a695697f12a613a5d Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 28 Jul 2026 10:50:02 +0200 Subject: [PATCH 3/4] test: add coverage for RoleAssignmentData.filter_platform_glob_assignments Co-Authored-By: Claude Sonnet 5 --- openedx_authz/tests/api/test_data.py | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) 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): From a431a01d654d28cc7434296a4ca404f64673d7bb Mon Sep 17 00:00:00 2001 From: "Maria Grimaldi (Majo)" Date: Wed, 29 Jul 2026 18:01:32 +0200 Subject: [PATCH 4/4] refactor: apply suggestions from code review Co-authored-by: Bryann Valderrama --- openedx_authz/rest_api/v1/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 404f95e2..a9db1441 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -628,7 +628,7 @@ def get_queryset(self) -> QuerySet: return orgs short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))} - return Organization.objects.filter(active=True, short_name__in=short_names).order_by("name") + return orgs.filter(short_name__in=short_names) @view_auth_classes()