This article looks at how to build custom permission classes in Django REST Framework (DRF).
--
Django REST Framework Permissions Series:
- Permissions in Django REST Framework
- Built-in Permission Classes in Django REST Framework
- Custom Permission Classes in Django REST Framework (this article!)
Contents
Objectives
By the end of this article, you should be able to:
- Create custom permission classes
- Explain when to use
has_permission
andhas_object_permission
in your custom permission classes - Return a custom error message when a permission is denied
- Combine and exclude permission classes using AND, OR, and NOT operators
Custom Permission Classes
If your application has some special requirements and the built-in permission classes don't meet those requirements, it's time to start building your own custom permissions.
Creating custom permissions allows you to set permissions based on whether the user is authenticated or not, the request method, the group that the user belongs to, object attributes, the IP address... or any of their combinations.
All permission classes, either custom or built-in, extend from the BasePermission
class:
class BasePermission(metaclass=BasePermissionMetaclass):
"""
A base class from which all permission classes should inherit.
"""
def has_permission(self, request, view):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True
def has_object_permission(self, request, view, obj):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True
BasePermission
has two methods, has_permission
and has_object_permission
, that both return True
. Permission classes override one or both of those methods to conditionally return True
. If you don't override the methods, they will always return True
, granting unlimited access.
For more on
has_permission
vshas_object_permission
, be sure to check out the first article in this series, Permissions in Django REST Framework.
By convention, you should put custom permissions in a permissions.py file. This is just a convention so you don't have to do this if you need to organize your permissions differently.
As with the built-in permissions, if any of the permission classes used in a view returns False
from either has_permission
or has_object_permission
, a PermissionDenied
exception is raised. To change the error message associated with the exception, you can set a message attribute directly on your custom permission class.
With that, let's look at some examples.
Custom Permission Examples
User Properties
You may want to give different levels of access to different users, based on their properties -- i.e., are they the creators of the object or are they a staff member?
Let's say you don't want staff members to be able to edit objects. Here's how a custom permission class for that case might look like:
# permissions.py
from rest_framework import permissions
class AuthorAllStaffAllButEditOrReadOnly(permissions.BasePermission):
edit_methods = ("PUT", "PATCH")
def has_permission(self, request, view):
if request.user.is_authenticated:
return True
def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True
if request.method in permissions.SAFE_METHODS:
return True
if obj.author == request.user:
return True
if request.user.is_staff and request.method not in self.edit_methods:
return True
return False
Here, the AuthorAllStaffAllButEditOrReadOnly
class extends BasePermission
and overrides both has_permission
and has_object_permission
.
has_permission:
In has_permission
only one thing gets checked: If the user is authenticated. If not, the NotAuthenticated
exception is raised and access is denied.
has_object_permission:
Since you should never limit the superuser's access, the first check -- request.user.is_superuser
-- grants access to the superuser.
Next, we check if the request method is one of the "safe" ones -- request.method in permissions.SAFE_METHODS
. Safe methods are defined in rest_framework/permissions.py:
SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')
These methods have no impact on the object; they can only read it.
At first glance, it may seem like the SAFE_METHODS
check should be in the has_permission
method. If you're only checking the request method then that's the place it should be. But in this case, other checks wouldn't be executed:
Since we want to grant access when the method is one of the safe ones or when the user is the author of the object or when the user is a staff member, we need to check that on the same level. In other words, since we can't check for the owner on the has_permission
level, we need to check everything on the has_object_permission
level.
The last possibility is that the user is a staff member: They are allowed all methods but the ones we defined as edit_methods
.
Finally, turn back to the class name: AuthorAllStaffAllButEditOrReadOnly
. You should always try to name the permission class as informative as possible.
Keep in mind that
has_object_permission
is never executed for list views (regardless of the view you're extending from) or when the request method isPOST
(since the object doesn't exist yet).
You use the custom permission class the same way as the built-in one:
# views.py
from rest_framework import viewsets
from .models import Message
from .permissions import AuthorAllStaffAllButEditOrReadOnly
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [AuthorAllStaffAllButEditOrReadOnly] # Custom permission class used
queryset = Message.objects.all()
serializer_class = MessageSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
The author of the object has full access to it. A staff member, meanwhile, can delete the object, but can't edit it:
And an authenticated user can view the object, but can't edit or delete it:
Object Properties
Although we briefly touched on the object's property in the previous example, the emphasis was much more on the user's properties (e.g., the object's author). In this example, we'll focus on the object's properties.
How can one or more of the object's properties have an impact on the permissions?
- As in the previous example, you can limit the access only to the owner of the object. You can also limit access to the group the owner belongs to.
- Objects may have an expiration date, so you can limit the access to objects older than n to only some users.
- You can have DELETE implemented as a flag (so that it's not actually removed from the database). You can then prevent access to objects with a delete flag.
Let's say you want to restrict access to objects older than 10 minutes for everyone except superusers:
# permissions.py
from datetime import datetime, timedelta
from django.utils import timezone
from rest_framework import permissions
class ExpiredObjectSuperuserOnly(permissions.BasePermission):
def object_expired(self, obj):
expired_on = timezone.make_aware(datetime.now() - timedelta(minutes=10))
return obj.created < expired_on
def has_object_permission(self, request, view, obj):
if self.object_expired(obj) and not request.user.is_superuser:
return False
else:
return True
In this permission class, the has_permission
method is not overridden -- so it will always return True
.
Since the only important property is the object's creation time, the check happens in has_object_permission
(since we don't have access to an object's properties in has_permission
).
So, if a user wants to access the expired object, the exception PermissionDenied
is raised:
Again, as with the previous example, we could check if the user is a superuser in has_permission
, but if they're not, the object's property would never get checked.
Take note of the error message. It's not very informative. The user has no idea why their access was denied. We can create a custom error message by adding a message
attribute to our permission class:
class ExpiredObjectSuperuserOnly(permissions.BasePermission):
message = "This object is expired." # custom error message
def object_expired(self, obj):
expired_on = timezone.make_aware(datetime.now() - timedelta(minutes=10))
return obj.created < expired_on
def has_object_permission(self, request, view, obj):
if self.object_expired(obj) and not request.user.is_superuser:
return False
else:
return True
Now the user sees exactly why the permission was denied:
Combining and Excluding Permission Classes
Typically, when using more than one permission class, you'd define them in the view like so:
permission_classes = [IsAuthenticated, IsStaff, SomeCustomPermissionClass]
This approach combines them so that permission is granted only if all of the classes return True
.
Since DRF version 3.9.0, you can also combine multiple classes using the AND (&
) or OR (|
) logical operators. Also, since 3.9.2, the NOT (~
) operator is supported.
These operators are not limited to custom permission classes. They can also be used with the built-in ones.
Instead of creating many complicated permission classes that are similar to each other, you could create simpler classes and combine them with the aforementioned operators.
For example, you may have different permissions for different combinations of groups. Let's say you want the following permissions:
- Permission for groups A or B
- Permission for groups B or C
- Permission for members of both B and C
- Permission for all groups but A
While four permission classes doesn't seem like much this won't scale well. What if you had eight different groups -- A, B, C, D, E, F, G? It would quickly ballon to a point where it's impossible to understand and maintain.
You could simplify it and combine them with operators by first creating permission classes for groups A, B, and C. Then, you can implement them like so:
permission_classes = [PermGroupA | PermGroupB]
permission_classes = [PermGroupB | PermGroupC]
permission_classes = [PermGroupB & PermGroupC]
permission_classes = [~PermGroupA]
When OR (
|
) is involved, things can get a little more complicated. Errors can often fall through the cracks. For more, review the discussion on the permissions: Allow permissions to be composed pull request.
AND Operator
AND is the default behavior of permission classes, achieved by using ,
:
permission_classes = [IsAuthenticated, IsStaff, SomeCustomPermissionClass]
It can also be written with &
:
permission_classes = [IsAuthenticated & IsStaff & SomeCustomPermissionClass]
OR Operator
With the OR (|
), when any of the permission classes return True
, the permission is granted. You can use the OR operator to offer multiple possibilities in which the user gets granted permission.
Let's look at an example where either the owner of the object or a staff member can edit or delete the object.
We'll need two classes:
IsStaff
returnsTrue
if the useris_staff
IsOwner
returnsTrue
if the user is the same as theobj.author
Code:
class IsStaff(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.is_staff:
return True
return False
def has_object_permission(self, request, view, obj):
if request.user.is_staff:
return True
return False
class IsOwner(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.is_authenticated:
return True
return False
def has_object_permission(self, request, view, obj):
if obj.author == request.user:
return True
return False
There's quite a bit of redundancy here, but it's necessary.
Why?
-
For covering list views
Again, the list view doesn't check for
has_object_permission
. However, each of the created permissions needs to be standalone. You shouldn't create a permission class that needs to be combined with another permission class to cover the list view.IsOwner
limits access to authenticated users inhas_permission
-- so that if theIsOwner
is the only class used, access to API is still controlled. -
Both methods by default return
True
When using OR, if you don't provide the
has_object_permission
method, the user will have access to the object, even though they shouldn't.Notes:
-
if you omit
has_permission
on theIsOwner
class, anyone will be able to see or create on the list. -
if you omit
has_object_permission
onIsStaff
and combine it withIsOwner
withor
, either one or the other will returnTrue
. That way, a registered user that's neither an owner nor a staff member, would be able to change the content.
-
Now, when we have our permission classes well designed, it's easy to combine them:
from rest_framework import viewsets
from .models import Message
from .permissions import IsStaff, IsOwner
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsStaff | IsOwner] # or operator used
queryset = Message.objects.all()
serializer_class = MessageSerializer
Here, we allow either a staff member or the owner of the object to change or delete it.
The only requirement IsOwner
has for the list views, is that the user is authenticated. That means that the authenticated user that's not a staff member, will be able to create objects.
NOT Operator
The NOT operator results in the exact opposite to the defined permission class. In other words, permission is granted to all users except the ones from the permission class.
Let's say you have three groups of users:
- Tech
- Management
- Finances
Each of these groups should have access to API endpoints meant only for their specific group.
Here's a permission class that grants access to only members of the Finances group:
class IsFinancesMember(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.groups.filter(name="Finances").exists():
return True
Now, say you have a new view that's meant for all users who are not part of the Finances group. You can use the NOT operator to implement this:
from rest_framework import viewsets
from .models import Message
from .permissions import IsFinancesMember
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [~IsFinancesMember] # using not operator
queryset = Message.objects.all()
serializer_class = MessageSerializer
So, only members of the Finances group won't have access.
Be careful! If you only use the NOT operator, everybody else will be allowed access, including unauthenticated users! If that's not what you meant to do, you can fix that by adding another class like so:
permission_classes = [~IsFinancesMember & IsAuthenticated]
Parentheses
Inside permission_classes
you can also use parentheses (()
) to control which expression gets resolved first.
Quick example:
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [(IsFinancesMember | IsTechMember) & IsOwner] # using parentheses
queryset = Message.objects.all()
serializer_class = MessageSerializer
In this example, (IsFinancesMember | IsTechMember)
will be resolved first. Then, the result of that will be used with & IsOwner
-- e.g., ResultsFromFinancesOrTech & IsOwner
. This means that a user that's either a member of the Tech OR Finances groups AND is the owner of the object will be granted access.
Conclusion
Despite a wide range of built-in permission classes, there will be situations where they won't fit your needs. That's when custom permission classes come in handy.
With custom permission classes you must override one or both of the following methods:
has_permission
has_object_permission
If permission isn't granted in the has_permission
method, it doesn't matter what's written in has_object_permission
-- the permission is denied. If you don't override one (or both) of them, you need to take into account that by default, the method will always return True
.
You can combine and exclude permission classes with the AND, OR, and NOT operators. You can even decide the order in which permissions resolve in with parenthesis.
--
Django REST Framework Permissions Series: