Multi-tenancy
Acme and Northwind both use your application. Each has projects, and both might
name one docs. An Acme request should see Acme's projects, without every handler
having to remember an extra filter.
Tenancy answers which customer's rows a request can reach. It does not decide which people inside that customer can read or edit each resource. Add relationship permissions for that, or start with how the two boundaries fit together.
Tenancy gives each request one customer's scope. Bind that scope when the request starts, then write the query you actually need:
let client = base_client
.clone()
.with_viewer(Viewer::anonymous().tenant(tenant_id));
let projects = client
.project()
.status_eq("active")
.all()
.await?;The application query says only "active projects." Rent supplies tenant_id = current_tenant, including create, bulk create, update, and delete operations. A scoped create fills tenant_id automatically and rejects an explicit conflicting tenant.
A subject identity is optional when you use tenancy alone. Binding the viewer
returns a ScopedClient. See scoped clients for
passing it through request handlers.
Declaring tenancy
The schema states which column holds the tenant, once:
tenancy {
scope = "required"
field = "tenant_id"
}
model Project {
id BigInt @id
tenant_id BigInt
slug String
@@unique([tenant_id, slug])
}
model Plan {
id BigInt @id
name String @unique
@@rent.system
}Every model is scoped unless it is marked @@rent.system, so a new model is isolated by default and a platform-wide
one is an explicit exception. Each scoped model needs the tenant field as a required BigInt or String, one type
for all of them. To make a unique constraint per tenant, explicitly list the tenant field, as in
@@unique([tenant_id, slug]). Rent does not add it automatically: @@unique([slug]) is rejected with RSL240
unless it declares global: true. With the per-tenant constraint, two tenants can each have a project called
docs. A value that really is unique across every tenant says @@unique([slug], global: true).
@@rent.system exempts tenancy. It does not make a model public. If the schema also enables
permissions with access, ordinary reads and writes of system models are denied, including
administrator viewers. Trusted infrastructure retains a system handle for that work. Without
access enabled, the marker adds no permission checks to ordinary ORM operations.
A schema whose tenant_id is ordinary data declares tenancy { scope = "none" }. A schema with a tenant_id field and
no block is rejected with RSL236, so isolation is never inferred from a column name.
The tenant guard
The generated client installs a tenant guard for every scoped model:
- It fails closed. A client with no tenant in scope gets
EntityError::MissingScopefor every read and write of a scoped model, instead of an unscoped query. - An application policy cannot remove it.
with_policysets your own policy. The guard runs alongside it, checks the final values after your policy runs, and adds the tenant filter last. - Keys are typed. A text tenant column takes
Viewer::anonymous().tenant("acme")and an integer oneViewer::anonymous().tenant(10). A mismatch isEntityError::TenantKeyKind, never a coercion.
Frameworks that carry the tenant in the request's task can supply it once instead of building a viewer per request:
struct RequestTenant;
impl ScopeSource for RequestTenant {
fn current(&self) -> Option<TenantKey> {
current_request_tenant().map(TenantKey::from)
}
}
let client = base_client.with_scope_source(Arc::new(RequestTenant));A viewer tenant and an ambient tenant that disagree fail with EntityError::ScopeConflict. A transaction captures the tenant when it begins, so every statement in it uses one tenant.
A read captures scope for its complete query tree before related-model policies run. If a policy
changes the ambient tenant during that operation, Rent returns EntityError::ScopeConflict.
It cannot redirect the parent or related query into another tenant. This applies both with and
without an access block. Start a new operation to use a different ambient scope.
Trusted cross-tenant work, such as a maintenance job, uses a system handle, the one way to reach every tenant's rows:
let system = base_client.system();
let every_project = system.project().all().await?;A system handle is the only bypass. Viewer::administrator() names no tenant, so the guard refuses it like any
other unscoped viewer. Application policies may still read its admin flag. Setting a viewer that names a tenant
on a system handle turns it back into a guarded tenant handle, so a tenant client derived from a system one is never
unguarded.
PostgreSQL RLS can provide an additional database-enforced boundary. Rent's migration catalog tracks RLS enablement and policies.
See the tenancy tutorial for tenant-scoped reads, writes and relationships.
Combine tenancy with relationship permissions
The optional permissions language adds resource-level access rules alongside the tenant guard. A permission cannot widen tenant scope. Inferred direct-assignment tables retain the full resource and subject keys, with a shared tenant value participating in both foreign keys. This also applies when the tenant column is outside an endpoint's primary key: the runtime checks the endpoint's actual tenant before assigning it.
The grant example demonstrates direct users, team membership, and rejection of a foreign tenant's identically numbered user. Trusted system assignment still requires both endpoints to belong to the same tenant. Permissions also work in a schema without tenancy. Tenancy alone needs no permission rules.
