Jump to content

Multitenant Permissions in Django: Client-Based Access Control

From JOHNWICK

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