piovincecang

Django: Decorators for Permissions Checks

Created October 7, 2021

Role-Based Access Using Decorators

Below, I go through a Python decorator which takes arguments to enforce a granular level of permission handling on Django REST Framework API Views.

Why use a decorator instead of a custom middleware?

Implementing this feature as a middleware would apply globally i.e., every view used by the app would be affected. I needed to granularly enforce the user permissions on a per-view basis, as not all views needed Authentication. There would be no point in checking the permissions of the role of the AnonymousUser. Case in point, we wouldn't require endpoints used by the public-facing landing page to require visitors be logged in.

Additionally, each view authorizes a different set of roles. For example, endpoints for data dashboards might require more stringent write (POST, PUT, PATCH, DELETE) permissions given only to administrators or managers. In contrast, read (GET) permissions might be given to all support staff.

Writing the Decorator

# examples/decorators.py

from functools import wraps
from rest_framework import status
from rest_framework.response import Response

def permit_if_role_in(allowed_roles=()):
    """ This decorator takes arguments and returns a Closure that utilizes these arguments. """

    def view_wrapper_function(decorated_view_function):
        """ This intermediate wrapper function takes the decorated View function (e.g. get, post) itself. """
    
        @wraps(decorated_view_function)
        def enforce_user_permissions(view, request, *args, **kwargs):
            """ A function that intercepts the View function and enforces permissions """
        
            # Perform permissions checking here, before control is passed to the View function that was decorated
            permissions_evaluations = map(lambda role: gettattr(user, role, False), allowed_roles)
            is_authorized = any(permissions_evaluations)
    
            if not is_authorized:
                return Response("You are authenticated but unauthorized!", status=status.HTTP_403_FORBIDDEN)
        

            # Passing the arguments and control over to the view function that was decorated
            response =  decorated_view_function(view, request, *args, **kwargs)

            # Perform actions here after the control is returned by the view function that was decorated
            
            return response
    
        return enforce_user_permissions
    
    return view_wrapper_function

Using the Decorator

Below I show some examples of how to use the decorator in custom Views and ViewSets

APIView example

# examples/views.py

from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from .decorators import permit_if_role_in

class MyDecoratedAPIView(APIView):
    """ A sample APIView to demonstrate the decorator usage """

    permission_classes = [IsAuthenticated]

    @permit_if_role_in(['is_support', 'is_staff'])
    def get(self, request):
        """ This function only gets called if request.user.is_support or request.user.is_staff is True """

        # Your get code here
        pass

    @permit_if_role_in(['is_admin', 'is_management'])
    def post(self, request):
        """ This function only gets called if request.user.is_admin or request.user.is_management is True """

        # Your get code here
        pass

ViewSet example

# examples/views.py

from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ViewSet
from .decorators import permit_if_role_in

class MyDecoratedViewSet(ViewSet):

    permission_classes = [IsAuthenticated]

    @permit_if_role_in(['is_support', 'is_staff'])
    def fetch_dashboard_data(self, request):
        """ This function only gets called if request.user.is_support or request.user.is_staff is True """

        # Your get code here
        pass

    @permit_if_role_in(['is_admin', 'is_management'])
    def modify_dashboard_data(self, request):
        """ This function only gets called if request.user.is_admin or request.user.is_management is True """

        # Your get code here
        pass

How and Why Does This Work?

By decorating @permit_if_role_in(allowed_roles) to the fetch_dashboard_data(self, request) function, we are actually performing the following equivalent statement, which is a series of nested function calls.

response = permit_if_role_in(allowed_roles)(modify_dashboard_data)(request, *args, **kwargs)

Let's break down the convoluted statement above into shorter, more manageabl statements using intermediate variables.

allowed_roles = ['is_admin', 'is_management']

# `permit_if_role_in` takes a list/tuple of `allowed_roles` and returns a callable `view_wrapper_function`
view_wrapper_function = permit_if_role_in(allowed_roles)

# `view_wrapper_function` takes an APIView `modify_dashboad_data` to wrap over
# and returns its own APIView function `enforce_user_permissions`
enforce_user_permissions = view_wrapper_function(modify_dashboad_data)

# `enforce_user_permissions` takes the rest_framework.request.Request() passed to the view,
# along with other optional args and kwargs
response = enforce_user_permissions(request, *args, **kwargs)

And finally, it is inside enforce_user_permissions where the checking of allowed_roles is done, before passing control to modify_dashboad_data.

With just a single line modification, so much takes place under the hood. That is the power syntactic sugar Python provides.

Closing

So I've shown you how I used these decorators and closures to implement a per-view permissions checker for your views. Functional programming is a tricky subject to learn at first, but it's important to remember that in Python, functions (or callables) are also objects that can be passed around as if they were primitive data types. I hope the examples helped.