From 38870eaf929fe44f2684e0e2dd4b9725f0cb98f5 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 14 Jul 2025 14:22:37 -0700 Subject: [PATCH] fix: Expand DRF permission composition support to JwtRedirectToLoginIfUnauthenticatedMiddleware IDAs which enable this middleware should still be able to use DRF permission composition. This PR reinforces the stated promise that this middleware is a no-op for any ViewSet that does not actually use LoginRedirectIfUnauthenticated. --- CHANGELOG.rst | 4 ++ edx_rest_framework_extensions/__init__.py | 2 +- .../auth/jwt/middleware.py | 50 ++++++++++--------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 871e54bd..767f08ee 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,10 @@ Change Log Unreleased ---------- +[10.6.1] - 2025-07-14 +--------------------- +* fix: Expand DRF permission composition support to JwtRedirectToLoginIfUnauthenticatedMiddleware. + [10.6.0] - 2025-04-04 --------------------- * Added Support for ``django 5.2``. diff --git a/edx_rest_framework_extensions/__init__.py b/edx_rest_framework_extensions/__init__.py index 8ae62253..f5a593fe 100644 --- a/edx_rest_framework_extensions/__init__.py +++ b/edx_rest_framework_extensions/__init__.py @@ -1,3 +1,3 @@ """ edx Django REST Framework extensions. """ -__version__ = '10.6.0' # pragma: no cover +__version__ = '10.6.1' # pragma: no cover diff --git a/edx_rest_framework_extensions/auth/jwt/middleware.py b/edx_rest_framework_extensions/auth/jwt/middleware.py index ba57023b..04462eda 100644 --- a/edx_rest_framework_extensions/auth/jwt/middleware.py +++ b/edx_rest_framework_extensions/auth/jwt/middleware.py @@ -35,6 +35,27 @@ log = logging.getLogger(__name__) +def _iter_included_base_classes(view_permissions): + """ + Yield all the permissions that are encapsulated in provided view_permissions, directly or as + a part of DRF's composed permissions. + """ + # Not all permissions are classes, some will be OperandHolder + # objects from DRF. So we have to crawl all those and expand them to see + # if our target classes are inside the conditionals somewhere. + for permission in view_permissions: + # Composition using DRF native support in 3.9+: + # IsStaff | IsSuperuser -> [IsStaff, IsSuperuser] + # IsOwner | IsStaff | IsSuperuser -> [IsOwner | IsStaff, IsSuperuser] + if isinstance(permission, OperandHolder): + decomposed_permissions = [permission.op1_class, permission.op2_class] + yield from _iter_included_base_classes(decomposed_permissions) + elif isinstance(permission, SingleOperandHolder): + yield permission.op1_class + else: + yield permission + + class EnsureJWTAuthSettingsMiddleware(MiddlewareMixin): """ Django middleware object that ensures the proper Permission classes @@ -42,26 +63,6 @@ class EnsureJWTAuthSettingsMiddleware(MiddlewareMixin): """ _required_permission_classes = (NotJwtRestrictedApplication,) - def _iter_included_base_classes(self, view_permissions): - """ - Yield all the permissions that are encapsulated in provided view_permissions, directly or as - a part of DRF's composed permissions. - """ - # Not all permissions are classes, some will be OperandHolder - # objects from DRF. So we have to crawl all those and expand them to see - # if our target classes are inside the conditionals somewhere. - for permission in view_permissions: - # Composition using DRF native support in 3.9+: - # IsStaff | IsSuperuser -> [IsStaff, IsSuperuser] - # IsOwner | IsStaff | IsSuperuser -> [IsOwner | IsStaff, IsSuperuser] - if isinstance(permission, OperandHolder): - decomposed_permissions = [permission.op1_class, permission.op2_class] - yield from self._iter_included_base_classes(decomposed_permissions) - elif isinstance(permission, SingleOperandHolder): - yield permission.op1_class - else: - yield permission - def _add_missing_jwt_permission_classes(self, view_class): """ Adds permissions classes that should exist for Jwt based authentication, @@ -71,7 +72,7 @@ def _add_missing_jwt_permission_classes(self, view_class): view_permissions = list(getattr(view_class, 'permission_classes', [])) for perm_class in self._required_permission_classes: - if not _includes_base_class(self._iter_included_base_classes(view_permissions), perm_class): + if not _includes_base_class(_iter_included_base_classes(view_permissions), perm_class): message = ( "The view %s allows Jwt Authentication. The required permission class, %s,", " was automatically added." @@ -161,8 +162,11 @@ def _check_and_cache_login_required_found(self, view_func): Checks for LoginRedirectIfUnauthenticated permission and caches the result. """ view_class = _get_view_class(view_func) - view_permission_classes = getattr(view_class, 'permission_classes', tuple()) - is_login_required_found = _includes_base_class(view_permission_classes, LoginRedirectIfUnauthenticated) + view_permissions = getattr(view_class, 'permission_classes', tuple()) + is_login_required_found = _includes_base_class( + _iter_included_base_classes(view_permissions), + LoginRedirectIfUnauthenticated, + ) self._get_request_cache()[self._LOGIN_REQUIRED_FOUND_CACHE_KEY] = is_login_required_found