Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

4 min read

Tutorial: Build a multi-tenant SaaS dashboard

Multi-tenancy is the single hardest invariant in a SaaS app: a user from Acme Corp must never, under any circumstances, see a single row that belongs to Contoso Ltd. The same code paths (dashboard, search, billing) serve both tenants, but every query has to filter by the current tenant and every state field has to isolate per-tenant.

Roll-your-own multi-tenant starts as a if request.user.tenant != obj.tenant: raise 403 sprinkle and ends as an audit nightmare — one missed filter and a customer sees data they shouldn't. djust ships TenantScopedMixin to make the safe pattern the default and the unsafe pattern hard to write.

By the end of this tutorial you'll have:

  • A SaaS dashboard at acme.example.com/dashboard/ and contoso.example.com/dashboard/ — same view, different data.
  • Subdomain-based tenant resolution — the framework reads the request host, looks up the tenant, sets self.tenant.
  • A tenant_queryset() helper that auto-filters every model query by the current tenant. Forgetting to filter is impossible because there's no other way to query.
  • State isolation — alice@acme's session state can't leak to bob@contoso even if they hit the same in-memory state backend.
  • Defense against URL tampering — alice can't load /dashboard/?account_id=999 to see contoso's account 999.
You'll learnDocumented in
SubdomainResolver setupMulti-Tenant
TenantScopedMixin + self.tenant + self.tenant_queryset(Model)Multi-Tenant
Per-tenant state backend isolation (Redis key prefix)Multi-Tenant
Three subtle leak vectors most homegrown impls hitThis tutorial

Prerequisites: Quickstart, authentication tutorial, and a Django project with at least one model that should be tenant-scoped (an Account, Project, Document, etc.). Multiple subdomains pointed at your dev server (covered in Step 1).


Step 1 — Tenant resolution + dev DNS

Configure subdomain-based resolution:

# settings.py
DJUST_TENANT_RESOLVER = "djust.tenants.resolvers.SubdomainResolver"

DJUST_TENANT_CONFIG = {
    "default_tenant": "public",  # what to use if subdomain matches none
    "subdomain_resolver": {
        "domain": "example.com",
        "exclude_subdomains": ["www", "api", "admin"],
    },
}

ALLOWED_HOSTS = [".example.com", ".localhost"]

For local dev, point a wildcard to localhost:

# /etc/hosts (or use dnsmasq for *.localhost on macOS / Linux)
127.0.0.1   acme.localhost
127.0.0.1   contoso.localhost

Visit acme.localhost:8000 and contoso.localhost:8000 — same Django process, but the resolver gives each request a different tenant.


Step 2 — The Tenant model + tenant-scoped models

# myapp/models.py
from django.conf import settings
from django.db import models

from djust.tenants.managers import TenantManager


class Tenant(models.Model):
    slug = models.SlugField(unique=True)  # "acme", "contoso"
    name = models.CharField(max_length=200)
    plan = models.CharField(max_length=20, default="free")


class Account(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE,
                               related_name="accounts")
    name = models.CharField(max_length=200)
    balance_cents = models.BigIntegerField(default=0)

    objects = TenantManager()

    class Meta:
        # Optional: indexes that always include tenant_id make the
        # auto-filter cheap.
        indexes = [models.Index(fields=["tenant", "name"])]


class Membership(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE)
    role = models.CharField(max_length=20, choices=[
        ("admin", "Admin"), ("member", "Member"),
    ])

    class Meta:
        unique_together = [("tenant", "user")]

Two important pieces:

  1. Every tenant-scoped model has tenant = ForeignKey(Tenant). This is the field tenant_queryset() filters on.
  2. objects = TenantManager() swaps Django's default manager for one that REQUIRES .for_tenant(t) before returning rows. Trying Account.objects.all() raises a clear "specify a tenant" error in dev. Production-safe.

Step 3 — The view, with TenantScopedMixin

# myapp/views.py
from django.shortcuts import redirect
from djust import LiveView, action, state
from djust.tenants.mixins import TenantScopedMixin

from .models import Account, Membership


class DashboardView(TenantScopedMixin, LiveView):
    template_name = "dashboard.html"

    accounts = state(default_factory=list)
    selected_id = state(0)

    def mount(self, request, **kwargs):
        if not request.user.is_authenticated:
            self.redirect("/login/")
            return
        # self.tenant is set by TenantScopedMixin's pre-mount hook.
        # If the resolver couldn't find a tenant for this host, it
        # falls back to default_tenant ("public") — you may want to
        # 404 instead, depending on your model.
        if self.tenant.slug == "public":
            self.redirect("https://www.example.com/")
            return
        # Cross-check: is THIS user a member of THIS tenant? Without
        # this check, a logged-in user could visit any tenant's
        # subdomain and the per-tenant filter would happily render
        # rows they're not entitled to (the rows wouldn't leak across
        # tenants, but they'd see THIS tenant's rows).
        if not Membership.objects.filter(
            tenant=self.tenant, user=request.user,
        ).exists():
            self.redirect("/no-access/")
            return
        self._refresh()

    def _refresh(self):
        # tenant_queryset auto-filters by self.tenant. Equivalent to
        # Account.objects.for_tenant(self.tenant).
        qs = self.tenant_queryset(Account).order_by("name")
        self.accounts = [
            {"id": a.id, "name": a.name, "balance_cents": a.balance_cents}
            for a in qs
        ]

    @action
    def select_account(self, account_id: int = 0, **kwargs):
        # Even with a tampered account_id, tenant_queryset filters
        # the lookup — DoesNotExist for any account in another tenant.
        try:
            account = self.tenant_queryset(Account).get(pk=account_id)
        except Account.DoesNotExist:
            return  # silently ignore — would log + alert in prod
        self.selected_id = account.id

