Skip to content

Multi-tenant SaaS in production

The tenant isolation the framework guarantees, the explicit escape hatches, and the review checklist that pins them.

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.

crates/features/src/users/http/controller.rs (from the demo)
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController {
#[inject]
svc: Arc<UsersService>,
}

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. Repo joins the ability’s predicate into the SQL WHERE, so a member querying posts gets org_id = $caller_org_id appended.
  • 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 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:

crates/features/src/posts/service.rs
// 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:

crates/features/src/sessions/reaper.rs
#[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 with no ambient ability — and, by default, in one transaction per attempt. 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.

The payload carries the tenant id, and it is declared at the feature port beside the queue the processor consumes:

crates/features/src/posts/command.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishCommand {
pub post_id: Uuid,
pub org_id: Uuid,
}
#[queue(name = "posts.publish", job = PublishCommand)]
pub struct PostsPublishQueue;
crates/features/src/posts/queue/processor.rs
#[processor]
impl PostsPublishProcessor {
#[process(queue = PostsPublishQueue)]
async fn publish(&self, job: PublishCommand) -> Result<()> {
// No ambient ability — scope explicitly from the payload.
let post = post::Entity::find_by_id(job.post_id)
.filter(post::Column::OrgId.eq(job.org_id))
.one(&Repo::<post::Entity>::conn()?)
.await?;
// ...
Ok(())
}
}

A processor has no caller, so it has no scope to inherit — the tenant boundary travels as data, in the job payload, and the org_id predicate is yours to write. Treat every #[processor] as its own boundary: a load-by-id with no tenant predicate is the bug to grep for.

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.

the policy, in SQL
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.

Run this before a tenant-facing release. Each step is one grep or one read:

  1. Grep for Repo::conn() in feature code. Confirm each call site is a sanctioned unfiltered read, not a forgotten scope.
  2. Grep for Arc<DatabaseConnection> outside the framework crates. Each injection should be a contextless path (a hook), never a request handler.
  3. Confirm every #[processor] re-scopes. Trace the tenant id from the job payload into the query. A load-by-id with no org_id predicate is the bug.
  4. Confirm every controller carries #[use_guards(AuthnGuard, AuthzGuard)]. A missing binding fails closed to empty results, but a negative-path test pins it.
  5. Confirm RLS policies exist on tenant tables if you enabled the backstop. A table with RLS disabled is a silent gap in the floor.

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.