Skip to content

Commit bb4c3c6

Browse files
feat: adding bulk user validation endpoint for admin console
1 parent c6605a5 commit bb4c3c6

8 files changed

Lines changed: 466 additions & 4 deletions

File tree

CHANGELOG.rst

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

17+
1.3.0 - 2026-04-01
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add ``GlobalPermission`` class for cross-scope permission checking without requiring specific scope parameters.
24+
* Add ``users/`` endpoint endpoint for bulk validation of user identifiers (usernames or emails).
25+
1726
1.2.0 - 2026-03-30
1827
******************
1928

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.2.0"
7+
__version__ = "1.3.0"
88

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

openedx_authz/rest_api/v1/fields.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ def to_internal_value(self, data):
2525
def to_representation(self, value):
2626
"""Convert string to lowercase"""
2727
return value.strip().lower()
28+
29+
30+
class UserValidationSummarySerializer(serializers.Serializer): # pylint: disable=abstract-method
31+
"""Serializer for user validation summary statistics."""
32+
33+
total = serializers.IntegerField(help_text="Total number of users validated")
34+
valid_count = serializers.IntegerField(help_text="Number of valid users found")
35+
invalid_count = serializers.IntegerField(help_text="Number of invalid users")

openedx_authz/rest_api/v1/permissions.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Permissions for the Open edX AuthZ REST API."""
22

3+
import logging
34
from typing import ClassVar
45

56
from rest_framework.permissions import BasePermission
67

78
from openedx_authz import api
89

10+
logger = logging.getLogger(__name__)
11+
912

1013
class PermissionMeta(type(BasePermission)):
1114
"""Metaclass that automatically registers permission classes by namespace.
@@ -290,3 +293,52 @@ def has_permission(self, request, view) -> bool:
290293
return self.validate_permissions(request, permissions, scope_value)
291294

292295
return True
296+
297+
298+
class GlobalPermission(MethodPermissionMixin, BasePermission):
299+
"""Permission handler for global operations that don't target specific scopes.
300+
301+
This class implements permission checks for operations that require management
302+
capabilities across any scope, rather than within a specific scope. It checks
303+
if the user has the required permissions in ANY of their role assignments.
304+
"""
305+
306+
def has_permission(self, request, view) -> bool:
307+
"""Check if the user has required permissions in any scope.
308+
309+
Checks if the view method has @authz_permissions decorator and validates
310+
that the user has at least one of the required permissions in any scope.
311+
Superuser and staff users are automatically granted permission.
312+
313+
Returns:
314+
bool: True if user has required permissions in any scope, False otherwise.
315+
"""
316+
if request.user.is_superuser or request.user.is_staff:
317+
return True
318+
permissions = self.get_required_permissions(request, view)
319+
if not permissions:
320+
return False
321+
return self.validate_global_permissions(request, permissions)
322+
323+
def validate_global_permissions(self, request, permissions: list[str]) -> bool:
324+
"""Validate that user has at least one of the required permissions in any scope.
325+
326+
Args:
327+
request: The Django REST framework request object.
328+
permissions: List of permission identifiers to check.
329+
330+
Returns:
331+
bool: True if user has any required permission in any scope, False otherwise.
332+
"""
333+
try:
334+
username = request.user.username
335+
user_assignments = api.get_user_role_assignments(username)
336+
for assignment in user_assignments:
337+
for role in assignment.roles:
338+
role_permissions = role.get_permission_identifiers()
339+
if any(perm in role_permissions for perm in permissions):
340+
return True
341+
return False
342+
except Exception:
343+
logger.error(f"Error checking global permissions for user {username}", exc_info=True)
344+
return False

openedx_authz/rest_api/v1/serializers.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
from openedx_authz import api
77
from openedx_authz.rest_api.data import SortField, SortOrder
88
from openedx_authz.rest_api.utils import get_generic_scope
9-
from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField
9+
from openedx_authz.rest_api.v1.fields import (
10+
CommaSeparatedListField,
11+
LowercaseCharField,
12+
UserValidationSummarySerializer,
13+
)
1014

1115
User = get_user_model()
1216

@@ -203,3 +207,28 @@ def get_email(self, obj) -> str:
203207
def get_roles(self, obj: api.RoleAssignmentData) -> list[str]:
204208
"""Get the roles for the given role assignment."""
205209
return [role.external_key for role in obj.roles]
210+
211+
212+
class UserValidationAPIViewSerializer(serializers.Serializer): # pylint: disable=abstract-method
213+
"""Serializer for validating user existence."""
214+
215+
users = serializers.ListField(
216+
child=serializers.CharField(max_length=255), allow_empty=False, help_text="List of user identifiers to validate"
217+
)
218+
219+
def validate_users(self, value) -> list[str]:
220+
"""Eliminate duplicates preserving order"""
221+
return list(dict.fromkeys(value))
222+
223+
224+
class UserValidationAPIViewResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
225+
"""Serializer for user validation response."""
226+
227+
valid_users = serializers.ListField(
228+
child=serializers.CharField(max_length=255), help_text="List of user identifiers that were found to be valid"
229+
)
230+
invalid_users = serializers.ListField(
231+
child=serializers.CharField(max_length=255),
232+
help_text="List of user identifiers that were not found or are invalid",
233+
)
234+
summary = UserValidationSummarySerializer(help_text="Summary statistics for the validation operation")

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
),
1313
path("roles/", views.RoleListView.as_view(), name="role-list"),
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
15+
path("users/validate/", views.UserValidationAPIView.as_view(), name="user-validation"),
1516
]

