Multi-tenant applications
Acme and Northwind can both create a post without seeing each other's drafts. Declare which field identifies the tenant, then bind the current tenant at the request boundary. Your handlers can query posts without repeating that filter.
Start with the schema:
tenancy {
scope = "required"
field = "tenant_id"
}
model TenantProject {
id BigInt @id
tenant_id BigInt
name String
}The generated client guards every model the block scopes. A client with no tenant in scope is refused with
EntityError::MissingScope. A request-scoped viewer supplies the tenant:
let client = Client::new(pool)
.with_viewer(Viewer::anonymous().tenant(10));Seeding several tenants is trusted work. Use client.system(), the one handle that runs
without the guard.
With tenant 10 bound, reads omit tenant 20, updates and deletes cannot touch its
rows, and new rows receive tenant 10 automatically. An explicit conflicting tenant
is rejected. These rules also apply inside a transaction. You do not repeat
tenant_id_eq(...) at every call site, and with_policy cannot remove the tenant
filter.
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(tenant_project::unique::id)
.id(1)
.name("Alpha imported")
.save()
.await?;If ID 1 belongs to another tenant, the operation fails without changing its content or ownership.
An explicit on_conflict_update is authorized as the update it performs: the tenant guard rejects a change to the
tenant field, update policies see its assignments, and foreign keys it assigns must reference rows the viewer can
see. Policy row restrictions for that update join the conflict condition.
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 transaction adds database calls. A uniqueness conflict never grants permission to update another row.
Tenant-local identities and optional parents
Use a compound primary key when the same ID may exist in different tenants. Include that tenant key explicitly in each relationship, so the database constraint and generated policy checks refer to the same identity:
model TenantDiscussion {
tenant_id BigInt
id BigInt @rent.manual
body String
parent_id BigInt?
parent TenantDiscussion? @relation("Replies", fields: [tenant_id, parent_id], references: [tenant_id, id])
replies TenantDiscussion[] @relation("Replies")
@@id([tenant_id, id])
@@map("tenant_discussions")
}The tenant-scoped client supplies tenant_id. Creating a root omits parent_id. It remains SQL NULL because
the nullable field has no database default. Creating a reply connects the complete identity:
let root = client
.create_tenant_discussion()
.id(1)
.body("Root")
.save()
.await?;
let reply = client
.create_tenant_discussion()
.id(3)
.body("Reply")
.connect_parent(&root)
.save()
.await?;connect_parent copies both key members and checks the target under the client's tenant and policies. A parent
from another tenant is rejected, even when that tenant uses the same local ID. Rent does not add tenant members
to foreign keys implicitly: declare both fields and references as above.
Omission has this meaning on insert only. When changing a populated relationship, use the generated connection
method to supply its complete key. Use clear_parent_id() to detach this optional parent. Omitting a setter on
an update leaves the relationship unchanged. A database-computed or database-defaulted relationship key cannot
be authorized by guessing its eventual value, so scoped writes need explicit writable keys.
Count replies without loading them or maintaining a separate counter:
let discussion = client
.tenant_discussion()
.id_eq(1)
.with_replies_count(|replies| replies)
.only()
.await?;
let reply_count = discussion.replies_count()?;The count retains the tenant and related-model policies. Two tenants can use the same local root ID without mixing their replies: a foreign parent is rejected, and counting or detaching a reply affects only the current tenant. Rent's guard and policies provide this isolation. The schema needs no database row-level security.
Relationship authorization 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,
so query_raw, execute_raw, and an extension client bound to a generated client run only on a system handle,
and the statement filters the tenant itself. A non-system handle returns EntityError::RawSqlRequiresSystem from
raw SQL and DriverError::RawSqlRequiresSystem from extension SQL.
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.
filter_raw and Predicate::from_raw also accept trusted SQL expressions. The outer query retains its tenant
guard, but subqueries inside those expressions must include their own authorization and tenant conditions.
Use generated relationship filters when possible. They apply policies for the related entities.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-09-tenancy"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-09-tenancy"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
model TenantDiscussion {
tenant_id BigInt
id BigInt @rent.manual
body String
parent_id BigInt?
parent TenantDiscussion? @relation("Replies", fields: [tenant_id, parent_id], references: [tenant_id, id])
replies TenantDiscussion[] @relation("Replies")
@@id([tenant_id, id])
@@map("tenant_discussions")
}
tenancy {
scope = "required"
field = "tenant_id"
}
model TenantProject {
id BigInt @id
tenant_id BigInt
name String
@@map("tenant_projects")
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::policy::viewer::Viewer;
use rent::sql::Dialect;
use generated::client::{Client, ScopedClient};
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute(
"CREATE TABLE tenant_projects (id INTEGER PRIMARY KEY, tenant_id INTEGER NOT NULL, name TEXT NOT NULL)",
)
.await?;
// Seeding several tenants is trusted work, so it runs on a system handle; a client with no tenant is refused.
let unscoped = Client::new(pool.clone());
ensure!(matches!(
unscoped.tenant_project().all().await,
Err(rent::runtime::EntityError::MissingScope { .. })
));
let system = unscoped.system();
system.create_tenant_project().id(1).tenant_id(10).name("Alpha").save().await?;
system.create_tenant_project().id(2).tenant_id(20).name("Beta").save().await?;
let tenant = Client::new(pool.clone()).with_viewer(Viewer::anonymous().tenant(10));
ensure!(
tenant
.tenant_project()
.all()
.await?
.iter()
.all(|project| project.tenant_id == 10)
);
ensure!(tenant.update_tenant_project().id_eq(2).name("stolen").save().await? == 0);
ensure!(tenant.delete_tenant_project().id_eq(2).exec().await? == 0);
let created = tenant.create_tenant_project().id(3).name("Gamma").save().await?;
ensure!(created.tenant_id == 10);
ensure!(
tenant
.create_tenant_project()
.id(4)
.tenant_id(20)
.name("Forbidden")
.save()
.await
.is_err()
);
let visible_in_transaction = tenant
.transaction(async |tx| {
let count = tx.tenant_project().count().await?;
tx.update_tenant_project().name("Alpha updated").save().await?;
Ok(count)
})
.await?;
ensure!(visible_in_transaction == 2);
ensure!(system.tenant_project().id_eq(2).only().await?.name == "Beta");
let updated = tenant
.upsert_tenant_project(generated::tenant_project::unique::id)
.id(1)
.name("Alpha imported")
.save()
.await?;
ensure!(updated.tenant_id == 10);
ensure!(
tenant
.upsert_tenant_project(generated::tenant_project::unique::id)
.id(2)
.name("Forbidden import")
.save()
.await
.is_err()
);
let protected = system.tenant_project().id_eq(2).only().await?;
ensure!(protected.tenant_id == 20 && protected.name == "Beta");
optional_parents(&unscoped, &tenant, &pool).await?;
Ok(())
}
async fn optional_parents(
root: &Client,
client: &ScopedClient,
pool: &DatabasePool,
) -> Result<()> {
pool.execute("CREATE TABLE tenant_discussions (tenant_id INTEGER NOT NULL, id INTEGER NOT NULL, body TEXT NOT NULL, parent_id INTEGER, PRIMARY KEY (tenant_id, id), FOREIGN KEY (tenant_id, parent_id) REFERENCES tenant_discussions(tenant_id, id))").await?;
let other = root.clone().with_viewer(Viewer::anonymous().tenant(20));
let root = client.create_tenant_discussion().id(1).body("Root").save().await?;
ensure!(root.tenant_id == 10 && root.parent_id.is_none());
other
.create_tenant_discussion()
.id(1)
.body("Other root with the same ID")
.save()
.await?;
let foreign = other.create_tenant_discussion().id(2).body("Foreign parent").save().await?;
let reply = client
.create_tenant_discussion()
.id(3)
.body("Reply")
.connect_parent(&root)
.save()
.await?;
ensure!(reply.parent_id == Some(1));
ensure!(
client
.create_tenant_discussion()
.id(4)
.body("Forbidden reply")
.connect_parent(&foreign)
.save()
.await
.is_err()
);
let discussion = client
.tenant_discussion()
.id_eq(1)
.with_replies_count(|replies| replies)
.only()
.await?;
ensure!(discussion.replies_count()? == 1);
client.update_tenant_discussion_one(&reply).clear_parent_id().save_one().await?;
ensure!(client.tenant_discussion().id_eq(3).only().await?.parent_id.is_none());
ensure!(other.tenant_discussion().count().await? == 2);
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}