Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
*******************

Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "1.13.0"
__version__ = "1.14.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
9 changes: 3 additions & 6 deletions openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
35 changes: 23 additions & 12 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.

Expand All @@ -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(
Expand All @@ -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.

Expand All @@ -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(
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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
),
Expand All @@ -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
Expand All @@ -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.
Expand Down
168 changes: 168 additions & 0 deletions openedx_authz/tests/rest_api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down