Django REST Framework viewset per-action permissions

Is there a best practice to assign a different permission to each action of a given APIView or ViewSet?

Let's suppose I defined some permissions classes such as 'IsAdmin', 'IsRole1', 'IsRole2', ..., and I want to grant different permissions to the single actions (e.g. a user with Role1 can create or retrieve, a user with Role2 can update, and only an Admin can delete).

How can I structure a class based view in order to assign a permission class to the 'create', 'list', 'retrieve', 'update', 'delete' actions? I'm trying to do so to have a class that can be reused for different tables that have the same permission pattern.


Solution 1:

In DRF documentation,

Note: The instance-level has_object_permission method will only be called if the view-level has_permission checks have already passed

Let's assume following permission about user object

  • List : staff only
  • Create : anyone
  • Retrieve : own self or staff
  • Update, Partial update : own self or staff
  • Destroy : staff only

permissons.py

from rest_framework import permissions

class UserPermission(permissions.BasePermission):

    def has_permission(self, request, view):
        if view.action == 'list':
            return request.user.is_authenticated() and request.user.is_admin
        elif view.action == 'create':
            return True
        elif view.action in ['retrieve', 'update', 'partial_update', 'destroy']:
            return True
        else:
            return False
                                                                                                
    def has_object_permission(self, request, view, obj):
        # Deny actions on objects if the user is not authenticated
        if not request.user.is_authenticated():
            return False

        if view.action == 'retrieve':
            return obj == request.user or request.user.is_admin
        elif view.action in ['update', 'partial_update']:
            return obj == request.user or request.user.is_admin
        elif view.action == 'destroy':
            return request.user.is_admin
        else:
            return False

views.py

from .models import User
from .permissions import UserPermission
from .serializers import UserSerializer
from rest_framework import viewsets


class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    permission_classes = (UserPermission,)

For Django 2.0 replace is_authenticated() with is_authenticated. The method has been turned into an attribute.

Solution 2:

You can create a custom permission class extending DRF's BasePermission.

You implement has_permission where you have access to the request and view objects. You can check request.user for the appropriate role and return True/False as appropriate.

Have a look at the provided IsAuthenticatedOrReadOnly class (and others) for a good example of how easy it is.

I hope that helps.

Solution 3:

I personally hate this kind of Frankenstein's monster custom permissions, in my opinion, it's not very idiomatic when it comes to the Django framework.

So I came up with the following solution - it's very similar to how @list_route and @detail_route decorators work. We are relying on the fact that the methods/functions are first-class objects

First of all, I'm creating such decorator:

decorators.py

def route_action_arguments(**kwargs):
    """
    Add arguments to the action method
    """
    def decorator(func):
        func.route_action_kwargs = kwargs
        return func
    return decorator

As you can see it adds a dictionary to the function it decorates with parameters passed as arg list

Now I created such mixin: mixins.py

class RouteActionArgumentsMixin (object):
    """
    Use action specific parameters to 
    provide:
    - serializer
    - permissions
    """

    def _get_kwargs(self):
        action = getattr(self, 'action')
        if not action:
            raise AttributeError
        print('getting route kwargs for action:' + action)
        action_method = getattr(self, action)
        kwargs = getattr(action_method, 'route_action_kwargs')
        print(dir(kwargs))
        return kwargs

    def get_serializer_class(self):
        try:
            kwargs = self._get_kwargs()
            return kwargs['serializer']
        except (KeyError, AttributeError):
            return super(RouteActionArgumentsMixin, self).get_serializer_class()

    def get_permissions(self):
        try:
            kwargs = self._get_kwargs()
            return kwargs['permission_classes']
        except (KeyError, AttributeError):
            return super(RouteActionArgumentsMixin, self).get_permissions()

Mixin does two things; when get_permissions is called, it checks which 'action' is executed, and looksup the permission_classes collection from the route_action_kwargs associated with the viewset.action_method.route_action_kwargs

when get_serializer_class is called, it does the same and picks the serializer from route_action_kwargs

Now the way we can use it:

@method_decorator(route_action_arguments(serializer=LoginSerializer), name='create')
class UserViewSet (RouteActionArgumentsMixin, RequestContextMixin, viewsets.ModelViewSet):
    """
    User and profile managment viewset
    """

    queryset = User.objects.all()
    serializer_class = UserSerializer

    @list_route(methods=['post'])
    @route_action_arguments(permission_classes=(AllowAny,), serializer=LoginSerializer)
    def login(self, request):
        serializer = self.get_serializer_class()(data=request.data)

For custom routs we define explicitly we can just set the @route_action_arguments explicitly on the method.

In terms of the generic viewsets and methods, we can still add them using the @method_decorator

@method_decorator(route_action_arguments(serializer=LoginSerializer), name='create')
class UserViewSet (RouteActionArgumentsMixin, RequestContextMixin, viewsets.ModelViewSet):