openedx_authz/rest_api/v1/views.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
sort_users,
2727
)
2828
from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination
29-
from openedx_authz.rest_api.v1.permissions import DynamicScopePermission
29+
from openedx_authz.rest_api.v1.permissions import DynamicScopePermission, GlobalPermission
3030
from openedx_authz.rest_api.v1.serializers import (
3131
AddUsersToRoleWithScopeSerializer,
3232
ListRolesWithScopeResponseSerializer,
@@ -36,6 +36,8 @@
3636
PermissionValidationSerializer,
3737
RemoveUsersFromRoleWithScopeSerializer,
3838
UserRoleAssignmentSerializer,
39+
UserValidationAPIViewResponseSerializer,
40+
UserValidationAPIViewSerializer,
3941
)
4042

4143
logger = logging.getLogger(__name__)
@@ -449,3 +451,86 @@ def get(self, request: HttpRequest) -> Response:
449451
paginated_response_data = paginator.paginate_queryset(response_data, request)
450452
serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True)
451453
return paginator.get_paginated_response(serialized_data.data)
454+
455+
456+
@view_auth_classes()
457+
class UserValidationAPIView(APIView):
458+
"""API view for validating that provided user identifiers correspond to existing users.
459+
460+
This view allows clients to verify that a list of user identifiers (usernames or emails)
461+
correspond to valid users in the system. It is designed to support bulk validation of multiple
462+
user identifiers in a single request, providing a convenient way to check the validity of users before
463+
performing operations such as role assignments.
464+
465+
**Endpoints**
466+
- POST: Validate that the provided list of usernames or emails correspond to existing users
467+
468+
**Request Format (POST)**
469+
- users: List of user identifiers (username or email)
470+
471+
**Response Format (POST)**
472+
473+
Returns HTTP 200 OK with::
474+
475+
{
476+
"valid_users": ["john_doe", "[email protected]"],
477+
"invalid_users": ["nonexistent_user"],
478+
"summary": {
479+
"total": 3,
480+
"valid_count": 2,
481+
"invalid_count": 1
482+
}
483+
}
484+
485+
**Authentication and Permissions**
486+
487+
- Requires authenticated user.
488+
- Requires ``manage_library_team`` or ``manage_course_team`` permission in any scope.
489+
490+
**Example Request**
491+
492+
POST /api/authz/v1/users/validate/ ::
493+
494+
{
495+
"users": ["john_doe", "[email protected]", "nonexistent_user"]
496+
}
497+
"""
498+
499+
permission_classes = [GlobalPermission]
500+
501+
@apidocs.schema(
502+
body=UserValidationAPIViewSerializer,
503+
responses={
504+
status.HTTP_200_OK: UserValidationAPIViewResponseSerializer,
505+
status.HTTP_400_BAD_REQUEST: "The request data is invalid",
506+
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated",
507+
status.HTTP_403_FORBIDDEN: "The user does not have the required permissions",
508+
},
509+
)
510+
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier, permissions.COURSES_MANAGE_COURSE_TEAM.identifier])
511+
def post(self, request: HttpRequest) -> Response:
512+
"""Validates the provided usernames or emails correspond to existing users."""
513+
514+
request_serializer = UserValidationAPIViewSerializer(data=request.data)
515+
request_serializer.is_valid(raise_exception=True)
516+
serialized_request_data = request_serializer.validated_data
517+
valid_users = []
518+
invalid_users = []
519+
for user_identifier in serialized_request_data["users"]:
520+
try:
521+
user = get_user_by_username_or_email(user_identifier)
522+
valid_users.append(user_identifier)
523+
except User.DoesNotExist:
524+
invalid_users.append(user_identifier)
525+
526+
response_data = {
527+
"valid_users": valid_users,
528+
"invalid_users": invalid_users,
529+
"summary": {
530+
"total": len(serialized_request_data["users"]),
531+
"valid_count": len(valid_users),
532+
"invalid_count": len(invalid_users),
533+
},
534+
}
535+
response_serializer = UserValidationAPIViewResponseSerializer(response_data)
536+
return Response(response_serializer.data, status=status.HTTP_200_OK)

0 commit comments

Comments
 (0)