rent
Learn by example

10. Multi-tenant applications

A request-scoped viewer carries the current tenant. Tenant ownership begins in the central schema:

model TenantProject {
  id        BigInt @id
  tenant_id BigInt
  name      String

  @@map("tenant_projects")
}

Generated tenant metadata applies Rent's built-in deny-by-default policy to every entity with a BigInt tenant_id field:

let client = Client::new(pool)
    .with_viewer(Viewer::tenant(10).user(42));

The runnable chapter proves reads cannot see tenant 20, updates and deletes cannot touch tenant 20, new rows automatically receive tenant 10, cross-tenant writes are rejected, and the policy remains active inside a transaction. Application code does not repeat tenant_id_eq(...) at every call site.

Tenant-scoped imports and upserts

An upsert must satisfy the policy for both its incoming values and the existing row it would update. Knowing a row's ID or another unique value does not grant access to it.

use generated::tenant_project::fields;

let project = client
    .upsert_tenant_project(fields::ID)
    .id(1)
    .name("Alpha imported")
    .save()
    .await?;

If ID 1 belongs to another tenant, the operation fails without changing its content or ownership. The executable chapter checks both an allowed update and a forbidden conflict.

PostgreSQL and SQLite enforce the existing-row restriction within the conflict statement. MySQL and MariaDB use a transaction and a locked authorized-row lookup, followed by an exact-key update or a plain insert. That fallback adds database calls; a uniqueness conflict never grants permission to update another row.

Relationships and mutation middleware

Generated join-table writes require permission to modify the source and visibility of the target. Removal and collection clearing affect only authorized links; links to hidden targets remain untouched. Direct foreign-key assignments also check the referenced row, including bulk creates and transaction-bound writes. Supply the complete key when changing a composite relationship; generated connection methods do that for you. For policy-scoped relationships, provide explicit key values instead of relying on a database-generated key default whose target cannot be checked before the write. Database-computed relationship keys are not supported for policy-scoped writes; use a writable key whose target Rent can authorize before changing the row.

Before-write hooks cannot grant additional access by rewriting the mutation. When hooks are installed, Rent checks the resulting values again and retains the previously established row restrictions. Keep policy rules deterministic and free of external side effects; they may run more than once for a mutation.

Use the generated client so writes carry their physical relationship metadata. A policy-scoped low-level join write without that metadata fails rather than guessing which entities need authorization. Raw SQL and extension SQL are separate, trusted escape hatches; they do not automatically acquire generated entity policies.

When the join table has its own model, its write policy also applies. If that policy adds row values or requires an insert restriction that a connection shortcut cannot express, use that join model's generated create API. Connection shortcuts never discard the join model's policy.

The complete tenant model is crates/rent/examples/tutorial_09_tenancy/schema.rsl. Run cargo run -p rent --example tutorial_09_tenancy. The same isolation workflow runs under nextest.

On this page