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:
- 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
- Have a class which checks for model-wide permissions but does this in the
has_object_permissionmethod:
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 expectedNavigating 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
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:
This allows:
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.