Skip to content
ZivoHR

Interface style

Colour scheme

All writing
Engineering

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.

The ZivoHR engineering teamEngineering12 Aug 2026 · 6 min read
How a request reaches only its own workspace's rowsA request to acme.zivohr.com binds the Acme tenant; every query goes through the tenant-scoped model manager, which returns only Acme's rows. A query with no tenant bound raises an error instead of returning everything.acme.zivohr.comtenant: Acmescoped managerAcme's rows onlyno tenant boundraises — never returns all

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:

  1. 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.
  2. The escape hatch became greppable. Cross-tenant reads are real: platform admin, billing, migrations. Those use all_tenants explicitly, so “show me every place we deliberately cross the boundary” is one search.
  3. 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.

Book a demo · 30 min

See it on your own numbers.

Thirty minutes, your policies, your payroll edge cases. We will show you the parts that usually break.