Multitenant Permissions in Django: Client-Based Access Control
In multitenant SaaS applications, isolating data access per tenant (e.g. client, company, or organization) is crucial. This article explores how to implement client-based access control in Django, ensuring users can only access data belonging to their organization.
We will walk through real-world modeling, query restrictions, permission enforcement, middleware, and role-based layering with practical examples to secure your SaaS platform.
Use Case: SaaS with Multiple Clients Imagine a platform where companies (clients) sign up, and each company has multiple users. These users should only access data owned by their respective companies. Example:
- Company A and Company B both use your SaaS.
- A user from Company A should not see Company B’s resources, even if they share the same resource type.
Multitenancy in Django can be achieved in two major ways:
- Shared Database with Discriminator Field (e.g. client_id) — simpler and widely used.
- Separate schemas or databases per tenant — more isolation is used in advanced setups.
This article focuses on the shared database pattern for its simplicity and ease of maintenance. Core Concepts
- Tenant Isolation Every model must be scoped by a foreign key to Client, which serves as the tenant identifier. This prevents cross-client data leakage.
- Request Context Awareness Every request is associated with an authenticated user, and all data access should be scoped to request.user.client.
- Role-Based Access Permissions can be fine-tuned by assigning roles within a tenant, e.g. Admin, Manager, Staff. Roles determine the scope of access within the tenant’s data.
- Security-First Validation Do not rely on frontend submissions for client IDs. Enforce permissions and data ownership at the backend.
- Scalability through Middleware and Reusability Encapsulate tenant extraction logic in middleware, and permission logic in reusable classes.
Step-by-Step Implementation
1. Define the Models At the core of a multitenant system is the relationship between Client, User, and domain models such as Project, Invoice, Report, etc.
class Client(models.Model):
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class User(AbstractUser):
client = models.ForeignKey(Client, on_delete=models.CASCADE)
role = models.CharField(
max_length=20,
choices=[('admin', 'Admin'), ('manager', 'Manager'), ('staff', 'Staff')],
default='staff'
)
class Project(models.Model):
client = models.ForeignKey(Client, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
Add client to every critical model to tie them to their owner organization.
2. Use Custom Permissions Start by creating a basic permission class that ensures the object being accessed belongs to the same client as the requesting user.
from rest_framework.permissions import BasePermission
class IsSameClient(BasePermission):
def has_object_permission(self, request, view, obj):
return hasattr(obj, 'client') and obj.client == request.user.client
Apply this permission class to views or override the queryset in viewsets to restrict access:
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [IsAuthenticated, IsSameClient]
def get_queryset(self):
return Project.objects.filter(client=self.request.user.client)
def perform_create(self, serializer):
serializer.save(client=self.request.user.client, owner=self.request.user)
3. Admin and Role Logic Use roles to control privilege within the same tenant.
class UserRole(models.TextChoices):
ADMIN = 'admin', 'Admin'
MANAGER = 'manager', 'Manager'
STAFF = 'staff', 'Staff'
class User(AbstractUser):
client = models.ForeignKey(Client, on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.STAFF)
Create a compound permission class:
class IsClientAdminOrOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return (
obj.client == request.user.client and (
request.user.role == UserRole.ADMIN or
obj.owner == request.user
)
)
Use role checks in serializers to allow certain fields to be modified only by admins:
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ['id', 'name', 'owner', 'created_at']
read_only_fields = ['owner']
def validate(self, attrs):
if self.context['request'].user.role != UserRole.ADMIN:
raise serializers.ValidationError("Only admins can create projects.")
return super().validate(attrs)
Middleware for Tenant Access For large applications, separating out the logic to determine tenant context improves code clarity.
class ClientMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
request.tenant = request.user.client
return self.get_response(request)
Serializers: Injecting Context Avoid exposing client in the serializer:
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ['id', 'name', 'created_at']
def create(self, validated_data):
validated_data['client'] = self.context['request'].user.client
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
Best Practices
- Never trust user-submitted client_id — always derive it from the authenticated user.
- Centralize permission logic — avoid duplicating access checks across views.
- Write shared mixins and base viewsets to encapsulate multitenant behavior.
- Document your permission model for better team collaboration.
Common Mistakes
- ❌ Forgetting to filter queryset by client.
- ❌ Accepting client_id in POST bodies.
- ❌ Missing role-based logic where needed (e.g. staff modifying others’ resources).
- ❌ Not testing cross-tenant access violations.
Testing Access Control Example test cases:
def test_user_cannot_access_others_project(api_client, user_a, project_b):
api_client.force_authenticate(user=user_a)
response = api_client.get(f'/api/projects/{project_b.id}/')
assert response.status_code == 404
def test_admin_can_create_project(api_client, admin_user):
api_client.force_authenticate(user=admin_user)
data = {'name': 'New Project'}
response = api_client.post('/api/projects/', data)
assert response.status_code == 201
Also, consider integration tests using tenant fixtures. Multitenant permission management in Django ensures strong data isolation and secure access control across client organizations. Implementing it correctly requires:
- Consistent tenant-scoping of all data models
- Permission classes that validate client ownership
- Role-based access is layered on top of tenant logic
- Middleware and reusable patterns to reduce repetition
- Strong testing to verify proper isolation
With these patterns, you can confidently scale your SaaS platform across multiple clients while maintaining privacy, security, and compliance.
Read the full article here: https://medium.com/django-unleashed/multitenant-permissions-in-django-client-based-access-control-3af2fec01ebb