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__)) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 17d09804..a4c1b359 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,10 +24,12 @@ "RoleData", "ScopeData", "SubjectData", + "ContentLibraryData", ] AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" EXTERNAL_KEY_SEPARATOR = ":" +GENERIC_SCOPE_WILDCARD = "*" NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$" @@ -249,6 +257,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 +323,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 +386,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 +606,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 +624,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 +653,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 +671,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/data.py b/openedx_authz/rest_api/data.py new file mode 100644 index 00000000..5af8afe1 --- /dev/null +++ b/openedx_authz/rest_api/data.py @@ -0,0 +1,52 @@ +"""Data classes and 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/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 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..370437a4 --- /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 openedx_authz.api.data import GENERIC_SCOPE_WILDCARD, ScopeData +from openedx_authz.rest_api.data import SearchField, SortField, SortOrder + +User = get_user_model() + + +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. + + 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]: + """ + 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..89800788 --- /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 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""" + 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..9fb0ad33 --- /dev/null +++ b/openedx_authz/rest_api/v1/permissions.py @@ -0,0 +1,292 @@ +"""Permissions for the Open edX AuthZ REST API.""" + +from typing import ClassVar + +from rest_framework.permissions import BasePermission + +from openedx_authz import api + + +class PermissionMeta(type(BasePermission)): + """Metaclass that automatically registers permission classes by namespace. + + 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. + """ + + 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") + + """ + 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. + """ + + 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. + + Args: + request: The Django REST framework request object. + + Returns: + 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") + + 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 = self.get_scope_value(request) + if not scope_value: + return self.NAMESPACE + try: + return api.ScopeData(external_key=scope_value).NAMESPACE + except ValueError: + 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 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. + + 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: 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. + + 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) + + 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) + + +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 diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py new file mode 100644 index 00000000..92dee429 --- /dev/null +++ b/openedx_authz/rest_api/v1/serializers.py @@ -0,0 +1,201 @@ +"""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.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() + + +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) -> 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"] + + 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) + 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}'") + + 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) + + def validate_users(self, value) -> list[str]: + """Eliminate duplicates preserving order""" + return list(dict.fromkeys(value)) + + +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 ListRolesWithScopeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for listing roles within a scope.""" + + scope = serializers.CharField(max_length=255) + + def validate_scope(self, value: str) -> api.ScopeData: + """Validate and convert scope string to a ScopeData instance. + + Checks that the provided scope is registered in the scope registry and + returns an instance of the appropriate ScopeData subclass. + + Args: + value: The scope string to validate (e.g., 'lib', 'sc', 'org'). + + Returns: + ScopeData: An instance of the appropriate ScopeData subclass for the scope. + + Raises: + serializers.ValidationError: If the scope is not registered in the scope registry. + + Examples: + >>> validate_scope('lib:DemoX:CSPROB') + ContentLibraryData(external_key='lib:DemoX:CSPROB') + """ + 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 + """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..a88e0232 --- /dev/null +++ b/openedx_authz/rest_api/v1/views.py @@ -0,0 +1,467 @@ +""" +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.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, +) +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, + ListRolesWithScopeResponseSerializer, + ListRolesWithScopeSerializer, + 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 + + .. code-block:: json + + [ + {"action": "edit_library", "scope": "lib:DemoX:CSPROB"}, + {"action": "delete_library_content", "scope": "lib:OpenedX:CS50"} + ] + + **Example Response** + + .. code-block:: json + + [ + {"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": true}, + {"action": "delete_library_content", "scope": "lib:OpenedX:CS50", "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 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( + 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, email or full name + - 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') + - order (Optional): Sort order ('asc' or 'desc') + + **Request Format (PUT)** + + - 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 (username or email) + - role: The role to remove users from + - scope: The scope to remove users from + + **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: + + .. code-block:: json + + { + "completed": [{"user_identifier": "john_doe", "status": "role_added"}], + "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 ``manage_library_team`` permission for the scope. + + **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"] + } + + DELETE /api/authz/v1/roles/users/?role=library_admin&scope=lib:DemoX:CSPROB&users=user1@example.com,username2 + """ + + pagination_class = AuthZAPIViewPagination + permission_classes = [DynamicScopePermission] + + @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 or does not have the required permissions", + }, + ) + @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) + 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} + context = {"user_map": get_user_map(usernames)} + serialized_data = UserRoleAssignmentSerializer(user_role_assignments, many=True, context=context) + + 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() + 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 or does not have the required permissions", + }, + ) + @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) + serializer.is_valid(raise_exception=True) + + 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, 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 or does not have the required permissions", + }, + ) + @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) + serializer.is_valid(raise_exception=True) + + 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.unassign_role_from_user(user.username, role, 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 scope. + + This view provides read-only access to role definitions within a specific + 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 scope + + **Query Parameters** + + - 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 + + **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 (e.g., 'delete_library_content') + - user_count: Number of users currently assigned to this role + + **Authentication and Permissions** + + - Requires authenticated user. + - Requires ``manage_library_team`` permission for the scope. + + **Example Request** + + GET /api/authz/v1/roles/?scope=lib:OpenedX:CSPROB&page=1&page_size=10 + + **Example Response** + + .. code-block:: json + + { + "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 + permission_classes = [DynamicScopePermission] + + @apidocs.schema( + parameters=[ + 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"), + ], + 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 or does not have the required permissions", + }, + ) + @authz_permissions(["manage_library_team"]) + def get(self, request: HttpRequest) -> Response: + """Retrieve all roles and their permissions for a specific scope.""" + serializer = ListRolesWithScopeSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + 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) + response_data.append( + { + "role": role.external_key, + "permissions": role.get_permission_identifiers(), + "user_count": len(users), + } + ) + + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(response_data, request) + serialized_data = ListRolesWithScopeResponseSerializer(paginated_response_data, many=True) + return paginator.get_paginated_response(serialized_data.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/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/__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..fca744a1 --- /dev/null +++ b/openedx_authz/tests/rest_api/test_views.py @@ -0,0 +1,694 @@ +""" +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.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_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'). + + The generic Django User model doesn't have a profile relation, + so we override this in tests to avoid FieldError. + """ + users = User.objects.filter(username__in=usernames) + return {user.username: user for user in users} + + +class ViewTestMixin(BaseRolesTestCase): + """Mixin providing common test utilities for view tests.""" + + @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() + 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.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:permission-validation-me") + + @data( + # Single permission - allowed + ([{"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:LIB1"}, + {"action": "view_library", "scope": "lib:Org2:LIB2"}, + {"action": "edit_library", "scope": "lib:Org1:LIB1"}, + ], + [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 + """ + 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 + + 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:LIB1"}], + [{"action": "edit_library", "scope": ""}], + [{"action": "edit_library", "scope": "s" * 256}], + [{"action": "", "scope": "lib:Org1:LIB1"}], + [{"action": "a" * 256, "scope": "lib:Org1:LIB1"}], + # Multiple permissions + [{}, {}], + [{}, {"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. + + 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: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) + + @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: + - Generic Exception: Returns 500 INTERNAL SERVER ERROR with appropriate message + - ValueError: Returns 400 BAD REQUEST with scope format error message + """ + 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" + ) + + self.assertEqual(response.status_code, status_code) + self.assertEqual(response.data, {"message": message}) + + +@ddt +class TestRoleUserAPIView(ViewTestMixin): + """Test suite for RoleUserAPIView.""" + + 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": "regular_1"}, 1), + ({"search": "regular"}, 2), + ({"search": "nonexistent"}, 0), + # Search by email + ({"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"}, 0), + ({"roles": "library_user"}, 2), + # Search by multiple roles + ({"roles": "library_admin,library_author"}, 1), + ({"roles": "library_author,library_user"}, 2), + ({"roles": "library_user,library_admin"}, 3), + ({"roles": "library_admin,library_author,library_user"}, 3), + # Search by role and username + ({"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": "admin_1@example.com", "roles": "library_admin"}, 1), + ({"search": "@example.com", "roles": "library_admin"}, 1), + ({"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): + """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:Org1:LIB1" + + 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: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. + + 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 + ("admin_1", status.HTTP_200_OK), + # Regular user with permission + ("regular_1", status.HTTP_200_OK), + # Regular user without permission + ("regular_3", 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:Org1:LIB1"}) + + self.assertEqual(response.status_code, status_code) + + @data( + # With username ----------------------------- + # Single user - success (admin user) + (["admin_1"], 1, 0), + # Single user - success (regular user) + (["regular_1"], 1, 0), + # Multiple users - success (admin and regular users) + (["admin_1", "regular_1", "regular_2"], 3, 0), + # With email --------------------------------- + # Single user - success (admin user) + (["admin_1@example.com"], 1, 0), + # Single user - success (regular user) + (["regular_1@example.com"], 1, 0), + # Multiple users - admin and regular users + (["admin_1@example.com", "regular_1@example.com", "regular_2@example.com"], 3, 0), + # With username and email -------------------- + # All success + (["admin_1", "regular_1@example.com", "regular_2@example.com"], 3, 0), + # Mixed results (user not found) + (["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): + """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:Org1:LIB3", "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) + (["admin_2"], 0, 1), + # Single user - success (regular user) + (["regular_3"], 0, 1), + # Multiple users - one user already has the role + (["regular_1", "regular_2", "regular_3"], 2, 1), + # Multiple users - all users already have the role + (["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_user" + scope = "lib:Org2:LIB2" + request_data = {"role": role, "scope": scope, "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) + + @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:LIB1", "users": ["regular_1"]} + 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"], "regular_1") + self.assertEqual(response.data["errors"][0]["error"], RoleOperationError.ROLE_ASSIGNMENT_ERROR) + + @data( + {}, + {"role": "library_admin"}, + {"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. + + Expected result: + - Returns 400 BAD REQUEST status + """ + 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) + + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user + ("admin_3", status.HTTP_207_MULTI_STATUS), + # Regular user with permission + ("regular_5", status.HTTP_207_MULTI_STATUS), + # Regular user without permission + ("regular_3", 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:LIB3", "users": ["regular_2"]} + 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) + (["admin_2"], 1, 0), + # Single user - success (regular user) + (["regular_3"], 1, 0), + # Multiple users - all success (admin and regular users) + (["admin_2", "regular_3", "regular_4"], 3, 0), + # With email -------------------------------- + # Single user - success (admin user) + (["admin_2@example.com"], 1, 0), + # Single user - success (regular user) + (["regular_3@example.com"], 1, 0), + # Multiple users - all success (admin and regular users) + (["admin_2@example.com", "regular_3@example.com", "regular_4@example.com"], 3, 0), + # With username and email ------------------- + # All success + (["admin_2", "regular_3@example.com", "regular_4@example.com"], 3, 0), + # Mixed results (user not found) + (["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): + """Test removing users from a role within a scope. + + Expected result: + - Returns 207 MULTI-STATUS status + - Returns appropriate completed and error counts + """ + 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)}") + + 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: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): + 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"], "regular_1") + self.assertEqual(response.data["completed"][0]["status"], RoleOperationStatus.ROLE_REMOVED) + 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"], "regular_3") + self.assertEqual(response.data["errors"][1]["error"], RoleOperationError.ROLE_REMOVAL_ERROR) + + @data( + {}, + {"role": "library_admin"}, + {"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. + + 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 + ("admin_3", status.HTTP_207_MULTI_STATUS), + # Regular user with permission + ("regular_5", status.HTTP_207_MULTI_STATUS), + # Regular user without permission + ("regular_3", 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:LIB3", "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.""" + + 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": "lib:Org1:LIB1"}) + + 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": "lib:Org1:LIB1"}) + + 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( + {}, + {"custom_param": "custom_value"}, + {"custom_param": "a" * 256, "another_param": "custom_value"}, + ) + 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 + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("required", [error.code for error in response.data["scope"]]) + + @data( + ({"scope": ""}, "blank"), + ({"scope": "a" * 256}, "max_length"), + ({"scope": "invalid"}, "invalid"), + ) + @unpack + 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 + """ + 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["scope"]]) + + @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"] = "lib:Org1:LIB1" + 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