0

I am experiencing some weird behavior where djangorestframework returns a 404 when trying to browse the browsable API, but attaching a ?format=json at the end returns a normal response.

Using:

Django==4.0.3
django-guardian==2.4.0
djangorestframework==3.13.1
djangorestframework-guardian==0.3.0

A simplified version of my project setup:

#### API views
...
class UserRUDViewSet(
    drf_mixins.RetrieveModelMixin,
    drf_mixins.UpdateModelMixin,
    drf_mixins.DestroyModelMixin,
    viewsets.GenericViewSet,
):
    """Viewset combining the RUD views for the User model"""

    serializer_class = serializers.UserSerializer
    queryset = models.User.objects.all()
    permission_classes = [permissions.RudUserModelPermissions | permissions.RudUserObjectPermissions]
...


#### app API urls
...

_api_prefix = lambda x: f"appprefix/{x}"

api_v1_router = routers.DefaultRouter()
...
api_v1_router.register(_api_prefix("user"), views.UserRUDViewSet, basename="user")


#### project urls
from app.api.urls import api_v1_router as app_api_v1_router
...

api_v1_router = routers.DefaultRouter()
api_v1_router.registry.extend(app_api_v1_router.registry)
...

urlpatterns = [
    ...
    path("api/v1/", include((api_v1_router.urls, "project_name"), namespace="v1")),
    ...
]

The problem:

I am trying to add permissions in such a way that:

  • A user can only retrieve, update or delete its own User model instance (using per-object permissions which are assigned to his model instance on creation)
  • A user with model-wide retrieve, update or delete permissions (for example assigned using the admin panel), who may or may not also be a django superuser (admin) can RUD all user models.

To achieve this my logic is as follows:

  1. Have a permissions class which only checks if a user has per-object permission:
class RudUserObjectPermissions(drf_permissions.DjangoObjectPermissions):
    perms_map = {
        'GET': ['%(app_label)s.view_%(model_name)s'],
        'OPTIONS': ['%(app_label)s.view_%(model_name)s'],
        'HEAD': ['%(app_label)s.view_%(model_name)s'],
        'POST': ['%(app_label)s.add_%(model_name)s'],
        'PUT': ['%(app_label)s.change_%(model_name)s'],
        'PATCH': ['%(app_label)s.change_%(model_name)s'],
        'DELETE': ['%(app_label)s.delete_%(model_name)s'],
    }

    def has_permission(self, request, view):
        return True
  1. Have a class which checks for model-wide permissions but does this in the has_object_permission method:
 class RudUserModelPermissions(drf_permissions.DjangoObjectPermissions):
    perms_map = {
        'GET': ['%(app_label)s.view_%(model_name)s'],
        ...
        # Same as the other permissions class
    }

    # has_permission() == true if we are to get anywhere - no need to override

    # Originally tried like this
    # def has_object_permission(self, request, view, obj):
    #     return super().has_permission(request, view)
    
    # Copied from the drf_permissions. DjangoObjectPermissions class 
    def has_object_permission(self, request, view, obj):
        # Changed the commented out lines only

        queryset = self._queryset(view)
        model_cls = queryset.model
        user = request.user

        perms = self.get_required_object_permissions(request.method, model_cls)

        # if not user.has_perms(perms, obj):
        if not user.has_perms(perms):

            if request.method in drf_permissions.SAFE_METHODS:
                raise drf_permissions.Http404

            read_perms = self.get_required_object_permissions('GET', model_cls)
            # if not user.has_perms(read_perms, obj):
            if not user.has_perms(read_perms):
                raise drf_permissions.Http404

            return False
            
        return True

The mystery:

Testing with a user who has:

  • PK == 3

  • per-object RUD permissions for User model instance with PK == 3 (its own model)

  • Model wide permissions for viewing users

  • Navigating to api/v1/appprefix/user/3: Returns HTTP 200, as expected

  • Navigating to api/v1/appprefix/user/2: Returns HTTP 404 (user with pk 2 exists)

  • Navigating to api/v1/appprefix/user/2?format=json: Returns HTTP 200, as expected

What I have tried:

Changing:

...
perms = self.get_required_object_permissions(request.method, model_cls)

# if not user.has_perms(perms, obj):
if not user.has_perms(perms):
...

To:

...
perms = ['myapp_label.view_user']

# if not user.has_perms(perms, obj):
if not user.has_perms(perms):
...

Weirdly this fixes it and api/v1/appprefix/user/2 starts returning HTTP 200

Slav
  • 147
  • 2
  • 13

1 Answers1

0

Still have not solved the weird HTTP404 error which only occurs when using the browsable API, but I found a solution to the problem I was trying to solve originally - allow users with model permissions to access all objects, while restricting the rest to only objects they have permissions for.

I have changed the permissions class to the following:

from rest_framework.permissions import DjangoObjectPermissions
from rest_framework.exceptions import PermissionDenied, NotFound
from django.http import Http404

class GlobalOrObjectPermission(DjangoObjectPermissions):
    
    perms_map = {...}

    def has_permission(self, request, view):
        # Always let the request to proceed. The endpoint only serves
        # individual objects so this is OK
        return True
        
    def has_object_permission(self, request, view, obj):
        try:
            has_perm = super().has_object_permission(request, view, obj)
        except (
            PermissionDenied,
            # has_object_permission() raises http.Http404 instead of
            # drf_exceptions.NotFound when user does not have read permissions
            # but this could change so check for both exceptions
            Http404,
            NotFound,
        ) as e:
            has_perm = super().has_permission(request, view)
            # If user does not have model permissions, raise the original
            # object permission error, which can be HTTP403 or HTTP404
            if not has_perm:
                if isinstance(e, Http404): # added these 2 lines
                    e = NotFound()         # see EDIT note
                raise e

        return has_perm

This allows:

  • Users with object permissions to access the individual objects they have permissions for
  • Users with model permissions to access all objects of the model

EDIT:

After more testing I found that it actually suffers from the same problem as the original post. I ran it with a debugger and it seems to be a bug. I don't have time to investigate this further but for some reason when Http404 is raised from the overridden method it propagates down to Django's normal 404 page renderer. While if it is raised from the super method it propagates to the BrowsableAPI 404 renderer where it is translated to a NotFound exception and rendered. Thus, translating the Http404 exception towards the DRF's native NotFound exception in the overridden method (see code) fixes the issue, as NotFound is handled by the Browsable API renderer in both cases.

Slav
  • 147
  • 2
  • 13