July 28, 2026
Stop Writing Custom Permission Classes: One DRF Pattern for Roles + Ownership
How to build a single DRF permission class that handles superusers, role permissions, ownership checks, and privileged-role bypasses —…
By Muhammad Yasin
4 min read
How to build a single DRF permission class that handles superusers, role permissions, ownership checks, and privileged-role bypasses — without repeating logic across every view.
If you've built more than a couple of APIs with Django REST Framework, you've probably written the same permission check five different ways — once per view, slightly differently each time. Maybe one view checks request.user == obj.owner, another checks a role field, a third has some half-finished admin bypass bolted on. It works, until someone asks "wait, can managers edit other people's documents or not?" and nobody's sure, because the logic lives in six places.
This post walks through a single, reusable permission class that handles all of it: authentication, superuser bypass, role-based permission checks, ownership checks, and an optional "privileged roles" bypass for users like managers or admins who need broader access. Drop it into any view, set a couple of attributes, and you're done.
What we're building
The RoleBasedPermission class handles:
- Authentication — no logged-in user, no access
- Superuser bypass — superusers always get through
- Role-based permissions — users need a role that carries the specific permission the view requires
- Ownership checks — regular users can only touch objects they own
- Nested ownership — supports dot notation, like
author.user, for cases where the "owner" isn't a direct field - Privileged role bypass — certain roles (e.g. managers, admins) can skip the ownership check entirely
Here's the full implementation:
python
from django.core.exceptions import ImproperlyConfigured
from rest_framework import permissions
class RoleBasedPermission(permissions.BasePermission):
"""
Fully DRF-native role + ownership permission.
View must define:
- required_permission: str (permission codename)
- owner_field: str (dot notation for ownership check)
- privileged_roles: set[str] (optional, roles that can bypass ownership)
"""
message = "You do not have permission to perform this action."
def has_permission(self, request, view):
user = request.user
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False):
return True
permission = getattr(view, "required_permission", None)
if not permission:
raise ImproperlyConfigured("View must define `required_permission`")
role = getattr(user, "role", None)
if not role:
return False
return role.permissions.filter(permission_codename=permission).exists()
def has_object_permission(self, request, view, obj):
user = request.user
if getattr(user, "is_superuser", False):
return True
permission = getattr(view, "required_permission", None)
if not permission:
raise ImproperlyConfigured("View must define `required_permission`")
role = getattr(user, "role", None)
if not role:
return False
privileged_roles = getattr(view, "privileged_roles", set())
if role.name in privileged_roles:
return role.permissions.filter(permission_codename=permission).exists()
owner_field = getattr(view, "owner_field", None)
if not owner_field:
return False
owner = self._resolve_attr(obj, owner_field)
if owner == user:
return role.permissions.filter(permission_codename=permission).exists()
return False
def _resolve_attr(self, obj, path):
"""Resolve nested attributes using dot notation."""
value = obj
for attr in path.split("."):
value = getattr(value, attr, None)
if value is None:
return None
return valuefrom django.core.exceptions import ImproperlyConfigured
from rest_framework import permissions
class RoleBasedPermission(permissions.BasePermission):
"""
Fully DRF-native role + ownership permission.
View must define:
- required_permission: str (permission codename)
- owner_field: str (dot notation for ownership check)
- privileged_roles: set[str] (optional, roles that can bypass ownership)
"""
message = "You do not have permission to perform this action."
def has_permission(self, request, view):
user = request.user
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False):
return True
permission = getattr(view, "required_permission", None)
if not permission:
raise ImproperlyConfigured("View must define `required_permission`")
role = getattr(user, "role", None)
if not role:
return False
return role.permissions.filter(permission_codename=permission).exists()
def has_object_permission(self, request, view, obj):
user = request.user
if getattr(user, "is_superuser", False):
return True
permission = getattr(view, "required_permission", None)
if not permission:
raise ImproperlyConfigured("View must define `required_permission`")
role = getattr(user, "role", None)
if not role:
return False
privileged_roles = getattr(view, "privileged_roles", set())
if role.name in privileged_roles:
return role.permissions.filter(permission_codename=permission).exists()
owner_field = getattr(view, "owner_field", None)
if not owner_field:
return False
owner = self._resolve_attr(obj, owner_field)
if owner == user:
return role.permissions.filter(permission_codename=permission).exists()
return False
def _resolve_attr(self, obj, path):
"""Resolve nested attributes using dot notation."""
value = obj
for attr in path.split("."):
value = getattr(value, attr, None)
if value is None:
return None
return valueHow it works
View-level access (has_permission) runs first, before DRF even loads a specific object. It checks that the user is authenticated, lets superusers straight through, and then checks whether the user's role carries the required_permission the view defines. If the view forgot to set required_permission, it raises ImproperlyConfigured — a deliberately loud failure, because a silently-missing permission check is far worse than a crash in development.
Object-level access (has_object_permission) runs once DRF has a specific object in hand (a GET, PUT, or DELETE on /tasks/42/, for example). Superusers still bypass everything. Then it checks privileged_roles — if the user's role is in that set, they skip the ownership check entirely and just need the underlying permission. Otherwise, it resolves the owner_field, which supports dot notation for nested relationships, and only grants access if the resolved owner matches the requesting user.
Using it in your views
Basic usage
python
from rest_framework.views import APIView
from core.permissions import RoleBasedPermission
class BlogPostDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'change_blogpost'
owner_field = 'author'
def get(self, request, pk):
# ... implementation
pass
def put(self, request, pk):
# ... implementation
passfrom rest_framework.views import APIView
from core.permissions import RoleBasedPermission
class BlogPostDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'change_blogpost'
owner_field = 'author'
def get(self, request, pk):
# ... implementation
pass
def put(self, request, pk):
# ... implementation
passWith privileged roles
python
class DocumentDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'change_document'
owner_field = 'uploaded_by'
privileged_roles = {'MANAGER', 'ADMIN'}
def put(self, request, pk):
# Managers and admins can edit any document
# Regular users can only edit their own documents
passclass DocumentDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'change_document'
owner_field = 'uploaded_by'
privileged_roles = {'MANAGER', 'ADMIN'}
def put(self, request, pk):
# Managers and admins can edit any document
# Regular users can only edit their own documents
passWith nested ownership
python
class CommentDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'delete_comment'
owner_field = 'author.user' # Nested attribute
def delete(self, request, pk):
# Checks if comment.author.user == request.user
passclass CommentDetailView(APIView):
permission_classes = [RoleBasedPermission]
required_permission = 'delete_comment'
owner_field = 'author.user' # Nested attribute
def delete(self, request, pk):
# Checks if comment.author.user == request.user
passThe supporting models
If you don't already have a role/permission structure, here's a minimal one that plugs straight into the class above.
Permission model:
python
from django.db import models
class Permission(models.Model):
permission_name = models.CharField(max_length=255, unique=True)
permission_codename = models.CharField(max_length=255, unique=True)
def __str__(self):
return f"{self.permission_name} - {self.permission_codename}"from django.db import models
class Permission(models.Model):
permission_name = models.CharField(max_length=255, unique=True)
permission_codename = models.CharField(max_length=255, unique=True)
def __str__(self):
return f"{self.permission_name} - {self.permission_codename}"Role model, attached to your user:
python
class Role(models.Model):
name = models.CharField(max_length=100, unique=True)
permissions = models.ManyToManyField(Permission)
class User(AbstractUser):
role = models.ForeignKey(Role, on_delete=models.SET_NULL, null=True, blank=True)class Role(models.Model):
name = models.CharField(max_length=100, unique=True)
permissions = models.ManyToManyField(Permission)
class User(AbstractUser):
role = models.ForeignKey(Role, on_delete=models.SET_NULL, null=True, blank=True)Full example
Putting it all together — a task detail view where project managers can edit any task, but regular users can only edit tasks assigned to them:
python
# views.py
from rest_framework.views import APIView
from core.permissions import RoleBasedPermission
class TaskDetailView(APIView):
"""
GET/PUT/DELETE /api/tasks/<id>/
"""
permission_classes = [RoleBasedPermission]
required_permission = 'change_task'
owner_field = 'assigned_to'
privileged_roles = {'PROJECT_MANAGER'}
def get(self, request, pk):
task = Task.objects.get(pk=pk)
serializer = TaskSerializer(task)
return Response(serializer.data)
def put(self, request, pk):
task = Task.objects.get(pk=pk)
serializer = TaskSerializer(task, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)# views.py
from rest_framework.views import APIView
from core.permissions import RoleBasedPermission
class TaskDetailView(APIView):
"""
GET/PUT/DELETE /api/tasks/<id>/
"""
permission_classes = [RoleBasedPermission]
required_permission = 'change_task'
owner_field = 'assigned_to'
privileged_roles = {'PROJECT_MANAGER'}
def get(self, request, pk):
task = Task.objects.get(pk=pk)
serializer = TaskSerializer(task)
return Response(serializer.data)
def put(self, request, pk):
task = Task.objects.get(pk=pk)
serializer = TaskSerializer(task, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)A few things worth keeping in mind
- Always set
required_permission. Every view using this class needs it defined, or you'll hit theImproperlyConfigurederror by design. - Keep permission codenames specific.
change_blogpost,delete_document— action-and-model, not vague labels. - Be deliberate with
privileged_roles. It's a full ownership bypass, so only hand it to roles that genuinely need broad access. - Test the nested attribute resolution. Dot-notation ownership is convenient but easy to get subtly wrong if your model relationships change.
- Missing roles fail closed. A user with no
roleset gets denied by default, which is the safer failure mode.
Wrapping up
The appeal of this pattern isn't that it's clever — it's that it's boring in a good way. One class, one place to look when access control breaks, and every view just declares its own rules through a few class attributes instead of reimplementing the logic. If your app grows more complex roles or a more nuanced ownership model later, you're extending one class instead of hunting through a dozen views.
If you use a pattern like this, I'd love to hear how you've adapted it — especially around multi-tenant setups or permission caching for high-traffic endpoints.