On this page
Multi-Tenant Applications¶
djust provides comprehensive multi-tenant support for building SaaS applications with complete tenant isolation, flexible resolution strategies, and tenant-scoped data access.
What You Get¶
- Automatic tenant resolution -- From subdomain, path, headers, session, or custom logic
- TenantMixin / TenantScopedMixin -- Tenant-aware LiveViews with scoped querysets
- State backend isolation -- Tenant-aware Redis and memory backends
- Chained resolution -- Try multiple strategies with fallback
- Template context -- Automatic tenant injection
Quick Start¶
1. Configure Tenant Resolution¶
# settings.py
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.SubdomainResolver'
DJUST_TENANT_CONFIG = {
'default_tenant': 'public',
'subdomain_resolver': {
'domain': 'myapp.com',
'exclude_subdomains': ['www', 'api', 'admin']
}
}
2. Use TenantMixin in Your Views¶
from djust import LiveView
from djust.tenants.mixins import TenantMixin, TenantScopedMixin
class DashboardView(TenantScopedMixin, LiveView):
template_name = 'dashboard.html'
def mount(self, request):
# self.tenant is automatically available
self.users_count = self.tenant_queryset(User).count()
self.projects = self.tenant_queryset(Project).order_by('-created_at')[:5]
3. Tenant-Scoped Models¶
class TenantScopedModel(models.Model):
tenant_id = models.CharField(max_length=50, db_index=True)
class Meta:
abstract = True
class Project(TenantScopedModel):
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
Resolution Strategies¶
Subdomain¶
# acme.myapp.com -> tenant_id: "acme"
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.SubdomainResolver'
DJUST_TENANT_CONFIG = {
'subdomain_resolver': {
'domain': 'myapp.com',
'exclude_subdomains': ['www', 'api'],
'default_tenant': 'public'
}
}
Path¶
# myapp.com/acme/dashboard -> tenant_id: "acme"
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.PathResolver'
DJUST_TENANT_CONFIG = {
'path_resolver': {
'position': 0, # First path segment
'default_tenant': 'public'
}
}
Header¶
# X-Tenant-ID: acme -> tenant_id: "acme"
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.HeaderResolver'
DJUST_TENANT_CONFIG = {
'header_resolver': {
'header_name': 'X-Tenant-ID',
'default_tenant': 'public'
}
}
Session / JWT¶
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.SessionResolver'
DJUST_TENANT_CONFIG = {
'session_resolver': {
'session_key': 'tenant_id',
'jwt_claim': 'tenant',
'user_attribute': 'tenant_id'
}
}
Custom¶
def custom_tenant_resolver(request):
from djust.tenants.resolvers import TenantInfo
tenant_id = request.user.organization.slug if request.user.is_authenticated else 'public'
return TenantInfo(id=tenant_id, name=tenant_id, settings={'theme': 'blue'})
# settings.py
DJUST_TENANT_RESOLVER = 'myapp.utils.custom_tenant_resolver'
Chained (Fallback)¶
DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.ChainedResolver'
DJUST_TENANT_CONFIG = {
'chained_resolver': {
'resolvers': [
'djust.tenants.resolvers.HeaderResolver',
'djust.tenants.resolvers.SubdomainResolver',
'djust.tenants.resolvers.SessionResolver'
],
'default_tenant': 'public'
}
}
Mixins¶
TenantMixin¶
Base mixin that resolves and injects self.tenant and adds it to template context.
class MyView(TenantMixin, LiveView):
def mount(self, request):
logger.info("Mounted for tenant: %s", self.tenant.name)
TenantScopedMixin¶
Extends TenantMixin with scoped querysets:
class ProjectListView(TenantScopedMixin, LiveView):
def mount(self, request):
self.projects = self.tenant_queryset(Project)
def get_project(self, project_id):
return self.tenant_get_object_or_404(Project, id=project_id)
| Method | Description |
|---|---|
tenant_queryset(model_class, tenant_field='tenant_id') |
Queryset filtered by current tenant |
tenant_get_object_or_404(model_class, **kwargs) |
Get object scoped to current tenant |
tenant_filter(queryset, tenant_field='tenant_id') |
Filter existing queryset by tenant |
State Backend Isolation¶
# settings.py
DJUST_STATE_BACKEND = 'djust.tenants.backends.TenantAwareRedisBackend'
# or
DJUST_STATE_BACKEND = 'djust.tenants.backends.TenantAwareMemoryBackend'
Template Context¶
Tenant info is automatically available in templates:
<h1>{{ tenant.name }} Dashboard</h1>
{% if tenant.settings.custom_branding %}
<style>
:root { --primary-color: {{ tenant.settings.primary_color }}; }
</style>
{% endif %}
<span>Plan: {{ tenant.settings.plan_type|default:"Free" }}</span>
Security Considerations¶
- Always scope queries to the current tenant using
TenantScopedMixinor manual filtering. - Index
tenant_idfields in the database for query performance. - Validate URL access to prevent cross-tenant data access:
class TenantPermissionMixin:
def dispatch(self, request, *args, **kwargs):
if 'tenant_slug' in kwargs:
if kwargs['tenant_slug'] != request.tenant.id:
raise PermissionDenied("Access denied")
return super().dispatch(request, *args, **kwargs)
Testing¶
from djust.tenants.test import TenantTestCase, override_tenant
from djust.tenants.resolvers import TenantInfo
from django.test import RequestFactory
class ProjectTestCase(TenantTestCase):
def test_tenant_scoped_query(self):
with override_tenant('acme'):
projects = Project.objects.all()
self.assertEqual(projects.count(), 2)
def test_view_with_tenant():
request = RequestFactory().get('/dashboard/')
request.tenant = TenantInfo(id='test', name='Test Org')
view = DashboardView()
view.setup(request)
view.mount(request)
assert view.tenant.id == 'test'
Best Practices¶
- Use
TenantScopedMixinfor all data-accessing views to prevent cross-tenant leakage. - Include
tenant_idin cache keys to prevent data bleed through caching. - Consider separate database connection pools per tenant for heavy workloads.
- Use
select_related/prefetch_relatedwith tenant-scoped querysets for performance.