Three protective layers in this view:

  1. request.user.is_authenticated — page-level auth (covered in tutorial #10).
  2. Membership check — is THIS user a member of THIS tenant? Without it, alice@acme could log into contoso.localhost and see Contoso's data. The framework can't infer this rule — it depends on your Membership model.
  3. self.tenant_queryset(Account) — every DB query for tenant-scoped models goes through this. There is no Account.objects.all() path that would skip the filter.

Step 4 — The template

<!-- myapp/templates/dashboard.html -->
<header class="dash-head">
  <h1>{{ tenant.name }} dashboard</h1>
  <p>{{ tenant.plan|capfirst }} plan</p>
</header>

<section class="dash-accounts">
  <h2>Accounts</h2>
  <ul>
    {% dj-for account in accounts %}
      <li class="account {% if account.id == selected_id %}is-selected{% endif %}">
        <button type="button" dj-click="select_account"
                data-account-id="{{ account.id }}">
          {{ account.name }}
          <span class="balance">${{ account.balance_cents|divisibleby:100 }}.00</span>
        </button>
      </li>
    {% end-dj-for %}
  </ul>
</section>

tenant is auto-injected into the template context by TenantScopedMixin, so the dashboard header just renders {{ tenant.name }} and gets the right value for whichever subdomain the user came in on.


Three subtle leak vectors most homegrown impls hit

If you're writing your own multi-tenancy from scratch, these are the three places you'll leak data:

1. The "I forgot the filter" leak

# Looks fine. Returns ALL accounts across ALL tenants.
recent_accounts = Account.objects.order_by("-created_at")[:10]

The fix: TenantManager raises if you call .objects.all() / .objects.filter(...) without going through .for_tenant(t). Forgetting the filter is a runtime error in dev, not a silent prod leak.

2. The "ID-based URL tampering" leak

# Looks like a normal handler. URL: /accounts/999/edit
def get_account(self, account_id: int = 0, **kwargs):
    return Account.objects.get(pk=account_id)  # ← any tenant's row

The fix: always go through self.tenant_queryset(Account).get(pk=...). Tampering with the URL gets DoesNotExist, not a foreign tenant's row.

3. The "shared cache key" leak

# Cache key without tenant prefix → tenants share cached values
@cache(key="dashboard_summary", ttl=60)
def get_summary(self):
    return self.tenant_queryset(Account).aggregate(...)

The fix: include self.tenant.id in cache keys. djust's TenantScopedMixin provides self.tenant_cache_key("dashboard_summary") which prepends the tenant ID automatically.


What just happened, end to end

   Browser                                 Server
       │                                       │
       │ GET acme.example.com/dashboard/       │
       │ ─────────────────────────────────► SubdomainResolver
       │                                       │   → Tenant("acme")
       │                                       │ DashboardView.mount()
       │                                       │   self.tenant = Tenant("acme")
       │                                       │   user authenticated? yes
       │                                       │   member of acme? yes
       │                                       │   tenant_queryset(Account)
       │                                       │     → SELECT * WHERE tenant_id=1
       │                                       │   self.accounts = [...]
       │ ◄ render dashboard.html ──────────────│
       │                                       │
       │                                       │
   Other browser                              Server
       │                                       │
       │ GET contoso.example.com/dashboard/    │
       │ ─────────────────────────────────► SubdomainResolver
       │                                       │   → Tenant("contoso")
       │                                       │ DashboardView.mount()
       │                                       │   self.tenant = Tenant("contoso")
       │                                       │   ... membership check
       │                                       │   tenant_queryset(Account)
       │                                       │     → SELECT * WHERE tenant_id=2
       │                                       │ (different rows, same view code)
       │ ◄ render dashboard.html ──────────────│

Two tenants, one process, one set of view code. The tenant_queryset filter and the membership check are the only two new lines of guardrail code.


Where to go next

  • Other resolvers: subdomain isn't the only option. PathResolver (example.com/acme/), HeaderResolver (X-Tenant: acme), and SessionResolver are all in djust.tenants.resolvers. Pick the one that matches your URL scheme.
  • Per-tenant Redis keys: the Multi-Tenant guide covers configuring TenantAwareRedisBackend so each tenant's WebSocket session state gets a unique Redis key prefix. Critical when you scale out beyond one process.
  • Tenant-aware presence: combine with the presence tutorial — the presence_key template can include {tenant.slug} so two tenants viewing the "same" doc URL never see each other's cursors.
  • Audit log: wrap every @action in a decorator that records (actor, tenant, action, target_id, timestamp) to a separate audit DB. The tenant context makes per-tenant audit trails trivial.
  • Cross-tenant admin views: for ops/support staff, build a separate set of views that DON'T inherit TenantScopedMixin — those rare paths explicitly opt out of tenant scoping. Mark them clearly and audit them on every PR.

The four-line shape — TenantScopedMixin + self.tenant + self.tenant_queryset(Model) + the membership check — is the entire daily-driver multi-tenant API. Once tenant_queryset is muscle memory, "did I forget to filter?" stops being a question you ask yourself.

Spotted a typo or want to improve this page? Edit on GitHub →