Multi-tenant SaaS in production
If you sell one deployment to many tenants, one leaked row is a breach. This page states what the framework isolates for you, names the exact places that bypass it, and gives you a checklist to audit before you trust it. The guarantees are strong; the escape hatches are real and deliberate — you should know both.
Isolation starts where every request enters: bind the two guards on the controller, and the caller’s org scope flows through every layer below.
#[controller(path = "/users")]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}The guarantee
Section titled “The guarantee”Once the authz modules are imported, the caller’s ambient Ability is
non-optional. It does three things on every request, without a
per-handler opt-in:
- Pre-filters every read.
Repojoins the ability’s predicate into the SQLWHERE, so a member querying posts getsorg_id = $caller_org_idappended. - Gates every by-id write. A denied-but-existing row updates zero
rows and returns
403, not the row. - Masks every response field. Fields the caller cannot read are stripped from the body before it leaves the process.
One predicate AST is interpreted twice — as SQL and in memory — so the rows a query returns and the rows the masker keeps cannot diverge. The mechanics live on one canonical page: Row-level filtering. This page does not re-derive them; it covers what happens at the edges of that guarantee.
The escape hatches
Section titled “The escape hatches”The framework does not pretend the choke point is inescapable. Three paths reach the database around the ambient scope. Each exists for a reason, and each is a place a reviewer must look.
1. Repo::conn() and injected Arc<DatabaseConnection>
Section titled “1. Repo::conn() and injected Arc<DatabaseConnection>”Repo::conn() is pub. It hands you the ambient executor for a custom
query. A find() against it that skips scope_for runs unfiltered:
// Escape hatch: an unfiltered read across every tenant. A review trigger.let every_post = posts::Entity::find() .all(&Repo::<posts::Entity>::conn()?) .await?;An injected Arc<DatabaseConnection> goes one step further — it has no
executor and no ability at all, the sanctioned bypass for a truly
contextless path such as a shutdown hook:
#[injectable]pub struct SessionReaper { #[inject] db: Arc<DatabaseConnection>, // no executor, no ability — fully unscoped}Both are deliberate seams for system-level work, not the default path. Feature code that reaches for either owes a comment saying why the scope does not apply, and a reviewer who confirms it.
2. Worker and queue jobs run unscoped by definition
Section titled “2. Worker and queue jobs run unscoped by definition”A #[processor] runs on a pool executor with no ambient ability and
no per-job transaction. There is no caller, so there is no scope to
inherit. That is correct for system work — and it means each processor
is a tenant boundary you re-establish by hand.
The pattern: carry the tenant id in the job payload, then filter on it
yourself. The ambient row filter is a no-op here, so the org_id
predicate is yours to add.
#[processor]impl PostsProcessor { #[process("posts.publish")] async fn publish(&self, job: PublishJob) -> Result<(), JobError> { // No ambient ability — scope explicitly from the payload. let post = posts::Entity::find_by_id(job.post_id) .filter(posts::Column::OrgId.eq(job.org_id)) .one(&Repo::<posts::Entity>::conn()?) .await?; // ... Ok(()) }}This is by design, not a defect. But it must be said plainly: the compiler does not remind you to filter inside a processor. A job that loads by id alone crosses tenants.
3. The Postgres RLS backstop
Section titled “3. The Postgres RLS backstop”For the highest-assurance deployments, add a defense-in-depth layer the
application cannot bypass: a Postgres Row-Level Security policy keyed on
tenant_id. Even an accidental unfiltered query — a stray conn()
find(), a processor that forgot its org_id predicate — returns
nothing, because the database itself refuses the rows.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON posts USING (org_id = current_setting('app.current_org')::uuid);You set app.current_org per connection from the authenticated org id.
This is belt-and-suspenders: the application-layer filter is the primary
control, and RLS is the floor under it. Frame it as insurance for the
tenants who demand it, not a substitute for the guards above.
Review checklist
Section titled “Review checklist”Run this before a tenant-facing release. Each step is one grep or one
read:
- Grep for
Repo::conn()in feature code. Confirm each call site is a sanctioned unfiltered read, not a forgotten scope. - Grep for
Arc<DatabaseConnection>outside the framework crates. Each injection should be a contextless path (a hook), never a request handler. - Confirm every
#[processor]re-scopes. Trace the tenant id from the job payload into the query. A load-by-id with noorg_idpredicate is the bug. - Confirm every controller carries
#[use_guards(AuthGuard, AuthzGuard)]. A missing binding fails closed to empty results, but a negative-path test pins it. - Confirm RLS policies exist on tenant tables if you enabled the backstop. A table with RLS disabled is a silent gap in the floor.
Limits
Section titled “Limits”Isolation of rows and fields is the framework’s job. Everything a production tenant boundary also depends on remains yours: key management, token revocation, TLS, and DDoS defense at the edge. The Threat model lists what each layer catches and where the responsibility falls back on your deployment. Read it before you go live.
Going further
Section titled “Going further”- Row-level filtering — how the ambient ability joins into every read.
- Threat model — what the layers cover and what stays your responsibility.
- Repo and executor — the choke point the escape hatches step around.