Skip to content

Commit d852f05

Browse files
authored
feat: add bulk scope support to PUT /api/authz/v1/roles/users/ (#256)
* feat: add bulk scope support to PUT /api/authz/v1/roles/users/ Accept a new list field alongside the existing string, allowing role assignment across multiple scopes in one request while keeping full backward compatibility. Each response entry now includes a field. * refactor: simplify validated_scopes list in AddUsersToRoleWithScopeSerializer * chore: bump version to 1.6.0 and update CHANGELOG * refactor: extract scope/role validation into reusable helper in RoleScopeValidationMixin * chore: update CHANGELOG for bulk scope role assignment Made-with: Cursor
1 parent 2663982 commit d852f05

5 files changed

Lines changed: 183 additions & 37 deletions

File tree

CHANGELOG.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.11.0 - 2026-04-16
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add bulk scope support to ``PUT /api/authz/v1/roles/users/``: accept a ``scopes`` list field to assign a role across multiple scopes in a single request, while keeping backward compatibility with the existing single ``scope`` field.
24+
1725
1.10.0 - 2026-04-16
1826
*******************
1927

@@ -23,7 +31,7 @@ Added
2331
* Add ``scopes/`` endpoint to list all scopes (courses and libraries), sorted by org, with search and pagination support.
2432

2533
1.9.0 - 2026-04-14
26-
******************
34+
*******************
2735

2836
Added
2937
=====

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.10.0"
7+
__version__ = "1.11.0"
88

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

openedx_authz/rest_api/v1/serializers.py

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,17 @@ class PermissionValidationResponseSerializer(PermissionValidationSerializer): #
7676
class RoleScopeValidationMixin(serializers.Serializer): # pylint: disable=abstract-method
7777
"""Mixin providing role and scope validation logic."""
7878

79-
def validate(self, attrs) -> dict:
80-
"""Validate that the specified role and scope are valid and that the role exists in the scope.
81-
82-
This method performs the following validations:
83-
1. Validates that the scope is registered in the scope registry
84-
2. Validates that the scope exists in the system
85-
3. Validates that the role is defined into the roles assigned to the scope
79+
def _validate_scope_and_role(self, scope_value: str, role_value: str) -> None:
80+
"""Validate that a single scope exists and the role is defined in it.
8681
8782
Args:
88-
attrs: Dictionary containing 'role' and 'scope' keys with their string values.
89-
90-
Returns:
91-
dict: The validated data dictionary with 'role' and 'scope' keys.
83+
scope_value: The scope string to validate.
84+
role_value: The role string to validate against the scope.
9285
9386
Raises:
9487
serializers.ValidationError: If the scope is not registered, doesn't exist,
9588
or if the role is not defined in the scope.
9689
"""
97-
validated_data = super().validate(attrs)
98-
scope_value = validated_data["scope"]
99-
role_value = validated_data["role"]
100-
10190
try:
10291
scope = api.ScopeData(external_key=scope_value)
10392
except ValueError as exc:
@@ -113,6 +102,26 @@ def validate(self, attrs) -> dict:
113102
if role not in role_definitions:
114103
raise serializers.ValidationError({"role": f"Role '{role_value}' does not exist in scope '{scope_value}'"})
115104

105+
def validate(self, attrs) -> dict:
106+
"""Validate that the specified role and scope are valid and that the role exists in the scope.
107+
108+
This method performs the following validations:
109+
1. Validates that the scope is registered in the scope registry
110+
2. Validates that the scope exists in the system
111+
3. Validates that the role is defined into the roles assigned to the scope
112+
113+
Args:
114+
attrs: Dictionary containing 'role' and 'scope' keys with their string values.
115+
116+
Returns:
117+
dict: The validated data dictionary with 'role' and 'scope' keys.
118+
119+
Raises:
120+
serializers.ValidationError: If the scope is not registered, doesn't exist,
121+
or if the role is not defined in the scope.
122+
"""
123+
validated_data = super().validate(attrs)
124+
self._validate_scope_and_role(validated_data["scope"], validated_data["role"])
116125
return validated_data
117126

118127

@@ -121,14 +130,53 @@ class AddUsersToRoleWithScopeSerializer(
121130
RoleMixin,
122131
ScopeMixin,
123132
): # pylint: disable=abstract-method
124-
"""Serializer for adding users to a role with a scope."""
133+
"""Serializer for adding users to a role with one or more scopes.
125134
135+
Accepts either a single ``scope`` string (backward-compatible) or a
136+
``scopes`` list for bulk assignment. Exactly one of the two must be
137+
provided per request.
138+
"""
139+
140+
scope = serializers.CharField(max_length=255, required=False, default=None, allow_null=True)
141+
scopes = serializers.ListField(
142+
child=serializers.CharField(max_length=255),
143+
required=False,
144+
default=None,
145+
)
126146
users = serializers.ListField(child=serializers.CharField(max_length=255), allow_empty=False)
127147

128148
def validate_users(self, value) -> list[str]:
129-
"""Eliminate duplicates preserving order"""
149+
"""Eliminate duplicates preserving order."""
130150
return list(dict.fromkeys(value))
131151

152+
def validate(self, attrs) -> dict:
153+
"""Validate that exactly one of 'scope'/'scopes' is provided and that every
154+
scope exists in the registry, exists in the system, and supports the role.
155+
Returns validated data with a unified ``scopes`` list of strings.
156+
"""
157+
validated_data = super(RoleScopeValidationMixin, self).validate(attrs)
158+
scope = validated_data.get("scope")
159+
scopes = validated_data.get("scopes")
160+
role_value = validated_data["role"]
161+
162+
if scope and scopes is not None:
163+
raise serializers.ValidationError(
164+
"Provide either 'scope' or 'scopes', not both."
165+
)
166+
167+
scopes_list = scopes if scopes is not None else ([scope] if scope else None)
168+
if not scopes_list:
169+
raise serializers.ValidationError(
170+
"Either 'scope' or 'scopes' must be provided."
171+
)
172+
173+
for scope_value in scopes_list:
174+
self._validate_scope_and_role(scope_value, role_value)
175+
176+
validated_data.pop("scope", None)
177+
validated_data["scopes"] = scopes_list
178+
return validated_data
179+
132180

133181
class RemoveUsersFromRoleWithScopeSerializer(
134182
RoleScopeValidationMixin,

openedx_authz/rest_api/v1/views.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -321,30 +321,31 @@ def get(self, request: HttpRequest) -> Response:
321321
)
322322
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier])
323323
def put(self, request: HttpRequest) -> Response:
324-
"""Assign multiple users to a specific role within a scope."""
324+
"""Assign multiple users to a specific role within one or more scopes."""
325325
serializer = AddUsersToRoleWithScopeSerializer(data=request.data)
326326
serializer.is_valid(raise_exception=True)
327327
data = serializer.validated_data
328328

329329
completed, errors = [], []
330-
for user_identifier in data["users"]:
331-
response_dict = {"user_identifier": user_identifier}
332-
try:
333-
user = get_user_by_username_or_email(user_identifier)
334-
result = api.assign_role_to_user_in_scope(user.username, data["role"], data["scope"])
335-
if result:
336-
response_dict["status"] = RoleOperationStatus.ROLE_ADDED
337-
completed.append(response_dict)
338-
else:
339-
response_dict["error"] = RoleOperationError.USER_ALREADY_HAS_ROLE
330+
for scope_value in data["scopes"]:
331+
for user_identifier in data["users"]:
332+
response_dict = {"user_identifier": user_identifier, "scope": scope_value}
333+
try:
334+
user = get_user_by_username_or_email(user_identifier)
335+
result = api.assign_role_to_user_in_scope(user.username, data["role"], scope_value)
336+
if result:
337+
response_dict["status"] = RoleOperationStatus.ROLE_ADDED
338+
completed.append(response_dict)
339+
else:
340+
response_dict["error"] = RoleOperationError.USER_ALREADY_HAS_ROLE
341+
errors.append(response_dict)
342+
except User.DoesNotExist:
343+
response_dict["error"] = RoleOperationError.USER_NOT_FOUND
344+
errors.append(response_dict)
345+
except Exception as e: # pylint: disable=broad-exception-caught
346+
logger.error(f"Error assigning role to user {user_identifier} in scope {scope_value}: {e}")
347+
response_dict["error"] = RoleOperationError.ROLE_ASSIGNMENT_ERROR
340348
errors.append(response_dict)
341-
except User.DoesNotExist:
342-
response_dict["error"] = RoleOperationError.USER_NOT_FOUND
343-
errors.append(response_dict)
344-
except Exception as e: # pylint: disable=broad-exception-caught
345-
logger.error(f"Error assigning role to user {user_identifier}: {e}")
346-
response_dict["error"] = RoleOperationError.ROLE_ASSIGNMENT_ERROR
347-
errors.append(response_dict)
348349

349350
response_data = {"completed": completed, "errors": errors}
350351
return Response(response_data, status=status.HTTP_207_MULTI_STATUS)

openedx_authz/tests/rest_api/test_views.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,95 @@ def test_add_users_to_role_invalid_data(self, request_data: dict):
542542

543543
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
544544

545+
@data(
546+
# Single scope in 'scopes' list - one user, success
547+
(["admin_1"], ["lib:Org1:LIB3"], 1, 0),
548+
# Single scope in 'scopes' list - multiple users, all success
549+
(["admin_1", "regular_1"], ["lib:Org1:LIB3"], 2, 0),
550+
# Two scopes - one user, success in both
551+
(["admin_1"], ["lib:Org1:LIB3", "lib:Org2:LIB4"], 2, 0),
552+
# Two scopes - two users, all succeed (2 scopes * 2 users = 4 completed)
553+
(["admin_1", "regular_1"], ["lib:Org1:LIB3", "lib:Org2:LIB4"], 4, 0),
554+
# Three scopes - one user, success in all
555+
(["admin_1"], ["lib:Org1:LIB3", "lib:Org2:LIB4", "lib:Org3:LIB5"], 3, 0),
556+
)
557+
@unpack
558+
def test_add_users_to_role_multi_scope_success(
559+
self,
560+
users: list[str],
561+
scopes: list[str],
562+
expected_completed: int,
563+
expected_errors: int,
564+
):
565+
"""Test adding users to a role using the new 'scopes' list field.
566+
567+
Expected result:
568+
- Returns 207 MULTI-STATUS
569+
- Completed count equals len(users) * len(scopes) when all succeed
570+
- Each completed entry contains a 'scope' field
571+
"""
572+
role = roles.LIBRARY_ADMIN.external_key
573+
request_data = {"role": role, "scopes": scopes, "users": users}
574+
575+
with patch.object(api.ContentLibraryData, "exists", return_value=True):
576+
response = self.client.put(self.url, data=request_data, format="json")
577+
578+
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
579+
self.assertEqual(len(response.data["completed"]), expected_completed)
580+
self.assertEqual(len(response.data["errors"]), expected_errors)
581+
for entry in response.data["completed"]:
582+
self.assertIn("scope", entry)
583+
self.assertIn(entry["scope"], scopes)
584+
585+
def test_add_users_to_role_backward_compat_response_includes_scope(self):
586+
"""Test that the old single-'scope' payload still works and response includes scope.
587+
588+
Expected result:
589+
- Returns 207 MULTI-STATUS
590+
- Each completed entry contains a 'scope' field matching the requested scope
591+
"""
592+
scope = "lib:Org1:LIB3"
593+
request_data = {
594+
"role": roles.LIBRARY_ADMIN.external_key,
595+
"scope": scope,
596+
"users": ["admin_1", "regular_1"],
597+
}
598+
599+
with patch.object(api.ContentLibraryData, "exists", return_value=True):
600+
response = self.client.put(self.url, data=request_data, format="json")
601+
602+
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
603+
self.assertEqual(len(response.data["completed"]), 2)
604+
for entry in response.data["completed"]:
605+
self.assertIn("scope", entry)
606+
self.assertEqual(entry["scope"], scope)
607+
608+
@data(
609+
# Both 'scope' and 'scopes' provided
610+
{
611+
"role": roles.LIBRARY_ADMIN.external_key,
612+
"scope": "lib:Org1:LIB1",
613+
"scopes": ["lib:Org2:LIB2"],
614+
"users": ["admin_1"],
615+
},
616+
# 'scopes' as empty list
617+
{
618+
"role": roles.LIBRARY_ADMIN.external_key,
619+
"scopes": [],
620+
"users": ["admin_1"],
621+
},
622+
)
623+
def test_add_users_to_role_invalid_scopes_field(self, request_data: dict):
624+
"""Test that invalid combinations of scope/scopes fields return 400.
625+
626+
Expected result:
627+
- Returns 400 BAD REQUEST status
628+
"""
629+
with patch.object(DynamicScopePermission, "has_permission", return_value=True):
630+
response = self.client.put(self.url, data=request_data, format="json")
631+
632+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
633+
545634
@data(
546635
# Unauthenticated
547636
(None, status.HTTP_401_UNAUTHORIZED),

0 commit comments

Comments
 (0)