Why tenant scoping belongs in the model layer, not the view
Every multi-tenant leak we have seen started the same way — a query that forgot which customer it was for. The fix is not more discipline. It is making the forgetful version impossible to write.
There is a version of multi-tenancy that looks fine in review and fails in production. It goes like this: every list view filters by the current tenant, every developer knows to do it, and the code review catches the ones who forget.
It works until it doesn’t. The failure is never dramatic. Someone adds an endpoint at 6pm on a Friday, copies a neighbouring view, and drops the one line that mattered.
Scoping at the view is scoping by memory
The problem with filtering in get_queryset() is that it is an opt-in safety property.
Every new view is a fresh chance to opt out, and nothing about opting out looks wrong. The
code reads normally. The tests pass, because the test database has one tenant in it.
We wanted the opposite shape: a system where the unsafe version does not run at all.
Fail closed, in the manager
So the scoping lives one layer down, in the default manager. A query with no tenant bound does not return everything — it raises.
class TenantManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(tenant=require_current_tenant())
require_current_tenant() is the whole idea. There is no ambient default, no “fall back to
the first tenant”, no None that quietly means “all”. Absence of context is an error.
Three things fall out of this that we did not fully expect:
- Related fields inherit it for free. A serializer that exposes a foreign key resolves
through
related_model._default_manager— which is now the scoped one. Dozens of relation fields became safe without anyone touching them. - The escape hatch became greppable. Cross-tenant reads are real: platform admin,
billing, migrations. Those use
all_tenantsexplicitly, so “show me every place we deliberately cross the boundary” is one search. - Writes needed the same treatment. Reading is only half of it.
save()stamps the tenant from context and refuses a row that belongs to someone else, so a mismatched write is an exception rather than a silent misfile.
The hole we left, and why it is worth naming
Not every model can be tenant-scoped. User is the obvious one — platform staff belong to
no tenant, so the user table has to be global.
That single exception was the source of a real bug. User._default_manager is unscoped, so
a serializer accepting a user ID would happily accept another workspace’s user ID.
Django’s ForeignKey.validate() checks through _base_manager, which is deliberately
unscoped, so the foreign UUID validated cleanly.
The fix was a dedicated relation field that resolves users through the scoped path. The lesson was more useful than the fix: when you carve an exception out of a safety invariant, the exception is where the next bug lives. Write it down, put a name on it, and make the safe path the one that is easy to reach for.
What we would tell someone starting today
Put the invariant where it cannot be skipped, not where it is merely conventional. If your safety property depends on every future developer remembering something at 6pm on a Friday, it is not a safety property. It is a hope.