Skip to content

Commit bf4d93d

Browse files
committed
feat: Apply view team permissions to user assignments and team members endpoints, align docs
1 parent f42d1ff commit bf4d93d

5 files changed

Lines changed: 72 additions & 29 deletions

File tree

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.9.0 - 2026-04-14
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add the ``/api/authz/v1/assignments/`` endpoint for listing all user role assignments, to be used in the admin console.
24+
25+
Changed
26+
=======
27+
28+
* Apply view team permissions to the user assignments and team members endpoints.
29+
* Align docstrings and API docs accordingly.
30+
1731
1.8.0 - 2026-04-14
1832
******************
1933

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.8.0"
7+
__version__ = "1.9.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/rest_api/v1/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ def get_email(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData)
368368
return obj.user.email if obj.user else ""
369369

370370

371-
class ListAllTeamMembersAssignmentsSerializer(ListTeamMemberAssignmentsSerializer): # pylint: disable=abstract-method
371+
class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer): # pylint: disable=abstract-method
372372
"""Serializer for query params for the list all team member assignments endpoint."""
373373

374374
search = LowercaseCharField(required=False, default=None)

openedx_authz/rest_api/v1/views.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission
4646
from openedx_authz.rest_api.v1.serializers import (
4747
AddUsersToRoleWithScopeSerializer,
48-
ListAllTeamMembersAssignmentsSerializer,
48+
ListAssignmentsQuerySerializer,
4949
ListRolesWithScopeResponseSerializer,
5050
ListRolesWithScopeSerializer,
5151
ListTeamMemberAssignmentsQuerySerializer,
@@ -496,6 +496,7 @@ def get(self, request: HttpRequest) -> Response:
496496
responses={
497497
status.HTTP_200_OK: OrganizationSerializer(many=True),
498498
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
499+
status.HTTP_403_FORBIDDEN: "The user does not have the required permisisons",
499500
},
500501
)
501502
class AdminConsoleOrgsAPIView(generics.ListAPIView):
@@ -625,6 +626,7 @@ class TeamMembersAPIView(APIView):
625626

626627
pagination_class = AuthZAPIViewPagination
627628
filter_backends = [TeamMemberSearchFilter, TeamMemberOrderingFilter]
629+
permission_classes = [AnyScopePermission]
628630

629631
@apidocs.schema(
630632
parameters=[
@@ -639,9 +641,16 @@ class TeamMembersAPIView(APIView):
639641
responses={
640642
status.HTTP_200_OK: ListRolesWithScopeResponseSerializer(many=True),
641643
status.HTTP_400_BAD_REQUEST: "The request parameters are invalid",
642-
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
644+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
645+
status.HTTP_403_FORBIDDEN: "The user does not have the required permisisons",
643646
},
644647
)
648+
@authz_permissions(
649+
[
650+
permissions.VIEW_LIBRARY_TEAM.identifier,
651+
permissions.COURSES_VIEW_COURSE_TEAM.identifier,
652+
]
653+
)
645654
def get(self, request: HttpRequest) -> Response:
646655
"""Retrieve all users that have at least one assignation according to the filtering fields."""
647656
serializer = ListTeamMembersSerializer(data=request.query_params)
@@ -817,6 +826,7 @@ class TeamMemberAssignmentsAPIView(APIView):
817826

818827
pagination_class = AuthZAPIViewPagination
819828
filter_backends = [TeamMemberAssignmentsOrderingFilter]
829+
permission_classes = [AnyScopePermission]
820830

821831
@apidocs.schema(
822832
parameters=[
@@ -837,8 +847,15 @@ class TeamMemberAssignmentsAPIView(APIView):
837847
status.HTTP_200_OK: TeamMemberAssignmentSerializer(many=True),
838848
status.HTTP_400_BAD_REQUEST: "The request parameters are invalid",
839849
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
850+
status.HTTP_403_FORBIDDEN: "The user does not have the required permisisons",
840851
},
841852
)
853+
@authz_permissions(
854+
[
855+
permissions.VIEW_LIBRARY_TEAM.identifier,
856+
permissions.COURSES_VIEW_COURSE_TEAM.identifier,
857+
]
858+
)
842859
def get(self, request: HttpRequest, username: str) -> Response:
843860
"""Retrieve all user role assignments."""
844861
serializer = ListTeamMemberAssignmentsQuerySerializer(data=request.query_params)
@@ -965,7 +982,7 @@ class AssignmentsAPIView(APIView):
965982
responses={
966983
status.HTTP_200_OK: TeamMemberUserAssignmentSerializer(many=True),
967984
status.HTTP_400_BAD_REQUEST: "The request parameters are invalid",
968-
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
985+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
969986
status.HTTP_403_FORBIDDEN: "The user does not have the required permisisons",
970987
},
971988
)
@@ -977,7 +994,7 @@ class AssignmentsAPIView(APIView):
977994
)
978995
def get(self, request: HttpRequest) -> Response:
979996
"""Retrieve all user role assignments."""
980-
serializer = ListAllTeamMembersAssignmentsSerializer(data=request.query_params)
997+
serializer = ListAssignmentsQuerySerializer(data=request.query_params)
981998
serializer.is_valid(raise_exception=True)
982999
query_params = serializer.validated_data
9831000

openedx_authz/tests/rest_api/test_views.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,31 +1089,34 @@ def setUp(self):
10891089

