From 1ffb2f66cdce5f21666a54d9a422ae7cc34ed904 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 30 Sep 2025 18:12:42 -0500 Subject: [PATCH 01/26] feat: add rest api for roles and permissions --- openedx_authz/api/data.py | 68 ++- openedx_authz/api/permissions.py | 9 +- openedx_authz/api/roles.py | 35 +- openedx_authz/api/users.py | 49 +- openedx_authz/apps.py | 4 +- openedx_authz/rest_api/__init__.py | 0 openedx_authz/rest_api/enums.py | 52 ++ openedx_authz/rest_api/urls.py | 9 + openedx_authz/rest_api/utils.py | 131 +++++ openedx_authz/rest_api/v1/__init__.py | 0 openedx_authz/rest_api/v1/fields.py | 27 + openedx_authz/rest_api/v1/paginators.py | 11 + openedx_authz/rest_api/v1/permissions.py | 57 ++ openedx_authz/rest_api/v1/serializers.py | 182 ++++++ openedx_authz/rest_api/v1/urls.py | 11 + openedx_authz/rest_api/v1/views.py | 384 +++++++++++++ openedx_authz/settings/common.py | 4 +- openedx_authz/settings/test.py | 1 + openedx_authz/tests/rest_api/__init__.py | 0 openedx_authz/tests/rest_api/test_views.py | 637 +++++++++++++++++++++ openedx_authz/urls.py | 14 +- requirements/base.in | 11 +- requirements/base.txt | 82 ++- requirements/dev.txt | 112 +++- requirements/doc.txt | 113 +++- requirements/quality.txt | 115 +++- requirements/test.txt | 127 +++- 27 files changed, 2177 insertions(+), 68 deletions(-) create mode 100644 openedx_authz/rest_api/__init__.py create mode 100644 openedx_authz/rest_api/enums.py create mode 100644 openedx_authz/rest_api/urls.py create mode 100644 openedx_authz/rest_api/utils.py create mode 100644 openedx_authz/rest_api/v1/__init__.py create mode 100644 openedx_authz/rest_api/v1/fields.py create mode 100644 openedx_authz/rest_api/v1/paginators.py create mode 100644 openedx_authz/rest_api/v1/permissions.py create mode 100644 openedx_authz/rest_api/v1/serializers.py create mode 100644 openedx_authz/rest_api/v1/urls.py create mode 100644 openedx_authz/rest_api/v1/views.py create mode 100644 openedx_authz/tests/rest_api/__init__.py create mode 100644 openedx_authz/tests/rest_api/test_views.py diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 17d09804..fde80913 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -1,6 +1,7 @@ """Data classes and enums for representing roles, permissions, and policies.""" import re +from abc import abstractmethod from enum import Enum from typing import ClassVar, Literal, Type @@ -8,6 +9,11 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2 +try: + from openedx.core.djangoapps.content_libraries.models import ContentLibrary +except ImportError: + ContentLibrary = None + __all__ = [ "UserData", "PermissionData", @@ -18,6 +24,7 @@ "RoleData", "ScopeData", "SubjectData", + "ContentLibraryData", ] AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" @@ -249,6 +256,20 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: return scope_subclass + @classmethod + def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]: + """Get all registered scope namespaces. + + Returns: + dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry. + Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'sc'). + + Examples: + >>> ScopeMeta.get_all_namespaces() + {'sc': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData} + """ + return mcs.scope_registry + @classmethod def validate_external_key(mcs, external_key: str) -> bool: """Validate the external_key format for the subclass. @@ -301,6 +322,15 @@ def validate_external_key(cls, _: str) -> bool: """ return True + @abstractmethod + def exists(self) -> bool: + """Check if the scope exists. + + Returns: + bool: True if the scope exists, False otherwise. + """ + raise NotImplementedError("Subclasses must implement exists method.") + @define class ContentLibraryData(ScopeData): @@ -355,6 +385,19 @@ def validate_external_key(cls, external_key: str) -> bool: except InvalidKeyError: return False + def exists(self) -> bool: + """Check if the content library exists. + + Returns: + bool: True if the content library exists, False otherwise. + """ + try: + library_key = LibraryLocatorV2.from_string(self.library_id) + ContentLibrary.objects.get_by_key(library_key=library_key) + return True + except ContentLibrary.DoesNotExist: + return False + def __str__(self): """Human readable string representation of the content library.""" return self.library_id @@ -562,6 +605,15 @@ class PermissionData: action: ActionData = None effect: Literal["allow", "deny"] = "allow" + @property + def identifier(self) -> str: + """Get the permission identifier. + + Returns: + str: The permission identifier (e.g., 'delete_library'). + """ + return self.action.external_key + def __str__(self): """Human readable string representation of the permission and its effect.""" return f"{self.action} - {self.effect}" @@ -571,7 +623,7 @@ def __repr__(self): return f"{self.action.namespaced_key} => {self.effect}" -@define +@define(eq=False) class RoleData(AuthZData): """A role is a named collection of permissions that can be assigned to subjects. @@ -600,6 +652,12 @@ class RoleData(AuthZData): NAMESPACE: ClassVar[str] = "role" permissions: list[PermissionData] = [] + def __eq__(self, other): + """Compare roles based on their namespaced_key.""" + if not isinstance(other, RoleData): + return False + return self.namespaced_key == other.namespaced_key + @property def name(self) -> str: """The human-readable name of the role (e.g., 'Library Admin', 'Course Instructor'). @@ -612,6 +670,14 @@ def name(self) -> str: """ return self.external_key.replace("_", " ").title() + def get_permission_identifiers(self) -> list[str]: + """Get the technical identifiers for all permissions in this role. + + Returns: + list[str]: Permission identifiers (e.g., ['delete_library', 'edit_content']). + """ + return [permission.identifier for permission in self.permissions] + def __str__(self): """Human readable string representation of the role and its permissions.""" return f"{self.name}: {', '.join(str(p) for p in self.permissions)}" diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index e538d2c1..097ebb81 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -42,9 +42,7 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]: Returns: list of PermissionData: A list of PermissionData objects associated with the given scope. """ - actions = enforcer.get_filtered_policy( - PolicyIndex.SCOPE.value, scope.namespaced_key - ) + actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.namespaced_key) return [get_permission_from_policy(action) for action in actions] @@ -63,6 +61,5 @@ def is_subject_allowed( Returns: bool: True if the subject has the specified permission in the scope, False otherwise. """ - return enforcer.enforce( - subject.namespaced_key, action.namespaced_key, scope.namespaced_key - ) + enforcer.load_policy() + return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 26f4461e..c1db6c9f 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -114,6 +114,7 @@ def get_permissions_for_active_roles_in_scope( dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its permissions and scopes. """ + enforcer.load_policy() filtered_policy = enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -145,6 +146,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]: Returns: list[Role]: A list of roles. """ + enforcer.load_policy() policy_filtered = enforcer.get_filtered_policy( PolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -190,6 +192,7 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: Returns: list[list[str]]: A list of policies in the specified scope. """ + enforcer.load_policy() return enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -197,14 +200,19 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: def assign_role_to_subject_in_scope( subject: SubjectData, role: RoleData, scope: ScopeData -) -> None: +) -> bool: """Assign a role to a subject. Args: subject: The ID of the subject. role: The role to assign. + scope: The scope to assign the role to. + + Returns: + bool: True if the role was assigned successfully, False otherwise. """ - enforcer.add_role_for_user_in_domain( + enforcer.load_policy() + return enforcer.add_role_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key, @@ -226,15 +234,19 @@ def batch_assign_role_to_subjects_in_scope( def unassign_role_from_subject_in_scope( subject: SubjectData, role: RoleData, scope: ScopeData -) -> None: +) -> bool: """Unassign a role from a subject. Args: subject: The ID of the subject. role: The role to unassign. scope: The scope from which to unassign the role. + + Returns: + bool: True if the role was unassigned successfully, False otherwise. """ - enforcer.delete_roles_for_user_in_domain( + enforcer.load_policy() + return enforcer.delete_roles_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key ) @@ -291,6 +303,7 @@ def get_subject_role_assignments_in_scope( Returns: list[RoleAssignmentData]: A list of role assignments for the subject in the scope. """ + enforcer.load_policy() # TODO: we still need to get the remaining data for the role like email, etc role_assignments = [] for namespaced_key in enforcer.get_roles_for_user_in_domain( @@ -378,3 +391,17 @@ def get_all_subject_role_assignments_in_scope( ) return list(role_assignments_per_subject.values()) + + +def get_subjects_for_role(role: RoleData) -> list[SubjectData]: + """Get all the subjects assigned to a specific role. + + Args: + role (RoleData): The role to filter subjects. + + Returns: + list[SubjectData]: A list of subjects assigned to the specified role. + """ + enforcer.load_policy() + policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key) + return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies] diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py index 14587ca8..62c20320 100644 --- a/openedx_authz/api/users.py +++ b/openedx_authz/api/users.py @@ -19,6 +19,7 @@ get_subject_role_assignments, get_subject_role_assignments_for_role_in_scope, get_subject_role_assignments_in_scope, + get_subjects_for_role, unassign_role_from_subject_in_scope, ) @@ -32,29 +33,29 @@ "get_user_role_assignments_for_role_in_scope", "get_all_user_role_assignments_in_scope", "is_user_allowed", + "get_users_for_role", ] -def assign_role_to_user_in_scope( - user_external_key: str, role_external_key: str, scope_external_key: str -) -> bool: +def assign_role_to_user_in_scope(user_external_key: str, role_external_key: str, scope_external_key: str) -> bool: """Assign a role to a user in a specific scope. Args: user (str): ID of the user (e.g., 'john_doe'). role_external_key (str): Name of the role to assign. scope (str): Scope in which to assign the role. + + Returns: + bool: True if the role was assigned successfully, False otherwise. """ - assign_role_to_subject_in_scope( + return assign_role_to_subject_in_scope( UserData(external_key=user_external_key), RoleData(external_key=role_external_key), ScopeData(external_key=scope_external_key), ) -def batch_assign_role_to_users_in_scope( - users: list[str], role_external_key: str, scope_external_key: str -): +def batch_assign_role_to_users_in_scope(users: list[str], role_external_key: str, scope_external_key: str): """Assign a role to multiple users in a specific scope. Args: @@ -70,26 +71,25 @@ def batch_assign_role_to_users_in_scope( ) -def unassign_role_from_user( - user_external_key: str, role_external_key: str, scope_external_key: str -): +def unassign_role_from_user(user_external_key: str, role_external_key: str, scope_external_key: str): """Unassign a role from a user in a specific scope. Args: user_external_key (str): ID of the user (e.g., 'john_doe'). role_external_key (str): Name of the role to unassign. scope_external_key (str): Scope in which to unassign the role. + + Returns: + bool: True if the role was unassigned successfully, False otherwise. """ - unassign_role_from_subject_in_scope( + return unassign_role_from_subject_in_scope( UserData(external_key=user_external_key), RoleData(external_key=role_external_key), ScopeData(external_key=scope_external_key), ) -def batch_unassign_role_from_users( - users: list[str], role_external_key: str, scope_external_key: str -): +def batch_unassign_role_from_users(users: list[str], role_external_key: str, scope_external_key: str): """Unassign a role from multiple users in a specific scope. Args: @@ -117,9 +117,7 @@ def get_user_role_assignments(user_external_key: str) -> list[RoleAssignmentData return get_subject_role_assignments(UserData(external_key=user_external_key)) -def get_user_role_assignments_in_scope( - user_external_key: str, scope_external_key: str -) -> list[RoleAssignmentData]: +def get_user_role_assignments_in_scope(user_external_key: str, scope_external_key: str) -> list[RoleAssignmentData]: """Get the roles assigned to a user in a specific scope. Args: @@ -164,9 +162,7 @@ def get_all_user_role_assignments_in_scope( Returns: list[RoleAssignmentData]: A list of user role assignments and all their metadata in the specified scope. """ - return get_all_subject_role_assignments_in_scope( - ScopeData(external_key=scope_external_key) - ) + return get_all_subject_role_assignments_in_scope(ScopeData(external_key=scope_external_key)) def is_user_allowed( @@ -189,3 +185,16 @@ def is_user_allowed( ActionData(external_key=action_external_key), ScopeData(external_key=scope_external_key), ) + + +def get_users_for_role(role_external_key: str) -> list[UserData]: + """Get all the users assigned to a specific role. + + Args: + role_external_key (str): The role to filter users (e.g., 'library_admin'). + + Returns: + list[UserData]: A list of users assigned to the specified role. + """ + users = get_subjects_for_role(RoleData(external_key=role_external_key)) + return [UserData(namespaced_key=user.namespaced_key) for user in users] diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 74dc1df7..7de0d16b 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -17,12 +17,12 @@ class OpenedxAuthzConfig(AppConfig): "url_config": { "lms.djangoapp": { "namespace": "openedx-authz", - "regex": r"^openedx-authz/", + "regex": r"^api/", "relative_path": "urls", }, "cms.djangoapp": { "namespace": "openedx-authz", - "regex": r"^openedx-authz/", + "regex": r"^api/", "relative_path": "urls", }, }, diff --git a/openedx_authz/rest_api/__init__.py b/openedx_authz/rest_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/rest_api/enums.py b/openedx_authz/rest_api/enums.py new file mode 100644 index 00000000..4f6d6ce3 --- /dev/null +++ b/openedx_authz/rest_api/enums.py @@ -0,0 +1,52 @@ +"""Enums for the Open edX AuthZ REST API.""" + +from enum import Enum + + +class BaseEnum(str, Enum): + """Base enum class.""" + + @classmethod + def values(cls): + """List the values of the enum.""" + return [e.value for e in cls] + + +class SortField(BaseEnum): + """Enum for the fields to sort by.""" + + USERNAME = "username" + FULL_NAME = "full_name" + EMAIL = "email" + + +class SortOrder(BaseEnum): + """Enum for the order to sort by.""" + + ASC = "asc" + DESC = "desc" + + +class SearchField(BaseEnum): + """Enum for the fields allowed for text search filtering.""" + + USERNAME = "username" + FULL_NAME = "full_name" + EMAIL = "email" + + +class RoleOperationStatus(BaseEnum): + """Enum for the status of role assignment and removal operations.""" + + ROLE_ADDED = "role_added" + ROLE_REMOVED = "role_removed" + + +class RoleOperationError(BaseEnum): + """Enum for errors that can occur during role assignment and removal operations.""" + + USER_NOT_FOUND = "user_not_found" + USER_ALREADY_HAS_ROLE = "user_already_has_role" + USER_DOES_NOT_HAVE_ROLE = "user_does_not_have_role" + ROLE_ASSIGNMENT_ERROR = "role_assignment_error" + ROLE_REMOVAL_ERROR = "role_removal_error" diff --git a/openedx_authz/rest_api/urls.py b/openedx_authz/rest_api/urls.py new file mode 100644 index 00000000..c46ce08d --- /dev/null +++ b/openedx_authz/rest_api/urls.py @@ -0,0 +1,9 @@ +"""Open edX AuthZ API URLs.""" + +from django.urls import include, path + +from openedx_authz.rest_api.v1 import urls as v1_urls + +urlpatterns = [ + path("v1/", include(v1_urls)), +] diff --git a/openedx_authz/rest_api/utils.py b/openedx_authz/rest_api/utils.py new file mode 100644 index 00000000..4d04483d --- /dev/null +++ b/openedx_authz/rest_api/utils.py @@ -0,0 +1,131 @@ +"""Utility functions for the Open edX AuthZ REST API.""" + +from django.contrib.auth import get_user_model +from django.db.models import Q +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework.permissions import IsAuthenticated + +from openedx_authz.rest_api.enums import SearchField, SortField, SortOrder + +User = get_user_model() + + +def view_auth_classes(is_authenticated=True): + """ + Function and class decorator that abstracts the authentication and permission checks for api views. + """ + + def _decorator(func_or_class): + """ + Requires either OAuth2 or Session-based authentication. + """ + func_or_class.authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + if is_authenticated: + func_or_class.permission_classes.insert(0, IsAuthenticated) + return func_or_class + + return _decorator + + +def get_user_map(usernames: list[str]) -> dict[str, User]: + """ + Retrieve a dictionary mapping usernames to User objects for efficient batch lookups. + + This function performs a single optimized database query to fetch multiple users, + making it ideal for scenarios where we need to look up several users at once + (e.g., when serializing multiple user role assignments). + + Args: + usernames (list[str]): List of usernames to retrieve. Duplicates are automatically + handled by the database query. + + Returns: + 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") + return {user.username: user for user in users} + + +def get_user_by_username_or_email(username_or_email: str) -> User: + """ + Retrieve a user by their username or email address. + + Args: + username_or_email (str): The username or email address to search for. + + Returns: + User: The User object if found and not retired. + + Raises: + User.DoesNotExist: If no user matches the provided username or email, + or if the user has an associated retirement request. + """ + user = User.objects.get(Q(email=username_or_email) | Q(username=username_or_email)) + if hasattr(user, "userretirementrequest"): + raise User.DoesNotExist + return user + + +def sort_users( + users: list[dict], sort_by: SortField = SortField.USERNAME, order: SortOrder = SortOrder.ASC +) -> list[dict]: + """ + Sort users by a given field and order. + + Args: + users (list[dict]): The users to sort. + sort_by (SortField, optional): The field to sort by. Defaults to SortField.USERNAME. + 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 users. + """ + if sort_by not in SortField.values(): + raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {SortField.values()}") + + if order not in SortOrder.values(): + raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}") + + sorted_users = sorted(users, key=lambda user: (user.get(sort_by) or "").lower(), reverse=order == SortOrder.DESC) + return sorted_users + + +def filter_users(users: list[dict], search: str | None, roles: list[str] | None) -> list[dict]: + """ + Filter users by a case-insensitive search string and/or by roles. + + Args: + users (list[dict]): The users to filter. + search (str | None): Optional search term matched against fields in ``SearchField``. + roles (list[str] | None): Optional list of roles; include users that have any of these roles. + + Returns: + list[dict]: The filtered users, preserving the original order. + """ + if not search and not roles: + return users + + filtered_users = [] + for user in users: + if search: + matches_search = any(search in (user.get(field) or "").lower() for field in SearchField.values()) + if not matches_search: + continue + + if roles: + matches_role = any(role in user.get("roles", []) for role in roles) + if not matches_role: + continue + + filtered_users.append(user) + + return filtered_users diff --git a/openedx_authz/rest_api/v1/__init__.py b/openedx_authz/rest_api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/rest_api/v1/fields.py b/openedx_authz/rest_api/v1/fields.py new file mode 100644 index 00000000..c62e9f39 --- /dev/null +++ b/openedx_authz/rest_api/v1/fields.py @@ -0,0 +1,27 @@ +"""Fields serializer for the Open edX AuthZ REST API.""" + +from rest_framework import serializers + + +class CommaSeparatedListField(serializers.CharField): + """Serializer for a comma-separated list of strings.""" + + def to_internal_value(self, data): + """Convert string separated by commas to list""" + return [item.strip().lower() for item in data.split(",") if item.strip()] + + def to_representation(self, value): + """Convert list to string separated by commas""" + return ",".join(value).lower() + + +class LowercaseCharField(serializers.CharField): + """Serializer for a lowercase string.""" + + def to_internal_value(self, data): + """Convert string to lowercase""" + return data.strip().lower() + + def to_representation(self, value): + """Convert string to lowercase""" + return value.strip().lower() diff --git a/openedx_authz/rest_api/v1/paginators.py b/openedx_authz/rest_api/v1/paginators.py new file mode 100644 index 00000000..4a2bbcdf --- /dev/null +++ b/openedx_authz/rest_api/v1/paginators.py @@ -0,0 +1,11 @@ +"""Pagination classes for the REST API.""" + +from rest_framework.pagination import PageNumberPagination + + +class AuthZAPIViewPagination(PageNumberPagination): + """Pagination class for the AuthZ API views.""" + + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py new file mode 100644 index 00000000..7565c68d --- /dev/null +++ b/openedx_authz/rest_api/v1/permissions.py @@ -0,0 +1,57 @@ +"""Permissions for the Open edX AuthZ REST API.""" + +# from rest_framework import serializers +from rest_framework.permissions import SAFE_METHODS, BasePermission + +from openedx_authz import api + +RESOURCE_PERMISSIONS = { + "lib": { + "view": "view_library_team", + "manage": "manage_library_team", + }, + "course": { + "view": "view_course_team", + "manage": "manage_course_team", + }, +} + + +class HasScopedPermission(BasePermission): + """Permission to check if the user has the library permission.""" + + def has_permission(self, request, view): + """ + Check if the user has the appropriate library permission based on the request method. + + For safe methods (GET, HEAD, OPTIONS), checks for 'view_library_team' permission. + For unsafe methods (POST, PUT, PATCH, DELETE), checks for 'manage_library_team' permission. + + Returns: + bool: True if user has the required permission for the scope, False otherwise + + Note: + Requires a 'scope' parameter in either request.data or query_params. + Returns False if no scope is provided. + """ + scope_value = request.data.get("scope") if request.data else request.query_params.get("scope") + if not scope_value: + return False + + try: + scope = api.ScopeData(external_key=scope_value) + except ValueError: + return False + + resource_type = scope.NAMESPACE + perms = RESOURCE_PERMISSIONS.get(resource_type) + + if not perms: + return False + + user = request.user + if user.is_superuser or user.is_staff: + return True + + perm_name = perms["view"] if request.method in SAFE_METHODS else perms["manage"] + return api.is_user_allowed(user.username, perm_name, scope) diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py new file mode 100644 index 00000000..6737d481 --- /dev/null +++ b/openedx_authz/rest_api/v1/serializers.py @@ -0,0 +1,182 @@ +"""Serializers for the Open edX AuthZ REST API.""" + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from openedx_authz import api +from openedx_authz.rest_api.enums import SortField, SortOrder +from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField + +User = get_user_model() + + +class ScopeMixin(serializers.Serializer): # pylint: disable=abstract-method + """Mixin providing scope field functionality.""" + + scope = serializers.CharField(max_length=255) + + +class RoleMixin(serializers.Serializer): # pylint: disable=abstract-method + """Mixin providing role field functionality.""" + + role = serializers.CharField(max_length=255) + + +class ActionMixin(serializers.Serializer): # pylint: disable=abstract-method + """Mixin providing action field functionality.""" + + action = serializers.CharField(max_length=255) + + +class PermissionValidationSerializer(ActionMixin, ScopeMixin): # pylint: disable=abstract-method + """Serializer for permission validation request.""" + + +class PermissionValidationResponseSerializer(PermissionValidationSerializer): # pylint: disable=abstract-method + """Serializer for permission validation response.""" + + allowed = serializers.BooleanField() + + +class RoleScopeValidationMixin(serializers.Serializer): # pylint: disable=abstract-method + """Mixin providing role and scope validation logic.""" + + def validate(self, attrs): + """Validate that role exists in scope.""" + validated_data = super().validate(attrs) + scope_value = validated_data["scope"] + role_value = validated_data["role"] + + try: + scope = api.ScopeData(external_key=scope_value) + except ValueError as exc: + raise serializers.ValidationError(exc) from exc + + if not scope.exists(): + raise serializers.ValidationError(f"Scope '{scope_value}' does not exist") + + role = api.RoleData(external_key=role_value) + general_scope = api.ScopeData(namespaced_key=f"{scope.NAMESPACE}{scope.SEPARATOR}*") + role_definitions = api.get_role_definitions_in_scope(general_scope) + + if role not in role_definitions: + raise serializers.ValidationError(f"Role '{role_value}' does not exist in scope '{scope_value}'") + + return validated_data + + +class AddUsersToRoleWithScopeSerializer( + RoleScopeValidationMixin, + RoleMixin, + ScopeMixin, +): # pylint: disable=abstract-method + """Serializer for adding users to a role with a scope.""" + + users = serializers.ListField(child=serializers.CharField(max_length=255), allow_empty=False) + + +class RemoveUsersFromRoleWithScopeSerializer( + RoleScopeValidationMixin, + RoleMixin, + ScopeMixin, +): # pylint: disable=abstract-method + """Serializer for removing users from a role with a scope.""" + + users = CommaSeparatedListField(allow_blank=False) + + +class ListUsersInRoleWithScopeSerializer(ScopeMixin): # pylint: disable=abstract-method + """Serializer for listing users in a role with a scope.""" + + roles = CommaSeparatedListField(required=False, default=[]) + sort_by = serializers.ChoiceField( + required=False, choices=[(e.value, e.name) for e in SortField], default=SortField.USERNAME + ) + order = serializers.ChoiceField( + required=False, choices=[(e.value, e.name) for e in SortOrder], default=SortOrder.ASC + ) + search = LowercaseCharField(required=False, default=None) + + +class ListRolesWithNamespaceSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for listing roles within a namespace.""" + + namespace = serializers.CharField(max_length=255) + + def validate_namespace(self, value: str) -> api.ScopeData: + """Validate and convert namespace string to a ScopeData instance. + + Checks that the provided namespace is registered in the scope registry and + returns an instance of the appropriate ScopeData subclass with a wildcard + external_key to represent all scopes within that namespace. + + Args: + value: The namespace string to validate (e.g., 'lib', 'sc', 'org'). + + Returns: + ScopeData: An instance of the appropriate ScopeData subclass for the + namespace, initialized with external_key="*". + + Raises: + serializers.ValidationError: If the namespace is not registered in the scope registry. + + Examples: + >>> validate_namespace('lib') + ContentLibraryData(external_key='*') + """ + namespaces = api.ScopeData.get_all_namespaces() + if value not in namespaces: + raise serializers.ValidationError(f"'{value}' is not a valid namespace") + return namespaces[value](external_key="*") + + +class ListUsersInRoleWithScopeResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for listing users in a role with a scope response.""" + + username = serializers.CharField(max_length=255) + full_name = serializers.CharField(max_length=255) + email = serializers.EmailField() + + +class ListRolesWithScopeResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for listing roles with a scope response.""" + + role = serializers.CharField(max_length=255) + permissions = serializers.ListField(child=serializers.CharField(max_length=255)) + user_count = serializers.IntegerField() + + +class UserRoleAssignmentSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for a user role assignment.""" + + username = serializers.SerializerMethodField() + full_name = serializers.SerializerMethodField() + email = serializers.SerializerMethodField() + roles = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._user_cache = {} + + def _get_user(self, obj) -> User | None: + """Get the user object for the given role assignment.""" + user_map = self.context.get("user_map", {}) + return user_map.get(obj.subject.username) + + def get_username(self, obj: api.RoleAssignmentData) -> str: + """Get the username for the given role assignment.""" + return obj.subject.username + + def get_full_name(self, obj) -> str: + """Get the full name for the given role assignment.""" + user = self._get_user(obj) + return getattr(user.profile, "name", "") if user and hasattr(user, "profile") else "" + + def get_email(self, obj) -> str: + """Get the email for the given role assignment.""" + user = self._get_user(obj) + return getattr(user, "email", "") if user else "" + + def get_roles(self, obj: api.RoleAssignmentData) -> list[str]: + """Get the roles for the given role assignment.""" + return [role.external_key for role in obj.roles] diff --git a/openedx_authz/rest_api/v1/urls.py b/openedx_authz/rest_api/v1/urls.py new file mode 100644 index 00000000..83c350f9 --- /dev/null +++ b/openedx_authz/rest_api/v1/urls.py @@ -0,0 +1,11 @@ +"""Open edX AuthZ API v1 URLs.""" + +from django.urls import path + +from openedx_authz.rest_api.v1 import views + +urlpatterns = [ + path("permissions/validate/me", views.PermissionValidationMeView.as_view(), name="permission-validation-me"), + path("roles/", views.RoleListView.as_view(), name="role-list"), + path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"), +] diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py new file mode 100644 index 00000000..3703ab38 --- /dev/null +++ b/openedx_authz/rest_api/v1/views.py @@ -0,0 +1,384 @@ +""" +REST API views for Open edX Authorization (AuthZ) system. + +This module provides Django REST Framework views for managing authorization +permissions, roles, and user assignments within Open edX platform. +""" + +import logging + +import edx_api_doc_tools as apidocs +from django.contrib.auth import get_user_model +from django.http import HttpRequest +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx_authz import api +from openedx_authz.rest_api.enums import RoleOperationError, RoleOperationStatus +from openedx_authz.rest_api.utils import ( + filter_users, + get_user_by_username_or_email, + get_user_map, + sort_users, + view_auth_classes, +) +from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination +from openedx_authz.rest_api.v1.permissions import HasScopedPermission +from openedx_authz.rest_api.v1.serializers import ( + AddUsersToRoleWithScopeSerializer, + ListRolesWithNamespaceSerializer, + ListRolesWithScopeResponseSerializer, + ListUsersInRoleWithScopeSerializer, + PermissionValidationResponseSerializer, + PermissionValidationSerializer, + RemoveUsersFromRoleWithScopeSerializer, + UserRoleAssignmentSerializer, +) + +logger = logging.getLogger(__name__) + +User = get_user_model() + + +@view_auth_classes() +class PermissionValidationMeView(APIView): + """ + API view for validating user permissions against authorization policies. + + This view enables authenticated users to verify their permissions for specific + actions and scopes within the Open edX authorization system. It supports batch + validation of multiple permissions in a single request. + + **Endpoints** + POST: Validate one or more permissions for the authenticated user + + **Request Format** + Expects a list of permission objects, each containing: + - action: The action to validate (e.g., 'edit_library', 'delete_library_content') + - scope: The authorization scope (e.g., 'lib:DemoX:CSPROB') + + **Response Format** + Returns a list of validation results, each containing: + - action: The requested action + - scope: The requested scope + - allowed: Boolean indicating if the user has the permission + + **Authentication and Permissions** + Requires authenticated user. + + **Example Request** + POST /api/authz/v1/permissions/validate/me + [ + {"action": "edit_library", "scope": "lib:DemoX:CSPROB"}, + {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2"} + ] + + **Example Response** + [ + {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": True}, + {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2", "allowed": False} + ] + """ + + @apidocs.schema( + body=PermissionValidationSerializer(help_text="The permissions to validate", many=True), + responses={ + status.HTTP_200_OK: PermissionValidationResponseSerializer, + status.HTTP_400_BAD_REQUEST: "The request data is invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, + ) + def post(self, request: HttpRequest) -> Response: + """Validate one or more permissions for the authenticated user.""" + serializer = PermissionValidationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + + username = request.user.username + + response_data = [] + for perm in serializer.validated_data: + try: + action = perm["action"] + scope = perm["scope"] + allowed = api.is_user_allowed(username, action, scope) + response_data.append( + { + "action": action, + "scope": scope, + "allowed": allowed, + } + ) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Error validating permission for user {username}: {e}") + return Response( + data={"message": "An error occurred while validating permissions"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + serializer = PermissionValidationResponseSerializer(response_data, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +@view_auth_classes() +class RoleUserAPIView(APIView): + """ + API view for managing user-role assignments within specific authorization scopes. + + This view provides comprehensive role management capabilities, allowing administrators + to view, assign, and remove role assignments for users within a given scope. It supports + bulk operations for adding and removing multiple users, along with filtering, searching, + sorting, and pagination of results. + + **Endpoints** + GET: Retrieve all users with their role assignments in a scope + PUT: Assign multiple users to a specific role within a scope + DELETE: Remove multiple users from a specific role within a scope + + **Query Parameters (GET)** + - scope (Required): The authorization scope to query (e.g., 'lib:DemoX:CSPROB') + - search (Optional): Search term to filter users by username or email + - roles (Optional): Filter by specific role names + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + - sort_by (Optional): Field to sort by (e.g., 'username', 'email') + - order (Optional): Sort order ('asc' or 'desc') + + **Request Format (PUT)** + { + "role": "library_admin", + "scope": "lib:DemoX:CSPROB", + "users": ["user1@example.com", "username2"] + } + + **Request Format (DELETE)** + Query parameters: + - users: Comma-separated list of user identifiers + - role: The role to remove users from + - scope: The scope to remove users from + + **Response Format (PUT/DELETE)** + Returns HTTP 207 Multi-Status with: + { + "completed": [{"user_identifier": "john_doe", "status": "role_added|role_removed"}], + "errors": [{"user_identifier": "jane_doe", "error": "error_type"}] + } + + **Authentication and Permissions** + Requires authenticated user. + Requires ``HasLibraryPermission``. Users must have appropriate permissions for the specified scope. + + **Notes** + - User identifiers can be either username or email + - Bulk operations return 207 Multi-Status to indicate partial success + - Individual operation failures are reported in the errors array + """ + + pagination_class = AuthZAPIViewPagination + permission_classes = [HasScopedPermission] + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("scope", str, description="The authorization scope for the role"), + apidocs.query_parameter("search", str, description="The search query to filter users by"), + apidocs.query_parameter("roles", str, description="The names of the roles to query"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + apidocs.query_parameter("sort_by", str, description="The field to sort by"), + apidocs.query_parameter("order", str, description="The order to sort by"), + ], + responses={ + status.HTTP_200_OK: "The users were retrieved successfully", + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, + ) + def get(self, request: HttpRequest) -> Response: + """Retrieve all users with role assignments within a specific scope.""" + serializer = ListUsersInRoleWithScopeSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query_params = serializer.validated_data + + user_role_assignments = api.get_all_user_role_assignments_in_scope(query_params["scope"]) + usernames = {assignment.subject.username for assignment in user_role_assignments} + response_data = UserRoleAssignmentSerializer( + user_role_assignments, many=True, context={"user_map": get_user_map(usernames)} + ).data + + filtered_users = filter_users(response_data, query_params["search"], query_params["roles"]) + user_role_assignments = sort_users(filtered_users, query_params["sort_by"], query_params["order"]) + + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(user_role_assignments, request) + return paginator.get_paginated_response(paginated_response_data) + + @apidocs.schema( + body=AddUsersToRoleWithScopeSerializer, + responses={ + status.HTTP_207_MULTI_STATUS: "The users were added to the role", + status.HTTP_400_BAD_REQUEST: "The request data is invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, + ) + def put(self, request: HttpRequest) -> Response: + """Assign multiple users to a specific role within a scope.""" + serializer = AddUsersToRoleWithScopeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + role_name = serializer.validated_data["role"] + scope = serializer.validated_data["scope"] + + completed, errors = [], [] + for user_identifier in serializer.validated_data["users"]: + response_dict = {"user_identifier": user_identifier} + try: + user = get_user_by_username_or_email(user_identifier) + result = api.assign_role_to_user_in_scope(user.username, role_name, scope) + if result: + response_dict["status"] = RoleOperationStatus.ROLE_ADDED + completed.append(response_dict) + else: + response_dict["error"] = RoleOperationError.USER_ALREADY_HAS_ROLE + errors.append(response_dict) + except User.DoesNotExist: + response_dict["error"] = RoleOperationError.USER_NOT_FOUND + errors.append(response_dict) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Error assigning role to user {user_identifier}: {e}") + response_dict["error"] = RoleOperationError.ROLE_ASSIGNMENT_ERROR + errors.append(response_dict) + + response_data = {"completed": completed, "errors": errors} + return Response(response_data, status=status.HTTP_207_MULTI_STATUS) + + @apidocs.schema( + parameters=[ + apidocs.query_parameter( + "users", str, description="List of user identifiers (username or email) separated by a comma" + ), + apidocs.query_parameter("role", str, description="The role to remove the users from"), + apidocs.query_parameter("scope", str, description="The scope to remove the users from"), + ], + responses={ + status.HTTP_207_MULTI_STATUS: "The users were removed from the role", + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, + ) + def delete(self, request: HttpRequest) -> Response: + """Remove multiple users from a specific role within a scope.""" + serializer = RemoveUsersFromRoleWithScopeSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + user_identifiers = serializer.validated_data["users"] + role_name = serializer.validated_data["role"] + scope = serializer.validated_data["scope"] + + completed, errors = [], [] + for user_identifier in user_identifiers: + response_dict = {"user_identifier": user_identifier} + try: + user = get_user_by_username_or_email(user_identifier) + result = api.unassign_role_from_user(user.username, role_name, scope) + if result: + response_dict["status"] = RoleOperationStatus.ROLE_REMOVED + completed.append(response_dict) + else: + response_dict["error"] = RoleOperationError.USER_DOES_NOT_HAVE_ROLE + errors.append(response_dict) + except User.DoesNotExist: + response_dict["error"] = RoleOperationError.USER_NOT_FOUND + errors.append(response_dict) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Error removing role from user {user_identifier}: {e}") + response_dict["error"] = RoleOperationError.ROLE_REMOVAL_ERROR + errors.append(response_dict) + + response_data = {"completed": completed, "errors": errors} + return Response(response_data, status=status.HTTP_207_MULTI_STATUS) + + +@view_auth_classes() +class RoleListView(APIView): + """API view for retrieving role definitions and their associated permissions within a specific namespace. + + This view provides read-only access to role definitions within a specific + authorization namespace. It returns detailed information about each role including + the permissions granted and the number of users assigned to each role. + + **Endpoints** + GET: Retrieve all roles and their permissions for a specific namespace + + **Query Parameters** + - namespace (Required): The namespace to query roles for (e.g., 'lib') + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + Returns a paginated list of role objects, each containing: + - role: The role's external identifier (e.g., 'library_author', 'library_user') + - permissions: List of permission action keys granted by this role + - user_count: Number of users currently assigned to this role + + **Authentication and Permissions** + Requires authenticated user. + + **Example Request** + GET /api/authz/v1/roles/?namespace=lib&page=1&page_size=10 + + **Example Response** + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "role": "library_author", + "permissions": ["delete_library_content", "edit_library"], + "user_count": 5 + }, + { + "role": "library_user", + "permissions": ["view_library", "view_library_team", "reuse_library_content"], + "user_count": 12 + } + ] + } + """ + + pagination_class = AuthZAPIViewPagination + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("namespace", str, description="The namespace to query roles for"), + 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: ListRolesWithScopeResponseSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + }, + ) + def get(self, request: HttpRequest) -> Response: + """Retrieve all roles and their permissions for a specific namespace.""" + serializer = ListRolesWithNamespaceSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + roles = api.get_role_definitions_in_scope(serializer.validated_data["namespace"]) + response_data = [] + for role in roles: + users = api.get_users_for_role(role.external_key) + response_data.append( + { + "role": role.external_key, + "permissions": role.get_permission_identifiers(), + "user_count": len(users), + } + ) + + serializer = ListRolesWithScopeResponseSerializer(response_data, many=True) + + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(response_data, request) + return paginator.get_paginated_response(paginated_response_data) diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index c7753860..22feabd3 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -22,9 +22,7 @@ def plugin_settings(settings): settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration - settings.CASBIN_MODEL = os.path.join( - ROOT_DIRECTORY, "engine", "config", "model.conf" - ) + settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") settings.CASBIN_WATCHER_ENABLED = True # TODO: Replace with a more dynamic configuration # Redis host and port are temporarily loaded here for the MVP diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index c52856c1..8e2ea3e7 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -57,3 +57,4 @@ SECRET_KEY = "test-secret-key" CASBIN_WATCHER_ENABLED = False USE_TZ = True +ROOT_URLCONF = "openedx_authz.urls" diff --git a/openedx_authz/tests/rest_api/__init__.py b/openedx_authz/tests/rest_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py new file mode 100644 index 00000000..0ae1843f --- /dev/null +++ b/openedx_authz/tests/rest_api/test_views.py @@ -0,0 +1,637 @@ +""" +Unit tests for the Open edX AuthZ REST API views. + +This test suite validates the functionality of the authorization REST API endpoints, +including permission validation, user-role management, and role listing capabilities. +""" + +from unittest.mock import patch +from urllib.parse import urlencode + +from ddt import data, ddt, unpack +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from openedx_authz import api +from openedx_authz.rest_api.enums import RoleOperationError, RoleOperationStatus +from openedx_authz.tests.api.test_users import UserAssignmentsSetupMixin + +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(UserAssignmentsSetupMixin): + """Mixin providing common test utilities for view tests.""" + + @classmethod + def setUpTestData(cls): + """Set up test fixtures once for the entire test class.""" + super().setUpTestData() + # Users with assigned roles + cls.admin_user = User.objects.create_superuser( + username="alice", + email="alice@example.com", + ) + cls.regular_user = User.objects.create_user( + username="bob", + email="bob@example.com", + ) + cls.regular_user2 = User.objects.create_user( + username="carol", + email="carol@example.com", + ) + cls.regular_user3 = User.objects.create_user( + username="ivy", + email="ivy@example.com", + ) + cls.regular_user4 = User.objects.create_user( + username="jack", + email="jack@example.com", + ) + cls.regular_user5 = User.objects.create_user( + username="kate", + email="kate@example.com", + ) + # Users without assigned roles + cls.regular_user7 = User.objects.create_user( + username="zoey", + email="zoey@example.com", + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.client = APIClient() + + +@ddt +class TestPermissionValidationMeView(ViewTestMixin): + """Test suite for PermissionValidationMeView.""" + + @classmethod + def setUpTestData(cls): + """Set up test fixtures.""" + super().setUpTestData() + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.client.force_authenticate(user=self.admin_user) + self.url = reverse("openedx_authz:permission-validation-me") + + @data( + # Single permission - allowed + ([{"action": "view_library", "scope": "lib:Org1:math_101"}], [True]), + # Single permission - denied (invalid scope) + ([{"action": "view_library", "scope": "lib:DemoX:CSPROB"}], [False]), + # Single permission - denied (invalid action) + ([{"action": "edit_library", "scope": "lib:Org1:math_101"}], [False]), + # Multiple permissions - mixed results + ( + [ + {"action": "view_library", "scope": "lib:Org1:math_101"}, + {"action": "view_library", "scope": "lib:DemoX:CSPROB"}, + {"action": "edit_library", "scope": "lib:Org1:math_101"}, + ], + [True, False, False], + ), + ) + @unpack + def test_permission_validation_success(self, request_data: list[dict], permission_map: list[bool]): + """Test successful permission validation requests. + + Expected result: + - Returns 200 OK status + - Returns correct permission validation results + """ + expected_response = request_data.copy() + for idx, perm in enumerate(permission_map): + expected_response[idx]["allowed"] = perm + + response = self.client.post(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, expected_response) + + @data( + # Single permission + [{"action": "edit_library"}], + [{"scope": "lib:Org1:math_101"}], + [{"action": "edit_library", "scope": ""}], + [{"action": "edit_library", "scope": "s" * 256}], + [{"action": "", "scope": "lib:Org1:math_101"}], + [{"action": "a" * 256, "scope": "lib:Org1:math_101"}], + # Multiple permissions + [{}, {}], + [{}, {"action": "edit_library", "scope": "lib:Org1:math_101"}], + [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {}], + [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "", "scope": "lib:Org1:math_101"}], + [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "edit_library", "scope": ""}], + [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"scope": "lib:Org1:math_101"}], + [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "edit_library"}], + ) + def test_permission_validation_invalid_data(self, invalid_data: list[dict]): + """Test permission validation with invalid request data. + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.post(self.url, data=invalid_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_permission_validation_unauthenticated(self): + """Test permission validation without authentication. + + Expected result: + - Returns 401 UNAUTHORIZED status + """ + action = "edit_library" + scope = "lib:DemoX:CSPROB" + self.client.force_authenticate(user=None) + + response = self.client.post(self.url, data=[{"action": action, "scope": scope}], format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @patch.object(api, "is_user_allowed") + def test_permission_validation_exception_handling(self, mock_is_user_allowed): + """Test permission validation when an exception occurs. + + Expected result: + - Returns 500 INTERNAL SERVER ERROR status + - Returns empty response data when exceptions occur + """ + action = "edit_library" + scope = "lib:DemoX:CSPROB" + mock_is_user_allowed.side_effect = Exception() + + response = self.client.post(self.url, data=[{"action": action, "scope": scope}], format="json") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data, {"message": "An error occurred while validating permissions"}) + + +@ddt +class TestRoleUserAPIView(ViewTestMixin): + """Test suite for RoleUserAPIView.""" + + @classmethod + def setUpTestData(cls): + """Set up test fixtures.""" + super().setUpTestData() + + def setUp(self): + """Set up test fixtures.""" + 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 + ({}, 3), + # Search by username + ({"search": "ivy"}, 1), + ({"search": "k"}, 2), + ({"search": "nonexistent"}, 0), + ({"search": "nonexistent"}, 0), + # Search by email + ({"search": "ivy@example.com"}, 1), + ({"search": "@example.com"}, 3), + ({"search": "nonexistent@example.com"}, 0), + # Search by single role + ({"roles": "library_admin"}, 1), + ({"roles": "library_author"}, 1), + ({"roles": "library_user"}, 1), + # Search by multiple roles + ({"roles": "library_admin,library_author"}, 2), + ({"roles": "library_author,library_user"}, 2), + ({"roles": "library_user,library_admin"}, 2), + ({"roles": "library_admin,library_author,library_user"}, 3), + # Search by role and username + ({"search": "ivy", "roles": "library_admin"}, 1), + ({"search": "jack", "roles": "library_admin"}, 0), + # Search by role and email + ({"search": "ivy@example.com", "roles": "library_admin"}, 1), + ({"search": "@example.com", "roles": "library_admin"}, 1), + ({"search": "jack@example.com", "roles": "library_admin"}, 0), + ) + @unpack + def test_get_users_by_scope_success(self, query_params: dict, expected_count: int): + """Test retrieving users with their role assignments in a scope. + + Expected result: + - Returns 200 OK status + - Returns correct user role assignments + """ + query_params["scope"] = "lib:Org3:cs_101" + + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertIn("count", response.data) + self.assertEqual(len(response.data["results"]), expected_count) + self.assertEqual(response.data["count"], expected_count) + + @data( + {}, + {"scope": ""}, + {"scope": "a" * 256}, + {"scope": "lib:DemoX:CSPROB", "sort_by": "invalid"}, + {"scope": "lib:DemoX:CSPROB", "sort_by": "name"}, + {"scope": "lib:DemoX:CSPROB", "order": "ascending"}, + {"scope": "lib:DemoX:CSPROB", "order": "descending"}, + {"scope": "lib:DemoX:CSPROB", "order": "up"}, + {"scope": "lib:DemoX:CSPROB", "order": "down"}, + ) + def test_get_users_by_scope_invalid_params(self, query_params: dict): + """Test retrieving users with invalid query parameters. + + Test cases: + - Missing scope parameter + - Empty scope value + - Scope exceeding max_length (255 chars) + - Invalid sort_by values (not in: username, full_name, email) + - Invalid order values (not in: asc, desc) + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user + ("alice", status.HTTP_200_OK), + # Regular user with permission + ("kate", status.HTTP_200_OK), + # Regular user without permission + ("zoey", status.HTTP_403_FORBIDDEN), + ) + @unpack + def test_get_users_by_scope_permissions(self, username: str, status_code: int): + """Test retrieving users in a role with different user permissions. + + Expected result: + - Returns appropriate status code based on permissions + """ + user = User.objects.filter(username=username).first() + self.client.force_authenticate(user=user) + + response = self.client.get(self.url, {"scope": "lib:Org3:cs_101"}) + + self.assertEqual(response.status_code, status_code) + + @data( + # With username ----------------------------- + # Single user - success (admin user) + (["alice"], 1, 0), + # Single user - success (regular user) + (["bob"], 1, 0), + # Multiple users - success (admin and regular users) + (["alice", "bob", "carol"], 3, 0), + # With email --------------------------------- + # Single user - success (admin user) + (["alice@example.com"], 1, 0), + # Single user - success (regular user) + (["bob@example.com"], 1, 0), + # Multiple users - admin and regular users + (["alice@example.com", "bob@example.com", "carol@example.com"], 3, 0), + # With username and email -------------------- + # All success + (["alice", "bob@example.com", "carol@example.com"], 3, 0), + # Mixed results (user not found) + (["alice", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), + ) + @unpack + def test_add_users_to_role_success(self, users: list[str], expected_completed: int, expected_errors: int): + """Test adding users to a role within a scope. + + Expected result: + - Returns 207 MULTI-STATUS status + - Returns appropriate completed and error counts + """ + role = "library_admin" + request_data = {"role": role, "scope": "lib:DemoX:CSPROB", "users": users} + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(len(response.data["completed"]), expected_completed) + self.assertEqual(len(response.data["errors"]), expected_errors) + + @data( + # Single user - success (admin user) + (["alice"], 0, 1), + # Single user - success (regular user) + (["bob"], 0, 1), + # Multiple users - success + (["kate", "ivy", "jack"], 3, 0), + # Multiple users - one user already has the role + (["alice", "ivy", "jack"], 2, 1), + # Multiple users - all users already have the role + (["alice", "bob", "carol"], 0, 3), + ) + @unpack + def test_add_users_to_role_already_has_role(self, users: list[str], expected_completed: int, expected_errors: int): + """Test adding users to a role that already has the role.""" + role = "library_admin" + scope = "lib:DemoX:CSPROB" + request_data = {"role": role, "scope": scope, "users": users} + assignments = [ + {"subject_name": "alice", "role_name": role, "scope_name": scope}, + {"subject_name": "bob", "role_name": role, "scope_name": scope}, + {"subject_name": "carol", "role_name": role, "scope_name": scope}, + ] + self._assign_roles_to_users(assignments=assignments) + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(len(response.data["completed"]), expected_completed) + self.assertEqual(len(response.data["errors"]), expected_errors) + + @patch.object(api, "assign_role_to_user_in_scope") + def test_add_users_to_role_exception_handling(self, mock_assign_role_to_user_in_scope): + """Test adding users to a role with exception handling.""" + request_data = {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": ["alice"]} + mock_assign_role_to_user_in_scope.side_effect = Exception() + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(len(response.data["completed"]), 0) + self.assertEqual(len(response.data["errors"]), 1) + self.assertEqual(response.data["errors"][0]["user_identifier"], "alice") + self.assertEqual(response.data["errors"][0]["error"], RoleOperationError.ROLE_ASSIGNMENT_ERROR) + + @data( + {}, + {"role": "library_admin"}, + {"scope": "lib:DemoX:CSPROB"}, + {"users": ["admin_user"]}, + {"role": "library_admin", "scope": "lib:DemoX:CSPROB"}, + {"scope": "lib:DemoX:CSPROB", "users": ["admin_user"]}, + {"users": ["admin_user", "regular_user"], "role": "library_admin"}, + {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": []}, + {"role": "", "scope": "lib:DemoX:CSPROB", "users": ["admin_user"]}, + {"role": "library_admin", "scope": "", "users": ["admin_user"]}, + ) + def test_add_users_to_role_invalid_data(self, request_data: dict): + """Test adding users with invalid request data. + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user + ("alice", status.HTTP_207_MULTI_STATUS), + # Regular user with permission + ("ivy", status.HTTP_207_MULTI_STATUS), + # Regular user without permission + ("zoey", status.HTTP_403_FORBIDDEN), + ) + @unpack + def test_add_users_to_role_permissions(self, username: str, status_code: int): + """Test adding users to role with different permission scenarios. + + Expected result: + - Returns appropriate status code based on permissions + """ + request_data = {"role": "library_admin", "scope": "lib:Org3:cs_101", "users": ["user1"]} + user = User.objects.filter(username=username).first() + self.client.force_authenticate(user=user) + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status_code) + + @data( + # With username ----------------------------- + # Single user - success (admin user) + (["alice"], 1, 0), + # Single user - success (regular user) + (["bob"], 1, 0), + # Multiple users - all success (admin and regular users) + (["alice", "bob", "carol"], 3, 0), + # With email -------------------------------- + # Single user - success (admin user) + (["alice@example.com"], 1, 0), + # Single user - success (regular user) + (["bob@example.com"], 1, 0), + # Multiple users - all success (admin and regular users) + (["alice@example.com", "bob@example.com", "carol@example.com"], 3, 0), + # With username and email ------------------- + # All success + (["alice", "bob@example.com", "carol@example.com"], 3, 0), + # Mixed results (user not found) + (["alice", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), + ) + @unpack + def test_remove_users_from_role_success(self, users: list[str], expected_completed: int, expected_errors: int): + """Test removing users from a role within a scope. + + Expected result: + - Returns 207 MULTI-STATUS status + - Returns appropriate completed and error counts + """ + role = "library_admin" + scope = "lib:DemoX:CSPROB" + users_to_assign = ["alice", "bob", "carol"] + assignments = [{"subject_name": user, "role_name": role, "scope_name": scope} for user in users_to_assign] + self._assign_roles_to_users(assignments=assignments) + query_params = {"role": role, "scope": scope, "users": ",".join(users)} + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(len(response.data["completed"]), expected_completed) + self.assertEqual(len(response.data["errors"]), expected_errors) + + @patch.object(api, "unassign_role_from_user") + def test_remove_users_from_role_exception_handling(self, mock_unassign_role_from_user): + """Test removing users from a role with exception handling.""" + query_params = {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": "alice,bob,carol"} + mock_unassign_role_from_user.side_effect = [True, False, Exception()] + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(len(response.data["completed"]), 1) + self.assertEqual(len(response.data["errors"]), 2) + self.assertEqual(response.data["completed"][0]["user_identifier"], "alice") + self.assertEqual(response.data["completed"][0]["status"], RoleOperationStatus.ROLE_REMOVED) + self.assertEqual(response.data["errors"][0]["user_identifier"], "bob") + self.assertEqual(response.data["errors"][0]["error"], RoleOperationError.USER_DOES_NOT_HAVE_ROLE) + self.assertEqual(response.data["errors"][1]["user_identifier"], "carol") + self.assertEqual(response.data["errors"][1]["error"], RoleOperationError.ROLE_REMOVAL_ERROR) + + @data( + {}, + {"role": "library_admin"}, + {"scope": "lib:DemoX:CSPROB"}, + {"users": "admin_user"}, + {"role": "library_admin", "scope": "lib:DemoX:CSPROB"}, + {"scope": "lib:DemoX:CSPROB", "users": "admin_user"}, + {"users": "admin_user,regular_user", "role": "library_admin"}, + {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": ""}, + {"role": "", "scope": "lib:DemoX:CSPROB", "users": "admin_user"}, + {"role": "library_admin", "scope": "", "users": "admin_user"}, + ) + def test_remove_users_from_role_invalid_params(self, query_params: dict): + """Test removing users with invalid query parameters. + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user + ("alice", status.HTTP_207_MULTI_STATUS), + # Regular user with permission + ("ivy", status.HTTP_207_MULTI_STATUS), + # Regular user without permission + ("zoey", status.HTTP_403_FORBIDDEN), + ) + @unpack + def test_remove_users_from_role_permissions(self, username: str, status_code: int): + """Test removing users from role with different permission scenarios. + + Expected result: + - Returns appropriate status code based on permissions + """ + query_params = {"role": "library_admin", "scope": "lib:Org3:cs_101", "users": "user1,user2"} + user = User.objects.filter(username=username).first() + self.client.force_authenticate(user=user) + + with patch.object(api.ContentLibraryData, "exists", return_value=True): + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") + + self.assertEqual(response.status_code, status_code) + + +@ddt +class TestRoleListView(ViewTestMixin): + """Test suite for RoleListView.""" + + @classmethod + def setUpTestData(cls): + """Set up test fixtures.""" + super().setUpTestData() + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.client.force_authenticate(user=self.admin_user) + self.url = reverse("openedx_authz:role-list") + + def test_get_roles_success(self): + """Test retrieving role definitions and their permissions. + + Expected result: + - Returns 200 OK status + - Returns correct role definitions with permissions and user counts + """ + response = self.client.get(self.url, {"scope": "*"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertIn("count", response.data) + self.assertEqual(len(response.data["results"]), response.data["count"]) + self.assertEqual(len(response.data["results"]), 4) + + @patch.object(api, "get_role_definitions_in_scope") + def test_get_roles_empty_result(self, mock_get_roles): + """Test retrieving roles when none exist in scope. + + Expected result: + - Returns 200 OK status + - Returns empty results list + """ + mock_get_roles.return_value = [] + + response = self.client.get(self.url, {"scope": "*"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertIn("count", response.data) + self.assertEqual(response.data["count"], 0) + self.assertEqual(len(response.data["results"]), 0) + + @data( + {}, + {"scope": ""}, + {"scope": "a" * 256}, + ) + def test_get_roles_invalid_params(self, query_params: dict): + """Test retrieving roles with invalid query parameters. + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @data( + ({}, 4, False), + ({"page": 1, "page_size": 2}, 2, True), + ({"page": 2, "page_size": 2}, 2, False), + ({"page": 1, "page_size": 4}, 4, False), + ) + @unpack + def test_get_roles_pagination(self, query_params: dict, expected_count: int, has_next: bool): + """Test retrieving roles with pagination. + + Expected result: + - Returns 200 OK status + - Returns paginated results with correct page size + """ + query_params["scope"] = "*" + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertEqual(len(response.data["results"]), expected_count) + self.assertIn("next", response.data) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) diff --git a/openedx_authz/urls.py b/openedx_authz/urls.py index 615cef5b..1114ba2f 100644 --- a/openedx_authz/urls.py +++ b/openedx_authz/urls.py @@ -1,11 +1,11 @@ -""" -URLs for openedx_authz. -""" +"""Open edX AuthZ API URLs.""" -from django.urls import re_path # pylint: disable=unused-import -from django.views.generic import TemplateView # pylint: disable=unused-import +from django.urls import include, path + +from openedx_authz.rest_api import urls + +app_name = "openedx_authz" urlpatterns = [ - # TODO: Fill in URL patterns and views here. - # re_path(r'', TemplateView.as_view(template_name="openedx_authz/base.html")), + path("authz/", include((urls, "openedx_authz"))), ] diff --git a/requirements/base.in b/requirements/base.in index 92e38c0b..2eabce5a 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,12 +1,13 @@ # Core requirements for using this application -c constraints.txt -Django # Web application framework - - -openedx-atlas +Django # Web application framework +djangorestframework # REST framework for Django +openedx-atlas # Open edX Atlas library attrs # Classes without boilerplate pycasbin # Authorization library for implementing access control models casbin-django-orm-adapter # Adapter for Django ORM for Casbin redis-watcher # Watcher for Redis for Casbin -edx-opaque-keys # Opaque keys for resource identification +edx-opaque-keys # Opaque keys for resource identification +edx-api-doc-tools # Tools for API documentation +edx-drf-extensions # Extensions for Django Rest Framework used by Open edX diff --git a/requirements/base.txt b/requirements/base.txt index c54c9f33..355cfde1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -10,33 +10,111 @@ attrs==25.3.0 # via -r requirements/base.in casbin-django-orm-adapter==1.7.0 # via -r requirements/base.in +certifi==2025.8.3 + # via requests +cffi==2.0.0 + # via + # cryptography + # pynacl +charset-normalizer==3.4.3 + # via requests +click==8.3.0 + # via edx-django-utils +cryptography==46.0.2 + # via pyjwt django==4.2.24 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # casbin-django-orm-adapter + # django-crum + # django-waffle + # djangorestframework + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-django-utils + # edx-drf-extensions +django-crum==0.7.9 + # via edx-django-utils +django-waffle==5.0.0 + # via + # edx-django-utils + # edx-drf-extensions +djangorestframework==3.16.1 + # via + # -r requirements/base.in + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-drf-extensions dnspython==2.8.0 # via pymongo -edx-opaque-keys==3.0.0 +drf-jwt==1.19.2 + # via edx-drf-extensions +drf-yasg==1.21.11 + # via edx-api-doc-tools +edx-api-doc-tools==2.1.0 + # via -r requirements/base.in +edx-django-utils==8.0.1 + # via edx-drf-extensions +edx-drf-extensions==10.6.0 # via -r requirements/base.in +edx-opaque-keys==3.0.0 + # via + # -r requirements/base.in + # edx-drf-extensions +idna==3.10 + # via requests +inflection==0.5.1 + # via drf-yasg openedx-atlas==0.7.0 # via -r requirements/base.in +packaging==25.0 + # via drf-yasg +psutil==7.1.0 + # via edx-django-utils pycasbin==2.2.0 # via # -r requirements/base.in # casbin-django-orm-adapter # redis-watcher +pycparser==2.23 + # via cffi +pyjwt[crypto]==2.10.1 + # via + # drf-jwt + # edx-drf-extensions pymongo==4.15.2 # via edx-opaque-keys +pynacl==1.6.0 + # via edx-django-utils +pytz==2025.2 + # via drf-yasg +pyyaml==6.0.3 + # via drf-yasg redis==6.4.0 # via redis-watcher redis-watcher==1.8.0 # via -r requirements/base.in +requests==2.32.5 + # via edx-drf-extensions +semantic-version==2.10.0 + # via edx-drf-extensions simpleeval==1.0.3 # via pycasbin sqlparse==0.5.3 # via django stevedore==5.5.0 - # via edx-opaque-keys + # via + # edx-django-utils + # edx-opaque-keys typing-extensions==4.15.0 # via edx-opaque-keys +uritemplate==4.2.0 + # via drf-yasg +urllib3==2.5.0 + # via requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/dev.txt b/requirements/dev.txt index 2dda5413..1bdec8aa 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -25,17 +25,31 @@ cachetools==6.2.0 # tox casbin-django-orm-adapter==1.7.0 # via -r requirements/quality.txt +certifi==2025.8.3 + # via + # -r requirements/quality.txt + # requests +cffi==2.0.0 + # via + # -r requirements/quality.txt + # cryptography + # pynacl chardet==5.2.0 # via # -r requirements/ci.txt # diff-cover # tox +charset-normalizer==3.4.3 + # via + # -r requirements/quality.txt + # requests click==8.3.0 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt # click-log # code-annotations + # edx-django-utils # edx-lint # pip-tools click-log==0.4.0 @@ -54,6 +68,10 @@ coverage[toml]==7.10.6 # via # -r requirements/quality.txt # pytest-cov +cryptography==46.0.2 + # via + # -r requirements/quality.txt + # pyjwt ddt==1.7.2 # via -r requirements/quality.txt diff-cover==9.6.0 @@ -71,22 +89,72 @@ django==4.2.24 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # casbin-django-orm-adapter + # django-crum + # django-waffle + # djangorestframework + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-django-utils + # edx-drf-extensions # edx-i18n-tools +django-crum==0.7.9 + # via + # -r requirements/quality.txt + # edx-django-utils +django-waffle==5.0.0 + # via + # -r requirements/quality.txt + # edx-django-utils + # edx-drf-extensions +djangorestframework==3.16.1 + # via + # -r requirements/quality.txt + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-drf-extensions dnspython==2.8.0 # via # -r requirements/quality.txt # pymongo +drf-jwt==1.19.2 + # via + # -r requirements/quality.txt + # edx-drf-extensions +drf-yasg==1.21.11 + # via + # -r requirements/quality.txt + # edx-api-doc-tools +edx-api-doc-tools==2.1.0 + # via -r requirements/quality.txt +edx-django-utils==8.0.1 + # via + # -r requirements/quality.txt + # edx-drf-extensions +edx-drf-extensions==10.6.0 + # via -r requirements/quality.txt edx-i18n-tools==1.9.0 # via -r requirements/dev.in edx-lint==5.6.0 # via -r requirements/quality.txt edx-opaque-keys==3.0.0 - # via -r requirements/quality.txt + # via + # -r requirements/quality.txt + # edx-drf-extensions filelock==3.19.1 # via # -r requirements/ci.txt # tox # virtualenv +idna==3.10 + # via + # -r requirements/quality.txt + # requests +inflection==0.5.1 + # via + # -r requirements/quality.txt + # drf-yasg iniconfig==2.1.0 # via # -r requirements/quality.txt @@ -122,6 +190,7 @@ packaging==25.0 # -r requirements/pip-tools.txt # -r requirements/quality.txt # build + # drf-yasg # pyproject-api # pytest # tox @@ -146,6 +215,10 @@ pluggy==1.6.0 # tox polib==1.2.0 # via edx-i18n-tools +psutil==7.1.0 + # via + # -r requirements/quality.txt + # edx-django-utils pycasbin==2.2.0 # via # -r requirements/quality.txt @@ -153,6 +226,10 @@ pycasbin==2.2.0 # redis-watcher pycodestyle==2.14.0 # via -r requirements/quality.txt +pycparser==2.23 + # via + # -r requirements/quality.txt + # cffi pydocstyle==6.3.0 # via -r requirements/quality.txt pygments==2.19.2 @@ -160,6 +237,11 @@ pygments==2.19.2 # -r requirements/quality.txt # diff-cover # pytest +pyjwt[crypto]==2.10.1 + # via + # -r requirements/quality.txt + # drf-jwt + # edx-drf-extensions pylint==3.3.8 # via # -r requirements/quality.txt @@ -184,6 +266,10 @@ pymongo==4.15.2 # via # -r requirements/quality.txt # edx-opaque-keys +pynacl==1.6.0 + # via + # -r requirements/quality.txt + # edx-django-utils pyproject-api==1.9.1 # via # -r requirements/ci.txt @@ -206,10 +292,15 @@ python-slugify==8.0.4 # via # -r requirements/quality.txt # code-annotations -pyyaml==6.0.2 +pytz==2025.2 + # via + # -r requirements/quality.txt + # drf-yasg +pyyaml==6.0.3 # via # -r requirements/quality.txt # code-annotations + # drf-yasg # edx-i18n-tools redis==6.4.0 # via @@ -217,6 +308,14 @@ redis==6.4.0 # redis-watcher redis-watcher==1.8.0 # via -r requirements/quality.txt +requests==2.32.5 + # via + # -r requirements/quality.txt + # edx-drf-extensions +semantic-version==2.10.0 + # via + # -r requirements/quality.txt + # edx-drf-extensions simpleeval==1.0.3 # via # -r requirements/quality.txt @@ -237,6 +336,7 @@ stevedore==5.5.0 # via # -r requirements/quality.txt # code-annotations + # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via @@ -252,6 +352,14 @@ typing-extensions==4.15.0 # via # -r requirements/quality.txt # edx-opaque-keys +uritemplate==4.2.0 + # via + # -r requirements/quality.txt + # drf-yasg +urllib3==2.5.0 + # via + # -r requirements/quality.txt + # requests virtualenv==20.34.0 # via # -r requirements/ci.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 4638a67d..e5f23e73 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -27,23 +27,34 @@ build==1.3.0 casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt certifi==2025.8.3 - # via requests + # via + # -r requirements/test.txt + # requests cffi==2.0.0 - # via cryptography + # via + # -r requirements/test.txt + # cryptography + # pynacl charset-normalizer==3.4.3 - # via requests + # via + # -r requirements/test.txt + # requests click==8.3.0 # via # -r requirements/test.txt # code-annotations + # edx-django-utils code-annotations==2.3.0 # via -r requirements/test.txt coverage[toml]==7.10.6 # via # -r requirements/test.txt # pytest-cov -cryptography==46.0.1 - # via secretstorage +cryptography==46.0.2 + # via + # -r requirements/test.txt + # pyjwt + # secretstorage ddt==1.7.2 # via -r requirements/test.txt django==4.2.24 @@ -51,6 +62,30 @@ django==4.2.24 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # casbin-django-orm-adapter + # django-crum + # django-waffle + # djangorestframework + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-django-utils + # edx-drf-extensions +django-crum==0.7.9 + # via + # -r requirements/test.txt + # edx-django-utils +django-waffle==5.0.0 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-drf-extensions +djangorestframework==3.16.1 + # via + # -r requirements/test.txt + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-drf-extensions dnspython==2.8.0 # via # -r requirements/test.txt @@ -64,16 +99,40 @@ docutils==0.21.2 # readme-renderer # restructuredtext-lint # sphinx -edx-opaque-keys==3.0.0 +drf-jwt==1.19.2 + # via + # -r requirements/test.txt + # edx-drf-extensions +drf-yasg==1.21.11 + # via + # -r requirements/test.txt + # edx-api-doc-tools +edx-api-doc-tools==2.1.0 + # via -r requirements/test.txt +edx-django-utils==8.0.1 + # via + # -r requirements/test.txt + # edx-drf-extensions +edx-drf-extensions==10.6.0 # via -r requirements/test.txt +edx-opaque-keys==3.0.0 + # via + # -r requirements/test.txt + # edx-drf-extensions id==1.5.0 # via twine idna==3.10 - # via requests + # via + # -r requirements/test.txt + # requests imagesize==1.4.1 # via sphinx importlib-metadata==8.7.0 # via keyring +inflection==0.5.1 + # via + # -r requirements/test.txt + # drf-yasg iniconfig==2.1.0 # via # -r requirements/test.txt @@ -115,6 +174,7 @@ packaging==25.0 # via # -r requirements/test.txt # build + # drf-yasg # pydata-sphinx-theme # pytest # sphinx @@ -124,13 +184,19 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +psutil==7.1.0 + # via + # -r requirements/test.txt + # edx-django-utils pycasbin==2.2.0 # via # -r requirements/test.txt # casbin-django-orm-adapter # redis-watcher pycparser==2.23 - # via cffi + # via + # -r requirements/test.txt + # cffi pydata-sphinx-theme==0.15.4 # via sphinx-book-theme pygments==2.19.2 @@ -143,10 +209,19 @@ pygments==2.19.2 # readme-renderer # rich # sphinx +pyjwt[crypto]==2.10.1 + # via + # -r requirements/test.txt + # drf-jwt + # edx-drf-extensions pymongo==4.15.2 # via # -r requirements/test.txt # edx-opaque-keys +pynacl==1.6.0 + # via + # -r requirements/test.txt + # edx-django-utils pyproject-hooks==1.2.0 # via build pytest==8.4.2 @@ -162,10 +237,15 @@ python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pyyaml==6.0.2 +pytz==2025.2 + # via + # -r requirements/test.txt + # drf-yasg +pyyaml==6.0.3 # via # -r requirements/test.txt # code-annotations + # drf-yasg readme-renderer==44.0 # via twine redis==6.4.0 @@ -176,6 +256,8 @@ redis-watcher==1.8.0 # via -r requirements/test.txt requests==2.32.5 # via + # -r requirements/test.txt + # edx-drf-extensions # id # requests-toolbelt # sphinx @@ -192,6 +274,10 @@ roman-numerals-py==3.1.0 # via sphinx secretstorage==3.4.0 # via keyring +semantic-version==2.10.0 + # via + # -r requirements/test.txt + # edx-drf-extensions simpleeval==1.0.3 # via # -r requirements/test.txt @@ -228,6 +314,7 @@ stevedore==5.5.0 # -r requirements/test.txt # code-annotations # doc8 + # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via @@ -241,9 +328,17 @@ typing-extensions==4.15.0 # beautifulsoup4 # edx-opaque-keys # pydata-sphinx-theme +uritemplate==4.2.0 + # via + # -r requirements/test.txt + # drf-yasg urllib3==2.5.0 # via + # -r requirements/test.txt # requests # twine zipp==3.23.0 # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/quality.txt b/requirements/quality.txt index 731b58df..21dfd066 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -16,11 +16,25 @@ attrs==25.3.0 # via -r requirements/test.txt casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt +certifi==2025.8.3 + # via + # -r requirements/test.txt + # requests +cffi==2.0.0 + # via + # -r requirements/test.txt + # cryptography + # pynacl +charset-normalizer==3.4.3 + # via + # -r requirements/test.txt + # requests click==8.3.0 # via # -r requirements/test.txt # click-log # code-annotations + # edx-django-utils # edx-lint click-log==0.4.0 # via edx-lint @@ -32,6 +46,10 @@ coverage[toml]==7.10.6 # via # -r requirements/test.txt # pytest-cov +cryptography==46.0.2 + # via + # -r requirements/test.txt + # pyjwt ddt==1.7.2 # via -r requirements/test.txt dill==0.4.0 @@ -41,14 +59,64 @@ django==4.2.24 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # casbin-django-orm-adapter + # django-crum + # django-waffle + # djangorestframework + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-django-utils + # edx-drf-extensions +django-crum==0.7.9 + # via + # -r requirements/test.txt + # edx-django-utils +django-waffle==5.0.0 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-drf-extensions +djangorestframework==3.16.1 + # via + # -r requirements/test.txt + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-drf-extensions dnspython==2.8.0 # via # -r requirements/test.txt # pymongo +drf-jwt==1.19.2 + # via + # -r requirements/test.txt + # edx-drf-extensions +drf-yasg==1.21.11 + # via + # -r requirements/test.txt + # edx-api-doc-tools +edx-api-doc-tools==2.1.0 + # via -r requirements/test.txt +edx-django-utils==8.0.1 + # via + # -r requirements/test.txt + # edx-drf-extensions +edx-drf-extensions==10.6.0 + # via -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.in edx-opaque-keys==3.0.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # edx-drf-extensions +idna==3.10 + # via + # -r requirements/test.txt + # requests +inflection==0.5.1 + # via + # -r requirements/test.txt + # drf-yasg iniconfig==2.1.0 # via # -r requirements/test.txt @@ -72,6 +140,7 @@ openedx-atlas==0.7.0 packaging==25.0 # via # -r requirements/test.txt + # drf-yasg # pytest platformdirs==4.4.0 # via pylint @@ -80,6 +149,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +psutil==7.1.0 + # via + # -r requirements/test.txt + # edx-django-utils pycasbin==2.2.0 # via # -r requirements/test.txt @@ -87,12 +160,21 @@ pycasbin==2.2.0 # redis-watcher pycodestyle==2.14.0 # via -r requirements/quality.in +pycparser==2.23 + # via + # -r requirements/test.txt + # cffi pydocstyle==6.3.0 # via -r requirements/quality.in pygments==2.19.2 # via # -r requirements/test.txt # pytest +pyjwt[crypto]==2.10.1 + # via + # -r requirements/test.txt + # drf-jwt + # edx-drf-extensions pylint==3.3.8 # via # edx-lint @@ -111,6 +193,10 @@ pymongo==4.15.2 # via # -r requirements/test.txt # edx-opaque-keys +pynacl==1.6.0 + # via + # -r requirements/test.txt + # edx-django-utils pytest==8.4.2 # via # -r requirements/test.txt @@ -124,16 +210,29 @@ python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pyyaml==6.0.2 +pytz==2025.2 + # via + # -r requirements/test.txt + # drf-yasg +pyyaml==6.0.3 # via # -r requirements/test.txt # code-annotations + # drf-yasg redis==6.4.0 # via # -r requirements/test.txt # redis-watcher redis-watcher==1.8.0 # via -r requirements/test.txt +requests==2.32.5 + # via + # -r requirements/test.txt + # edx-drf-extensions +semantic-version==2.10.0 + # via + # -r requirements/test.txt + # edx-drf-extensions simpleeval==1.0.3 # via # -r requirements/test.txt @@ -150,6 +249,7 @@ stevedore==5.5.0 # via # -r requirements/test.txt # code-annotations + # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via @@ -161,3 +261,14 @@ typing-extensions==4.15.0 # via # -r requirements/test.txt # edx-opaque-keys +uritemplate==4.2.0 + # via + # -r requirements/test.txt + # drf-yasg +urllib3==2.5.0 + # via + # -r requirements/test.txt + # requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/test.txt b/requirements/test.txt index dca86d1d..a7785fd8 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -12,24 +12,94 @@ attrs==25.3.0 # via -r requirements/base.txt casbin-django-orm-adapter==1.7.0 # via -r requirements/base.txt +certifi==2025.8.3 + # via + # -r requirements/base.txt + # requests +cffi==2.0.0 + # via + # -r requirements/base.txt + # cryptography + # pynacl +charset-normalizer==3.4.3 + # via + # -r requirements/base.txt + # requests click==8.3.0 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # edx-django-utils code-annotations==2.3.0 # via -r requirements/test.in coverage[toml]==7.10.6 # via pytest-cov +cryptography==46.0.2 + # via + # -r requirements/base.txt + # pyjwt ddt==1.7.2 # via -r requirements/test.in # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # casbin-django-orm-adapter + # django-crum + # django-waffle + # djangorestframework + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-django-utils + # edx-drf-extensions +django-crum==0.7.9 + # via + # -r requirements/base.txt + # edx-django-utils +django-waffle==5.0.0 + # via + # -r requirements/base.txt + # edx-django-utils + # edx-drf-extensions +djangorestframework==3.16.1 + # via + # -r requirements/base.txt + # drf-jwt + # drf-yasg + # edx-api-doc-tools + # edx-drf-extensions dnspython==2.8.0 # via # -r requirements/base.txt # pymongo -edx-opaque-keys==3.0.0 +drf-jwt==1.19.2 + # via + # -r requirements/base.txt + # edx-drf-extensions +drf-yasg==1.21.11 + # via + # -r requirements/base.txt + # edx-api-doc-tools +edx-api-doc-tools==2.1.0 # via -r requirements/base.txt +edx-django-utils==8.0.1 + # via + # -r requirements/base.txt + # edx-drf-extensions +edx-drf-extensions==10.6.0 + # via -r requirements/base.txt +edx-opaque-keys==3.0.0 + # via + # -r requirements/base.txt + # edx-drf-extensions +idna==3.10 + # via + # -r requirements/base.txt + # requests +inflection==0.5.1 + # via + # -r requirements/base.txt + # drf-yasg iniconfig==2.1.0 # via pytest jinja2==3.1.6 @@ -39,22 +109,42 @@ markupsafe==3.0.2 openedx-atlas==0.7.0 # via -r requirements/base.txt packaging==25.0 - # via pytest + # via + # -r requirements/base.txt + # drf-yasg + # pytest pluggy==1.6.0 # via # pytest # pytest-cov +psutil==7.1.0 + # via + # -r requirements/base.txt + # edx-django-utils pycasbin==2.2.0 # via # -r requirements/base.txt # casbin-django-orm-adapter # redis-watcher +pycparser==2.23 + # via + # -r requirements/base.txt + # cffi pygments==2.19.2 # via pytest +pyjwt[crypto]==2.10.1 + # via + # -r requirements/base.txt + # drf-jwt + # edx-drf-extensions pymongo==4.15.2 # via # -r requirements/base.txt # edx-opaque-keys +pynacl==1.6.0 + # via + # -r requirements/base.txt + # edx-django-utils pytest==8.4.2 # via # pytest-cov @@ -65,14 +155,29 @@ pytest-django==4.11.1 # via -r requirements/test.in python-slugify==8.0.4 # via code-annotations -pyyaml==6.0.2 - # via code-annotations +pytz==2025.2 + # via + # -r requirements/base.txt + # drf-yasg +pyyaml==6.0.3 + # via + # -r requirements/base.txt + # code-annotations + # drf-yasg redis==6.4.0 # via # -r requirements/base.txt # redis-watcher redis-watcher==1.8.0 # via -r requirements/base.txt +requests==2.32.5 + # via + # -r requirements/base.txt + # edx-drf-extensions +semantic-version==2.10.0 + # via + # -r requirements/base.txt + # edx-drf-extensions simpleeval==1.0.3 # via # -r requirements/base.txt @@ -85,6 +190,7 @@ stevedore==5.5.0 # via # -r requirements/base.txt # code-annotations + # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via python-slugify @@ -92,3 +198,14 @@ typing-extensions==4.15.0 # via # -r requirements/base.txt # edx-opaque-keys +uritemplate==4.2.0 + # via + # -r requirements/base.txt + # drf-yasg +urllib3==2.5.0 + # via + # -r requirements/base.txt + # requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools From 45c238cb660dcf2d424380b9142ac2f028bea5cd Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:02:04 -0500 Subject: [PATCH 02/26] refactor: enhance permission handling with dynamic scope delegation --- openedx_authz/rest_api/v1/permissions.py | 252 ++++++++++++++++++++--- openedx_authz/rest_api/v1/views.py | 4 +- 2 files changed, 220 insertions(+), 36 deletions(-) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index 7565c68d..bd70450b 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -1,57 +1,241 @@ """Permissions for the Open edX AuthZ REST API.""" -# from rest_framework import serializers -from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework.permissions import BasePermission from openedx_authz import api -RESOURCE_PERMISSIONS = { - "lib": { - "view": "view_library_team", - "manage": "manage_library_team", - }, - "course": { - "view": "view_course_team", - "manage": "manage_course_team", - }, -} +class PermissionMeta(type(BasePermission)): + """Metaclass that automatically registers permission classes by namespace. -class HasScopedPermission(BasePermission): - """Permission to check if the user has the library permission.""" + This metaclass maintains a registry of permission classes indexed by their NAMESPACE + attribute. When a permission class is defined with a NAMESPACE, it is automatically + registered in the permission_registry for later retrieval. + """ - def has_permission(self, request, view): + permission_registry: dict[str, type["BaseScopePermission"]] = {} + + def __init__(cls, name, bases, attrs): + """Initialize the metaclass and register subclasses.""" + super().__init__(name, bases, attrs) + namespace = getattr(cls, "NAMESPACE", None) + if namespace: + cls.permission_registry[namespace] = cls + + @classmethod + def get_permission_class(mcs, namespace: str) -> type["BaseScopePermission"]: + """Retrieve the permission class for the given namespace. + + Args: + namespace: The namespace identifier (e.g., 'lib', 'sc'). + + Returns: + type["BaseScopePermission"]: The permission class for the namespace, + or BaseScopePermission if the namespace is not registered. + + Examples: + >>> PermissionMeta.get_permission_class("lib") + + >>> PermissionMeta.get_permission_class("unknown") + """ - Check if the user has the appropriate library permission based on the request method. + return mcs.permission_registry.get(namespace, BaseScopePermission) + + +class BaseScopePermission(BasePermission, metaclass=PermissionMeta): + """Base permission class for all scope-based permissions. + + This class provides the foundation for implementing scope-based authorization checks + in the REST API. It extracts scope information from requests and provides hooks for + permission validation. Subclasses should override the permission methods to implement + specific authorization logic for their scope types. - For safe methods (GET, HEAD, OPTIONS), checks for 'view_library_team' permission. - For unsafe methods (POST, PUT, PATCH, DELETE), checks for 'manage_library_team' permission. + Attributes: + NAMESPACE: The namespace identifier for this permission class (default: 'sc' for generic scopes). + """ + + NAMESPACE = "sc" + + def get_scope_value(self, request) -> str | None: + """Extract the scope value from the request. + + Args: + request: The Django REST framework request object. Returns: - bool: True if user has the required permission for the scope, False otherwise + str | None: The scope value if found (e.g., 'lib:DemoX:CSPROB'), or None if not present. + """ + return request.data.get("scope") or request.query_params.get("scope") - Note: - Requires a 'scope' parameter in either request.data or query_params. - Returns False if no scope is provided. + def get_scope_namespace(self, request) -> str: + """Derive the namespace from the request scope value. + + Attempts to parse the scope value and extract its namespace. If the scope value + is invalid or missing, falls back to this class's NAMESPACE. + + Args: + request: The Django REST framework request object. + + Returns: + str: The scope namespace (e.g., 'lib', 'sc'). + + Examples: + >>> request.data = {"scope": "lib:DemoX:CSPROB"} + >>> permission.get_scope_namespace(request) + 'lib' + >>> request.data = {} + >>> permission.get_scope_namespace(request) + 'sc' """ - scope_value = request.data.get("scope") if request.data else request.query_params.get("scope") + scope_value = self.get_scope_value(request) if not scope_value: - return False - + return self.NAMESPACE try: - scope = api.ScopeData(external_key=scope_value) + return api.ScopeData(external_key=scope_value).NAMESPACE except ValueError: - return False + return self.NAMESPACE + + def has_permission(self, request, view) -> bool: + """Fallback permission check (deny by default). + + Subclasses should override this method to implement their specific permission logic. + + Returns: + bool: False (deny access by default). + """ + return False + + def has_object_permission(self, request, view, obj) -> bool: + """Fallback object-level permission check (deny by default). + + Subclasses should override this method to implement their specific object-level + permission logic. + + Returns: + bool: False (deny access by default). + """ + return False + + +class ContentLibraryPermission(BaseScopePermission): + """Permission handler for content library scopes. - resource_type = scope.NAMESPACE - perms = RESOURCE_PERMISSIONS.get(resource_type) + This class implements permission checks specific to content library operations. + It uses the authz API to verify whether a user has the necessary permissions + to perform actions on library team members. - if not perms: + Attributes: + NAMESPACE: 'lib' for content library scopes. + + Permission Rules: + - POST/PUT/PATCH/DELETE requests require ``manage_library_team`` permission. + - GET requests require ``view_library_team`` permission. + """ + + NAMESPACE = "lib" + + def has_permission(self, request, view) -> bool: + """Check if the user has permission to perform the requested action. + + Verifies that the user has the appropriate library team permission based on + the HTTP method. Modification operations require ``manage_library_team``, while read + operations require ``view_library_team``. + + Returns: + bool: True if the user has the required permission, False otherwise. + Also returns False if no scope value is provided in the request. + """ + scope_value = self.get_scope_value(request) + if not scope_value: return False - user = request.user - if user.is_superuser or user.is_staff: + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + return api.is_user_allowed(request.user.username, "manage_library_team", scope_value) + + return api.is_user_allowed(request.user.username, "view_library_team", scope_value) + + +class DynamicScopePermission(BaseScopePermission): + """Dispatcher permission class that delegates permission checks to scope-specific handlers. + + This class acts as a dispatcher that automatically selects and delegates to the appropriate + permission class based on the request's scope namespace. It also provides special handling + for superusers and staff members. + + Attributes: + NAMESPACE: None (this is a dispatcher, not tied to a specific namespace). + + Permission Flow: + 1. Check if user is superuser or staff (automatic approval). + 2. Extract the scope namespace from the request. + 3. Look up the appropriate permission class for that namespace. + 4. Delegate the permission check to that class. + + Examples: + >>> permission = ScopePermission() + >>> # For a library scope request, this will delegate to ContentLibraryPermission + >>> request.data = {"scope": "lib:DemoX:CSPROB"} + >>> ContentLibraryPermission.has_permission(request, view) + >>> # For a generic scope request, this will delegate to BaseScopePermission + >>> request.data = {"scope": "sc:generic"} + >>> BaseScopePermission.has_permission(request, view) + + Note: + Superusers and staff members always have permission regardless of scope. + """ + + NAMESPACE = None + + def _get_permission_instance(self, request) -> BaseScopePermission: + """Instantiate the permission class for the request scope. + + Determines the appropriate permission class based on the scope namespace + extracted from the request and returns an instance of that class. + + Args: + request: The Django REST framework request object. + + Returns: + BaseScopePermission: An instance of the permission class appropriate + for the request's scope namespace. + + Examples: + >>> request.data = {"scope": "lib:DemoX:CSPROB"} + >>> permission._get_permission_instance(request) + >>> ContentLibraryPermission + """ + scope_namespace = self.get_scope_namespace(request) + perm_class = PermissionMeta.get_permission_class(scope_namespace) + return perm_class() + + def has_permission(self, request, view) -> bool: + """Delegate permission check to the appropriate scope-specific permission class. + + Superusers and staff members are automatically granted permission. For other + users, the permission check is delegated to the permission class registered + for the request's scope namespace. + + Examples: + >>> # Regular user gets scope-specific check + >>> request.data = {"scope": "lib:DemoX:CSPROB"} + >>> permission.has_permission(request, view) # Delegates to ContentLibraryPermission + """ + if request.user.is_superuser or request.user.is_staff: return True + return self._get_permission_instance(request).has_permission(request, view) - perm_name = perms["view"] if request.method in SAFE_METHODS else perms["manage"] - return api.is_user_allowed(user.username, perm_name, scope) + def has_object_permission(self, request, view, obj) -> bool: + """Delegate object-level permission check to the appropriate scope-specific permission class. + + Superusers and staff members are automatically granted permission. For other + users, the object-level permission check is delegated to the permission class + registered for the request's scope namespace. + + Examples: + >>> # Regular user gets scope-specific check + >>> request.data = {"scope": "lib:DemoX:CSPROB"} + >>> permission.has_object_permission(request, view, obj) # Delegates to ContentLibraryPermission + """ + if request.user.is_superuser or request.user.is_staff: + return True + return self._get_permission_instance(request).has_object_permission(request, view, obj) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 3703ab38..b2d1f6d7 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -24,7 +24,7 @@ view_auth_classes, ) from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination -from openedx_authz.rest_api.v1.permissions import HasScopedPermission +from openedx_authz.rest_api.v1.permissions import DynamicScopePermission from openedx_authz.rest_api.v1.serializers import ( AddUsersToRoleWithScopeSerializer, ListRolesWithNamespaceSerializer, @@ -175,7 +175,7 @@ class RoleUserAPIView(APIView): """ pagination_class = AuthZAPIViewPagination - permission_classes = [HasScopedPermission] + permission_classes = [DynamicScopePermission] @apidocs.schema( parameters=[ From 3c5f3e7d8e8db06a755f8ae82ecbfdc5a7cd8442 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:02:23 -0500 Subject: [PATCH 03/26] test: update role user API tests to use new admin user and adjust scope parameters --- openedx_authz/tests/rest_api/test_views.py | 58 +++++++++++++++------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 0ae1843f..61176d95 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -16,6 +16,7 @@ from openedx_authz import api from openedx_authz.rest_api.enums import RoleOperationError, RoleOperationStatus +from openedx_authz.rest_api.v1.permissions import DynamicScopePermission from openedx_authz.tests.api.test_users import UserAssignmentsSetupMixin User = get_user_model() @@ -44,6 +45,10 @@ def setUpTestData(cls): username="alice", email="alice@example.com", ) + cls.admin_user2 = User.objects.create_superuser( + username="eve", + email="eve@example.com", + ) cls.regular_user = User.objects.create_user( username="bob", email="bob@example.com", @@ -306,23 +311,23 @@ def test_get_users_by_scope_permissions(self, username: str, status_code: int): @data( # With username ----------------------------- # Single user - success (admin user) - (["alice"], 1, 0), + (["eve"], 1, 0), # Single user - success (regular user) (["bob"], 1, 0), # Multiple users - success (admin and regular users) - (["alice", "bob", "carol"], 3, 0), + (["eve", "bob", "carol"], 3, 0), # With email --------------------------------- # Single user - success (admin user) - (["alice@example.com"], 1, 0), + (["eve@example.com"], 1, 0), # Single user - success (regular user) (["bob@example.com"], 1, 0), # Multiple users - admin and regular users - (["alice@example.com", "bob@example.com", "carol@example.com"], 3, 0), + (["eve@example.com", "bob@example.com", "carol@example.com"], 3, 0), # With username and email -------------------- # All success - (["alice", "bob@example.com", "carol@example.com"], 3, 0), + (["eve", "bob@example.com", "carol@example.com"], 3, 0), # Mixed results (user not found) - (["alice", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), + (["eve", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), ) @unpack def test_add_users_to_role_success(self, users: list[str], expected_completed: int, expected_errors: int): @@ -333,7 +338,7 @@ def test_add_users_to_role_success(self, users: list[str], expected_completed: i - Returns appropriate completed and error counts """ role = "library_admin" - request_data = {"role": role, "scope": "lib:DemoX:CSPROB", "users": users} + request_data = {"role": role, "scope": "lib:Org1:math_101", "users": users} with patch.object(api.ContentLibraryData, "exists", return_value=True): response = self.client.put(self.url, data=request_data, format="json") @@ -377,7 +382,7 @@ def test_add_users_to_role_already_has_role(self, users: list[str], expected_com @patch.object(api, "assign_role_to_user_in_scope") def test_add_users_to_role_exception_handling(self, mock_assign_role_to_user_in_scope): """Test adding users to a role with exception handling.""" - request_data = {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": ["alice"]} + request_data = {"role": "library_admin", "scope": "lib:Org1:math_101", "users": ["alice"]} mock_assign_role_to_user_in_scope.side_effect = Exception() with patch.object(api.ContentLibraryData, "exists", return_value=True): @@ -407,9 +412,10 @@ def test_add_users_to_role_invalid_data(self, request_data: dict): Expected result: - Returns 400 BAD REQUEST status """ - response = self.client.put(self.url, data=request_data, format="json") + with patch.object(DynamicScopePermission, "has_permission", return_value=True): + response = self.client.put(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @data( # Unauthenticated @@ -569,7 +575,7 @@ def test_get_roles_success(self): - Returns 200 OK status - Returns correct role definitions with permissions and user counts """ - response = self.client.get(self.url, {"scope": "*"}) + response = self.client.get(self.url, {"namespace": "lib"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -587,7 +593,7 @@ def test_get_roles_empty_result(self, mock_get_roles): """ mock_get_roles.return_value = [] - response = self.client.get(self.url, {"scope": "*"}) + response = self.client.get(self.url, {"namespace": "lib"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -597,11 +603,28 @@ def test_get_roles_empty_result(self, mock_get_roles): @data( {}, - {"scope": ""}, - {"scope": "a" * 256}, + {"scope": "custom_scope"}, + {"another_param": "a" * 256, "custom_scope": "custom_scope"}, ) - def test_get_roles_invalid_params(self, query_params: dict): - """Test retrieving roles with invalid query parameters. + def test_get_roles_namespace_is_missing(self, query_params: dict): + """Test retrieving roles with namespace is missing. + + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["namespace"], ["This field is required."]) + + @data( + ({"namespace": ""}, "blank"), + ({"namespace": "a" * 256}, "max_length"), + ({"namespace": "invalid"}, "invalid"), + ) + @unpack + def test_get_roles_namespace_is_invalid(self, query_params: dict, error_code: str): + """Test retrieving roles with invalid namespace. Expected result: - Returns 400 BAD REQUEST status @@ -609,6 +632,7 @@ def test_get_roles_invalid_params(self, query_params: dict): response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(error_code, [error.code for error in response.data["namespace"]]) @data( ({}, 4, False), @@ -624,7 +648,7 @@ def test_get_roles_pagination(self, query_params: dict, expected_count: int, has - Returns 200 OK status - Returns paginated results with correct page size """ - query_params["scope"] = "*" + query_params["namespace"] = "lib" response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_200_OK) From 43db8ff71dd2258f4b8d677583b59f3e10e77e59 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:17:45 -0500 Subject: [PATCH 04/26] docs: fix docstring warnings --- openedx_authz/rest_api/v1/views.py | 115 +++++++++++++++++++---------- 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index b2d1f6d7..2efc9dbe 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -51,30 +51,43 @@ class PermissionValidationMeView(APIView): validation of multiple permissions in a single request. **Endpoints** - POST: Validate one or more permissions for the authenticated user + + POST: Validate one or more permissions for the authenticated user **Request Format** - Expects a list of permission objects, each containing: - - action: The action to validate (e.g., 'edit_library', 'delete_library_content') - - scope: The authorization scope (e.g., 'lib:DemoX:CSPROB') + + Expects a list of permission objects, each containing: + + - action: The action to validate (e.g., 'edit_library', 'delete_library_content') + - scope: The authorization scope (e.g., 'lib:DemoX:CSPROB') **Response Format** - Returns a list of validation results, each containing: - - action: The requested action - - scope: The requested scope - - allowed: Boolean indicating if the user has the permission + + Returns a list of validation results, each containing: + + - action: The requested action + - scope: The requested scope + - allowed: Boolean indicating if the user has the permission **Authentication and Permissions** - Requires authenticated user. + + Requires authenticated user. **Example Request** - POST /api/authz/v1/permissions/validate/me + + POST /api/authz/v1/permissions/validate/me + + .. code-block:: json + [ {"action": "edit_library", "scope": "lib:DemoX:CSPROB"}, {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2"} ] **Example Response** + + .. code-block:: json + [ {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": True}, {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2", "allowed": False} @@ -131,20 +144,25 @@ class RoleUserAPIView(APIView): sorting, and pagination of results. **Endpoints** - GET: Retrieve all users with their role assignments in a scope - PUT: Assign multiple users to a specific role within a scope - DELETE: Remove multiple users from a specific role within a scope + + - GET: Retrieve all users with their role assignments in a scope + - PUT: Assign multiple users to a specific role within a scope + - DELETE: Remove multiple users from a specific role within a scope **Query Parameters (GET)** - - scope (Required): The authorization scope to query (e.g., 'lib:DemoX:CSPROB') - - search (Optional): Search term to filter users by username or email - - roles (Optional): Filter by specific role names - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - sort_by (Optional): Field to sort by (e.g., 'username', 'email') - - order (Optional): Sort order ('asc' or 'desc') + + - scope (Required): The authorization scope to query (e.g., 'lib:DemoX:CSPROB') + - search (Optional): Search term to filter users by username, email or full name + - roles (Optional): Filter by specific role names + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + - sort_by (Optional): Field to sort by (e.g., 'username', 'email', 'full_name') + - order (Optional): Sort order ('asc' or 'desc') **Request Format (PUT)** + + .. code-block:: json + { "role": "library_admin", "scope": "lib:DemoX:CSPROB", @@ -152,26 +170,34 @@ class RoleUserAPIView(APIView): } **Request Format (DELETE)** - Query parameters: - - users: Comma-separated list of user identifiers - - role: The role to remove users from - - scope: The scope to remove users from + + Query parameters: + + - users: Comma-separated list of user identifiers + - role: The role to remove users from + - scope: The scope to remove users from **Response Format (PUT/DELETE)** - Returns HTTP 207 Multi-Status with: + + Returns HTTP 207 Multi-Status with: + + .. code-block:: json + { "completed": [{"user_identifier": "john_doe", "status": "role_added|role_removed"}], "errors": [{"user_identifier": "jane_doe", "error": "error_type"}] } **Authentication and Permissions** - Requires authenticated user. - Requires ``HasLibraryPermission``. Users must have appropriate permissions for the specified scope. + + Requires authenticated user. + Requires ``HasLibraryPermission``. Users must have appropriate permissions for the specified scope. **Notes** - - User identifiers can be either username or email - - Bulk operations return 207 Multi-Status to indicate partial success - - Individual operation failures are reported in the errors array + + - User identifiers can be either username or email + - Bulk operations return 207 Multi-Status to indicate partial success + - Individual operation failures are reported in the errors array """ pagination_class = AuthZAPIViewPagination @@ -307,26 +333,35 @@ class RoleListView(APIView): the permissions granted and the number of users assigned to each role. **Endpoints** - GET: Retrieve all roles and their permissions for a specific namespace + + GET: Retrieve all roles and their permissions for a specific namespace **Query Parameters** - - namespace (Required): The namespace to query roles for (e.g., 'lib') - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page + + - namespace (Required): The namespace to query roles for (e.g., 'lib') + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page **Response Format** - Returns a paginated list of role objects, each containing: - - role: The role's external identifier (e.g., 'library_author', 'library_user') - - permissions: List of permission action keys granted by this role - - user_count: Number of users currently assigned to this role + + Returns a paginated list of role objects, each containing: + + - role: The role's external identifier (e.g., 'library_author', 'library_user') + - permissions: List of permission action keys granted by this role + - user_count: Number of users currently assigned to this role **Authentication and Permissions** - Requires authenticated user. + + Requires authenticated user. **Example Request** - GET /api/authz/v1/roles/?namespace=lib&page=1&page_size=10 + + GET /api/authz/v1/roles/?namespace=lib&page=1&page_size=10 **Example Response** + + .. code-block:: json + { "count": 2, "next": null, From 8730e35442f47df68cf11f434c9625e35c38ef12 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:29:09 -0500 Subject: [PATCH 05/26] docs: update json examples in view docstrings --- openedx_authz/rest_api/v1/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 2efc9dbe..a38625cb 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -89,8 +89,8 @@ class PermissionValidationMeView(APIView): .. code-block:: json [ - {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": True}, - {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2", "allowed": False} + {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": true}, + {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2", "allowed": false} ] """ @@ -184,8 +184,8 @@ class RoleUserAPIView(APIView): .. code-block:: json { - "completed": [{"user_identifier": "john_doe", "status": "role_added|role_removed"}], - "errors": [{"user_identifier": "jane_doe", "error": "error_type"}] + "completed": [{"user_identifier": "john_doe", "status": "role_added"}], + "errors": [{"user_identifier": "jane_doe", "error": "user_already_has_role"}] } **Authentication and Permissions** From ceba0aec14c90e06754859dc502955a721bfde2d Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:50:29 -0500 Subject: [PATCH 06/26] docs: improve docstrings for permission classes by adding attribute descriptions --- openedx_authz/rest_api/v1/permissions.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index bd70450b..83ded086 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -49,12 +49,10 @@ class BaseScopePermission(BasePermission, metaclass=PermissionMeta): in the REST API. It extracts scope information from requests and provides hooks for permission validation. Subclasses should override the permission methods to implement specific authorization logic for their scope types. - - Attributes: - NAMESPACE: The namespace identifier for this permission class (default: 'sc' for generic scopes). """ NAMESPACE = "sc" + """The namespace identifier for this permission class (default: 'sc' for generic scopes).""" def get_scope_value(self, request) -> str | None: """Extract the scope value from the request. @@ -124,15 +122,13 @@ class ContentLibraryPermission(BaseScopePermission): It uses the authz API to verify whether a user has the necessary permissions to perform actions on library team members. - Attributes: - NAMESPACE: 'lib' for content library scopes. - Permission Rules: - POST/PUT/PATCH/DELETE requests require ``manage_library_team`` permission. - GET requests require ``view_library_team`` permission. """ NAMESPACE = "lib" + """'lib' for content library scopes.""" def has_permission(self, request, view) -> bool: """Check if the user has permission to perform the requested action. @@ -162,9 +158,6 @@ class DynamicScopePermission(BaseScopePermission): permission class based on the request's scope namespace. It also provides special handling for superusers and staff members. - Attributes: - NAMESPACE: None (this is a dispatcher, not tied to a specific namespace). - Permission Flow: 1. Check if user is superuser or staff (automatic approval). 2. Extract the scope namespace from the request. @@ -185,6 +178,7 @@ class DynamicScopePermission(BaseScopePermission): """ NAMESPACE = None + """None (this is a dispatcher, not tied to a specific namespace).""" def _get_permission_instance(self, request) -> BaseScopePermission: """Instantiate the permission class for the request scope. From 8d68f1fbd2c59286d450e444bb2b59b04bd74761 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 13:52:28 -0500 Subject: [PATCH 07/26] docs: update docstrings in permission classes to use backticks for code formatting --- openedx_authz/rest_api/v1/permissions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index 83ded086..20819614 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -52,7 +52,7 @@ class BaseScopePermission(BasePermission, metaclass=PermissionMeta): """ NAMESPACE = "sc" - """The namespace identifier for this permission class (default: 'sc' for generic scopes).""" + """The namespace identifier for this permission class (default: ``sc`` for generic scopes).""" def get_scope_value(self, request) -> str | None: """Extract the scope value from the request. @@ -128,7 +128,7 @@ class ContentLibraryPermission(BaseScopePermission): """ NAMESPACE = "lib" - """'lib' for content library scopes.""" + """``lib`` for content library scopes.""" def has_permission(self, request, view) -> bool: """Check if the user has permission to perform the requested action. @@ -178,7 +178,7 @@ class DynamicScopePermission(BaseScopePermission): """ NAMESPACE = None - """None (this is a dispatcher, not tied to a specific namespace).""" + """``None`` (this is a dispatcher, not tied to a specific namespace).""" def _get_permission_instance(self, request) -> BaseScopePermission: """Instantiate the permission class for the request scope. From deaee9331a6e00e18540a0d420d220c2f7d715b8 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 14:06:22 -0500 Subject: [PATCH 08/26] refactor: update NAMESPACE attributes to use ClassVar for type hinting --- openedx_authz/rest_api/v1/permissions.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index 20819614..078aba56 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -1,5 +1,7 @@ """Permissions for the Open edX AuthZ REST API.""" +from typing import ClassVar + from rest_framework.permissions import BasePermission from openedx_authz import api @@ -51,8 +53,8 @@ class BaseScopePermission(BasePermission, metaclass=PermissionMeta): specific authorization logic for their scope types. """ - NAMESPACE = "sc" - """The namespace identifier for this permission class (default: ``sc`` for generic scopes).""" + NAMESPACE: ClassVar[str] = "sc" + """The namespace identifier for this permission class. Default ``sc`` for generic scopes.""" def get_scope_value(self, request) -> str | None: """Extract the scope value from the request. @@ -127,7 +129,7 @@ class ContentLibraryPermission(BaseScopePermission): - GET requests require ``view_library_team`` permission. """ - NAMESPACE = "lib" + NAMESPACE: ClassVar[str] = "lib" """``lib`` for content library scopes.""" def has_permission(self, request, view) -> bool: @@ -177,8 +179,8 @@ class DynamicScopePermission(BaseScopePermission): Superusers and staff members always have permission regardless of scope. """ - NAMESPACE = None - """``None`` (this is a dispatcher, not tied to a specific namespace).""" + NAMESPACE: ClassVar[None] = None + """This is a dispatcher, not tied to a specific namespace.""" def _get_permission_instance(self, request) -> BaseScopePermission: """Instantiate the permission class for the request scope. From 9b1db5bd783edc1d87f7acb2700e86abaec569dd Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 14:09:29 -0500 Subject: [PATCH 09/26] chore: rename enums.py to data.py --- openedx_authz/rest_api/{enums.py => data.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename openedx_authz/rest_api/{enums.py => data.py} (94%) diff --git a/openedx_authz/rest_api/enums.py b/openedx_authz/rest_api/data.py similarity index 94% rename from openedx_authz/rest_api/enums.py rename to openedx_authz/rest_api/data.py index 4f6d6ce3..5af8afe1 100644 --- a/openedx_authz/rest_api/enums.py +++ b/openedx_authz/rest_api/data.py @@ -1,4 +1,4 @@ -"""Enums for the Open edX AuthZ REST API.""" +"""Data classes and enums for the Open edX AuthZ REST API.""" from enum import Enum From c0c05d61b06ad088b7b776cfbc54dd69149634b7 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 14:11:35 -0500 Subject: [PATCH 10/26] fix: update import path --- openedx_authz/rest_api/v1/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index a38625cb..b8d33e28 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -15,7 +15,7 @@ from rest_framework.views import APIView from openedx_authz import api -from openedx_authz.rest_api.enums import RoleOperationError, RoleOperationStatus +from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.utils import ( filter_users, get_user_by_username_or_email, From ac5fb4bbbd7244251ddaf0f9b510cfc0108f5e10 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 14:15:42 -0500 Subject: [PATCH 11/26] refactor: update imports --- openedx_authz/rest_api/utils.py | 2 +- openedx_authz/rest_api/v1/serializers.py | 2 +- openedx_authz/tests/rest_api/test_views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx_authz/rest_api/utils.py b/openedx_authz/rest_api/utils.py index 4d04483d..c41e5e3d 100644 --- a/openedx_authz/rest_api/utils.py +++ b/openedx_authz/rest_api/utils.py @@ -6,7 +6,7 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework.permissions import IsAuthenticated -from openedx_authz.rest_api.enums import SearchField, SortField, SortOrder +from openedx_authz.rest_api.data import SearchField, SortField, SortOrder User = get_user_model() diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index 6737d481..54da3d40 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers from openedx_authz import api -from openedx_authz.rest_api.enums import SortField, SortOrder +from openedx_authz.rest_api.data import SortField, SortOrder from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField User = get_user_model() diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 61176d95..5adafc04 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -15,7 +15,7 @@ from rest_framework.test import APIClient from openedx_authz import api -from openedx_authz.rest_api.enums import RoleOperationError, RoleOperationStatus +from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.v1.permissions import DynamicScopePermission from openedx_authz.tests.api.test_users import UserAssignmentsSetupMixin From 71adb579c587a2f92997da94fa75a0e0a3f512b8 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 14:19:18 -0500 Subject: [PATCH 12/26] feat: add DynamicScopePermission to RoleListView --- openedx_authz/rest_api/v1/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index b8d33e28..02970b41 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -382,6 +382,7 @@ class RoleListView(APIView): """ pagination_class = AuthZAPIViewPagination + permission_classes = [DynamicScopePermission] @apidocs.schema( parameters=[ From 63807195123a437370eff7f82d074fe1bb0648a2 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 15:02:58 -0500 Subject: [PATCH 13/26] refactor: use temporary permission instead a dynamic permission validation --- openedx_authz/rest_api/v1/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 02970b41..ca1ad331 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -11,6 +11,7 @@ from django.contrib.auth import get_user_model from django.http import HttpRequest from rest_framework import status +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView @@ -382,7 +383,7 @@ class RoleListView(APIView): """ pagination_class = AuthZAPIViewPagination - permission_classes = [DynamicScopePermission] + permission_classes = [IsAdminUser] @apidocs.schema( parameters=[ From c8a45ff9c8d5591fd137b76f85b418a9cb3495f9 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 15:09:18 -0500 Subject: [PATCH 14/26] chore: bump version to 0.3.0 --- CHANGELOG.rst | 8 ++++++++ openedx_authz/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ad418646..bb4c7a14 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,3 +33,11 @@ Added * ADRs for key design decisions. * Casbin model (CONF) and engine layer for authorization. * Implementation of public API for roles and permissions management. + +0.3.0 - 2025-10-10 +****************** + +Added +===== + +* Implementation of REST API for roles and permissions management. diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 4d6d6d3a..b40bc4b9 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "0.1.0" +__version__ = "0.3.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) From ef605409b6cf598c1e168ff14304e0a1e6f7da01 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 18:33:23 -0500 Subject: [PATCH 15/26] refactor: eliminate duplicates while preserving order in serializer list fields --- openedx_authz/rest_api/v1/fields.py | 4 ++-- openedx_authz/rest_api/v1/serializers.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openedx_authz/rest_api/v1/fields.py b/openedx_authz/rest_api/v1/fields.py index c62e9f39..89800788 100644 --- a/openedx_authz/rest_api/v1/fields.py +++ b/openedx_authz/rest_api/v1/fields.py @@ -7,8 +7,8 @@ class CommaSeparatedListField(serializers.CharField): """Serializer for a comma-separated list of strings.""" def to_internal_value(self, data): - """Convert string separated by commas to list""" - return [item.strip().lower() for item in data.split(",") if item.strip()] + """Convert string separated by commas to list of unique items preserving order""" + return list(dict.fromkeys(item.strip().lower() for item in data.split(",") if item.strip())) def to_representation(self, value): """Convert list to string separated by commas""" diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index 54da3d40..d595e7d3 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -74,6 +74,10 @@ class AddUsersToRoleWithScopeSerializer( users = serializers.ListField(child=serializers.CharField(max_length=255), allow_empty=False) + def validate_users(self, value) -> list[str]: + """Eliminate duplicates preserving order""" + return list(dict.fromkeys(value)) + class RemoveUsersFromRoleWithScopeSerializer( RoleScopeValidationMixin, From a18eb722375c66f9e5657ef4cb95a85ac105590d Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:16:38 -0500 Subject: [PATCH 16/26] feat: add generic scope wildcard constant to data module --- openedx_authz/api/data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index fde80913..a4c1b359 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -29,6 +29,7 @@ AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" EXTERNAL_KEY_SEPARATOR = ":" +GENERIC_SCOPE_WILDCARD = "*" NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$" From 3a61f4870595cf0f1a6dbfb49138a6f7559f1a41 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:17:01 -0500 Subject: [PATCH 17/26] feat: add decorators for authentication and authorization --- openedx_authz/rest_api/decorators.py | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 openedx_authz/rest_api/decorators.py diff --git a/openedx_authz/rest_api/decorators.py b/openedx_authz/rest_api/decorators.py new file mode 100644 index 00000000..1aead964 --- /dev/null +++ b/openedx_authz/rest_api/decorators.py @@ -0,0 +1,76 @@ +"""Decorators for the Open edX AuthZ REST API.""" + +from functools import wraps + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework.permissions import IsAuthenticated + + +def view_auth_classes(is_authenticated=True): + """ + Function and class decorator that abstracts the authentication and permission checks for api views. + + Args: + is_authenticated: Whether the view requires authentication. + + Returns: + The decorated view or class. + + Examples: + >>> @view_auth_classes(is_authenticated=False) + ... class MyView(APIView): + ... def get(self, request): + ... return Response("Hello, world!") + """ + + def _decorator(func_or_class): + """ + Requires either OAuth2 or Session-based authentication. + + Args: + func_or_class: The view or class to decorate. + + Returns: + The decorated view or class. + """ + func_or_class.authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + if is_authenticated: + func_or_class.permission_classes = [IsAuthenticated] + getattr(func_or_class, "permission_classes", []) + return func_or_class + + return _decorator + + +def authz_permissions(permissions: list[str]): + """Decorator to attach required permissions to view methods. + + This decorator stores a list of permission identifiers that will be checked + by MethodPermissionMixin during authorization. + + Args: + permissions: List of permission identifiers (e.g., ["view_library_team", "manage_library_team"]) + + Examples: + >>> class MyView(APIView): + ... @authz_permissions(["view_library_team"]) + ... def get(self, request): + ... pass + ... + ... @authz_permissions(["manage_library_team"]) + ... def post(self, request): + ... pass + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + wrapper.required_permissions = permissions + return wrapper + + return decorator From 65bdb7327f97f911dfa4061ef5139e8bbb5fa942 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:17:33 -0500 Subject: [PATCH 18/26] feat: implement function to create generic scope from specific scope --- openedx_authz/rest_api/utils.py | 38 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/openedx_authz/rest_api/utils.py b/openedx_authz/rest_api/utils.py index c41e5e3d..370437a4 100644 --- a/openedx_authz/rest_api/utils.py +++ b/openedx_authz/rest_api/utils.py @@ -2,33 +2,33 @@ from django.contrib.auth import get_user_model from django.db.models import Q -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from rest_framework.permissions import IsAuthenticated +from openedx_authz.api.data import GENERIC_SCOPE_WILDCARD, ScopeData from openedx_authz.rest_api.data import SearchField, SortField, SortOrder User = get_user_model() -def view_auth_classes(is_authenticated=True): - """ - Function and class decorator that abstracts the authentication and permission checks for api views. +def get_generic_scope(scope: ScopeData) -> ScopeData: """ + Create a generic scope from a given scope by replacing its key with a wildcard. + + This function preserves the namespace of the original scope but replaces the specific + key with a wildcard, allowing for broader permission checks across all scopes within + the same namespace. + + Args: + scope (ScopeData): The specific scope to generalize. - def _decorator(func_or_class): - """ - Requires either OAuth2 or Session-based authentication. - """ - func_or_class.authentication_classes = [ - JwtAuthentication, - SessionAuthenticationAllowInactiveUser, - ] - if is_authenticated: - func_or_class.permission_classes.insert(0, IsAuthenticated) - return func_or_class - - return _decorator + Returns: + ScopeData: A new scope with the same namespace but a wildcard key. + + Examples: + >>> scope = ScopeData(namespaced_key="lib^lib:DemoX:CSPROB") + >>> get_generic_scope(scope) + ScopeData(namespaced_key="lib^*") + """ + return ScopeData(namespaced_key=f"{scope.NAMESPACE}{ScopeData.SEPARATOR}{GENERIC_SCOPE_WILDCARD}") def get_user_map(usernames: list[str]) -> dict[str, User]: From dfab7ce2972dda0eeca1d3e1a341212adccee5ad Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:18:24 -0500 Subject: [PATCH 19/26] feat: add init to rest_api.v1 module --- openedx_authz/rest_api/v1/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx_authz/rest_api/v1/__init__.py b/openedx_authz/rest_api/v1/__init__.py index e69de29b..8b137891 100644 --- a/openedx_authz/rest_api/v1/__init__.py +++ b/openedx_authz/rest_api/v1/__init__.py @@ -0,0 +1 @@ + From ee6992b6822ed664b3a18310f1e2cd5cd6c1ce2a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:28:26 -0500 Subject: [PATCH 20/26] refactor: restructure ContentLibraryPermission to use MethodPermissionMixin --- openedx_authz/rest_api/v1/permissions.py | 127 ++++++++++++++++------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index 078aba56..9fb0ad33 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -117,42 +117,6 @@ def has_object_permission(self, request, view, obj) -> bool: return False -class ContentLibraryPermission(BaseScopePermission): - """Permission handler for content library scopes. - - This class implements permission checks specific to content library operations. - It uses the authz API to verify whether a user has the necessary permissions - to perform actions on library team members. - - Permission Rules: - - POST/PUT/PATCH/DELETE requests require ``manage_library_team`` permission. - - GET requests require ``view_library_team`` permission. - """ - - NAMESPACE: ClassVar[str] = "lib" - """``lib`` for content library scopes.""" - - def has_permission(self, request, view) -> bool: - """Check if the user has permission to perform the requested action. - - Verifies that the user has the appropriate library team permission based on - the HTTP method. Modification operations require ``manage_library_team``, while read - operations require ``view_library_team``. - - Returns: - bool: True if the user has the required permission, False otherwise. - Also returns False if no scope value is provided in the request. - """ - scope_value = self.get_scope_value(request) - if not scope_value: - return False - - if request.method in ("POST", "PUT", "PATCH", "DELETE"): - return api.is_user_allowed(request.user.username, "manage_library_team", scope_value) - - return api.is_user_allowed(request.user.username, "view_library_team", scope_value) - - class DynamicScopePermission(BaseScopePermission): """Dispatcher permission class that delegates permission checks to scope-specific handlers. @@ -235,3 +199,94 @@ def has_object_permission(self, request, view, obj) -> bool: if request.user.is_superuser or request.user.is_staff: return True return self._get_permission_instance(request).has_object_permission(request, view, obj) + + +class MethodPermissionMixin: + """Mixin that validates permissions defined via @authz_permissions decorator. + + This mixin reads the required_permissions attribute set by the @authz_permissions + decorator and validates each permission using ``is_user_allowed``. All permissions + must be satisfied for the check to pass. + + Usage: + Combine this mixin with BaseScopePermission to create permission classes + that use method-level permission declarations: + + >>> class MyPermission(MethodPermissionMixin, BaseScopePermission): + ... NAMESPACE = "lib" + ... + >>> class MyView(APIView): + ... permission_classes = [MyPermission] + ... + ... @authz_permissions(["view_library_team"]) + ... def get(self, request): + ... pass + """ + + def get_required_permissions(self, request, view) -> list[str]: + """Extract required permissions from the view method. + + Args: + request: The Django REST framework request object. + view: The view being accessed. + + Returns: + list[str]: List of permission identifiers, or empty list if not defined. + """ + method = request.method.lower() + handler = getattr(view, method, None) + if handler and hasattr(handler, "required_permissions"): + return handler.required_permissions + return [] + + def validate_permissions(self, request, permissions: list[str], scope_value: str) -> bool: + """Validate that the user has all required permissions for the scope. + + Args: + request: The Django REST framework request object. + permissions: List of permission identifiers to check. + scope_value: The scope to check permissions against. + + Returns: + bool: True if user has all required permissions, False otherwise. + """ + if not permissions: + return False + + for permission in permissions: + if not api.is_user_allowed(request.user.username, permission, scope_value): + return False + return True + + +class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission): + """Permission handler for content library scopes. + + This class implements permission checks specific to content library operations. + It uses the authz API to verify whether a user has the necessary permissions + to perform actions on library team members. + """ + + NAMESPACE: ClassVar[str] = "lib" + """``lib`` for content library scopes.""" + + def has_permission(self, request, view) -> bool: + """Check if the user has permission to perform the requested action. + + First checks if the view method has @authz_permissions decorator. + If present, validates all required permissions. If not present, + allows access by default. + + Returns: + bool: True if the user has the required permission, False otherwise. + Also returns False if no scope value is provided in the request. + """ + scope_value = self.get_scope_value(request) + if not scope_value: + return False + + permissions = self.get_required_permissions(request, view) + if permissions: + return self.validate_permissions(request, permissions, scope_value) + + return True From 0a2def7bcce9336294477df487731c466ca6b4bf Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:28:42 -0500 Subject: [PATCH 21/26] feat: enhance role and scope validation in serializers --- openedx_authz/rest_api/v1/serializers.py | 59 +++++++++++++++--------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index d595e7d3..92dee429 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -5,6 +5,7 @@ from openedx_authz import api from openedx_authz.rest_api.data import SortField, SortOrder +from openedx_authz.rest_api.utils import get_generic_scope from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField User = get_user_model() @@ -41,8 +42,24 @@ class PermissionValidationResponseSerializer(PermissionValidationSerializer): # class RoleScopeValidationMixin(serializers.Serializer): # pylint: disable=abstract-method """Mixin providing role and scope validation logic.""" - def validate(self, attrs): - """Validate that role exists in scope.""" + def validate(self, attrs) -> dict: + """Validate that the specified role and scope are valid and that the role exists in the scope. + + This method performs the following validations: + 1. Validates that the scope is registered in the scope registry + 2. Validates that the scope exists in the system + 3. Validates that the role is defined into the roles assigned to the scope + + Args: + attrs: Dictionary containing 'role' and 'scope' keys with their string values. + + Returns: + dict: The validated data dictionary with 'role' and 'scope' keys. + + Raises: + serializers.ValidationError: If the scope is not registered, doesn't exist, + or if the role is not defined in the scope. + """ validated_data = super().validate(attrs) scope_value = validated_data["scope"] role_value = validated_data["role"] @@ -56,8 +73,8 @@ def validate(self, attrs): raise serializers.ValidationError(f"Scope '{scope_value}' does not exist") role = api.RoleData(external_key=role_value) - general_scope = api.ScopeData(namespaced_key=f"{scope.NAMESPACE}{scope.SEPARATOR}*") - role_definitions = api.get_role_definitions_in_scope(general_scope) + generic_scope = get_generic_scope(scope) + role_definitions = api.get_role_definitions_in_scope(generic_scope) if role not in role_definitions: raise serializers.ValidationError(f"Role '{role_value}' does not exist in scope '{scope_value}'") @@ -102,36 +119,34 @@ class ListUsersInRoleWithScopeSerializer(ScopeMixin): # pylint: disable=abstrac search = LowercaseCharField(required=False, default=None) -class ListRolesWithNamespaceSerializer(serializers.Serializer): # pylint: disable=abstract-method - """Serializer for listing roles within a namespace.""" +class ListRolesWithScopeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for listing roles within a scope.""" - namespace = serializers.CharField(max_length=255) + scope = serializers.CharField(max_length=255) - def validate_namespace(self, value: str) -> api.ScopeData: - """Validate and convert namespace string to a ScopeData instance. + def validate_scope(self, value: str) -> api.ScopeData: + """Validate and convert scope string to a ScopeData instance. - Checks that the provided namespace is registered in the scope registry and - returns an instance of the appropriate ScopeData subclass with a wildcard - external_key to represent all scopes within that namespace. + Checks that the provided scope is registered in the scope registry and + returns an instance of the appropriate ScopeData subclass. Args: - value: The namespace string to validate (e.g., 'lib', 'sc', 'org'). + value: The scope string to validate (e.g., 'lib', 'sc', 'org'). Returns: - ScopeData: An instance of the appropriate ScopeData subclass for the - namespace, initialized with external_key="*". + ScopeData: An instance of the appropriate ScopeData subclass for the scope. Raises: - serializers.ValidationError: If the namespace is not registered in the scope registry. + serializers.ValidationError: If the scope is not registered in the scope registry. Examples: - >>> validate_namespace('lib') - ContentLibraryData(external_key='*') + >>> validate_scope('lib:DemoX:CSPROB') + ContentLibraryData(external_key='lib:DemoX:CSPROB') """ - namespaces = api.ScopeData.get_all_namespaces() - if value not in namespaces: - raise serializers.ValidationError(f"'{value}' is not a valid namespace") - return namespaces[value](external_key="*") + try: + return api.ScopeData(external_key=value) + except ValueError as exc: + raise serializers.ValidationError(exc) from exc class ListUsersInRoleWithScopeResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method From 58603249f38e531b911e00d7084106ed54825e34 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:29:22 -0500 Subject: [PATCH 22/26] feat: update views to use authz_permissions decorator --- openedx_authz/rest_api/v1/views.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index ca1ad331..f209c082 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -11,25 +11,25 @@ from django.contrib.auth import get_user_model from django.http import HttpRequest from rest_framework import status -from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView from openedx_authz import api from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus +from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes from openedx_authz.rest_api.utils import ( filter_users, + get_generic_scope, get_user_by_username_or_email, get_user_map, sort_users, - view_auth_classes, ) from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination from openedx_authz.rest_api.v1.permissions import DynamicScopePermission from openedx_authz.rest_api.v1.serializers import ( AddUsersToRoleWithScopeSerializer, - ListRolesWithNamespaceSerializer, ListRolesWithScopeResponseSerializer, + ListRolesWithScopeSerializer, ListUsersInRoleWithScopeSerializer, PermissionValidationResponseSerializer, PermissionValidationSerializer, @@ -220,6 +220,7 @@ class RoleUserAPIView(APIView): status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", }, ) + @authz_permissions(["view_library_team"]) def get(self, request: HttpRequest) -> Response: """Retrieve all users with role assignments within a specific scope.""" serializer = ListUsersInRoleWithScopeSerializer(data=request.query_params) @@ -247,6 +248,7 @@ def get(self, request: HttpRequest) -> Response: status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", }, ) + @authz_permissions(["manage_library_team"]) def put(self, request: HttpRequest) -> Response: """Assign multiple users to a specific role within a scope.""" serializer = AddUsersToRoleWithScopeSerializer(data=request.data) @@ -292,6 +294,7 @@ def put(self, request: HttpRequest) -> Response: status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", }, ) + @authz_permissions(["manage_library_team"]) def delete(self, request: HttpRequest) -> Response: """Remove multiple users from a specific role within a scope.""" serializer = RemoveUsersFromRoleWithScopeSerializer(data=request.query_params) @@ -383,11 +386,11 @@ class RoleListView(APIView): """ pagination_class = AuthZAPIViewPagination - permission_classes = [IsAdminUser] + permission_classes = [DynamicScopePermission] @apidocs.schema( parameters=[ - apidocs.query_parameter("namespace", str, description="The namespace to query roles for"), + apidocs.query_parameter("scope", str, description="The scope to query roles for"), apidocs.query_parameter("page", int, description="Page number for pagination"), apidocs.query_parameter("page_size", int, description="Number of items per page"), ], @@ -397,12 +400,14 @@ class RoleListView(APIView): status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", }, ) + @authz_permissions(["manage_library_team"]) def get(self, request: HttpRequest) -> Response: - """Retrieve all roles and their permissions for a specific namespace.""" - serializer = ListRolesWithNamespaceSerializer(data=request.query_params) + """Retrieve all roles and their permissions for a specific scope.""" + serializer = ListRolesWithScopeSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) - roles = api.get_role_definitions_in_scope(serializer.validated_data["namespace"]) + generic_scope = get_generic_scope(serializer.validated_data["scope"]) + roles = api.get_role_definitions_in_scope(generic_scope) response_data = [] for role in roles: users = api.get_users_for_role(role.external_key) From 8ee18d561f64433deb2cf198dba82437210529f9 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:37:25 -0500 Subject: [PATCH 23/26] fix: update test cases to use 'scope' parameter instead of 'namespace' --- openedx_authz/tests/rest_api/test_views.py | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 5adafc04..abf33c9a 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -575,7 +575,7 @@ def test_get_roles_success(self): - Returns 200 OK status - Returns correct role definitions with permissions and user counts """ - response = self.client.get(self.url, {"namespace": "lib"}) + response = self.client.get(self.url, {"scope": "lib:DemoX:CSPROB"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -593,7 +593,7 @@ def test_get_roles_empty_result(self, mock_get_roles): """ mock_get_roles.return_value = [] - response = self.client.get(self.url, {"namespace": "lib"}) + response = self.client.get(self.url, {"scope": "lib:DemoX:CSPROB"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -603,11 +603,11 @@ def test_get_roles_empty_result(self, mock_get_roles): @data( {}, - {"scope": "custom_scope"}, - {"another_param": "a" * 256, "custom_scope": "custom_scope"}, + {"custom_param": "custom_value"}, + {"custom_param": "a" * 256, "another_param": "custom_value"}, ) - def test_get_roles_namespace_is_missing(self, query_params: dict): - """Test retrieving roles with namespace is missing. + def test_get_roles_scope_is_missing(self, query_params: dict): + """Test retrieving roles with scope is missing. Expected result: - Returns 400 BAD REQUEST status @@ -615,16 +615,16 @@ def test_get_roles_namespace_is_missing(self, query_params: dict): response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["namespace"], ["This field is required."]) + self.assertIn("required", [error.code for error in response.data["scope"]]) @data( - ({"namespace": ""}, "blank"), - ({"namespace": "a" * 256}, "max_length"), - ({"namespace": "invalid"}, "invalid"), + ({"scope": ""}, "blank"), + ({"scope": "a" * 256}, "max_length"), + ({"scope": "invalid"}, "invalid"), ) @unpack - def test_get_roles_namespace_is_invalid(self, query_params: dict, error_code: str): - """Test retrieving roles with invalid namespace. + def test_get_roles_scope_is_invalid(self, query_params: dict, error_code: str): + """Test retrieving roles with invalid scope. Expected result: - Returns 400 BAD REQUEST status @@ -632,7 +632,7 @@ def test_get_roles_namespace_is_invalid(self, query_params: dict, error_code: st response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(error_code, [error.code for error in response.data["namespace"]]) + self.assertIn(error_code, [error.code for error in response.data["scope"]]) @data( ({}, 4, False), @@ -648,7 +648,7 @@ def test_get_roles_pagination(self, query_params: dict, expected_count: int, has - Returns 200 OK status - Returns paginated results with correct page size """ - query_params["namespace"] = "lib" + query_params["scope"] = "lib:DemoX:CSPROB" response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_200_OK) From 3250b6831e0415ec74f9105b751884045f2214dd Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 10 Oct 2025 22:39:34 -0500 Subject: [PATCH 24/26] chore: remove trailing newline --- openedx_authz/rest_api/v1/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openedx_authz/rest_api/v1/__init__.py b/openedx_authz/rest_api/v1/__init__.py index 8b137891..e69de29b 100644 --- a/openedx_authz/rest_api/v1/__init__.py +++ b/openedx_authz/rest_api/v1/__init__.py @@ -1 +0,0 @@ - From b702f4f605e5715a80eac56a1c5dfe06fc943c15 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Sat, 11 Oct 2025 17:01:33 -0500 Subject: [PATCH 25/26] docs: update API documentation --- openedx_authz/rest_api/v1/views.py | 135 +++++++++++++++++++---------- 1 file changed, 88 insertions(+), 47 deletions(-) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index f209c082..a88e0232 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -53,7 +53,7 @@ class PermissionValidationMeView(APIView): **Endpoints** - POST: Validate one or more permissions for the authenticated user + - POST: Validate one or more permissions for the authenticated user **Request Format** @@ -72,7 +72,7 @@ class PermissionValidationMeView(APIView): **Authentication and Permissions** - Requires authenticated user. + - Requires authenticated user. **Example Request** @@ -82,7 +82,7 @@ class PermissionValidationMeView(APIView): [ {"action": "edit_library", "scope": "lib:DemoX:CSPROB"}, - {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2"} + {"action": "delete_library_content", "scope": "lib:OpenedX:CS50"} ] **Example Response** @@ -91,7 +91,7 @@ class PermissionValidationMeView(APIView): [ {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": true}, - {"action": "delete_library_content", "scope": "lib:DemoX:CSPR2", "allowed": false} + {"action": "delete_library_content", "scope": "lib:OpenedX:CS50", "allowed": false} ] """ @@ -109,7 +109,6 @@ def post(self, request: HttpRequest) -> Response: serializer.is_valid(raise_exception=True) username = request.user.username - response_data = [] for perm in serializer.validated_data: try: @@ -123,6 +122,9 @@ def post(self, request: HttpRequest) -> Response: "allowed": allowed, } ) + except ValueError as e: + logger.error(f"Error validating permission for user {username}: {e}") + return Response(data={"message": "Invalid scope format"}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: # pylint: disable=broad-exception-caught logger.error(f"Error validating permission for user {username}: {e}") return Response( @@ -154,7 +156,7 @@ class RoleUserAPIView(APIView): - scope (Required): The authorization scope to query (e.g., 'lib:DemoX:CSPROB') - search (Optional): Search term to filter users by username, email or full name - - roles (Optional): Filter by specific role names + - roles (Optional): Filter by comma-separated list of specific role names - page (Optional): Page number for pagination - page_size (Optional): Number of items per page - sort_by (Optional): Field to sort by (e.g., 'username', 'email', 'full_name') @@ -162,23 +164,45 @@ class RoleUserAPIView(APIView): **Request Format (PUT)** - .. code-block:: json - - { - "role": "library_admin", - "scope": "lib:DemoX:CSPROB", - "users": ["user1@example.com", "username2"] - } + - users: List of user identifiers (username or email) + - role: The role to add users to + - scope: The scope to add users to **Request Format (DELETE)** Query parameters: - - users: Comma-separated list of user identifiers + - users: Comma-separated list of user identifiers (username or email) - role: The role to remove users from - scope: The scope to remove users from - **Response Format (PUT/DELETE)** + **Response Format (GET)** + + Returns HTTP 200 OK with: + + .. code-block:: json + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "username": "john_doe", + "email": "john_doe@example.com", + "full_name": "John Doe" + "roles": ["library_admin", "library_user"] + }, + { + "username": "jane_doe", + "email": "jane_doe@example.com", + "full_name": "Jane Doe" + "roles": ["library_user"] + } + ] + } + + **Response Format (PUT)** Returns HTTP 207 Multi-Status with: @@ -189,16 +213,37 @@ class RoleUserAPIView(APIView): "errors": [{"user_identifier": "jane_doe", "error": "user_already_has_role"}] } + **Response Format (DELETE)** + + Returns HTTP 207 Multi-Status with: + + .. code-block:: json + + { + "completed": [{"user_identifier": "john_doe", "status": "role_removed"}], + "errors": [{"user_identifier": "jane_doe", "error": "user_does_not_have_role"}] + } + **Authentication and Permissions** - Requires authenticated user. - Requires ``HasLibraryPermission``. Users must have appropriate permissions for the specified scope. + - Requires authenticated user. + - Requires ``manage_library_team`` permission for the scope. - **Notes** + **Example Request** + + GET /api/authz/v1/roles/users/?scope=lib:DemoX:CSPROB&search=john&roles=library_admin + + PUT /api/authz/v1/roles/users/ + + .. code-block:: json + + { + "role": "library_admin", + "scope": "lib:DemoX:CSPROB", + "users": ["user1@example.com", "username2"] + } - - User identifiers can be either username or email - - Bulk operations return 207 Multi-Status to indicate partial success - - Individual operation failures are reported in the errors array + DELETE /api/authz/v1/roles/users/?role=library_admin&scope=lib:DemoX:CSPROB&users=user1@example.com,username2 """ pagination_class = AuthZAPIViewPagination @@ -217,7 +262,7 @@ class RoleUserAPIView(APIView): responses={ status.HTTP_200_OK: "The users were retrieved successfully", status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions", }, ) @authz_permissions(["view_library_team"]) @@ -229,11 +274,10 @@ def get(self, request: HttpRequest) -> Response: user_role_assignments = api.get_all_user_role_assignments_in_scope(query_params["scope"]) usernames = {assignment.subject.username for assignment in user_role_assignments} - response_data = UserRoleAssignmentSerializer( - user_role_assignments, many=True, context={"user_map": get_user_map(usernames)} - ).data + context = {"user_map": get_user_map(usernames)} + serialized_data = UserRoleAssignmentSerializer(user_role_assignments, many=True, context=context) - filtered_users = filter_users(response_data, query_params["search"], query_params["roles"]) + filtered_users = filter_users(serialized_data.data, query_params["search"], query_params["roles"]) user_role_assignments = sort_users(filtered_users, query_params["sort_by"], query_params["order"]) paginator = self.pagination_class() @@ -245,7 +289,7 @@ def get(self, request: HttpRequest) -> Response: responses={ status.HTTP_207_MULTI_STATUS: "The users were added to the role", status.HTTP_400_BAD_REQUEST: "The request data is invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions", }, ) @authz_permissions(["manage_library_team"]) @@ -254,15 +298,14 @@ def put(self, request: HttpRequest) -> Response: serializer = AddUsersToRoleWithScopeSerializer(data=request.data) serializer.is_valid(raise_exception=True) - role_name = serializer.validated_data["role"] + role = serializer.validated_data["role"] scope = serializer.validated_data["scope"] - completed, errors = [], [] for user_identifier in serializer.validated_data["users"]: response_dict = {"user_identifier": user_identifier} try: user = get_user_by_username_or_email(user_identifier) - result = api.assign_role_to_user_in_scope(user.username, role_name, scope) + result = api.assign_role_to_user_in_scope(user.username, role, scope) if result: response_dict["status"] = RoleOperationStatus.ROLE_ADDED completed.append(response_dict) @@ -291,7 +334,7 @@ def put(self, request: HttpRequest) -> Response: responses={ status.HTTP_207_MULTI_STATUS: "The users were removed from the role", status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions", }, ) @authz_permissions(["manage_library_team"]) @@ -300,16 +343,14 @@ def delete(self, request: HttpRequest) -> Response: serializer = RemoveUsersFromRoleWithScopeSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) - user_identifiers = serializer.validated_data["users"] - role_name = serializer.validated_data["role"] + role = serializer.validated_data["role"] scope = serializer.validated_data["scope"] - completed, errors = [], [] - for user_identifier in user_identifiers: + for user_identifier in serializer.validated_data["users"]: response_dict = {"user_identifier": user_identifier} try: user = get_user_by_username_or_email(user_identifier) - result = api.unassign_role_from_user(user.username, role_name, scope) + result = api.unassign_role_from_user(user.username, role, scope) if result: response_dict["status"] = RoleOperationStatus.ROLE_REMOVED completed.append(response_dict) @@ -330,19 +371,19 @@ def delete(self, request: HttpRequest) -> Response: @view_auth_classes() class RoleListView(APIView): - """API view for retrieving role definitions and their associated permissions within a specific namespace. + """API view for retrieving role definitions and their associated permissions within a specific scope. This view provides read-only access to role definitions within a specific - authorization namespace. It returns detailed information about each role including + authorization scope. It returns detailed information about each role including the permissions granted and the number of users assigned to each role. **Endpoints** - GET: Retrieve all roles and their permissions for a specific namespace + - GET: Retrieve all roles and their permissions for a specific scope **Query Parameters** - - namespace (Required): The namespace to query roles for (e.g., 'lib') + - scope (Required): The scope to query roles for (e.g., 'lib:OpenedX:CSPROB') - page (Optional): Page number for pagination - page_size (Optional): Number of items per page @@ -351,16 +392,17 @@ class RoleListView(APIView): Returns a paginated list of role objects, each containing: - role: The role's external identifier (e.g., 'library_author', 'library_user') - - permissions: List of permission action keys granted by this role + - permissions: List of permission action keys granted by this role (e.g., 'delete_library_content') - user_count: Number of users currently assigned to this role **Authentication and Permissions** - Requires authenticated user. + - Requires authenticated user. + - Requires ``manage_library_team`` permission for the scope. **Example Request** - GET /api/authz/v1/roles/?namespace=lib&page=1&page_size=10 + GET /api/authz/v1/roles/?scope=lib:OpenedX:CSPROB&page=1&page_size=10 **Example Response** @@ -397,7 +439,7 @@ class RoleListView(APIView): responses={ status.HTTP_200_OK: ListRolesWithScopeResponseSerializer(many=True), status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions", }, ) @authz_permissions(["manage_library_team"]) @@ -419,8 +461,7 @@ def get(self, request: HttpRequest) -> Response: } ) - serializer = ListRolesWithScopeResponseSerializer(response_data, many=True) - paginator = self.pagination_class() paginated_response_data = paginator.paginate_queryset(response_data, request) - return paginator.get_paginated_response(paginated_response_data) + serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True) + return paginator.get_paginated_response(serialized_data.data) From 76e2df22ab474be5f20dcae7533182f14e670e2a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Sun, 12 Oct 2025 23:34:32 -0500 Subject: [PATCH 26/26] refactor: restructure role test cases to enhance clarity and maintainability --- openedx_authz/tests/api/test_roles.py | 48 ++- openedx_authz/tests/rest_api/test_views.py | 387 +++++++++++---------- 2 files changed, 243 insertions(+), 192 deletions(-) diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 2a6fbc46..6a8df637 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -35,8 +35,13 @@ from openedx_authz.engine.utils import migrate_policy_between_enforcers -class RolesTestSetupMixin(TestCase): - """Mixin to set up roles and assignments for tests.""" +class BaseRolesTestCase(TestCase): + """Base test case with helper methods for roles testing. + + This class provides the infrastructure for testing roles without + loading any specific test data. Subclasses should override setUpClass + to define their own test data assignments. + """ @classmethod def _seed_database_with_policies(cls): @@ -83,9 +88,33 @@ def _assign_roles_to_users( @classmethod def setUpClass(cls): - """Set up test class environment.""" + """Set up test class environment. + + Seeds the database with policies. Subclasses should override this + to add their specific role assignments by calling _assign_roles_to_users. + """ + super().setUpClass() + cls._seed_database_with_policies() + + def setUp(self): + """Set up test environment.""" + super().setUp() + global_enforcer.load_policy() # Load policies before each test to simulate fresh start + + def tearDown(self): + """Clean up after each test to ensure isolation.""" + super().tearDown() + global_enforcer.clear_policy() # Clear policies after each test to ensure isolation + + +class RolesTestSetupMixin(BaseRolesTestCase): + """Test case with comprehensive role assignments for general roles testing.""" + + @classmethod + def setUpClass(cls): + """Set up test class environment with predefined role assignments.""" super().setUpClass() - # Ensure the database is seeded once for all tests in this class + # Define specific assignments for this test class assignments = [ # Basic library roles from authz.policy { @@ -210,19 +239,8 @@ def setUpClass(cls): "scope_name": "lib:Org6:project_epsilon", }, ] - cls._seed_database_with_policies() cls._assign_roles_to_users(assignments=assignments) - def setUp(self): - """Set up test environment.""" - super().setUp() - global_enforcer.load_policy() # Load policies before each test to simulate fresh start - - def tearDown(self): - """Clean up after each test to ensure isolation.""" - super().tearDown() - global_enforcer.clear_policy() # Clear policies after each test to ensure isolation - @ddt class TestRolesAPI(RolesTestSetupMixin): diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index abf33c9a..fca744a1 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -15,16 +15,17 @@ from rest_framework.test import APIClient from openedx_authz import api +from openedx_authz.api.users import assign_role_to_user_in_scope from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.v1.permissions import DynamicScopePermission -from openedx_authz.tests.api.test_users import UserAssignmentsSetupMixin +from openedx_authz.tests.api.test_roles import BaseRolesTestCase 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'). + 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. @@ -33,82 +34,132 @@ def get_user_map_without_profile(usernames: list[str]) -> dict[str, User]: return {user.username: user for user in users} -class ViewTestMixin(UserAssignmentsSetupMixin): +class ViewTestMixin(BaseRolesTestCase): """Mixin providing common test utilities for view tests.""" + @classmethod + def _assign_roles_to_users( + cls, + assignments: list[dict] | None = None, + ): + """Helper method to assign roles to multiple users. + + This method can be used to assign a role to a single user or multiple users + in a specific scope. It can also handle batch assignments. + + Args: + assignments (list of dict): List of assignment dictionaries, each containing: + - subject_name (str): External key of the user (e.g., 'john_doe'). + - role_name (str): External key of the role to assign (e.g., 'library_admin'). + - scope_name (str): External key of the scope in which to assign the role (e.g., 'lib:Org1:math_101'). + """ + for assignment in assignments or []: + assign_role_to_user_in_scope( + user_external_key=assignment["subject_name"], + role_external_key=assignment["role_name"], + scope_external_key=assignment["scope_name"], + ) + + @classmethod + def setUpClass(cls): + """Set up test class with custom role assignments.""" + super().setUpClass() + assignments = [ + # Assign roles to admin users + { + "subject_name": "admin_1", + "role_name": "library_admin", + "scope_name": "lib:Org1:LIB1", + }, + { + "subject_name": "admin_2", + "role_name": "library_user", + "scope_name": "lib:Org2:LIB2", + }, + { + "subject_name": "admin_3", + "role_name": "library_admin", + "scope_name": "lib:Org3:LIB3", + }, + # Assign roles to regular users + { + "subject_name": "regular_1", + "role_name": "library_user", + "scope_name": "lib:Org1:LIB1", + }, + { + "subject_name": "regular_2", + "role_name": "library_user", + "scope_name": "lib:Org1:LIB1", + }, + { + "subject_name": "regular_3", + "role_name": "library_user", + "scope_name": "lib:Org2:LIB2", + }, + { + "subject_name": "regular_4", + "role_name": "library_user", + "scope_name": "lib:Org2:LIB2", + }, + { + "subject_name": "regular_5", + "role_name": "library_admin", + "scope_name": "lib:Org3:LIB3", + }, + ] + cls._assign_roles_to_users(assignments=assignments) + + @classmethod + def create_regular_users(cls, quantity: int): + """Create regular users.""" + for i in range(1, quantity + 1): + User.objects.create_user(username=f"regular_{i}", email=f"regular_{i}@example.com") + + @classmethod + def create_admin_users(cls, quantity: int): + """Create admin users.""" + for i in range(1, quantity + 1): + User.objects.create_superuser(username=f"admin_{i}", email=f"admin_{i}@example.com") + @classmethod def setUpTestData(cls): """Set up test fixtures once for the entire test class.""" super().setUpTestData() - # Users with assigned roles - cls.admin_user = User.objects.create_superuser( - username="alice", - email="alice@example.com", - ) - cls.admin_user2 = User.objects.create_superuser( - username="eve", - email="eve@example.com", - ) - cls.regular_user = User.objects.create_user( - username="bob", - email="bob@example.com", - ) - cls.regular_user2 = User.objects.create_user( - username="carol", - email="carol@example.com", - ) - cls.regular_user3 = User.objects.create_user( - username="ivy", - email="ivy@example.com", - ) - cls.regular_user4 = User.objects.create_user( - username="jack", - email="jack@example.com", - ) - cls.regular_user5 = User.objects.create_user( - username="kate", - email="kate@example.com", - ) - # Users without assigned roles - cls.regular_user7 = User.objects.create_user( - username="zoey", - email="zoey@example.com", - ) + cls.create_admin_users(quantity=3) + cls.create_regular_users(quantity=7) def setUp(self): """Set up test fixtures.""" super().setUp() self.client = APIClient() + self.admin_user = User.objects.get(username="admin_1") + self.regular_user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=self.admin_user) @ddt class TestPermissionValidationMeView(ViewTestMixin): """Test suite for PermissionValidationMeView.""" - @classmethod - def setUpTestData(cls): - """Set up test fixtures.""" - super().setUpTestData() - def setUp(self): """Set up test fixtures.""" super().setUp() - self.client.force_authenticate(user=self.admin_user) self.url = reverse("openedx_authz:permission-validation-me") @data( # Single permission - allowed - ([{"action": "view_library", "scope": "lib:Org1:math_101"}], [True]), - # Single permission - denied (invalid scope) - ([{"action": "view_library", "scope": "lib:DemoX:CSPROB"}], [False]), - # Single permission - denied (invalid action) - ([{"action": "edit_library", "scope": "lib:Org1:math_101"}], [False]), - # Multiple permissions - mixed results + ([{"action": "view_library", "scope": "lib:Org1:LIB1"}], [True]), + # Single permission - denied (scope not assigned to user) + ([{"action": "view_library", "scope": "lib:Org2:LIB2"}], [False]), + # # Single permission - denied (action not assigned to user) + ([{"action": "edit_library", "scope": "lib:Org1:LIB1"}], [False]), + # # Multiple permissions - mixed results ( [ - {"action": "view_library", "scope": "lib:Org1:math_101"}, - {"action": "view_library", "scope": "lib:DemoX:CSPROB"}, - {"action": "edit_library", "scope": "lib:Org1:math_101"}, + {"action": "view_library", "scope": "lib:Org1:LIB1"}, + {"action": "view_library", "scope": "lib:Org2:LIB2"}, + {"action": "edit_library", "scope": "lib:Org1:LIB1"}, ], [True, False, False], ), @@ -121,6 +172,7 @@ def test_permission_validation_success(self, request_data: list[dict], permissio - Returns 200 OK status - Returns correct permission validation results """ + self.client.force_authenticate(user=self.regular_user) expected_response = request_data.copy() for idx, perm in enumerate(permission_map): expected_response[idx]["allowed"] = perm @@ -133,19 +185,19 @@ def test_permission_validation_success(self, request_data: list[dict], permissio @data( # Single permission [{"action": "edit_library"}], - [{"scope": "lib:Org1:math_101"}], + [{"scope": "lib:Org1:LIB1"}], [{"action": "edit_library", "scope": ""}], [{"action": "edit_library", "scope": "s" * 256}], - [{"action": "", "scope": "lib:Org1:math_101"}], - [{"action": "a" * 256, "scope": "lib:Org1:math_101"}], + [{"action": "", "scope": "lib:Org1:LIB1"}], + [{"action": "a" * 256, "scope": "lib:Org1:LIB1"}], # Multiple permissions [{}, {}], - [{}, {"action": "edit_library", "scope": "lib:Org1:math_101"}], - [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {}], - [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "", "scope": "lib:Org1:math_101"}], - [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "edit_library", "scope": ""}], - [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"scope": "lib:Org1:math_101"}], - [{"action": "edit_library", "scope": "lib:Org1:math_101"}, {"action": "edit_library"}], + [{}, {"action": "edit_library", "scope": "lib:Org1:LIB1"}], + [{"action": "edit_library", "scope": "lib:Org1:LIB1"}, {}], + [{"action": "edit_library", "scope": "lib:Org1:LIB1"}, {"action": "", "scope": "lib:Org1:LIB1"}], + [{"action": "edit_library", "scope": "lib:Org1:LIB1"}, {"action": "edit_library", "scope": ""}], + [{"action": "edit_library", "scope": "lib:Org1:LIB1"}, {"scope": "lib:Org1:LIB1"}], + [{"action": "edit_library", "scope": "lib:Org1:LIB1"}, {"action": "edit_library"}], ) def test_permission_validation_invalid_data(self, invalid_data: list[dict]): """Test permission validation with invalid request data. @@ -164,40 +216,38 @@ def test_permission_validation_unauthenticated(self): - Returns 401 UNAUTHORIZED status """ action = "edit_library" - scope = "lib:DemoX:CSPROB" + scope = "lib:Org1:LIB1" self.client.force_authenticate(user=None) response = self.client.post(self.url, data=[{"action": action, "scope": scope}], format="json") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - @patch.object(api, "is_user_allowed") - def test_permission_validation_exception_handling(self, mock_is_user_allowed): - """Test permission validation when an exception occurs. + @data( + (Exception(), status.HTTP_500_INTERNAL_SERVER_ERROR, "An error occurred while validating permissions"), + (ValueError(), status.HTTP_400_BAD_REQUEST, "Invalid scope format"), + ) + @unpack + def test_permission_validation_exception_handling(self, exception: Exception, status_code: int, message: str): + """Test permission validation exception handling for different error types. Expected result: - - Returns 500 INTERNAL SERVER ERROR status - - Returns empty response data when exceptions occur + - Generic Exception: Returns 500 INTERNAL SERVER ERROR with appropriate message + - ValueError: Returns 400 BAD REQUEST with scope format error message """ - action = "edit_library" - scope = "lib:DemoX:CSPROB" - mock_is_user_allowed.side_effect = Exception() + with patch.object(api, "is_user_allowed", side_effect=exception): + response = self.client.post( + self.url, data=[{"action": "edit_library", "scope": "lib:Org1:LIB1"}], format="json" + ) - response = self.client.post(self.url, data=[{"action": action, "scope": scope}], format="json") - - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertEqual(response.data, {"message": "An error occurred while validating permissions"}) + self.assertEqual(response.status_code, status_code) + self.assertEqual(response.data, {"message": message}) @ddt class TestRoleUserAPIView(ViewTestMixin): """Test suite for RoleUserAPIView.""" - @classmethod - def setUpTestData(cls): - """Set up test fixtures.""" - super().setUpTestData() - def setUp(self): """Set up test fixtures.""" super().setUp() @@ -213,30 +263,31 @@ def setUp(self): # All users ({}, 3), # Search by username - ({"search": "ivy"}, 1), - ({"search": "k"}, 2), - ({"search": "nonexistent"}, 0), + ({"search": "regular_1"}, 1), + ({"search": "regular"}, 2), ({"search": "nonexistent"}, 0), # Search by email - ({"search": "ivy@example.com"}, 1), + ({"search": "regular_1@example.com"}, 1), ({"search": "@example.com"}, 3), ({"search": "nonexistent@example.com"}, 0), # Search by single role ({"roles": "library_admin"}, 1), - ({"roles": "library_author"}, 1), - ({"roles": "library_user"}, 1), + ({"roles": "library_author"}, 0), + ({"roles": "library_user"}, 2), # Search by multiple roles - ({"roles": "library_admin,library_author"}, 2), + ({"roles": "library_admin,library_author"}, 1), ({"roles": "library_author,library_user"}, 2), - ({"roles": "library_user,library_admin"}, 2), + ({"roles": "library_user,library_admin"}, 3), ({"roles": "library_admin,library_author,library_user"}, 3), # Search by role and username - ({"search": "ivy", "roles": "library_admin"}, 1), - ({"search": "jack", "roles": "library_admin"}, 0), + ({"search": "admin_1", "roles": "library_admin"}, 1), + ({"search": "regular_1", "roles": "library_user"}, 1), + ({"search": "regular_1", "roles": "library_admin"}, 0), # Search by role and email - ({"search": "ivy@example.com", "roles": "library_admin"}, 1), + ({"search": "admin_1@example.com", "roles": "library_admin"}, 1), ({"search": "@example.com", "roles": "library_admin"}, 1), - ({"search": "jack@example.com", "roles": "library_admin"}, 0), + ({"search": "@example.com", "roles": "library_user"}, 2), + ({"search": "regular_1@example.com", "roles": "library_admin"}, 0), ) @unpack def test_get_users_by_scope_success(self, query_params: dict, expected_count: int): @@ -246,7 +297,7 @@ def test_get_users_by_scope_success(self, query_params: dict, expected_count: in - Returns 200 OK status - Returns correct user role assignments """ - query_params["scope"] = "lib:Org3:cs_101" + query_params["scope"] = "lib:Org1:LIB1" response = self.client.get(self.url, query_params) @@ -260,12 +311,12 @@ def test_get_users_by_scope_success(self, query_params: dict, expected_count: in {}, {"scope": ""}, {"scope": "a" * 256}, - {"scope": "lib:DemoX:CSPROB", "sort_by": "invalid"}, - {"scope": "lib:DemoX:CSPROB", "sort_by": "name"}, - {"scope": "lib:DemoX:CSPROB", "order": "ascending"}, - {"scope": "lib:DemoX:CSPROB", "order": "descending"}, - {"scope": "lib:DemoX:CSPROB", "order": "up"}, - {"scope": "lib:DemoX:CSPROB", "order": "down"}, + {"scope": "lib:Org1:LIB1", "sort_by": "invalid"}, + {"scope": "lib:Org1:LIB1", "sort_by": "name"}, + {"scope": "lib:Org1:LIB1", "order": "ascending"}, + {"scope": "lib:Org1:LIB1", "order": "descending"}, + {"scope": "lib:Org1:LIB1", "order": "up"}, + {"scope": "lib:Org1:LIB1", "order": "down"}, ) def test_get_users_by_scope_invalid_params(self, query_params: dict): """Test retrieving users with invalid query parameters. @@ -288,11 +339,11 @@ def test_get_users_by_scope_invalid_params(self, query_params: dict): # Unauthenticated (None, status.HTTP_401_UNAUTHORIZED), # Admin user - ("alice", status.HTTP_200_OK), + ("admin_1", status.HTTP_200_OK), # Regular user with permission - ("kate", status.HTTP_200_OK), + ("regular_1", status.HTTP_200_OK), # Regular user without permission - ("zoey", status.HTTP_403_FORBIDDEN), + ("regular_3", status.HTTP_403_FORBIDDEN), ) @unpack def test_get_users_by_scope_permissions(self, username: str, status_code: int): @@ -304,30 +355,30 @@ def test_get_users_by_scope_permissions(self, username: str, status_code: int): user = User.objects.filter(username=username).first() self.client.force_authenticate(user=user) - response = self.client.get(self.url, {"scope": "lib:Org3:cs_101"}) + response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status_code) @data( # With username ----------------------------- # Single user - success (admin user) - (["eve"], 1, 0), + (["admin_1"], 1, 0), # Single user - success (regular user) - (["bob"], 1, 0), + (["regular_1"], 1, 0), # Multiple users - success (admin and regular users) - (["eve", "bob", "carol"], 3, 0), + (["admin_1", "regular_1", "regular_2"], 3, 0), # With email --------------------------------- # Single user - success (admin user) - (["eve@example.com"], 1, 0), + (["admin_1@example.com"], 1, 0), # Single user - success (regular user) - (["bob@example.com"], 1, 0), + (["regular_1@example.com"], 1, 0), # Multiple users - admin and regular users - (["eve@example.com", "bob@example.com", "carol@example.com"], 3, 0), + (["admin_1@example.com", "regular_1@example.com", "regular_2@example.com"], 3, 0), # With username and email -------------------- # All success - (["eve", "bob@example.com", "carol@example.com"], 3, 0), + (["admin_1", "regular_1@example.com", "regular_2@example.com"], 3, 0), # Mixed results (user not found) - (["eve", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), + (["admin_1", "regular_1@example.com", "nonexistent", "notexistent@example.com"], 2, 2), ) @unpack def test_add_users_to_role_success(self, users: list[str], expected_completed: int, expected_errors: int): @@ -338,7 +389,7 @@ def test_add_users_to_role_success(self, users: list[str], expected_completed: i - Returns appropriate completed and error counts """ role = "library_admin" - request_data = {"role": role, "scope": "lib:Org1:math_101", "users": users} + request_data = {"role": role, "scope": "lib:Org1:LIB3", "users": users} with patch.object(api.ContentLibraryData, "exists", return_value=True): response = self.client.put(self.url, data=request_data, format="json") @@ -349,28 +400,20 @@ def test_add_users_to_role_success(self, users: list[str], expected_completed: i @data( # Single user - success (admin user) - (["alice"], 0, 1), + (["admin_2"], 0, 1), # Single user - success (regular user) - (["bob"], 0, 1), - # Multiple users - success - (["kate", "ivy", "jack"], 3, 0), + (["regular_3"], 0, 1), # Multiple users - one user already has the role - (["alice", "ivy", "jack"], 2, 1), + (["regular_1", "regular_2", "regular_3"], 2, 1), # Multiple users - all users already have the role - (["alice", "bob", "carol"], 0, 3), + (["admin_2", "regular_3", "regular_4"], 0, 3), ) @unpack def test_add_users_to_role_already_has_role(self, users: list[str], expected_completed: int, expected_errors: int): """Test adding users to a role that already has the role.""" - role = "library_admin" - scope = "lib:DemoX:CSPROB" + role = "library_user" + scope = "lib:Org2:LIB2" request_data = {"role": role, "scope": scope, "users": users} - assignments = [ - {"subject_name": "alice", "role_name": role, "scope_name": scope}, - {"subject_name": "bob", "role_name": role, "scope_name": scope}, - {"subject_name": "carol", "role_name": role, "scope_name": scope}, - ] - self._assign_roles_to_users(assignments=assignments) with patch.object(api.ContentLibraryData, "exists", return_value=True): response = self.client.put(self.url, data=request_data, format="json") @@ -382,7 +425,7 @@ def test_add_users_to_role_already_has_role(self, users: list[str], expected_com @patch.object(api, "assign_role_to_user_in_scope") def test_add_users_to_role_exception_handling(self, mock_assign_role_to_user_in_scope): """Test adding users to a role with exception handling.""" - request_data = {"role": "library_admin", "scope": "lib:Org1:math_101", "users": ["alice"]} + request_data = {"role": "library_admin", "scope": "lib:Org1:LIB1", "users": ["regular_1"]} mock_assign_role_to_user_in_scope.side_effect = Exception() with patch.object(api.ContentLibraryData, "exists", return_value=True): @@ -391,20 +434,20 @@ def test_add_users_to_role_exception_handling(self, mock_assign_role_to_user_in_ self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) self.assertEqual(len(response.data["completed"]), 0) self.assertEqual(len(response.data["errors"]), 1) - self.assertEqual(response.data["errors"][0]["user_identifier"], "alice") + self.assertEqual(response.data["errors"][0]["user_identifier"], "regular_1") self.assertEqual(response.data["errors"][0]["error"], RoleOperationError.ROLE_ASSIGNMENT_ERROR) @data( {}, {"role": "library_admin"}, - {"scope": "lib:DemoX:CSPROB"}, - {"users": ["admin_user"]}, - {"role": "library_admin", "scope": "lib:DemoX:CSPROB"}, - {"scope": "lib:DemoX:CSPROB", "users": ["admin_user"]}, - {"users": ["admin_user", "regular_user"], "role": "library_admin"}, - {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": []}, - {"role": "", "scope": "lib:DemoX:CSPROB", "users": ["admin_user"]}, - {"role": "library_admin", "scope": "", "users": ["admin_user"]}, + {"scope": "lib:Org1:LIB1"}, + {"users": ["admin_1"]}, + {"role": "library_admin", "scope": "lib:Org1:LIB1"}, + {"scope": "lib:Org1:LIB1", "users": ["admin_1"]}, + {"users": ["admin_1", "regular_1"], "role": "library_admin"}, + {"role": "library_admin", "scope": "lib:Org1:LIB1", "users": []}, + {"role": "", "scope": "lib:Org1:LIB1", "users": ["admin_1"]}, + {"role": "library_admin", "scope": "", "users": ["admin_1"]}, ) def test_add_users_to_role_invalid_data(self, request_data: dict): """Test adding users with invalid request data. @@ -421,11 +464,11 @@ def test_add_users_to_role_invalid_data(self, request_data: dict): # Unauthenticated (None, status.HTTP_401_UNAUTHORIZED), # Admin user - ("alice", status.HTTP_207_MULTI_STATUS), + ("admin_3", status.HTTP_207_MULTI_STATUS), # Regular user with permission - ("ivy", status.HTTP_207_MULTI_STATUS), + ("regular_5", status.HTTP_207_MULTI_STATUS), # Regular user without permission - ("zoey", status.HTTP_403_FORBIDDEN), + ("regular_3", status.HTTP_403_FORBIDDEN), ) @unpack def test_add_users_to_role_permissions(self, username: str, status_code: int): @@ -434,7 +477,7 @@ def test_add_users_to_role_permissions(self, username: str, status_code: int): Expected result: - Returns appropriate status code based on permissions """ - request_data = {"role": "library_admin", "scope": "lib:Org3:cs_101", "users": ["user1"]} + request_data = {"role": "library_admin", "scope": "lib:Org3:LIB3", "users": ["regular_2"]} user = User.objects.filter(username=username).first() self.client.force_authenticate(user=user) @@ -446,23 +489,23 @@ def test_add_users_to_role_permissions(self, username: str, status_code: int): @data( # With username ----------------------------- # Single user - success (admin user) - (["alice"], 1, 0), + (["admin_2"], 1, 0), # Single user - success (regular user) - (["bob"], 1, 0), + (["regular_3"], 1, 0), # Multiple users - all success (admin and regular users) - (["alice", "bob", "carol"], 3, 0), + (["admin_2", "regular_3", "regular_4"], 3, 0), # With email -------------------------------- # Single user - success (admin user) - (["alice@example.com"], 1, 0), + (["admin_2@example.com"], 1, 0), # Single user - success (regular user) - (["bob@example.com"], 1, 0), + (["regular_3@example.com"], 1, 0), # Multiple users - all success (admin and regular users) - (["alice@example.com", "bob@example.com", "carol@example.com"], 3, 0), + (["admin_2@example.com", "regular_3@example.com", "regular_4@example.com"], 3, 0), # With username and email ------------------- # All success - (["alice", "bob@example.com", "carol@example.com"], 3, 0), + (["admin_2", "regular_3@example.com", "regular_4@example.com"], 3, 0), # Mixed results (user not found) - (["alice", "bob@example.com", "nonexistent", "notexistent@example.com"], 2, 2), + (["admin_2", "regular_3@example.com", "nonexistent", "notexistent@example.com"], 2, 2), ) @unpack def test_remove_users_from_role_success(self, users: list[str], expected_completed: int, expected_errors: int): @@ -472,12 +515,7 @@ def test_remove_users_from_role_success(self, users: list[str], expected_complet - Returns 207 MULTI-STATUS status - Returns appropriate completed and error counts """ - role = "library_admin" - scope = "lib:DemoX:CSPROB" - users_to_assign = ["alice", "bob", "carol"] - assignments = [{"subject_name": user, "role_name": role, "scope_name": scope} for user in users_to_assign] - self._assign_roles_to_users(assignments=assignments) - query_params = {"role": role, "scope": scope, "users": ",".join(users)} + query_params = {"role": "library_user", "scope": "lib:Org2:LIB2", "users": ",".join(users)} with patch.object(api.ContentLibraryData, "exists", return_value=True): response = self.client.delete(f"{self.url}?{urlencode(query_params)}") @@ -489,7 +527,7 @@ def test_remove_users_from_role_success(self, users: list[str], expected_complet @patch.object(api, "unassign_role_from_user") def test_remove_users_from_role_exception_handling(self, mock_unassign_role_from_user): """Test removing users from a role with exception handling.""" - query_params = {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": "alice,bob,carol"} + query_params = {"role": "library_admin", "scope": "lib:Org1:LIB1", "users": "regular_1,regular_2,regular_3"} mock_unassign_role_from_user.side_effect = [True, False, Exception()] with patch.object(api.ContentLibraryData, "exists", return_value=True): @@ -497,24 +535,24 @@ def test_remove_users_from_role_exception_handling(self, mock_unassign_role_from self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) self.assertEqual(len(response.data["completed"]), 1) self.assertEqual(len(response.data["errors"]), 2) - self.assertEqual(response.data["completed"][0]["user_identifier"], "alice") + self.assertEqual(response.data["completed"][0]["user_identifier"], "regular_1") self.assertEqual(response.data["completed"][0]["status"], RoleOperationStatus.ROLE_REMOVED) - self.assertEqual(response.data["errors"][0]["user_identifier"], "bob") + self.assertEqual(response.data["errors"][0]["user_identifier"], "regular_2") self.assertEqual(response.data["errors"][0]["error"], RoleOperationError.USER_DOES_NOT_HAVE_ROLE) - self.assertEqual(response.data["errors"][1]["user_identifier"], "carol") + self.assertEqual(response.data["errors"][1]["user_identifier"], "regular_3") self.assertEqual(response.data["errors"][1]["error"], RoleOperationError.ROLE_REMOVAL_ERROR) @data( {}, {"role": "library_admin"}, - {"scope": "lib:DemoX:CSPROB"}, - {"users": "admin_user"}, - {"role": "library_admin", "scope": "lib:DemoX:CSPROB"}, - {"scope": "lib:DemoX:CSPROB", "users": "admin_user"}, - {"users": "admin_user,regular_user", "role": "library_admin"}, - {"role": "library_admin", "scope": "lib:DemoX:CSPROB", "users": ""}, - {"role": "", "scope": "lib:DemoX:CSPROB", "users": "admin_user"}, - {"role": "library_admin", "scope": "", "users": "admin_user"}, + {"scope": "lib:Org1:LIB1"}, + {"users": "admin_1"}, + {"role": "library_admin", "scope": "lib:Org1:LIB1"}, + {"scope": "lib:Org1:LIB1", "users": "admin_1"}, + {"users": "admin_1,regular_1", "role": "library_admin"}, + {"role": "library_admin", "scope": "lib:Org1:LIB1", "users": ""}, + {"role": "", "scope": "lib:Org1:LIB1", "users": "admin_1"}, + {"role": "library_admin", "scope": "", "users": "admin_1"}, ) def test_remove_users_from_role_invalid_params(self, query_params: dict): """Test removing users with invalid query parameters. @@ -530,11 +568,11 @@ def test_remove_users_from_role_invalid_params(self, query_params: dict): # Unauthenticated (None, status.HTTP_401_UNAUTHORIZED), # Admin user - ("alice", status.HTTP_207_MULTI_STATUS), + ("admin_3", status.HTTP_207_MULTI_STATUS), # Regular user with permission - ("ivy", status.HTTP_207_MULTI_STATUS), + ("regular_5", status.HTTP_207_MULTI_STATUS), # Regular user without permission - ("zoey", status.HTTP_403_FORBIDDEN), + ("regular_3", status.HTTP_403_FORBIDDEN), ) @unpack def test_remove_users_from_role_permissions(self, username: str, status_code: int): @@ -543,7 +581,7 @@ def test_remove_users_from_role_permissions(self, username: str, status_code: in Expected result: - Returns appropriate status code based on permissions """ - query_params = {"role": "library_admin", "scope": "lib:Org3:cs_101", "users": "user1,user2"} + query_params = {"role": "library_admin", "scope": "lib:Org3:LIB3", "users": "user1,user2"} user = User.objects.filter(username=username).first() self.client.force_authenticate(user=user) @@ -557,11 +595,6 @@ def test_remove_users_from_role_permissions(self, username: str, status_code: in class TestRoleListView(ViewTestMixin): """Test suite for RoleListView.""" - @classmethod - def setUpTestData(cls): - """Set up test fixtures.""" - super().setUpTestData() - def setUp(self): """Set up test fixtures.""" super().setUp() @@ -575,7 +608,7 @@ def test_get_roles_success(self): - Returns 200 OK status - Returns correct role definitions with permissions and user counts """ - response = self.client.get(self.url, {"scope": "lib:DemoX:CSPROB"}) + response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -593,7 +626,7 @@ def test_get_roles_empty_result(self, mock_get_roles): """ mock_get_roles.return_value = [] - response = self.client.get(self.url, {"scope": "lib:DemoX:CSPROB"}) + response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -648,7 +681,7 @@ def test_get_roles_pagination(self, query_params: dict, expected_count: int, has - Returns 200 OK status - Returns paginated results with correct page size """ - query_params["scope"] = "lib:DemoX:CSPROB" + query_params["scope"] = "lib:Org1:LIB1" response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_200_OK)