Skip to content
Concepts

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.

Drag to pan · scroll or pinch to zoom · use the controls to reset or open fullscreen

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::MissingScope for every read and write of a scoped model, instead of an unscoped query.
  • An application policy cannot remove it. with_policy sets 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 one Viewer::anonymous().tenant(10). A mismatch is EntityError::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.