Multi-Tenancy¶
Row-level isolation for a shared database serving multiple tenants — one
tenants table's worth of customers in the same tables as everyone
else's, rather than a database (or schema) per tenant. A model opts in
just by declaring a tenant_id column; there's no mixin and nothing to
remember to add to each query.
A different question from Feature Flags: tenancy answers "which tenant's data should this query see," not "is this capability switched on right now."
Setup¶
# main.py
from zeython import Application, TenancyServiceProvider
def resolve_tenant(request):
# e.g. acme.example.com -> "acme"
return request.url.hostname.split(".")[0]
app = Application()
app.register(TenancyServiceProvider(app, resolver=resolve_tenant))
resolver is a required argument — there is deliberately no default.
How a request maps to a tenant is entirely app-specific (a subdomain, a
header, the logged-in user's own tenant_id), and guessing wrong here is
a cross-tenant data leak, not a cosmetic mistake:
resolver=lambda request: request.url.hostname.split(".")[0] # subdomain
resolver=lambda request: request.headers.get("X-Tenant-ID") # header
async def resolver(request): # the logged-in user's tenant
user = await current_user(request)
return user.tenant_id if user else None
It may be sync or async, and should return None for a request that
doesn't belong to a tenant (a public marketing page, a health check) —
Model query methods apply no filter at all in that case, not "filter to
tenant None".
Opting a model in¶
# app/Models/post.py
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped, mapped_column
from zeython import Model
class Post(Model):
__tablename__ = "posts"
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255))
That's the whole opt-in. From here:
Post.find(id),Post.all(),Post.find_by(...),Post.paginate(...), andPost.search(...)(see Full-Text Search) all scope to the current request's resolved tenant automatically — includingfind()by ID, so a request can't read another tenant's row just by guessing (or enumerating) its ID.Post.create(...)(andsave()on a freshly-constructedPost()) assignstenant_idfrom the current tenant automatically, unless it's already set (via direct attribute assignment on the instance before callingsave()).tenant_idis guarded fromPost.create(**kwargs)/post.update(**kwargs)the same wayid/created_at/etc. are — a caller can't reassign a row's tenant withPost.create(**await request.json())orpost.update(**data)(the pattern this framework's own docs teach everywhere else) just by including a"tenant_id"key in the body. Direct attribute assignment from trusted code (post.tenant_id = other_tenant_id; await post.save()) still works — only the bulk-kwargs path is guarded. See Mass-assignment protection.- A model with no
tenant_idcolumn is completely unaffected — registeringTenancyServiceProviderchanges nothing for it.
Outside a request¶
A background job, a scheduled task, or a script has no request for
TenancyServiceProvider's middleware to resolve a tenant from — use
as_tenant to scope a block of code explicitly:
from zeython.tenancy import as_tenant
with as_tenant(tenant.id):
posts = await Post.all() # only this tenant's rows
With no as_tenant block and no request, current_tenant_id() is
None and queries are unscoped — the same "no filter, not a filter to
nothing" behavior as a request whose resolver returned None. This is
the deliberate default: a script that genuinely needs to operate across
every tenant (a nightly report, a migration) shouldn't have to fight the
framework to do it, and a script that forgot to scope itself is a bug in
the script, the same as forgetting a WHERE clause would be without this
feature at all.
Reading it directly¶
from zeython.tenancy import current_tenant_id
tenant_id = current_tenant_id() # whatever the resolver returned, or None
What this isn't¶
- Not database- or schema-per-tenant. Every tenant's rows live in the
same tables. If you need hard physical isolation (a compliance
requirement, wildly different tenant sizes), this isn't that — you'd
be routing to a different
Database/connection per tenant instead, which this feature doesn't help with. - No tenant management UI or provisioning. Creating a tenant, inviting users to it, billing — all yours to build; this is purely the query- and-assignment isolation mechanism.
- Relationship eager-loading isn't tenant-scoped.
include=("author",)onfind/all/etc. fetches the related row via its own foreign key (selectinload()), never through the related model's own_base_select()— so if a foreign key on a tenant-scoped row can ever point at another tenant's row (a legitimately-fillable FK, or a bug elsewhere), eager-loading it returns that other tenant's data with no error. Keep a tenant-crossing foreign key from being set in the first place (validate it against the current tenant in the model's owncreating()/updating()hook — see Model Events) rather than relying on the relationship load to catch it.