Cookbook / Multi-tenant apps

Tenant-scoped data

Make cross-tenant leaks impossible by default: queries without a tenant fail instead of returning everyone's rows.

The worst multi-tenant bug is a query that forgot WHERE org_id = ?. Mark the tenant column on a model, and axeon-orm refuses to run its queries without a tenant scope: use db.for_tenant(id) for one tenant's rows, or opt out explicitly with db.all_tenants().

src/main.rs
use axeon_orm::prelude::*;

#[derive(Model, Clone, Debug)]
struct Selection {
    id: i64,
    #[model(tenant)]
    org_id: i64,
    product: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let db = Db::connect("sqlite://tenants.db?mode=rwc").await?;
    db.create_table::<Selection>().await?;

    // Writes through a tenant's handle must belong to that tenant.
    let acme = db.for_tenant(1);
    let globex = db.for_tenant(2);
    Selection { id: 0, org_id: 1, product: "lamp".into() }.insert(&acme).await?;
    Selection { id: 0, org_id: 2, product: "desk".into() }.insert(&globex).await?;

    // Each tenant sees only its own rows.
    let mine = Selection::query().all(&acme).await?;
    println!("acme sees {} row(s): {:?}", mine.len(), mine[0].product);

    // Writing another tenant's row fails.
    let wrong = Selection { id: 0, org_id: 2, product: "stolen".into() }.insert(&acme).await;
    println!("insert for another tenant: {}", wrong.unwrap_err());

    // A plain handle refuses tenant-scoped models.
    let forgot = Selection::query().all(&db).await;
    println!("without a tenant: {}", forgot.unwrap_err());

    // Reporting across tenants is an explicit choice.
    let everyone = Selection::query().all(&db.all_tenants()).await?;
    println!("all tenants: {} rows", everyone.len());
    Ok(())
}
Terminal
cargo run

In an Axeon app#

TenantScope scopes req.db() and Transactional to the tenant of each request, so handlers cannot forget:

Rust
use axeon_orm::server::{DbRequest, TenantScope};

app.state(db.clone());
// After authentication has attached a `Member { org_id }` extension:
app.middleware(TenantScope::new(|req| req.extension::<Member>().map(|m| m.org_id)));
app.get("/selections", |req| async move {
    let mine = Selection::query().all(req.db()?).await?; // this org's rows only
    Ok::<_, ServerError>(Json(mine))
});

What is covered#

Queries, aggregates, bulk updates and deletes, instance writes, upserts (which never overwrite another tenant's conflicting row), eager loading, relation filters and joins. Transactions inherit the scope. A write for another tenant fails with Error::WrongTenant (403 in Axeon handlers), and a missing scope with Error::TenantScope.

Notes#

  • Not covered: raw SQL (execute, postgres() and similar) and models without a tenant column. Review raw queries by hand.
  • Combine it with host routing: resolve the tenant from the host in middleware, then scope the database to it.
  • Unique constraints should include the tenant column, for example (org_id, sku), so two customers can use the same SKU.