Skip to content
★ GitHubGet started

Add row-level multi-tenancy

Goal: isolate tenant-owned rows in one shared database while keeping the active tenant explicit in application code.

Loco provides three opt-in traits: TenantEntity identifies an entity’s tenant column, TenantQueryExt::in_tenant filters queries, and TenantActiveModelExt::set_tenant assigns the tenant on an active model. The tenant ID is passed explicitly, so these helpers do not depend on request-global or thread-local state.

Add multi-tenancy to the features of your existing loco-rs dependency:

loco-rs = { version = "1", features = ["multi-tenancy"] }

This feature is disabled by default and enables with-db. It makes the traits available from both loco_rs::model and loco_rs::prelude.

Generate a tenant table and a resource with a tenant foreign key:

Terminal window
$ cargo loco generate model tenants name:string!
$ cargo loco generate model documents title:string! tenant:references

Implement TenantEntity in each hand-written model module. Do not edit the generated _entities file:

use loco_rs::prelude::*;
pub use super::_entities::documents::{self, ActiveModel, Entity, Model};
impl TenantEntity for documents::Entity {
type TenantId = i64;
fn tenant_column() -> documents::Column {
documents::Column::TenantId
}
}

TenantId must match the type stored in the tenant column, such as i64, Uuid, or String. Only implement the trait on tenant-owned entities. Use migrations to add indexes and composite unique constraints for tenant-specific values, such as (tenant_id, title) when document titles must be unique within a tenant.

Resolve the tenant from authenticated data, then verify membership before running a scoped query. Do not trust a tenant ID from a path, header, or request body on its own.

Tenant resolution and permissions remain application policy. A resolver might use a URL slug, subdomain, JWT claim, or API-key relationship, but must authorize the caller for that tenant. In the examples below, tenant_id is the result of this check. Workers can carry the tenant ID in their serializable arguments and validate access when the job runs.

in_tenant composes with ordinary Sea-ORM filters:

let documents = documents::Entity::find()
.in_tenant(tenant_id)
.all(&ctx.db)
.await?;
let document = documents::Entity::find()
.filter(documents::Column::Id.eq(document_id))
.in_tenant(tenant_id)
.one(&ctx.db)
.await?
.ok_or(ModelError::EntityNotFound)?;

The generated SQL includes documents.tenant_id = ?; a valid ID from another tenant therefore behaves like a missing row.

Use set_tenant after building a new active model and before insertion:

let document = documents::ActiveModel {
title: Set(params.title),
..Default::default()
}
.set_tenant(tenant_id)?
.insert(&ctx.db)
.await?;

The helper sets an empty tenant key, accepts an identical key, and returns ModelError::TenantMismatch if the model was pre-populated with a different tenant. That prevents request data from overriding the trusted tenant. A tenant mismatch maps to HTTP 400 with a stable tenant_mismatch error code.

Use scoped bulk builders when changing or deleting an existing tenant-owned row. An active model’s normal .update() scopes only by its primary key.

let changes = documents::ActiveModel {
title: Set(params.title),
..Default::default()
};
let updated = documents::Entity::update_many()
.set(changes)
.filter(documents::Column::Id.eq(document_id))
.in_tenant(tenant_id)
.exec(&ctx.db)
.await?;
let deleted = documents::Entity::delete_many()
.filter(documents::Column::Id.eq(document_id))
.in_tenant(tenant_id)
.exec(&ctx.db)
.await?;

Check rows_affected when your endpoint must distinguish success from a missing or cross-tenant ID.

These traits do not automatically scope every database operation. Calling Sea-ORM without .in_tenant(...) remains unscoped, which is useful for authorized cross-tenant administration, reporting, and migrations. Audit tenant-owned queries for the filter.

in_tenant filters the target entity; it does not scope joined tables or validate referenced rows. Validate that related records belong to the same tenant and enforce this in the database where possible. Build mutation fields explicitly so clients cannot change tenant ownership through bulk updates. The helpers do not install database row-level security or provide membership, role, or subscription management.

  • Query data for filters that compose with tenant scope.
  • Add a model for generated entities and migrations.
  • Add middleware if tenant and permission resolution should be shared by a route group.