TenantScopedMixin — multi-tenant SaaS pattern
Two seeded tenants. Switch via the URL. Querysets auto-scope; writes stamp the tenant; IDs from another tenant 404 instead of leaking. The three leak vectors closed.
You're seeing 3 orders for globex
(out of 6 total in the database). The other
3 rows belong to
acme
(or to legacy unscoped rows) and are invisible from here.
| # | Customer | Total | Status |
|---|---|---|---|
6 |
Globex Field Ops | $199.00 | pending |
5 |
Globex Lab | $149.00 | pending |
4 |
Globex Tower | $99.00 | pending |
The three leak-vector defenses
- List queries always include
.filter(tenant=self.tenant)— a globex user never sees an acme order in any list. - Detail/edit lookups by ID also filter by tenant — prevents IDOR via guessable PKs across tenant boundaries (a globex user passing an acme order's ID gets 404, not the row).
- Write paths stamp
tenant=self.tenanton creation — a tenant can't sneak rows into another's namespace by spoofing form fields.
Production: TenantScopedMixin handles all three automatically
from djust.tenants import TenantScopedMixin
class OrderList(TenantScopedMixin, LiveView):
model = Order
def mount(self, request, **kwargs):
# self.tenant is set automatically by TenantMiddleware
# get_tenant_queryset() always filters by self.tenant.id
self.orders = self.get_tenant_queryset()
@event_handler()
def add_order(self, customer, total, **kwargs):
# Always stamp the tenant on writes — closing the
# third leak vector (cross-tenant create).
Order.objects.create(
customer=customer, total=total,
tenant=self.tenant,
)
This demo manually filters via ?tenant= query param to keep the
scaffold lightweight. Production setup uses
DJUST_CONFIG['TENANT_RESOLVER'] (subdomain / path / header / session)
plus TenantMiddleware. See
docs.djust.org/api/tenants/.