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.
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(...)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, if you didn't already set it explicitly.- 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.
- Reassigning
tenant_idon an existing row isn't prevented. Only a new row getstenant_idauto-assigned; an explicitpost.update(tenant_id=other_tenant)isn't blocked. If that matters for your app, add a check in the model's ownupdating()hook (see Model Events).