Skip to content

Commit d806435

Browse files
committed
squash!: Add permission validation for orgs endpoint
1 parent f9553ca commit d806435

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

openedx_authz/rest_api/v1/permissions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,32 @@ def validate_permissions(self, request, permissions: list[str], scope_value: str
259259
return True
260260

261261

262+
class AnyScopePermission(MethodPermissionMixin, BaseScopePermission):
263+
"""Permission handler for endpoints that are not tied to a specific scope.
264+
265+
Grants access if the user has at least one of the required permissions in any scope.
266+
"""
267+
268+
# Not a namespace-specific handler; set to None to avoid registration in PermissionMeta.
269+
NAMESPACE: ClassVar[None] = None
270+
271+
def has_permission(self, request, view) -> bool:
272+
"""Check if the user has any of the required permissions across all scopes.
273+
274+
Superusers and staff are automatically granted access. For other users,
275+
grants access if the user has at least one required permission in any scope.
276+
277+
Returns:
278+
bool: True if the user has at least one required permission in any scope.
279+
"""
280+
if request.user.is_superuser or request.user.is_staff:
281+
return True
282+
required = self.get_required_permissions(request, view)
283+
if not required:
284+
return False
285+
return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required)
286+
287+
262288
class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission):
263289
"""Permission handler for content library scopes.
264290

openedx_authz/rest_api/v1/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import edx_api_doc_tools as apidocs
1111
from django.contrib.auth import get_user_model
1212
from django.http import HttpRequest
13+
from django.utils.decorators import method_decorator
1314
from edx_api_doc_tools import schema_for
1415
from organizations.models import Organization
1516
from organizations.serializers import OrganizationSerializer
@@ -29,7 +30,7 @@
2930
sort_users,
3031
)
3132
from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination
32-
from openedx_authz.rest_api.v1.permissions import DynamicScopePermission
33+
from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission
3334
from openedx_authz.rest_api.v1.serializers import (
3435
AddUsersToRoleWithScopeSerializer,
3536
ListRolesWithScopeResponseSerializer,
@@ -455,6 +456,15 @@ def get(self, request: HttpRequest) -> Response:
455456

456457

457458
@view_auth_classes()
459+
@method_decorator(
460+
authz_permissions(
461+
[
462+
permissions.VIEW_LIBRARY_TEAM.identifier,
463+
permissions.COURSES_VIEW_COURSE_TEAM.identifier,
464+
]
465+
),
466+
name="get",
467+
)
458468
@schema_for(
459469
"get",
460470
parameters=[
@@ -524,3 +534,4 @@ class OrgsAPIView(generics.ListAPIView):
524534
pagination_class = AuthZAPIViewPagination
525535
filter_backends = [filters.SearchFilter]
526536
search_fields = ["name", "short_name"]
537+
permission_classes = [AnyScopePermission]

openedx_authz/tests/rest_api/test_views.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,20 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig
858858
class TestOrgsAPIView(ViewTestMixin):
859859
"""Test suite for OrgsAPIView."""
860860

861+
@classmethod
862+
def setUpClass(cls):
863+
"""Assign a course role to regular_9 for COURSES_VIEW_COURSE_TEAM permission tests."""
864+
super().setUpClass()
865+
cls._assign_roles_to_users(
866+
[
867+
{
868+
"subject_name": "regular_9",
869+
"role_name": roles.COURSE_STAFF.external_key,
870+
"scope_name": "course-v1:Org1+COURSE1+2024",
871+
},
872+
]
873+
)
874+
861875
@classmethod
862876
def setUpTestData(cls):
863877
"""Create Organization fixtures."""
@@ -968,6 +982,59 @@ def test_get_orgs_excludes_inactive(self):
968982
result_names = [org["name"] for org in response.data["results"]]
969983
self.assertNotIn("Inactive Org", result_names)
970984

985+
@data(
986+
# Only VIEW_LIBRARY_TEAM (library_user role in a lib scope)
987+
("regular_1", status.HTTP_200_OK),
988+
# Only COURSES_VIEW_COURSE_TEAM (course_staff role in a course scope)
989+
("regular_9", status.HTTP_200_OK),
990+
# No relevant permissions
991+
("regular_10", status.HTTP_403_FORBIDDEN),
992+
# Superuser
993+
("admin_1", status.HTTP_200_OK),
994+
)
995+
@unpack
996+
def test_get_orgs_permissions(self, username: str, expected_status: int):
997+
"""Test access control for OrgsAPIView.
998+
999+
Test cases:
1000+
- User with only VIEW_LIBRARY_TEAM (via library role): allowed
1001+
- User with only COURSES_VIEW_COURSE_TEAM (via course role): allowed
1002+
- User with neither permission: forbidden
1003+
- Superuser/staff: allowed
1004+
1005+
Expected result:
1006+
- Returns appropriate status code based on user permissions
1007+
"""
1008+
user = User.objects.get(username=username)
1009+
self.client.force_authenticate(user=user)
1010+
1011+
response = self.client.get(self.url)
1012+
1013+
self.assertEqual(response.status_code, expected_status)
1014+
1015+
def test_get_orgs_user_with_both_permissions_allowed(self):
1016+
"""Test that a user with both VIEW_LIBRARY_TEAM and COURSES_VIEW_COURSE_TEAM can access the endpoint.
1017+
1018+
Expected result:
1019+
- Returns 200 OK status
1020+
"""
1021+
# regular_1 has library_user (VIEW_LIBRARY_TEAM); assign a course role too
1022+
self._assign_roles_to_users(
1023+
[
1024+
{
1025+
"subject_name": "regular_1",
1026+
"role_name": roles.COURSE_STAFF.external_key,
1027+
"scope_name": "course-v1:Org1+COURSE1+2024",
1028+
},
1029+
]
1030+
)
1031+
user = User.objects.get(username="regular_1")
1032+
self.client.force_authenticate(user=user)
1033+
1034+
response = self.client.get(self.url)
1035+
1036+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1037+
9711038
def test_get_orgs_unauthenticated(self):
9721039
"""Test that unauthenticated requests are rejected.
9731040

0 commit comments

Comments
 (0)