Note 04 / Tenant isolation
Django: bind data access to the tenant
A document ID is not a permission. Check membership, scope queries and test IDs belonging to other tenants.
The problem: a valid object owned by someone else
In a shared Django application, one user may belong to several tenants. A detail endpoint receives a document ID and a selected tenant. If it looks up only the primary key, a valid foreign ID can open the wrong document. A hard-to-guess UUID does not replace a permission check.
Treat the tenant from a URL, cookie or form as input. The database must confirm that the authenticated user is allowed to access it. Only then should the document query run.
Two checks in one place
The example uses a simple permission model: membership grants read access to all documents in that tenant. The service checks membership and looks up the ID only within the scoped QuerySet. Missing membership raises PermissionDenied; an invisible document raises Http404. The view must call this service after authentication.
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import models
from django.shortcuts import get_object_or_404
class Tenant(models.Model):
name = models.CharField(max_length=120)
class Meta:
app_label = "notes_demo"
class Membership(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
class Meta:
app_label = "notes_demo"
constraints = [models.UniqueConstraint(
fields=["tenant", "user"], name="demo_member_unique")]
class Document(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
title = models.CharField(max_length=240)
class Meta:
app_label = "notes_demo"
def read_document(user, tenant_id, document_id):
# Check tenant membership in the database.
if not user.is_authenticated or not Membership.objects.filter(
user=user, tenant_id=tenant_id
).exists():
raise PermissionDenied
# Look up the document ID within the authorised tenant.
return get_object_or_404(
Document.objects.filter(tenant_id=tenant_id),
pk=document_id,
)
Test the forbidden access
The local test creates two synthetic tenants. A user belongs to only one. It reads the permitted document, requests a foreign document ID within the allowed tenant, then tries selecting the foreign tenant. Both forbidden paths must fail. An anonymous user is rejected as well.
Executed with Python 3.12 and Django 5.2 against local SQLite. This checks the ORM access in this example. It does not verify PostgreSQL-specific locking, Row-Level Security or concurrency.
Limits: the service is not the whole application
Unfiltered Document.objects remains accessible. Admin, background jobs, exports and new views can bypass the service. Apply the same rule to every access path and look for unscoped object queries during review. Cache keys must include the tenant; a cache keyed only by document ID can bypass the check.
This read example is insufficient for writes: referenced foreign keys and roles also need checks. Concurrent membership revocation requires an explicit transaction strategy. PostgreSQL Row-Level Security can add another defence; account for table owners, BYPASSRLS roles and connection-pool context.