Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Multi-Tenant Scoping

The scoping tutorial showed a public vs. private split: an unauthenticated tier that is read-only and hides private rows. Multi-tenancy is a different shape of the same primitive:

  • Every caller is authenticated, and each one is confined to their own tenant’s rows.
  • Scoped callers usually still need to create and update within their tenant.

You build it from the same ScopeCondition, with two differences from the public/private case: the condition is per-tenant (a value, not an is_private boolean), and you have to be deliberate about writes.

Apply the condition to every method

A ScopeCondition filters reads and confines writes. Inject it on every request from a tenant:

use axum::{extract::Request, middleware::Next, response::Response};
use crudcrate::ScopeCondition;
use sea_orm::{ColumnTrait, Condition};

async fn scope_tenant(mut request: Request, next: Next) -> Response {
    if let Some(tenant_id) = current_tenant(&request) {
        request.extensions_mut().insert(ScopeCondition::new(
            Condition::all().add(widget::Column::TenantId.eq(tenant_id)),
        ));
    }
    next.run(request).await
}

A caller without an extension can read and write every row. Authenticate callers and decide which methods they may use before injecting the condition. Use read_only_router() for a public mount that must not accept writes.

A per-tenant condition across a join (typed, injection-safe)

Often the tenant_id lives on a parent table and the entity you’re serving only has a foreign key to it. A Condition works on the entity’s own columns, but it can hold a subquery, so you scope the child by the set of parent ids the tenant owns in one statement, with no extra round-trip.

The tutorial used Expr::cust() (raw SQL, with a SQL-injection warning). Prefer the typed builder when you can. It can’t be injected into and it survives column renames:

use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QuerySelect, QueryTrait};

// `gadget` only has `widget_id`; the tenant lives on `widget`.
fn gadget_scope(tenant_id: Uuid) -> Condition {
    let widgets_in_tenant = widget::Entity::find()
        .select_only()
        .column(widget::Column::Id)
        .filter(widget::Column::TenantId.eq(tenant_id))
        .into_query(); // -> sea_query::SelectStatement

    Condition::all().add(gadget::Column::WidgetId.in_subquery(widgets_in_tenant))
}

Rows whose foreign key is NULL (an unassigned child) drop out of an IN (subquery) by construction, usually exactly what you want for a scoped caller.

Confining writes

An update or delete selects the existing row under the condition and locks it for the transaction. An excluded row returns 404. Creates and updates check their resulting rows before commit; a row outside the condition returns 403 and rolls the transaction back. This also catches an update that changes the row’s tenant.

Batches are atomic by default. With ?partial=true, each item has its own transaction and an excluded item is reported as a failure. Custom resource hooks run inside the same transaction as the scope checks. Hooks remain responsible for any additional rows they write and for external side effects that a database rollback cannot undo.

The derive implements CRUDResource::resource_id() to identify a created row for the check. A manual resource implementation must provide it for scoped creates and updates; the default fails closed. Direct CRUDResource calls remain unscoped; use crudcrate::scope write helpers when composing a scoped operation outside the generated handlers.

Fail closed with REQUIRE_SCOPE

For an entity that must never be served without a scope (a misrouted handler would otherwise leak every tenant’s rows), set REQUIRE_SCOPE. The generated read handlers then return 500 when no ScopeCondition is present, instead of silently returning everything:

#[crudcrate(require_scope, generate_router)]
pub struct Widget { /* ... */ }

Leave it off for entities that have a legitimate unscoped (admin) caller; there, the absence of the extension is the intended “see everything” path.

REQUIRE_SCOPE governs reads only. Writes with an extension are confined; writes without one remain unrestricted. It does not replace authentication or method permissions.

Quick reference

ConcernWhere it lives
Confine reads to a tenantScopeCondition injected by middleware
Scope across a joinColumn::Fk.in_subquery(parent_ids_for_tenant) (typed, not raw SQL)
Let tenants write their own dataInject the scope on writes too
Confine writes to a tenantOne condition, checked inside the write transaction
Never serve unscoped by accident#[crudcrate(require_scope)]
Hide a column from scoped readsexclude(scoped) on the field (see the tutorial)

See also: Public & Private Endpoints for the read-only/public tier and exclude(scoped), and Security Best Practices.