10901090
@data(
10911091
# Staff/superuser sees all users across all scopes
1092-
("admin_1", 11),
1092+
("admin_1", status.HTTP_200_OK, 11),
10931093
# regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY_TEAM granted) → sees only Org1 members
1094-
("regular_1", 3),
1094+
("regular_1", status.HTTP_200_OK, 3),
10951095
# regular_3 has LIBRARY_USER in lib:Org2:LIB2 (VIEW_LIBRARY_TEAM granted) → sees only Org2 members
1096-
("regular_3", 3),
1096+
("regular_3", status.HTTP_200_OK, 3),
10971097
# regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 (VIEW_LIBRARY_TEAM granted) → sees only Org3 members
1098-
("regular_6", 5),
1099-
# regular_9 has no assignments → sees nothing
1100-
("regular_9", 0),
1098+
("regular_6", status.HTTP_200_OK, 5),
1099+
# regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission)
1100+
("regular_9", status.HTTP_403_FORBIDDEN, None),
11011101
)
11021102
@unpack
1103-
def test_visibility_limited_to_accessible_scopes(self, username: str, expected_count: int):
1103+
def test_visibility_limited_to_accessible_scopes(
1104+
self, username: str, expected_status: int, expected_count: int | None
1105+
):
11041106
"""Calling user only sees assignments for scopes it has VIEW_*_TEAM access to.
11051107
11061108
Expected result:
11071109
- Staff/superuser sees all users across all scopes.
11081110
- Regular users only see members of scopes they have VIEW_*_TEAM permission for.
1109-
- Users with no assignments see no results.
1111+
- Users with no relevant permissions get 403.
11101112
"""
11111113
user = User.objects.get(username=username)
11121114
self.client.force_authenticate(user=user)
11131115

11141116
response = self.client.get(self.url)
1115-
self.assertEqual(response.status_code, status.HTTP_200_OK)
1116-
self.assertEqual(response.data["count"], expected_count)
1117+
self.assertEqual(response.status_code, expected_status)
1118+
if expected_count is not None:
1119+
self.assertEqual(response.data["count"], expected_count)
11171120

11181121
def test_unauthenticated_returns_401(self):
11191122
"""Unauthenticated requests are rejected.
@@ -1333,8 +1336,8 @@ class TestTeamMemberAssignmentsAPIView(ViewTestMixin):
13331336
entry when the target is a superadmin.
13341337
- regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 role assignments,
13351338
plus the superadmin entry when the target is a superadmin.
1336-
- regular_9 (no assignments): sees no role assignments for any user, but still
1337-
sees the superadmin entry when the target is a superadmin.
1339+
- regular_9 (no assignments): rejected with 403 by AnyScopePermission
1340+
(requires at least one VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission).
13381341
"""
13391342

13401343
def setUp(self):
@@ -1356,20 +1359,27 @@ def _url(self, username: str) -> str:
13561359

13571360
@data(
13581361
# Staff/superuser targets get 1 superadmin entry + their role assignment(s)
1359-
("admin_1", "admin_1", 2), # superadmin entry + library_admin in Org1
1360-
("admin_1", "admin_2", 2), # superadmin entry + library_user in Org2
1361-
("admin_1", "admin_3", 2), # superadmin entry + library_admin in Org3
1362+
("admin_1", "admin_1", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org1
1363+
("admin_1", "admin_2", status.HTTP_200_OK, 2), # superadmin entry + library_user in Org2
1364+
("admin_1", "admin_3", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org3
13621365
# Regular user targets get only their role assignments (no superadmin entry)
1363-
("admin_1", "regular_5", 1),
1366+
("admin_1", "regular_5", status.HTTP_200_OK, 1),
13641367
# The superadmin entry is always included for superadmin targets, visible to all callers
1365-
("regular_1", "admin_1", 2), # superadmin entry + library_admin in Org1 (visible via Org1 access)
1368+
(
1369+
"regular_1",
1370+
"admin_1",
1371+
status.HTTP_200_OK,
1372+
2,
1373+
), # superadmin entry + library_admin in Org1 (visible via Org1 access)
13661374
# regular_1 cannot see admin_2's Org2 role assignment, but superadmin entry is still included
1367-
("regular_1", "admin_2", 1), # superadmin entry only
1368-
# regular_9 has no assignments but superadmin entry is still included for admin targets
1369-
("regular_9", "admin_1", 1), # superadmin entry only
1375+
("regular_1", "admin_2", status.HTTP_200_OK, 1), # superadmin entry only
1376+
# regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission)
1377+
("regular_9", "admin_1", status.HTTP_403_FORBIDDEN, None),
13701378
)
13711379
@unpack
1372-
def test_visibility_limited_to_accessible_scopes(self, caller: str, target: str, expected_count: int):
1380+
def test_visibility_limited_to_accessible_scopes(
1381+
self, caller: str, target: str, expected_status: int, expected_count: int | None
1382+
):
13731383
"""Calling user only sees role assignments for scopes it has view access to.
13741384
13751385
The superadmin entry is always included when the target is a superadmin,
@@ -1379,13 +1389,15 @@ def test_visibility_limited_to_accessible_scopes(self, caller: str, target: str,
13791389
- Superadmin targets always include the superadmin entry.
13801390
- Role assignments are filtered by the calling user's permissions.
13811391
- Regular user targets return only their visible role assignments.
1392+
- Users with no relevant permissions get 403.
13821393
"""
13831394
self.client.force_authenticate(user=User.objects.get(username=caller))
13841395

13851396
response = self.client.get(self._url(target))
13861397

1387-
self.assertEqual(response.status_code, status.HTTP_200_OK)
1388-
self.assertEqual(response.data["count"], expected_count)
1398+
self.assertEqual(response.status_code, expected_status)
1399+
if expected_count is not None:
1400+
self.assertEqual(response.data["count"], expected_count)
13891401

13901402
def test_unauthenticated_returns_401(self):
13911403
"""Unauthenticated requests are rejected.

0 commit comments

Comments
 (0)