Filter concepts by levelShowing all levels.

Django · Section 84

Multi-Tenancy

Level
advanced
Read
36 min
Concepts
3

Multi-tenancy is one deployment serving many customers whose data must never mix, and it turns on two structural decisions. The first is how a request's tenant is identified. A subdomain, a path segment or an `X-Tenant-Id` header is a *claim* supplied by the caller, so the only safe use of one is to check it against something the server already knows — this user's membership — and then to fail closed with a 404, because a 403 confirms the tenant exists and turns your login page into a customer list. The second is where the separation lives: a `tenant_id` column in a shared schema (cheapest, and isolation is entirely your code), a PostgreSQL schema per tenant (a forgotten filter finds nothing, but migrations run N times), or a database per tenant (real per-customer restores and data residency, paid for in N pools and N migration runs). Pick from requirements you have rather than ones you can imagine, because this is the hardest decision here to change later. The second concept is the one the roadmap puts a rule against: a missing tenant filter can become a cross-customer data leak. The goal is therefore not to remember the filter but to make forgetting it harmless, across four surfaces rather than one. A filtering default manager covers `Model.objects` — and, per Django's own documentation, does *not* cover relationship access, because base managers "aren't used when querying on related models". So the filter has to be applied where a row is first selected: `get_object_or_404(Invoice, pk=pk, tenant=request.tenant)`, after which everything traversed from that object is already correct. Authorization is the third surface, where a permission is never global but always "this role, on this tenant". Caching is the fourth, and the one that leaks with no query being wrong at all — a key of `dashboard_stats` is shared by everybody, so the first tenant to populate it serves the rest. Where the database supports it, PostgreSQL row-level security inverts the whole failure mode, since enabling RLS with no policy is default-deny. The last concept follows from sharing infrastructure: limits, configuration and audit all move out of code and into data. Limits are scoped per tenant because one customer is one blast radius, and a separate queue per plan tier bounds damage structurally where a rate limit only slows it. Configuration lives in rows resolved tenant → plan → system default, so changing a customer's cap is a write rather than a deploy. And an audit entry needs three identities — the tenant acted on, the actor, and the impersonator — or every support action is recorded as the customer's own.

What is true here

  1. Every tenant hint from a client is a claim; membership is what makes it an identity.
  2. Three isolation models on one dial — cheap and dense, or isolated and expensive.
  3. A filtering manager covers direct queries only; scope at the lookup for everything else.
  4. Cache keys and permission checks are tenant surfaces too, not just querysets.
  5. Shared infrastructure pushes limits, configuration and audit into per-tenant data.

What you will be able to do

  • Resolve a tenant safely from a client-supplied hint
  • Choose an isolation model for a reason you can state
  • Close the relationship path that a filtering manager silently leaves open
  • Keep one customer's bulk job from becoming everyone's incident
One request, and every gate between a URL and a row
nomembershipverifiedwrong tenant

GET acme.app.test/invoices/1042

the subdomain and the id are both user input

Tenant claim: "acme"

supplied by the caller — trusted by nothing yet

Membership check

is this user active on acme?

404 Not Found

not 403 — a 403 confirms the customer exists

request.tenant + ContextVar

cleared in a finally, never a global

Permission on THIS tenant

a role without a tenant passes on the wrong data

Scoped lookup

get_object_or_404(Invoice, pk, tenant=…)

invoice.line_items.all()

base manager — unfiltered, but the root row was scoped

Cache key with the tenant in it

the surface that leaks with no wrong query

PostgreSQL RLS (optional)

enabled with no policy = default deny

Only this tenant's rows

  • GET acme.app.test/invoices/1042 — the subdomain and the id are both user input
    • leads to Tenant claim: "acme"
  • Tenant claim: "acme" — supplied by the caller — trusted by nothing yet
    • leads to Membership check
  • Membership check — is this user active on acme?
    • on error, leads to 404 Not Found (no membership)
    • leads to request.tenant + ContextVar (verified)
  • 404 Not Found — not 403 — a 403 confirms the customer exists
  • request.tenant + ContextVar — cleared in a finally, never a global
    • leads to Permission on THIS tenant
  • Permission on THIS tenant — a role without a tenant passes on the wrong data
    • on error, leads to 404 Not Found (wrong tenant)
    • leads to Scoped lookup
  • Scoped lookup — get_object_or_404(Invoice, pk, tenant=…)
    • leads to invoice.line_items.all()
    • leads to Cache key with the tenant in it
  • invoice.line_items.all() — base manager — unfiltered, but the root row was scoped
    • leads to PostgreSQL RLS (optional)
  • Cache key with the tenant in it — the surface that leaks with no wrong query
    • leads to PostgreSQL RLS (optional)
  • PostgreSQL RLS (optional) — enabled with no policy = default deny
    • leads to Only this tenant's rows
  • Only this tenant's rows

