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().
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(())
}
cargo run
In an Axeon app#
TenantScope scopes req.db() and Transactional to the tenant of each request, so handlers cannot forget:
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.