diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5e71b4ca..82e50821 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,14 @@ Change Log Unreleased ********** +1.14.0 - 2026-04-22 +******************* + +Added +===== + +* Add optional ``orgs`` query param to the ``GET /api/authz/v1/scopes/`` endpoint, that supports filtering results by multiple orgs. + 1.13.0 - 2026-04-22 ******************* diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index d8aafb1b..28e97ad9 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.13.0" +__version__ = "1.14.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index fd85064e..9a982461 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -160,15 +160,11 @@ def validate(self, attrs) -> dict: role_value = validated_data["role"] if scope and scopes is not None: - raise serializers.ValidationError( - "Provide either 'scope' or 'scopes', not both." - ) + raise serializers.ValidationError("Provide either 'scope' or 'scopes', not both.") scopes_list = scopes if scopes is not None else ([scope] if scope else None) if not scopes_list: - raise serializers.ValidationError( - "Either 'scope' or 'scopes' must be provided." - ) + raise serializers.ValidationError("Either 'scope' or 'scopes' must be provided.") for scope_value in scopes_list: self._validate_scope_and_role(scope_value, role_value) @@ -285,6 +281,7 @@ class ListScopesQuerySerializer(OrgMixin): # pylint: disable=abstract-method choices=[(e.value, e.name) for e in ScopesTypeField], required=False, default=None, allow_null=True ) search = serializers.CharField(required=False, default="", allow_blank=True) + orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[]) class ListTeamMembersSerializer(OrderMixin): # pylint: disable=abstract-method diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 7bf4f832..7e1d3946 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -595,6 +595,9 @@ def get_queryset(self) -> QuerySet: parameters=[ apidocs.query_parameter("search", str, description="Filter scopes by display name"), apidocs.query_parameter("org", str, description="Filter scopes by org"), + apidocs.query_parameter( + "orgs", str, description="Filter scopes by multiple orgs (comma separated list of orgs)" + ), apidocs.query_parameter("page", int, description="Page number for pagination"), apidocs.query_parameter("page_size", int, description="Number of items per page"), apidocs.query_parameter( @@ -631,6 +634,7 @@ class ScopesAPIView(generics.ListAPIView): - search (Optional): Search term to filter scopes by display name - org (Optional): Filter scopes by org + - orgs (Optional): Filter scopes by multiple orgs (comma separated list of orgs) - page (Optional): Page number for pagination - page_size (Optional): Number of items per page - scope_type (Optional): Filter scopes by type. Supported values are `course` and `library`. @@ -700,7 +704,7 @@ def _get_courses_queryset( allowed_ids: set | None = None, allowed_orgs: set | None = None, search: str = "", - org: str = "", + orgs: set[str] | None = None, ) -> QuerySet: """Return a CourseOverview queryset projected to the unified scope shape. @@ -717,8 +721,8 @@ def _get_courses_queryset( qs = qs.none() else: qs = qs.filter(combined_filter) - if org: - qs = qs.filter(org=org) + if orgs: + qs = qs.filter(org__in=orgs) if search: qs = qs.filter(display_name__icontains=search) return qs.annotate( @@ -738,7 +742,7 @@ def _get_libraries_queryset( allowed_pairs: set | None = None, allowed_orgs: set | None = None, search: str = "", - org: str = "", + orgs: set[str] | None = None, ) -> QuerySet: """Return a ContentLibrary queryset projected to the unified scope shape. @@ -759,8 +763,8 @@ def _get_libraries_queryset( qs = qs.none() else: qs = qs.filter(combined) - if org: - qs = qs.filter(org__short_name=org) + if orgs: + qs = qs.filter(org__short_name__in=orgs) if search: qs = qs.filter(learning_package__title__icontains=search) return qs.annotate( @@ -785,7 +789,7 @@ def _get_allowed_scope_queryset( queryset_builder: callable, extract_ids: callable, search: str = "", - org: str = "", + orgs: set[str] | None = None, ) -> QuerySet: """Resolve allowed scopes from Casbin and return a filtered queryset. @@ -811,7 +815,7 @@ def _get_allowed_scope_queryset( specific_scopes = [s for s in allowed_scopes if not isinstance(s, glob_cls)] allowed_ids = extract_ids(specific_scopes) allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, glob_cls)} - return queryset_builder(allowed_ids, allowed_orgs, search=search, org=org) + return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs) def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet: """Union the provided querysets and sort deterministically. @@ -834,17 +838,24 @@ def get_queryset(self) -> QuerySet: scope_type = params_serializer.validated_data["scope_type"] search = params_serializer.validated_data["search"] org = params_serializer.validated_data.get("org", "") + orgs_param = params_serializer.validated_data.get("orgs", []) + + orgs = set() + orgs.update(orgs_param) + + if org: + orgs.add(org) # Staff and superusers can see all scopes, skip permission filtering. if user.is_staff or user.is_superuser: return self._build_queryset( courses_qs=( - self._get_courses_queryset(search=search, org=org) + self._get_courses_queryset(search=search, orgs=orgs) if scope_type != ScopesTypeField.LIBRARY else None ), libraries_qs=( - self._get_libraries_queryset(search=search, org=org) + self._get_libraries_queryset(search=search, orgs=orgs) if scope_type != ScopesTypeField.COURSE else None ), @@ -867,7 +878,7 @@ def get_permission(scope_cls): queryset_builder=self._get_courses_queryset, extract_ids=lambda scopes: {s.external_key for s in scopes}, search=search, - org=org, + orgs=orgs, ) libraries_qs = None @@ -882,7 +893,7 @@ def get_permission(scope_cls): (s.external_key.split(":")[1], s.external_key.split(":")[2]) for s in scopes }, search=search, - org=org, + orgs=orgs, ) # Union the requested querysets and sort by org at the DB level. diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 5ad5055a..72001aac 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -1582,6 +1582,174 @@ def test_org_filter_combined_with_search(self): self.assertIn(self.COURSE_ORG1, external_keys) self.assertNotIn(self.COURSE_ORG2, external_keys) + # ------------------------------------------------------------------ # + # Orgs filter # + # ------------------------------------------------------------------ # + + def test_orgs_filter_staff_courses(self): + """Staff user with orgs param sees only courses from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertIn("Org1", item["external_key"]) + # Org2 course should not appear + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_staff_libraries(self): + """Staff user with orgs param sees only libraries from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG2, external_keys) + self.assertNotIn(self.LIBRARY_ORG1, external_keys) + + def test_orgs_filter_staff_no_match(self): + """Staff user with orgs param for a non-existent org gets empty results.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "NonExistentOrg", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_non_staff_with_permission(self): + """Non-staff user with orgs param sees scopes only if they have permission for that org.""" + # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + + def test_orgs_filter_non_staff_without_permission(self): + """Non-staff user with org param for an org they have no permission for gets empty results.""" + # regular_1 has no permissions on Org2 + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_non_staff_courses(self): + """Non-staff user with orgs param sees only courses from that org if they have permission.""" + # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + + def test_orgs_filter_non_staff_courses_no_permission(self): + """Non-staff user with orgs param for an org they have no course permission for gets empty results.""" + # regular_9 has no course permissions on Org2 + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_with_glob_permission(self): + """Non-staff user with orgs glob permission and org filter sees only that org's scopes.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + self.assertNotIn(self.LIBRARY_ORG2, external_keys) + + def test_orgs_filter_with_glob_permission_wrong_org(self): + """Non-staff user with org glob for Org1 but filtering by Org2 gets empty results.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_combined_with_search(self): + """Orgs filter works together with search filter.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "search": "Course", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_combined_with_org(self): + """Orgs filter works together with the singluar org filter.""" + self.build_qs_patcher.stop() + + response = self.client.get( + self.url, {"org": "Org2", "orgs": "Org1", "search": "Course", "scope_type": "course"} + ) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_with_multiple_orgs(self): + """Orgs filter with multiple orgs returns scopes from both orgs.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1,Org2", "search": "Course", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + @ddt class TestAdminConsoleOrgsAPIView(ViewTestMixin):