Who is asking, and where the separation lives

Turning a client-supplied claim into a verified tenant, and choosing between three isolation models.

Identifying the tenant, and the three isolation models

coreadvanced

Multi-tenancy is one deployment serving many customers whose data must never mix. Two decisions define it. First, **how you know which tenant a request belongs to** — a subdomain, a path segment, a header, or the logged-in user's membership. Second, **where the separation lives**: one database and one schema with a `tenant_id` column on every row (shared schema), one database with a schema per tenant, or a database per tenant. They trade the same thing in the same direction — the stronger the isolation, the higher the per-tenant cost of running it.

Think of it as

Read the three models as a single dial between *cheap and dense* and *isolated and expensive*, and place your product on it using the requirements you actually have rather than the ones you can imagine. Shared schema is one row set with a discriminator column, so a thousand tenants cost one migration, one connection pool and one backup — but every single query must be filtered, and the isolation is only as good as the code that remembers to do it. Schema-per-tenant keeps one database and one connection pool while giving each tenant its own tables, so a query that forgets the filter usually finds nothing rather than someone else's data; the cost is that migrations now run N times and a schema catalogue with tens of thousands of tables becomes its own operational problem. Database-per-tenant is the strongest: separate backups, separate restores, a genuinely per-tenant blast radius, and the ability to put one customer in another region — paid for with N connection pools, N migration runs, and cross-tenant reporting that is no longer a query. The identification decision looks smaller and is not, because it determines what an attacker can influence. Anything the client supplies — a subdomain, a path segment, an `X-Tenant-Id` header — is a *claim*, and the only safe use of a claim is to check it against something the server already knows: this authenticated user's membership. Deriving the tenant from the session alone avoids that entirely but forfeits shareable per-tenant URLs. Deriving it from the URL and then verifying membership gives you both, and the verification is the load-bearing half. The order matters too — resolve the tenant before authorisation runs, so every permission check downstream is already scoped, and fail closed when no tenant can be established rather than defaulting to a first, a last, or an unfiltered view of everything.

python
request.tenant = resolve_tenant(request)   # resolved BEFORE authorisation runs

What we're doing: Resolve the tenant from the subdomain, verify it against the user's membership, and make the result unavailable to code that runs outside a request.

tenancy/middleware.pypython
from django.http import Http404


