diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8736370a..b87f7f31 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,20 @@ Change Log Unreleased ********** +1.9.0 - 2026-04-14 +****************** + +Added +===== + +* Add the ``/api/authz/v1/assignments/`` endpoint for listing all user role assignments, to be used in the admin console. + +Changed +======= + +* Apply view team permissions to the user assignments and team members endpoints. +* Align docstrings and API docs accordingly. + 1.8.0 - 2026-04-14 ****************** diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 1beebf98..96948662 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.8.0" +__version__ = "1.9.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 3220ce15..2319fd8d 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -38,6 +38,7 @@ "ScopeData", "SubjectData", "SuperAdminAssignmentData", + "UserAssignmentData", "UserData", ] @@ -1128,14 +1129,30 @@ class SuperAdminAssignmentData: staff/superuser and their access is not derived from a specific role assignment. """ - user: "User" = None + user: "User" | None = None is_staff: bool = False is_superuser: bool = False +@define +class UserAssignmentData(RoleAssignmentData): + """Represents a user entry in a team member assignment list. + + Used alongside SuperAdminAssignmentData in serializer contexts where individual + assignment along with its assigned user information is needed. + """ + + user: "User" | None = None + + @define class UserAssignments: - """A user with their role assignments""" + """A user with their role assignments + + Used in serializer context where a user is grouped with their assignments. + + This is different to UserAssignmentData because here we are grouping multiple assignments to an individual user. + """ user: "User" assignments: list[RoleAssignmentData] @@ -1146,3 +1163,4 @@ class UserAssignmentsFilter(Enum): SCOPES = "scopes" ORGS = "orgs" + ROLES = "roles" diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py index fa67f122..e84bd590 100644 --- a/openedx_authz/api/users.py +++ b/openedx_authz/api/users.py @@ -296,6 +296,7 @@ def _filter_allowed_assignments( def get_visible_role_assignments_for_user( orgs: list[str] = None, scopes: list[str] = None, + roles: list[str] = None, allowed_for_user_external_key: str = None, ) -> list[UserAssignments]: """ @@ -329,6 +330,11 @@ def get_visible_role_assignments_for_user( by=UserAssignmentsFilter.ORGS, values=orgs, ) + users_with_assignments = filter_user_assignments( + users_with_assignments=users_with_assignments, + by=UserAssignmentsFilter.ROLES, + values=roles, + ) return users_with_assignments diff --git a/openedx_authz/api/utils.py b/openedx_authz/api/utils.py index 9ce10091..00cf720a 100644 --- a/openedx_authz/api/utils.py +++ b/openedx_authz/api/utils.py @@ -27,7 +27,7 @@ def get_user_map(usernames: list[str]) -> dict[str, User]: dict[str, User]: Dictionary mapping each username to its corresponding User object. Only users that exist in the database are included in the returned dictionary. """ - users = User.objects.filter(username__in=usernames).select_related("profile") + users = User.objects.filter(username__in=usernames, is_active=True).select_related("profile") return {user.username: user for user in users} @@ -74,6 +74,8 @@ def _get_value_to_filter(assignment: RoleAssignmentData) -> str: return assignment.scope.external_key elif by == UserAssignmentsFilter.ORGS: return getattr(assignment.scope, "org", None) + elif by == UserAssignmentsFilter.ROLES: + return assignment.roles[0].external_key if assignment.roles else None else: raise ValueError(f"Invalid filter: '{by}'. Must be one of {[f.value for f in UserAssignmentsFilter]}") diff --git a/openedx_authz/rest_api/data.py b/openedx_authz/rest_api/data.py index 74eb4432..3e3d1e24 100644 --- a/openedx_authz/rest_api/data.py +++ b/openedx_authz/rest_api/data.py @@ -28,6 +28,17 @@ class AssignmentSortField(BaseEnum): SCOPE = "scope" +class UserAssignmentSortField(BaseEnum): + """Enum for the user role assignment fields to sort by.""" + + ROLE = "role" + ORG = "org" + SCOPE = "scope" + FULL_NAME = "full_name" + USERNAME = "username" + EMAIL = "email" + + class SortOrder(BaseEnum): """Enum for the order to sort by.""" diff --git a/openedx_authz/rest_api/utils.py b/openedx_authz/rest_api/utils.py index 06b04462..0403d844 100644 --- a/openedx_authz/rest_api/utils.py +++ b/openedx_authz/rest_api/utils.py @@ -4,7 +4,14 @@ GLOBAL_SCOPE_WILDCARD, ScopeData, ) -from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder +from openedx_authz.rest_api.data import ( + AssignmentSortField, + BaseEnum, + SearchField, + SortField, + SortOrder, + UserAssignmentSortField, +) def get_generic_scope(scope: ScopeData) -> ScopeData: @@ -95,6 +102,41 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None) return filtered_users +def _sort_by_field( + items: list[dict], + sort_by: str, + order: str, + allowed_fields: type[BaseEnum], +) -> list[dict]: + """ + Sort a list of dicts by a given field and order, validating against the provided enum. + + Args: + items (list[dict]): The items to sort. + sort_by (str): The field to sort by. + order (str): The order to sort by. + allowed_fields (type[BaseEnum]): The enum class whose values are the valid sort fields. + + Raises: + ValueError: If the sort field is invalid. + ValueError: If the sort order is invalid. + + Returns: + list[dict]: The sorted items. + """ + if sort_by not in allowed_fields.values(): + raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {allowed_fields.values()}") + + if order not in SortOrder.values(): + raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}") + + return sorted( + items, + key=lambda item: (item.get(sort_by) or "").lower(), + reverse=order == SortOrder.DESC, + ) + + def sort_assignments( assignments: list[dict], sort_by: AssignmentSortField = AssignmentSortField.ROLE, @@ -115,15 +157,27 @@ def sort_assignments( Returns: list[dict]: The sorted assignments. """ - if sort_by not in AssignmentSortField.values(): - raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}") + return _sort_by_field(assignments, sort_by, order, AssignmentSortField) - if order not in SortOrder.values(): - raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}") - sorted_assignments = sorted( - assignments, - key=lambda assignment: (assignment.get(sort_by) or "").lower(), - reverse=order == SortOrder.DESC, - ) - return sorted_assignments +def sort_user_assignments( + assignments: list[dict], + sort_by: UserAssignmentSortField = UserAssignmentSortField.ROLE, + order: SortOrder = SortOrder.ASC, +) -> list[dict]: + """ + Sort role assignments by a given field and order. + + Args: + assignments (list[dict]): The assignments to sort. + sort_by (UserAssignmentSortField, optional): The field to sort by. Defaults to UserAssignmentSortField.ROLE. + order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC. + + Raises: + ValueError: If the sort field is invalid. + ValueError: If the sort order is invalid. + + Returns: + list[dict]: The sorted assignments. + """ + return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField) diff --git a/openedx_authz/rest_api/v1/filters.py b/openedx_authz/rest_api/v1/filters.py index ebf95396..7d8c275b 100644 --- a/openedx_authz/rest_api/v1/filters.py +++ b/openedx_authz/rest_api/v1/filters.py @@ -2,8 +2,8 @@ from rest_framework.filters import BaseFilterBackend -from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder -from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users +from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder, UserAssignmentSortField +from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_user_assignments, sort_users class TeamMemberSearchFilter(BaseFilterBackend): @@ -30,3 +30,27 @@ def filter_queryset(self, request, queryset, view): sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE) order = request.query_params.get("order", SortOrder.ASC) return sort_assignments(assignments=queryset, sort_by=sort_by, order=order) + + +class UserAssignmentsSearchFilter(BaseFilterBackend): + """Filter user assignments by a search term over full_name, username, and email.""" + + def filter_queryset(self, request, queryset, view): + search = request.query_params.get("search") + if not search: + return queryset + search = search.lower() + return [ + item + for item in queryset + if any(search in (item.get(field) or "").lower() for field in SearchField.values()) + ] + + +class UserAssignmentsOrderingFilter(BaseFilterBackend): + """Sort user assignments by a given field and order.""" + + def filter_queryset(self, request, queryset, view): + sort_by = request.query_params.get("sort_by", UserAssignmentSortField.FULL_NAME) + order = request.query_params.get("order", SortOrder.ASC) + return sort_user_assignments(assignments=queryset, sort_by=sort_by, order=order) diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index 822f813a..e095f12e 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -5,7 +5,7 @@ from openedx_authz import api from openedx_authz.api.data import UserAssignments -from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder +from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder, UserAssignmentSortField from openedx_authz.rest_api.utils import get_generic_scope from openedx_authz.rest_api.v1.fields import ( CaseSensitiveCommaSeparatedListField, @@ -346,3 +346,36 @@ def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssig return None case api.RoleAssignmentData(): return len(obj.roles[0].permissions) if obj.roles else 0 + + +class TeamMemberUserAssignmentSerializer(TeamMemberAssignmentSerializer): # pylint: disable=abstract-method + """Serializer for team member assignments with user information.""" + + full_name = serializers.SerializerMethodField() + username = serializers.SerializerMethodField() + email = serializers.SerializerMethodField() + + def get_full_name(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str: + """Get user full name.""" + return obj.user.get_full_name() if obj.user else "" + + def get_username(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str: + """Get username.""" + return obj.user.username if obj.user else "" + + def get_email(self, obj: api.UserAssignmentData | api.SuperAdminAssignmentData) -> str: + """Get user email.""" + return obj.user.email if obj.user else "" + + +class ListAssignmentsQuerySerializer(ListTeamMemberAssignmentsQuerySerializer): # pylint: disable=abstract-method + """Serializer for query params for the list all team member assignments endpoint.""" + + search = LowercaseCharField(required=False, default=None) + scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[]) + # Overriding sort_by from OrderMixin due to different choices and default value + sort_by = serializers.ChoiceField( + required=False, + choices=[(e.value, e.name) for e in UserAssignmentSortField], + default=UserAssignmentSortField.FULL_NAME, + ) diff --git a/openedx_authz/rest_api/v1/urls.py b/openedx_authz/rest_api/v1/urls.py index c2608145..4bb1f896 100644 --- a/openedx_authz/rest_api/v1/urls.py +++ b/openedx_authz/rest_api/v1/urls.py @@ -18,4 +18,5 @@ path( "users//assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list" ), + path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"), ] diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 661a4b54..766601ab 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -20,7 +20,7 @@ from rest_framework.views import APIView from openedx_authz import api -from openedx_authz.api.data import RoleAssignmentData, SuperAdminAssignmentData +from openedx_authz.api.data import RoleAssignmentData, SuperAdminAssignmentData, UserAssignmentData from openedx_authz.api.users import ( get_superadmin_assignments, get_visible_user_role_assignments_filtered_by_current_user, @@ -38,11 +38,14 @@ TeamMemberAssignmentsOrderingFilter, TeamMemberOrderingFilter, TeamMemberSearchFilter, + UserAssignmentsOrderingFilter, + UserAssignmentsSearchFilter, ) from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission from openedx_authz.rest_api.v1.serializers import ( AddUsersToRoleWithScopeSerializer, + ListAssignmentsQuerySerializer, ListRolesWithScopeResponseSerializer, ListRolesWithScopeSerializer, ListTeamMemberAssignmentsQuerySerializer, @@ -53,6 +56,7 @@ RemoveUsersFromRoleWithScopeSerializer, TeamMemberAssignmentSerializer, TeamMemberSerializer, + TeamMemberUserAssignmentSerializer, UserRoleAssignmentSerializer, UserValidationAPIViewResponseSerializer, UserValidationAPIViewSerializer, @@ -492,6 +496,7 @@ def get(self, request: HttpRequest) -> Response: responses={ status.HTTP_200_OK: OrganizationSerializer(many=True), status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", }, ) class AdminConsoleOrgsAPIView(generics.ListAPIView): @@ -621,6 +626,7 @@ class TeamMembersAPIView(APIView): pagination_class = AuthZAPIViewPagination filter_backends = [TeamMemberSearchFilter, TeamMemberOrderingFilter] + permission_classes = [AnyScopePermission] @apidocs.schema( parameters=[ @@ -633,11 +639,18 @@ class TeamMembersAPIView(APIView): apidocs.query_parameter("page_size", int, description="Number of items per page"), ], responses={ - status.HTTP_200_OK: ListRolesWithScopeResponseSerializer(many=True), + status.HTTP_200_OK: TeamMemberSerializer(many=True), status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", }, ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) def get(self, request: HttpRequest) -> Response: """Retrieve all users that have at least one assignation according to the filtering fields.""" serializer = ListTeamMembersSerializer(data=request.query_params) @@ -813,6 +826,7 @@ class TeamMemberAssignmentsAPIView(APIView): pagination_class = AuthZAPIViewPagination filter_backends = [TeamMemberAssignmentsOrderingFilter] + permission_classes = [AnyScopePermission] @apidocs.schema( parameters=[ @@ -833,8 +847,15 @@ class TeamMemberAssignmentsAPIView(APIView): status.HTTP_200_OK: TeamMemberAssignmentSerializer(many=True), status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", }, ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) def get(self, request: HttpRequest, username: str) -> Response: """Retrieve all user role assignments.""" serializer = ListTeamMemberAssignmentsQuerySerializer(data=request.query_params) @@ -861,3 +882,148 @@ def get(self, request: HttpRequest, username: str) -> Response: paginator = self.pagination_class() paginated_response_data = paginator.paginate_queryset(assignments, request) return paginator.get_paginated_response(paginated_response_data) + + +@view_auth_classes() +class AssignmentsAPIView(APIView): + """ + API view for listing all user role assignments + This API is used on the main team members view on the Admin Console. + + **Endpoints** + + - GET: Retrieve all user role assignments + + **Query Parameters** + + - orgs (Optional): Comma-separated list of orgs to filter assignments by + - roles (Optional): Comma-separated list of roles to filter assignments by + - scopes (Optional): Comma-separated list of scopes to filter assignments by + - search (Optional): Search term to filter assignments by full_name, username, or email + - sort_by (Optional): Field to sort by. Options: role, org, scope, full_name, username, email. Defaults to full_name + - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of user assignment objects, each containing: + + - is_superadmin: whether this entry denotes a superadmin + - role: The role + - org: The org over which this role is applied + - scope: The scope over which this role is applied + - permission_count: The number of permissions that apply to this role + - full_name: The full name of the user in this assignment + - username: The username of the user in this assignment + - email: The email of the user in this assignment + + **Authentication and Permissions** + + - Requires authenticated user. + - Results are filtered according to calling user's "view scope team members" permissions. + + **Example Request** + + GET /api/authz/v1/assignments/?order=desc&sort_by=role&page=1&page_size=2&search=cont + + **Example Response**:: + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "is_superadmin": false, + "role": "course_staff", + "org": "OpenedX", + "scope": "course-v1:OpenedX+DemoX+DemoCourse", + "permission_count": 27, + "full_name": "", + "username": "contributor", + "email": "contributor@example.com" + }, + { + "is_superadmin": true, + "role": "django.superuser", + "org": "*", + "scope": "*", + "permission_count": null, + "full_name": "", + "username": "admin", + "email": "admin@example.com" + }, + ] + } + """ + + pagination_class = AuthZAPIViewPagination + filter_backends = [UserAssignmentsSearchFilter, UserAssignmentsOrderingFilter] + permission_classes = [AnyScopePermission] + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), + apidocs.query_parameter("roles", str, description="The roles to query assignments for"), + apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), + apidocs.query_parameter( + "search", str, description="The search query to filter assignments by full_name, username, or email" + ), + apidocs.query_parameter( + "sort_by", + str, + description="The field to sort by. Options: role, org, scope, full_name, username, email", + ), + apidocs.query_parameter("order", str, description="The order to sort by"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: TeamMemberUserAssignmentSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, + ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) + def get(self, request: HttpRequest) -> Response: + """Retrieve all user role assignments.""" + serializer = ListAssignmentsQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query_params = serializer.validated_data + + user_role_assignments: list[UserAssignmentData | SuperAdminAssignmentData] = [] + + # Retrieve superadmin assignments (django staff or superuser users), as they always have access to everything + user_role_assignments += get_superadmin_assignments() + + users_with_assignments = api.get_visible_role_assignments_for_user( + orgs=query_params.get("orgs"), + scopes=query_params.get("scopes"), + roles=query_params.get("roles"), + allowed_for_user_external_key=request.user.username, + ) + + # Unpack list of UserAssignments to a list of UserAssignmentData + for uwa in users_with_assignments: + user_role_assignments += [ + UserAssignmentData( + user=uwa.user, subject=assignment.subject, roles=assignment.roles, scope=assignment.scope + ) + for assignment in uwa.assignments + ] + + assignments = TeamMemberUserAssignmentSerializer(user_role_assignments, many=True).data + for backend in self.filter_backends: + assignments = backend().filter_queryset(request, assignments, self) + + # Paginate + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(assignments, request) + return paginator.get_paginated_response(paginated_response_data) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 3cbcb538..713cc628 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -26,17 +26,6 @@ User = get_user_model() -def get_user_map_without_profile(usernames: list[str]) -> dict[str, User]: - """ - Test version of ``get_user_map`` that doesn't use select_related('profile'). - - The generic Django User model doesn't have a profile relation, - so we override this in tests to avoid FieldError. - """ - users = User.objects.filter(username__in=usernames) - return {user.username: user for user in users} - - class ViewTestMixin(BaseRolesTestCase): """Mixin providing common test utilities for view tests.""" @@ -322,11 +311,6 @@ def setUp(self): super().setUp() self.client.force_authenticate(user=self.admin_user) self.url = reverse("openedx_authz:role-user-list") - self.get_user_map_patcher = patch( - "openedx_authz.rest_api.v1.views.get_user_map", - side_effect=get_user_map_without_profile, - ) - self.get_user_map_patcher.start() @data( # All users @@ -1076,12 +1060,6 @@ def setUp(self): """Set up test fixtures.""" super().setUp() self.url = reverse("openedx_authz:user-list") - self.get_user_map_patcher = patch( - "openedx_authz.api.utils.get_user_map", - side_effect=get_user_map_without_profile, - ) - self.get_user_map_patcher.start() - self.addCleanup(self.get_user_map_patcher.stop) # -------------------------------------------------------------------- # # Visibility: calling user only sees assignments it has view access to # @@ -1089,31 +1067,34 @@ def setUp(self): @data( # Staff/superuser sees all users across all scopes - ("admin_1", 11), + ("admin_1", status.HTTP_200_OK, 11), # regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY_TEAM granted) → sees only Org1 members - ("regular_1", 3), + ("regular_1", status.HTTP_200_OK, 3), # regular_3 has LIBRARY_USER in lib:Org2:LIB2 (VIEW_LIBRARY_TEAM granted) → sees only Org2 members - ("regular_3", 3), + ("regular_3", status.HTTP_200_OK, 3), # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 (VIEW_LIBRARY_TEAM granted) → sees only Org3 members - ("regular_6", 5), - # regular_9 has no assignments → sees nothing - ("regular_9", 0), + ("regular_6", status.HTTP_200_OK, 5), + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", status.HTTP_403_FORBIDDEN, None), ) @unpack - def test_visibility_limited_to_accessible_scopes(self, username: str, expected_count: int): + def test_visibility_limited_to_accessible_scopes( + self, username: str, expected_status: int, expected_count: int | None + ): """Calling user only sees assignments for scopes it has VIEW_*_TEAM access to. Expected result: - Staff/superuser sees all users across all scopes. - Regular users only see members of scopes they have VIEW_*_TEAM permission for. - - Users with no assignments see no results. + - Users with no relevant permissions get 403. """ user = User.objects.get(username=username) self.client.force_authenticate(user=user) response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) def test_unauthenticated_returns_401(self): """Unauthenticated requests are rejected. @@ -1333,20 +1314,10 @@ class TestTeamMemberAssignmentsAPIView(ViewTestMixin): entry when the target is a superadmin. - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 role assignments, plus the superadmin entry when the target is a superadmin. - - regular_9 (no assignments): sees no role assignments for any user, but still - sees the superadmin entry when the target is a superadmin. + - regular_9 (no assignments): rejected with 403 by AnyScopePermission + (requires at least one VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission). """ - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.get_user_map_patcher = patch( - "openedx_authz.api.utils.get_user_map", - side_effect=get_user_map_without_profile, - ) - self.get_user_map_patcher.start() - self.addCleanup(self.get_user_map_patcher.stop) - def _url(self, username: str) -> str: return reverse("openedx_authz:user-assignment-list", kwargs={"username": username}) @@ -1356,20 +1327,27 @@ def _url(self, username: str) -> str: @data( # Staff/superuser targets get 1 superadmin entry + their role assignment(s) - ("admin_1", "admin_1", 2), # superadmin entry + library_admin in Org1 - ("admin_1", "admin_2", 2), # superadmin entry + library_user in Org2 - ("admin_1", "admin_3", 2), # superadmin entry + library_admin in Org3 + ("admin_1", "admin_1", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org1 + ("admin_1", "admin_2", status.HTTP_200_OK, 2), # superadmin entry + library_user in Org2 + ("admin_1", "admin_3", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org3 # Regular user targets get only their role assignments (no superadmin entry) - ("admin_1", "regular_5", 1), + ("admin_1", "regular_5", status.HTTP_200_OK, 1), # The superadmin entry is always included for superadmin targets, visible to all callers - ("regular_1", "admin_1", 2), # superadmin entry + library_admin in Org1 (visible via Org1 access) + ( + "regular_1", + "admin_1", + status.HTTP_200_OK, + 2, + ), # superadmin entry + library_admin in Org1 (visible via Org1 access) # regular_1 cannot see admin_2's Org2 role assignment, but superadmin entry is still included - ("regular_1", "admin_2", 1), # superadmin entry only - # regular_9 has no assignments but superadmin entry is still included for admin targets - ("regular_9", "admin_1", 1), # superadmin entry only + ("regular_1", "admin_2", status.HTTP_200_OK, 1), # superadmin entry only + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", "admin_1", status.HTTP_403_FORBIDDEN, None), ) @unpack - def test_visibility_limited_to_accessible_scopes(self, caller: str, target: str, expected_count: int): + def test_visibility_limited_to_accessible_scopes( + self, caller: str, target: str, expected_status: int, expected_count: int | None + ): """Calling user only sees role assignments for scopes it has view access to. The superadmin entry is always included when the target is a superadmin, @@ -1379,13 +1357,15 @@ def test_visibility_limited_to_accessible_scopes(self, caller: str, target: str, - Superadmin targets always include the superadmin entry. - Role assignments are filtered by the calling user's permissions. - Regular user targets return only their visible role assignments. + - Users with no relevant permissions get 403. """ self.client.force_authenticate(user=User.objects.get(username=caller)) response = self.client.get(self._url(target)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) def test_unauthenticated_returns_401(self): """Unauthenticated requests are rejected. @@ -2049,3 +2029,900 @@ def test_post_empty_role_assignments_denied(self): request_data = {"users": ["admin_1", "regular_1"]} response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@ddt +class TestAssignmentsAPIView(ViewTestMixin): + """ + Test suite for AssignmentsAPIView. + + Setup summary (from ViewTestMixin.setUpClass): + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + URL: /api/authz/v1/assignments/ + Response fields per item: is_superadmin, role, org, scope, permission_count, full_name, username, email + + This endpoint returns one row per (user, assignment) pair — i.e. assignments are + "unpacked" so each row carries user info alongside the assignment fields. + + Superadmin entries: + admin_1..3 are staff/superusers. get_superadmin_assignments() (called without + user_external_keys filter) returns one SuperAdminAssignmentData per superadmin. + These entries always appear regardless of org/role/scope filters, since those + filters are applied only to the role assignments fetched separately. + + Total rows when called by a staff user with no filters: + 3 superadmin entries (admin_1, admin_2, admin_3) + + 11 role assignments (see setup above) + = 14 rows + + Visibility via get_visible_role_assignments_for_user: + - Staff/superuser: sees all role assignments across all scopes. + - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 assignments (3). + - regular_9 (no assignments): sees no role assignments. + Superadmin entries are always included for all callers. + """ + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:assignment-list") + + # -------------------------------------------------------------------- # + # Visibility: calling user only sees assignments it has view access to # + # -------------------------------------------------------------------- # + + @data( + # Staff/superuser sees all: 3 superadmin entries + 11 role assignments = 14 + ("admin_1", status.HTTP_200_OK, 14), + # regular_1 has LIBRARY_USER in lib:Org1:LIB1 → sees 3 Org1 role assignments + 3 superadmin entries = 6 + ("regular_1", status.HTTP_200_OK, 6), + # regular_3 has LIBRARY_USER in lib:Org2:LIB2 → sees 3 Org2 role assignments + 3 superadmin entries = 6 + ("regular_3", status.HTTP_200_OK, 6), + # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 → sees 5 Org3 role assignments + 3 superadmin entries = 8 + ("regular_6", status.HTTP_200_OK, 8), + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", status.HTTP_403_FORBIDDEN, None), + ) + @unpack + def test_visibility_limited_to_accessible_scopes( + self, username: str, expected_status: int, expected_count: int | None + ): + """Calling user only sees role assignments for scopes it has view access to. + + Superadmin entries are always included regardless of the calling user's permissions. + Users with no VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission in any scope + are rejected with 403 by AnyScopePermission. + + Expected result: + - Staff/superuser sees all role assignments plus superadmin entries. + - Regular users see only assignments for their accessible scopes plus superadmin entries. + - Users with no relevant permissions get 403. + """ + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) + + def test_unauthenticated_returns_401(self): + """Unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ------------------------------------------------------------------ # + # Filter by orgs # + # ------------------------------------------------------------------ # + + @data( + # Single org: only role assignments in that org + 3 superadmin entries + ("Org1", 6), # 3 Org1 role assignments + 3 superadmin entries + ("Org2", 6), # 3 Org2 role assignments + 3 superadmin entries + ("Org3", 8), # 5 Org3 role assignments + 3 superadmin entries + # Non-existent org: only superadmin entries + ("OrgX", 3), + ) + @unpack + def test_filter_by_orgs(self, orgs: str, expected_count: int): + """Results are filtered to the requested orgs. + + Superadmin entries are always included regardless of org filter. + + Expected result: + - Only role assignments in the given org(s) are returned, plus superadmin entries. + """ + response = self.client.get(self.url, {"orgs": orgs}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_orgs(self): + """Multiple orgs are OR-combined. + + Expected result: + - Returns role assignments matching any of the given orgs, plus superadmin entries. + """ + # Org1 has 3 role assignments, Org2 has 3 → 6 role assignments + 3 superadmin = 9 + response = self.client.get(self.url, {"orgs": "Org1,Org2"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 9) + + # ------------------------------------------------------------------ # + # Filter by roles # + # ------------------------------------------------------------------ # + + @data( + # library_admin: admin_1 (Org1), admin_3 (Org3), regular_5 (Org3) = 3 + 3 superadmin = 6 + (roles.LIBRARY_ADMIN.external_key, 6), + # library_user: admin_2 (Org2), regular_1 (Org1), regular_2 (Org1), + # regular_3 (Org2), regular_4 (Org2), regular_8 (Org3) = 6 + 3 superadmin = 9 + (roles.LIBRARY_USER.external_key, 9), + # library_author: regular_6 (Org3) = 1 + 3 superadmin = 4 + (roles.LIBRARY_AUTHOR.external_key, 4), + # library_contributor: regular_7 (Org3) = 1 + 3 superadmin = 4 + ("library_contributor", 4), + # Non-existent role: only superadmin entries + ("non_existent_role", 3), + ) + @unpack + def test_filter_by_roles(self, role_filter: str, expected_count: int): + """Results are filtered to the requested roles. + + Superadmin entries are always included regardless of role filter. + + Expected result: + - Only role assignments with the given role(s) are returned, plus superadmin entries. + """ + response = self.client.get(self.url, {"roles": role_filter}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_roles(self): + """Multiple roles are OR-combined. + + Expected result: + - Returns role assignments matching any of the given roles, plus superadmin entries. + """ + # library_admin (3) + library_author (1) = 4 role assignments + 3 superadmin = 7 + response = self.client.get( + self.url, + {"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 7) + + # ------------------------------------------------------------------ # + # Filter by scopes # + # ------------------------------------------------------------------ # + + @data( + # Single scope + ("lib:Org1:LIB1", 6), # 3 Org1 role assignments + 3 superadmin entries + ("lib:Org2:LIB2", 6), # 3 Org2 role assignments + 3 superadmin entries + ("lib:Org3:LIB3", 8), # 5 Org3 role assignments + 3 superadmin entries + # Non-existent scope: only superadmin entries + ("lib:Org99:NOLIB", 3), + ) + @unpack + def test_filter_by_scopes(self, scopes: str, expected_count: int): + """Results are filtered to the requested scopes. + + Superadmin entries are always included regardless of scope filter. + + Expected result: + - Only role assignments in the given scope(s) are returned, plus superadmin entries. + """ + response = self.client.get(self.url, {"scopes": scopes}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_scopes(self): + """Multiple scopes are OR-combined. + + Expected result: + - Returns role assignments matching any of the given scopes, plus superadmin entries. + """ + # Org1 (3) + Org2 (3) = 6 role assignments + 3 superadmin = 9 + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1,lib:Org2:LIB2"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 9) + + # ------------------------------------------------------------------ # + # Search (full_name, username, email) # + # ------------------------------------------------------------------ # + + @data( + # Exact username match — admin_1 has 1 superadmin entry + 1 role assignment = 2 + ("admin_1", 2), + # Partial username match — "admin" matches admin_1, admin_2, admin_3 + # Each has 1 superadmin entry + 1 role assignment = 6 + ("admin", 6), + # Partial username match — "regular" matches regular_1..8 (8 role assignments, no superadmin entries) + ("regular", 8), + # Email match + ("admin_1@example.com", 2), + # Partial email match — all users have @example.com + ("@example.com", 14), + # No match + ("nonexistent", 0), + ) + @unpack + def test_search(self, search: str, expected_count: int): + """Search filters by full_name, username, or email (case-insensitive). + + Expected result: + - Returns only assignments whose user's full_name, username, or email + contains the search term. + """ + response = self.client.get(self.url, {"search": search}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_search_case_insensitive(self): + """Search is case-insensitive. + + Expected result: + - Uppercase and lowercase search terms return the same results. + """ + response_lower = self.client.get(self.url, {"search": "admin_1"}) + response_upper = self.client.get(self.url, {"search": "ADMIN_1"}) + + self.assertEqual(response_lower.status_code, status.HTTP_200_OK) + self.assertEqual(response_upper.status_code, status.HTTP_200_OK) + self.assertEqual(response_lower.data["count"], response_upper.data["count"]) + + # ------------------------------------------------------------------ # + # Sorting # + # ------------------------------------------------------------------ # + + @data( + ("role", "asc"), + ("role", "desc"), + ("org", "asc"), + ("org", "desc"), + ("scope", "asc"), + ("scope", "desc"), + ("full_name", "asc"), + ("full_name", "desc"), + ("username", "asc"), + ("username", "desc"), + ("email", "asc"), + ("email", "desc"), + ) + @unpack + def test_sorting(self, sort_by: str, order: str): + """Results can be sorted by role, org, scope, full_name, username, or email. + + Expected result: + - Returns 200 OK. + - Results are ordered according to the requested field and direction. + """ + response = self.client.get(self.url, {"sort_by": sort_by, "order": order}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreater(len(response.data["results"]), 1) + values = [item[sort_by] for item in response.data["results"]] + expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") + self.assertEqual(values, expected) + + @data( + {"sort_by": "invalid"}, + {"sort_by": "permission_count"}, + {"order": "ascending"}, + {"order": "descending"}, + ) + def test_sorting_invalid_params(self, query_params: dict): + """Invalid sort_by or order values return 400. + + Expected result: + - Returns 400 BAD REQUEST. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ------------------------------------------------------------------ # + # Pagination # + # ------------------------------------------------------------------ # + + @data( + # Total is 14 (3 superadmin + 11 role assignments) + ({"page": 1, "page_size": 5}, 5, True), + ({"page": 2, "page_size": 5}, 5, True), + ({"page": 3, "page_size": 5}, 4, False), + ({"page": 1, "page_size": 14}, 14, False), + ({"page": 1, "page_size": 7}, 7, True), + ({"page": 2, "page_size": 7}, 7, False), + ) + @unpack + def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): + """Results are paginated correctly. + + Expected result: + - Returns 200 OK. + - Page contains the expected number of items. + - `next` link is present only when more pages exist. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 14) + self.assertEqual(len(response.data["results"]), expected_page_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + # ------------------------------------------------------------------ # + # Response shape # + # ------------------------------------------------------------------ # + + def test_response_shape(self): + """Each result item contains the expected fields. + + Expected result: + - Returns 200 OK. + - Each item has is_superadmin, role, org, scope, permission_count, + full_name, username, and email. + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_fields = { + "is_superadmin", + "role", + "org", + "scope", + "permission_count", + "full_name", + "username", + "email", + } + for item in response.data["results"]: + self.assertEqual(set(item.keys()), expected_fields) + + def test_response_shape_superadmin_entry(self): + """Superadmin entries have the expected field values. + + Expected result: + - Superadmin entries have role in ("django.superuser", "django.staff"), + org="*", scope="*", permission_count=None, is_superadmin=True, + and populated username/email fields. + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 3) + for item in superadmin_items: + self.assertIn(item["role"], ("django.superuser", "django.staff")) + self.assertEqual(item["org"], "*") + self.assertEqual(item["scope"], "*") + self.assertIsNone(item["permission_count"]) + self.assertTrue(item["username"]) + self.assertTrue(item["email"]) + + def test_response_shape_role_assignment_entry(self): + """Role assignment entries have the expected field values. + + Expected result: + - Role assignment entries have is_superadmin=False, concrete role/org/scope + values, a non-null permission_count, and populated user fields. + """ + # Filter to a single scope to get predictable results + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + role_items = [item for item in response.data["results"] if not item["is_superadmin"]] + self.assertGreater(len(role_items), 0) + for item in role_items: + self.assertFalse(item["is_superadmin"]) + self.assertIn("role", item) + self.assertEqual(item["org"], "Org1") + self.assertEqual(item["scope"], "lib:Org1:LIB1") + self.assertIsNotNone(item["permission_count"]) + self.assertGreater(item["permission_count"], 0) + self.assertTrue(item["username"]) + self.assertTrue(item["email"]) + + # ------------------------------------------------------------------ # + # Superadmin special cases # + # ------------------------------------------------------------------ # + + def test_superadmin_entries_always_present(self): + """Superadmin entries are always included regardless of filters. + + Expected result: + - Even with a non-matching org filter, superadmin entries are returned. + """ + response = self.client.get(self.url, {"orgs": "NonExistentOrg"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 3) + + def test_superadmin_entries_not_filtered_by_roles(self): + """Superadmin entries are not affected by role filters. + + Expected result: + - Filtering by a specific role still returns all superadmin entries. + """ + response = self.client.get(self.url, {"roles": roles.LIBRARY_ADMIN.external_key}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 3) + + def test_superadmin_entries_not_filtered_by_scopes(self): + """Superadmin entries are not affected by scope filters. + + Expected result: + - Filtering by a specific scope still returns all superadmin entries. + """ + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 3) + + def test_superadmin_entries_searchable(self): + """Superadmin entries are searchable by username. + + Expected result: + - Searching for a superadmin username returns their entries. + """ + response = self.client.get(self.url, {"search": "admin_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # admin_1 has 1 superadmin entry + 1 role assignment = 2 + self.assertEqual(response.data["count"], 2) + usernames = {item["username"] for item in response.data["results"]} + self.assertEqual(usernames, {"admin_1"}) + + def test_unprivileged_user_gets_403(self): + """A user with no relevant permissions is rejected by AnyScopePermission. + + Expected result: + - Returns 403 FORBIDDEN. + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------ # + # Combined filters # + # ------------------------------------------------------------------ # + + def test_combined_org_and_role_filter(self): + """Org and role filters can be combined. + + Expected result: + - Only role assignments matching both the org and role are returned, + plus superadmin entries. + """ + # library_admin in Org1 = admin_1 (1 assignment) + 3 superadmin = 4 + response = self.client.get( + self.url, + {"orgs": "Org1", "roles": roles.LIBRARY_ADMIN.external_key}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + + def test_combined_scope_and_search(self): + """Scope filter and search can be combined. + + Expected result: + - Results are filtered by scope first, then search is applied. + """ + # Org1 has admin_1, regular_1, regular_2 → 3 role assignments + 3 superadmin = 6 + # Search "regular" matches regular_1, regular_2 → 2 results + response = self.client.get( + self.url, + {"scopes": "lib:Org1:LIB1", "search": "regular"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + + # ------------------------------------------------------------------ # + # Active user filtering # + # ------------------------------------------------------------------ # + + def test_inactive_users_excluded_from_results(self): + """Role assignments for inactive users are not included in results. + + Deactivating a user (is_active=False) should remove their role assignments + from the response, even though the assignments still exist in the database. + Superadmin entries are also excluded for inactive staff/superusers. + + Expected result: + - Returns 200 OK. + - The inactive user's assignments do not appear in the results. + - The total count decreases by the number of assignments the inactive user had. + """ + # Baseline: admin_1 (staff) sees all 14 rows (3 superadmin + 11 role assignments) + baseline_response = self.client.get(self.url) + self.assertEqual(baseline_response.status_code, status.HTTP_200_OK) + baseline_count = baseline_response.data["count"] + + # Deactivate regular_1, who has 1 role assignment in lib:Org1:LIB1 + inactive_user = User.objects.get(username="regular_1") + inactive_user.is_active = False + inactive_user.save() + try: + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # regular_1 had 1 role assignment → count should drop by 1 + self.assertEqual(response.data["count"], baseline_count - 1) + # Confirm regular_1 is not in the results + usernames = {item["username"] for item in response.data["results"]} + self.assertNotIn("regular_1", usernames) + finally: + inactive_user.is_active = True + inactive_user.save() + + +@ddt +class TestAssignmentsAPIViewPermissions(ViewTestMixin): + """ + Test suite for AssignmentsAPIView calling-user permission scenarios. + + This class extends the base ViewTestMixin setup with course-scope assignments + to test cross-scope visibility rules. + + Base setup (from ViewTestMixin.setUpClass) — library scopes only: + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + Additional course-scope assignments (added in this class): + course-v1:Org1+COURSE1+2024 → regular_9 (course_staff), regular_10 (course_auditor) + + Permission model: + - Library scopes require VIEW_LIBRARY_TEAM to be visible. + - Course scopes require COURSES_VIEW_COURSE_TEAM to be visible. + - Superadmins (staff/superuser) bypass all permission checks and see everything. + - Superadmin entries (from get_superadmin_assignments) are always included for all callers. + + Total role assignments after setup: + 11 library assignments + 2 course assignments = 13 role assignments + + 3 superadmin entries (admin_1, admin_2, admin_3) + = 16 total rows for a superadmin caller with no filters. + """ + + @classmethod + def setUpClass(cls): + """Add course-scope assignments on top of the base library assignments.""" + super().setUpClass() + cls._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:Org1+COURSE1+2024", + }, + { + "subject_name": "regular_10", + "role_name": roles.COURSE_AUDITOR.external_key, + "scope_name": "course-v1:Org1+COURSE1+2024", + }, + ] + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:assignment-list") + + # ------------------------------------------------------------------ # + # Superadmin caller # + # ------------------------------------------------------------------ # + + def test_superadmin_sees_all_assignments(self): + """A superadmin caller sees all role assignments across all scope types. + + admin_1 is staff/superuser → bypasses all permission checks. + + Expected result: + - Returns 200 OK. + - Sees all 13 role assignments + 3 superadmin entries = 16 total. + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 16) + + def test_superadmin_sees_library_and_course_assignments(self): + """A superadmin caller sees both library and course scope assignments. + + Expected result: + - Response includes assignments with both lib: and course-v1: scope prefixes. + """ + response = self.client.get(self.url, {"page_size": 100}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + scopes = {item["scope"] for item in non_superadmin_items} + lib_scopes = {s for s in scopes if s.startswith("lib:")} + course_scopes = {s for s in scopes if s.startswith("course-v1:")} + self.assertGreater(len(lib_scopes), 0) + self.assertGreater(len(course_scopes), 0) + + # ------------------------------------------------------------------ # + # No permissions at all # + # ------------------------------------------------------------------ # + + def test_user_without_any_permissions_gets_403(self): + """A user with no role assignments at all is rejected by AnyScopePermission. + + AnyScopePermission requires the user to have at least one of + VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM in any scope. + A user with no assignments has neither, so they get 403. + + Expected result: + - Returns 403 FORBIDDEN. + """ + no_perms_user, _ = User.objects.get_or_create( + username="no_perms_user", + defaults={"email": "no_perms@example.com"}, + ) + self.client.force_authenticate(user=no_perms_user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------ # + # Scoped permissions: courses only # + # ------------------------------------------------------------------ # + + def test_user_with_course_scope_permission_sees_course_assignments(self): + """A user with COURSES_VIEW_COURSE_TEAM on a specific course sees those assignments. + + regular_9 has course_staff in course-v1:Org1+COURSE1+2024. + course_staff includes COURSES_VIEW_COURSE_TEAM. + + Expected result: + - Sees the 2 course assignments in course-v1:Org1+COURSE1+2024 + + 3 superadmin entries = 5 total. + - Does NOT see any library assignments (no VIEW_LIBRARY_TEAM). + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 5) + + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be course assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") + + def test_user_with_course_scope_permission_does_not_see_library_assignments(self): + """A user with only course permissions cannot see library assignments. + + regular_9 has course_staff in course-v1:Org1+COURSE1+2024 but no library roles. + + Expected result: + - No library-scope assignments appear in the results. + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + self.assertEqual(len(lib_items), 0) + + # ------------------------------------------------------------------ # + # Scoped permissions: libraries only # + # ------------------------------------------------------------------ # + + def test_user_with_library_scope_permission_sees_library_assignments(self): + """A user with VIEW_LIBRARY_TEAM on a specific library sees those assignments. + + regular_1 has library_user in lib:Org1:LIB1. + library_user includes VIEW_LIBRARY_TEAM. + + Expected result: + - Sees the 3 library assignments in lib:Org1:LIB1 + + 3 superadmin entries = 6 total. + - Does NOT see any course assignments (no COURSES_VIEW_COURSE_TEAM). + """ + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be library assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("lib:"), f"Expected library scope, got {item['scope']}") + + def test_user_with_library_scope_permission_does_not_see_course_assignments(self): + """A user with only library permissions cannot see course assignments. + + regular_1 has library_user in lib:Org1:LIB1 but no course roles. + + Expected result: + - No course-scope assignments appear in the results. + """ + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] + self.assertEqual(len(course_items), 0) + + # ------------------------------------------------------------------ # + # Org-level permissions: courses # + # ------------------------------------------------------------------ # + + def test_user_with_org_course_permission_sees_org_course_assignments(self): + """A user with course_staff at org level sees all course assignments in that org. + + Assign regular_10 course_staff at org-level glob course-v1:Org1+* so they + can see all course assignments in Org1. + + Expected result: + - Sees course assignments in Org1 + superadmin entries. + - Does NOT see library assignments. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_10", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": "course-v1:Org1+*", + }, + ] + ) + user = User.objects.get(username="regular_10") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be course assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") + # Should not see any library assignments + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + self.assertEqual(len(lib_items), 0) + + # ------------------------------------------------------------------ # + # Org-level permissions: libraries # + # ------------------------------------------------------------------ # + + def test_user_with_org_library_permission_sees_org_library_assignments(self): + """A user with library_user at org level sees all library assignments in that org. + + Assign regular_9 library_user at org-level glob lib:Org1:* so they + can see all library assignments in Org1, in addition to their existing + course assignments. + + Expected result: + - Sees library assignments in Org1 + course assignments + superadmin entries. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] + # Should see library assignments in Org1 (3 assignments) + self.assertGreater(len(lib_items), 0) + for item in lib_items: + self.assertEqual(item["org"], "Org1") + # Should also see course assignments (from their existing course_staff role) + self.assertGreater(len(course_items), 0) + + def test_user_with_org_library_permission_does_not_see_other_org_libraries(self): + """A user with org-level library permission only sees that org's library assignments. + + Assign regular_9 library_user at org-level glob lib:Org1:* — they should + NOT see Org2 or Org3 library assignments. + + Expected result: + - Library assignments are limited to Org1. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + lib_orgs = {item["org"] for item in lib_items} + self.assertEqual(lib_orgs, {"Org1"}) + + # ------------------------------------------------------------------ # + # Mixed permissions: both library and course # + # ------------------------------------------------------------------ # + + def test_user_with_both_library_and_course_permissions(self): + """A user with permissions in both library and course scopes sees both. + + Assign regular_9 library_user at lib:Org1:* (in addition to their existing + course_staff at course-v1:Org1+COURSE1+2024). + + Expected result: + - Sees both library and course assignments + superadmin entries. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + scope_types = {item["scope"].split(":")[0] for item in non_superadmin_items} + self.assertIn("lib", scope_types) + self.assertIn("course-v1", scope_types) diff --git a/openedx_authz/tests/stubs/migrations/0002_userprofile.py b/openedx_authz/tests/stubs/migrations/0002_userprofile.py new file mode 100644 index 00000000..84f14102 --- /dev/null +++ b/openedx_authz/tests/stubs/migrations/0002_userprofile.py @@ -0,0 +1,38 @@ +"""Migration to add the UserProfile stub model.""" + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("stubs", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", max_length=255)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/openedx_authz/tests/stubs/models.py b/openedx_authz/tests/stubs/models.py index 20f53c67..317a4410 100644 --- a/openedx_authz/tests/stubs/models.py +++ b/openedx_authz/tests/stubs/models.py @@ -13,6 +13,26 @@ from organizations.models import Organization +class UserProfile(models.Model): + """Stub model mimicking the Open edX UserProfile for testing purposes. + + Provides the ``profile`` reverse relation that ``select_related('profile')`` + expects on the User model, along with the ``name`` field used by serializers. + + .. no_pii: + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="profile", + ) + name = models.CharField(max_length=255, blank=True, default="") + + def __str__(self): + return f"UserProfile({self.user.username})" + + class ContentLibraryManager(models.Manager): """Manager for ContentLibrary model with helper methods."""