class TenantMiddleware:
    """Runs BEFORE authorisation, so every permission check downstream is
    already scoped to one tenant."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # The subdomain is a CLAIM. It comes from the client.
        host = request.get_host().partition(":")[0]
        slug = host.split(".")[0]

        tenant = None
        if request.user.is_authenticated:
            # Verified against a server-side fact: does this user belong here?
            membership = (
                Membership.objects
                .select_related("tenant")
                .filter(user=request.user, tenant__slug=slug, is_active=True)
                .first()
            )
            if membership is not None:
                tenant = membership.tenant
                request.role = membership.role

        if tenant is None:
            # 404, not 403: "no such tenant" and "not your tenant" must be
            # indistinguishable, or the error itself enumerates customers.
            raise Http404("Unknown tenant")

        request.tenant = tenant
        set_current_tenant(tenant)          # a ContextVar, for the ORM layer
        try:
            return self.get_response(request)
        finally:
            set_current_tenant(None)        # never leak into the next request
12–14
Naming the claim in a comment is not decoration — every subsequent line exists because this value is attacker-controlled.
19–24
The verification. Membership is a row the server owns, so checking the claim against it is what converts "the URL says acme" into "this user may act as acme".
29–32
404 rather than 403. A 403 confirms the tenant exists, which turns the login page into a customer-list oracle — a real disclosure even when no data leaks.
35
A `ContextVar` rather than a global. Globals are shared across threads, so under any concurrency one request would see another's tenant — the exact bug this whole section exists to prevent.
36–39
Cleared in a `finally`. Workers reuse threads, and a context left set means the next request on that thread starts already scoped to someone else's tenant.

Why this works: A forged subdomain gets a 404, the tenant is fixed before any permission check runs, and no request can inherit the tenant of the one before it.

Trusting a client-supplied tenant id

Wrong

python
tenant_id = request.headers["X-Tenant-Id"]        # attacker picks this
invoices = Invoice.objects.filter(tenant_id=tenant_id)
# changing one header returns another customer's invoices

Better

python
tenant = get_tenant_for(request.user, claimed=request.headers.get("X-Tenant-Id"))
invoices = Invoice.objects.filter(tenant=tenant)   # membership-verified

What you see: Nothing — until someone changes one header value and receives a different company's data, which is a reportable breach rather than a bug.

Why: A header, a subdomain and a path segment are all supplied by the caller, so filtering by one of them filters by whatever the caller asked for. The query is *correctly scoped to the wrong tenant*, which is why it looks right in review and passes every test that uses one tenant. The claim has to be checked against something the server independently knows — membership — before it is allowed to scope anything.

Three places the separation can live — and what a forgotten filter returns in each

Shared DB, shared schema

Cheapest to run

one migration, one pool, one backup

A missing filter leaks

the query returns another tenant's rows

Isolation is your code

so it has to be structural, not remembered

Shared DB, schema per tenant

A missing filter finds nothing

search_path scopes the connection

Migrations run N times

and a failure part-way leaves tenants on two versions

Catalogue growth

tens of thousands of tables is its own problem

Database per tenant

Real blast radius

restore or move one customer alone

Data residency is possible

this tenant's database, in their region

N pools, N migrations

and reporting becomes an ETL job, not a query

  • Shared DB, shared schema — one row set, a tenant_id column
    • Cheapest to run — one migration, one pool, one backup
    • A missing filter leaks — the query returns another tenant's rows
    • Isolation is your code — so it has to be structural, not remembered
  • Shared DB, schema per tenant — one connection pool, N sets of tables
    • A missing filter finds nothing — search_path scopes the connection
    • Migrations run N times — and a failure part-way leaves tenants on two versions
    • Catalogue growth — tens of thousands of tables is its own problem
  • Database per tenant — the strongest, and the most to operate
    • Real blast radius — restore or move one customer alone
    • Data residency is possible — this tenant's database, in their region
    • N pools, N migrations — and reporting becomes an ETL job, not a query

The three models, on one dial

The three models, on one dial
PropertyShared schemaSchema per tenantDatabase per tenant
separation isa `tenant_id` columna PostgreSQL schemaa whole database
a missing filter returns**another tenant's rows**usually nothingnothing — wrong connection
migrationsonceonce per tenantonce per tenant
connection poolsoneoneone per tenant
restore one tenanthard — surgery on shared tablesper-schema dumptrivial
cross-tenant reportinga querya query per schemaan ETL job
practical ceilingvery highhundreds to low thousandstens to hundreds

Together

python
# Shared schema: every model carries the discriminator, and every index leads with it.
class Invoice(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.PROTECT, db_index=True)

    class Meta:
        indexes = [models.Index(fields=["tenant", "-issued_at"])]

Where the tenant claim comes from, and what makes it safe

Where the tenant claim comes from, and what makes it safe
SourceShareable URLs?Must be checked against
subdomain (`acme.app.test`)yesthe user's membership of `acme`
path segment (`/t/acme/…`)yesthe same — the path is user input
`X-Tenant-Id` headern/a (API)the token's own tenant claim
the sessionnonothing — the server set it
the user's single membershipnonothing, but breaks for multi-tenant users

Together

python
slug = request.get_host().split(".")[0]          # a CLAIM
membership = Membership.objects.filter(
    user=request.user, tenant__slug=slug, is_active=True
).first()
if membership is None:
    raise Http404                                  # fail closed

Remember: Two decisions. Identification: a subdomain, path or header is a *claim* from the client, and it is only safe once checked against the user's membership — then fail closed with a 404, because a 403 confirms which customers exist. Isolation: shared schema is cheapest and leaks if a filter is forgotten; schema-per-tenant makes a forgotten filter find nothing but multiplies migrations; database-per-tenant gives real blast-radius and residency isolation for N pools and N migration runs. Pick from requirements you have, not ones you imagine — and hold the current tenant in a `ContextVar` cleared in a `finally`, never a global.

See also: making the filter impossible to forget · per tenant limits configuration and audit · object level and resource level authorization

Advertisement

Four surfaces, not one

Querysets, relationship access, authorization and cache keys — and the one Django will not cover for you.

Making the tenant filter impossible to forget

coreadvanced

The roadmap states the stake plainly: **a missing tenant filter can become a cross-customer data leak.** So the goal is not to remember the filter — it is to build a system where forgetting it cannot produce another tenant's data. That means four surfaces, not one: the ORM, related-object access, authorization, and cache keys. A tenant-filtering default manager covers the first and, importantly, **not the second** — Django's documentation is explicit that base managers are not used when accessing a relationship.

Think of it as

Treat every path that can reach a row as a place the filter must already be applied, and then ask which of those paths your defence actually covers. The obvious one is a direct query, and a custom manager whose `get_queryset()` filters by the current tenant handles it well: `Invoice.objects.all()` returns only this tenant's invoices, and code that never writes a filter is still safe. The non-obvious one is relationship traversal, and this is where confidence outruns coverage. Django resolves related objects through the *base* manager, and the documentation says outright that base managers are not used when querying on related models or when accessing a one-to-many or many-to-many relationship — so `some_invoice.line_items.all()` and `Payment.objects.filter(invoice__reference=…)` do not pass through your filtering manager at all. If the object you started from belongs to another tenant, the traversal returns their data. That is why object lookup must be scoped at the point of lookup, and why the manager is a convenience rather than a boundary. The third surface is authorization, and the rule is that a permission is never global — "can view invoices" is meaningless without "which tenant's". Checking a role without also checking membership of *this* tenant means a legitimate admin of tenant A passes a permission check while operating on tenant B. The fourth is caching, and it is the one that leaks without any database query being wrong at all: a cache key of `dashboard_stats` is shared by every tenant, so whoever populates it first serves it to everyone else. Every key, every fragment cache, every memoised value needs the tenant in it. The strongest answer, where the database supports it, is to stop relying on application code entirely. PostgreSQL row-level security enforces the predicate in the database, and when RLS is enabled with no policy the default is deny — nothing is visible. That inverts the failure mode: forgetting the filter returns nothing instead of returning someone else's rows. Note one sharp edge if you adopt it — table owners normally bypass RLS unless the table is set to `FORCE ROW LEVEL SECURITY`, and Django's migration user is often the owner.

python
get_object_or_404(Invoice, pk=pk, tenant=request.tenant)

What we're doing: Layer the defences: a filtering manager for direct queries, a scoped lookup helper for the relationship path it cannot cover, tenant-scoped permissions, and cache keys that carry the tenant.

tenancy/db.py + billing/views.pypython
# tenancy/db.py
class TenantManager(models.Manager):
    def get_queryset(self):
        tenant = get_current_tenant()
        queryset = super().get_queryset()
        return queryset.filter(tenant=tenant) if tenant else queryset.none()


class TenantModel(models.Model):
    tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.PROTECT)

    objects = TenantManager()        # filtered: covers Model.objects.*
    all_objects = models.Manager()   # unfiltered: for migrations and admin jobs

    class Meta:
        abstract = True
        # Related access uses the BASE manager, which Django's docs say is
        # "not used when querying on related models". Naming an unfiltered
        # base manager makes that explicit rather than accidental — and is
        # why the lookup below must still be scoped by hand.
        base_manager_name = "all_objects"


# billing/views.py
def invoice_detail(request, pk):
    # Scope at the LOOKUP. Relationship traversal from a correctly scoped
    # object is then safe, because the starting row is already the right one.
    invoice = get_object_or_404(Invoice, pk=pk, tenant=request.tenant)

    # A permission is never global — the role must be held ON this tenant.
    if not has_permission(request.user, "billing.view_invoice", tenant=request.tenant):
        raise PermissionDenied

    lines = invoice.line_items.select_related("product")   # safe: invoice was scoped

    # The tenant belongs in the key, or the first tenant to populate it
    # serves every other tenant from cache with no query running.
    key = f"invoice_totals:{request.tenant.id}:{invoice.pk}:{invoice.updated_at.timestamp()}"
    totals = cache.get_or_set(key, lambda: compute_totals(invoice), 300)

    return render(request, "billing/invoice.html",
                  {"invoice": invoice, "lines": lines, "totals": totals})
3–6
`.none()` when no tenant is set is the fail-closed choice. Returning the unfiltered queryset instead would make every management command and every misconfigured code path a leak.
12–13
Two managers, named for what they do. An unfiltered escape hatch has to exist — migrations and cross-tenant jobs need it — and naming it `all_objects` makes each use a deliberate, greppable decision.
17–21
The documented caveat, made explicit in the model. Declaring the base manager as the unfiltered one is honest: Django was going to use an unfiltered manager for related access regardless, so pretending otherwise would be the dangerous option.
30
The single most important line. Scoping at lookup means every relationship traversed afterwards starts from a row that belongs to this tenant — which is the only way to cover the path the manager cannot.
33–34
Role plus tenant. A permission check that omits the tenant lets an admin of one customer pass while operating on another — a legitimate role, applied to the wrong data.
40–41
Tenant in the key, plus `updated_at` so an edit invalidates it. Without the tenant this line leaks across customers even though every query on the page is correctly filtered.

Why this works: Direct queries are filtered by default, the relationship path is covered by scoping the lookup, permissions cannot pass on the wrong tenant, and no cache entry is shared between customers.

Assuming a filtering default manager protects related access

Wrong

python
invoice = get_object_or_404(Invoice, pk=pk)     # NOT scoped: any pk
lines = invoice.line_items.all()               # base manager: unfiltered
# a guessed pk returns another tenant's invoice and all of its line items

Better

python
invoice = get_object_or_404(Invoice, pk=pk, tenant=request.tenant)
lines = invoice.line_items.all()               # safe: the root row is scoped

What you see: Changing the id in a URL returns another customer's invoice — and every test passes, because the test suite uses one tenant.

Why: Django resolves related objects through the base manager, and the documentation is explicit that base managers are not used when accessing a one-to-many or many-to-many relationship. So a `TenantManager` on `objects` never runs for `invoice.line_items`, and `get_object_or_404(Invoice, pk=pk)` may itself bypass it depending on the manager used. The filter has to be applied where the row is first selected. This is the specific failure the roadmap means by "a missing tenant filter can become a cross-customer data leak", and it is invisible in single-tenant tests — which is why the test suite needs a second tenant whose data must never appear.

One cache key, two tenants — a leak with no wrong query anywhere

Every database query here is correctly filtered. The leak happens above the ORM, in a cache key that does not name the tenant — which is why tenant-aware caching is listed as its own concern rather than folded into "filter your querysets".

  • Tenant Acme requests the dashboard. The cache key "dashboard_stats" misses, so a correctly filtered query runs and Acme's figures are stored under that key.
  • Tenant Globex then requests the same dashboard. The key "dashboard_stats" hits, and Globex is served Acme's figures without any query running at all.
  • The fix shown below is to include the tenant in the key: "dashboard_stats:acme" and "dashboard_stats:globex" are separate entries.

Four surfaces, and what each defence actually covers

Four surfaces, and what each defence actually covers
SurfaceFiltering default managerWhat you must do
`Invoice.objects.all()`coverednothing more
`invoice.line_items.all()`**not covered**scope the lookup of `invoice` itself
`Payment.objects.filter(invoice__ref=…)`**not covered**the join uses the base manager
`get_object_or_404(Invoice, pk=pk)`covered *if* it uses `objects`pass `tenant=request.tenant` explicitly
permission checksirrelevantcheck role **and** membership of this tenant
cache keysirrelevantput the tenant id in every key
raw SQL / `extra()`not coveredRLS, or a reviewed exception

Together

python
# Scope at lookup. Everything traversed from here is then already correct.
invoice = get_object_or_404(Invoice, pk=pk, tenant=request.tenant)
lines = invoice.line_items.all()      # safe because `invoice` was scoped

Where to enforce, ranked by what a mistake costs

Where to enforce, ranked by what a mistake costs
LayerForgetting it returnsCost to adopt
nothing — filter by hand each timeanother tenant's rowsnone, and it will be forgotten
filtering default managernothing, *except* through relationslow
scoped lookup helper + managernothing, on every ORM pathlow, once it is the convention
PostgreSQL RLSnothing — default-denyhigher: policies, and a non-owner role

Together

sql
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;   -- owners bypass RLS without this
CREATE POLICY tenant_isolation ON invoice
    USING (tenant_id = current_setting('app.tenant_id')::int);

Remember: A missing tenant filter is a cross-customer data leak, so make forgetting it impossible rather than unlikely. A filtering default manager covers `Model.objects` and — per Django's own docs — **not** relationship access, so scope at the *lookup* (`get_object_or_404(Invoice, pk=pk, tenant=request.tenant)`) and everything traversed from there is safe. Permissions are always "this role, on this tenant". Put the tenant in every cache key, or the first tenant to populate one serves all the others with no query being wrong. Hold the current tenant in a `ContextVar`, never a global. Where you can, let PostgreSQL RLS make the default deny.

See also: identifying the tenant and the three models · default vs base manager · object level and resource level authorization

Advertisement

What becomes per-tenant data

Limits, configuration and audit, once one deployment serves everybody.

Per-tenant limits, configuration and audit

standardadvanced

Once tenants share infrastructure they compete for it, so limits stop being an abuse control and become a fairness mechanism: one customer's bulk import must not exhaust the workers everyone else needs. Configuration stops being a setting and becomes a row, because tenants differ — a plan tier, a feature flag, a retention period, a currency. And an audit log stops being optional, because in a shared system the answer to "who saw this?" has to include *which tenant* — an entry without a tenant id cannot answer the only question that matters.

Think of it as

The unifying idea is that in single-tenant software these three things live in the code and the deployment, and in multi-tenant software they all move into data. Limits move first, and the reason is the noisy-neighbour failure: a shared pool means one tenant's spike is felt as latency by everyone, so the limit is not there to punish them but to keep their spike from becoming everyone's incident. That means limits have to be scoped per tenant, not per user or per IP — a hundred users at one customer are one bill and one blast radius — and they need to cover the resources that are actually scarce, which is usually worker time and database connections rather than request count. A separate queue per plan tier is often more effective than any rate limit, because it bounds the damage structurally: a slow bulk job cannot starve interactive work if it cannot reach those workers. Configuration moves next, and the trap is doing it in settings. A `TENANT_OVERRIDES` dictionary in `settings.py` makes every plan change a deploy, cannot be edited by support, and grows a branch per customer. A row on the tenant — or a small typed config model with defaults — makes the same change a database write, auditable and instant, and keeps the code path identical for all tenants. Resolve it as a chain: an explicit per-tenant value, then the plan default, then the system default, so a missing value is never a crash and never a surprise. Audit is the third, and multi-tenancy changes what an entry has to contain. Every record needs the acting user, the tenant *the action was performed on*, and the tenant the actor belongs to — because those differ precisely in the cases that matter: support staff acting on a customer's behalf, and impersonation. Impersonation deserves being explicit about: when a support engineer views a customer's account, the audit entry must record both identities, or the log says the customer did it themselves. And the log has to be scoped like everything else, since a tenant reading their own audit trail must not see anyone else's — an audit log is data, and it obeys the same isolation rule as the rest.

python
AuditEntry.objects.create(tenant=target, actor=user, impersonated_by=real_user, action=…)

What we're doing: Enforce a per-tenant job limit from configuration, and record an audit entry that stays truthful when support staff act on a customer's behalf.

tenancy/services.pypython
def start_import(request, upload):
    tenant = request.tenant

    # Limits are configuration, resolved through the chain — so raising a
    # customer's cap is a database write by support, not a deploy.
    running = ImportRun.objects.filter(tenant=tenant, status="running").count()
    if running >= config(tenant, "max_concurrent_imports"):
        raise TenantLimitExceeded("max_concurrent_imports", running)

    if upload.size > config(tenant, "max_upload_bytes"):
        raise TenantLimitExceeded("max_upload_bytes", upload.size)

    run = ImportRun.objects.create(tenant=tenant, uploaded_by=request.user)

    # Plan decides the QUEUE, not just the rate. A shared-tier import cannot
    # occupy enterprise workers, so a slow tenant delays only its own tier.
    queue = f"imports-{config(tenant, 'queue_tier')}"
    transaction.on_commit(lambda: run_import.apply_async([run.id], queue=queue))

    record_audit(request, action="import.started", target=run)
    return run


def record_audit(request, *, action, target):
    """An audit entry in a shared system needs three identities, not one."""
    return AuditEntry.objects.create(
        # The tenant the action was performed ON — the one whose log this is.
        tenant=request.tenant,
        # The identity the action ran AS.
        actor=request.user,
        # And, if support is acting on the customer's behalf, who that really
        # is. Without this the log says the customer did it themselves.
        impersonated_by=getattr(request, "impersonator", None),
        action=action,
        target_type=target.__class__.__name__,
        target_id=str(target.pk),
        request_id=getattr(request, "request_id", ""),
        occurred_at=timezone.now(),
    )
6–8
The limit is read from configuration rather than hard-coded, which is what lets support raise a specific customer's cap during a migration without shipping code.
17–18
Routing by plan tier is the structural control. A rate limit slows a noisy tenant down; a separate queue means they cannot reach the workers other tenants depend on at all.
29–33
The three identities. `tenant` is whose log this belongs to, `actor` is who ran it, and `impersonated_by` is the support engineer — omit the third and the log states that the customer performed an action they never saw.
36
The request id joins this entry to the logs and traces for the same request, which is what turns an audit line into something you can actually investigate.

Why this works: One tenant cannot consume another's capacity, a cap can be changed by support instead of by a deploy, and the audit trail stays accurate about who really acted.

What moves from code into data once one deployment serves many customers

Request — resolved tenant

request.tenant, verified against membership

Limits, per tenant

job slots, connections, export size — scoped to a customer, not a user

Configuration, per tenant

tenant value → plan default → system default; a row, never a setting

Isolation, on every path

queryset, relation, permission, cache key

Audit, per tenant

actor + target tenant + impersonator — and scoped when read back

Shared infrastructure

one pool, one worker fleet, one bill — which is why the layers above exist

  1. Request — resolved tenant — request.tenant, verified against membership
  2. Limits, per tenant — job slots, connections, export size — scoped to a customer, not a user
  3. Configuration, per tenant — tenant value → plan default → system default; a row, never a setting
  4. Isolation, on every path — queryset, relation, permission, cache key
  5. Audit, per tenant — actor + target tenant + impersonator — and scoped when read back
  6. Shared infrastructure — one pool, one worker fleet, one bill — which is why the layers above exist

What to limit, and why request count is rarely the right one

What to limit, and why request count is rarely the right one
ResourceScope it byBecause
background job slotstenant, per queueone bulk import can starve every other customer
database connectionstenant or planthe pool is the hard ceiling everything shares
export size / row counttenantan unbounded export is an unbounded memory peak
API requeststenant, then userper-user limits let a big customer bypass the cap
storage and retentiontenantcost grows quietly and is never noticed until billing
webhook fan-outtenanta slow endpoint should delay only its own tenant

Together

python
queue = "bulk-enterprise" if tenant.plan == "enterprise" else "bulk-shared"
import_products.apply_async(args=[run.id], queue=queue)
# a slow tenant cannot starve interactive work it cannot reach

Three places a per-tenant value could live

Three places a per-tenant value could live
WhereChanging it meansVerdict
`settings.TENANT_OVERRIDES`a deployno — it grows a branch per customer
a column on `Tenant`a database writegood for a handful of stable values
a `TenantConfig` row per keya database writegood when values are many or plan-derived
a feature-flag servicea flag changegood for rollout, not for contractual limits

Together

python
def config(tenant, key):
    return (tenant.overrides.get(key)
            or PLAN_DEFAULTS[tenant.plan].get(key)
            or SYSTEM_DEFAULTS[key])          # never a KeyError

Remember: In a shared deployment, limits, configuration and audit all move from code into data. Scope limits by tenant rather than by user or IP — a hundred users at one customer are one blast radius — and prefer a separate queue per tier over a rate limit, because it bounds damage structurally instead of merely slowing it. Put per-tenant values in rows and resolve them as tenant → plan → system default, so a plan change is a write rather than a deploy. And give every audit entry three identities: the tenant acted on, the actor, and the impersonator when support is acting on a customer's behalf — otherwise the log claims the customer did it.

See also: making the filter impossible to forget · abuse prevention and quotas · three kinds of record

Advertisement