#Multi-Tenant Isolation Cookbook
Version: 0.40.0 | Updated: 2026-03-23 | Applies to: ranvier-core 0.40+ | Category: Cookbook
#Overview
Multi-tenancy is the most common requirement for SaaS applications. Ranvier's Bus + Guard model provides tenant isolation without framework extensions: a Guard extracts the tenant identity, the Bus propagates it transparently, and every downstream Transition reads it.
This cookbook covers tenant extraction, Bus propagation, DB query isolation, per-tenant rate limiting, and testing strategies — all with existing v0.40 primitives.
#1. Tenant Context Type
Define a single newtype that carries the tenant identity through the entire pipeline.
/// Bus type: tenant identity extracted by the TenantGuard.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantContext {
pub tenant_id: String,
pub plan: TenantPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TenantPlan {
Free,
Pro,
Enterprise,
}
impl TenantContext {
pub fn new(tenant_id: impl Into<String>, plan: TenantPlan) -> Self {
Self {
tenant_id: tenant_id.into(),
plan,
}
}
}Keep TenantContext in a shared module so both Guards and Transitions can import it.
#2. Tenant Guard — Header Extraction
The simplest strategy: read X-Tenant-Id from the request header. A custom Guard
validates the tenant and inserts TenantContext into the Bus.
use async_trait::async_trait;
use ranvier_core::prelude::*;
#[derive(Debug, Clone)]
pub struct TenantGuard<T> {
_marker: std::marker::PhantomData<T>,
}
impl<T> TenantGuard<T> {
pub fn new() -> Self {
Self { _marker: std::marker::PhantomData }
}
}
#[async_trait]
impl<T: Send + Sync + 'static> Transition<T, T> for TenantGuard<T> {
type Error = String;
type Resources = ();
async fn run(
&self,
input: T,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<T, Self::Error> {
// Read raw headers injected by bus_injector
let headers = bus.get_cloned::<Vec<(String, String)>>();
let tenant_id = headers.ok()
.and_then(|h| {
h.iter()
.find(|(k, _)| k == "x-tenant-id")
.map(|(_, v)| v.clone())
});
match tenant_id {
Some(id) if !id.is_empty() => {
// Look up tenant plan (in production, query DB or cache)
let plan = resolve_plan(&id);
bus.insert(TenantContext::new(id, plan));
Outcome::next(input)
}
_ => Outcome::fault("401 Unauthorized: missing X-Tenant-Id header".into()),
}
}
}
fn resolve_plan(tenant_id: &str) -> TenantPlan {
// Stub — replace with DB/cache lookup
match tenant_id {
id if id.starts_with("ent-") => TenantPlan::Enterprise,
id if id.starts_with("pro-") => TenantPlan::Pro,
_ => TenantPlan::Free,
}
}#3. Alternative Extraction Strategies
The Guard body changes by 2–3 lines depending on where the tenant identity lives.
#JWT Claim
// Inside TenantGuard::run()
let identity = bus.get_cloned::<ranvier_core::iam::IamIdentity>();
let tenant_id = identity.ok()
.and_then(|id| id.metadata.get("tenant_id").cloned());Requires AuthGuard to run first, which inserts IamIdentity into the Bus.
#Subdomain
// Inside TenantGuard::run()
let host = headers
.and_then(|h| h.iter().find(|(k, _)| k == "host").map(|(_, v)| v.clone()));
let tenant_id = host
.and_then(|h| h.split('.').next().map(String::from))
.filter(|sub| sub != "www" && sub != "api");#Path Prefix
// Inside bus_injector, extract from PathParams
use ranvier_http::PathParams;
if let Some(params) = parts.extensions.get::<PathParams>() {
if let Some(tid) = params.get("tenant_id") {
bus.insert(TenantContext::new(tid.clone(), TenantPlan::Free));
}
}
// Route: /api/:tenant_id/orders#4. Bus Propagation
Once the Guard inserts TenantContext, every downstream Transition reads it
transparently. No explicit parameter passing required.
use ranvier_core::prelude::*;
use ranvier_runtime::Axon;
let order_pipeline = Axon::typed::<CreateOrder, String>("create-order")
.then_fn("validate", |order, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
tracing::info!(tenant_id = %tenant.tenant_id, "validating order");
if order.quantity == 0 {
return Outcome::fault("quantity must be > 0".into());
}
Outcome::next(order)
})
.then_fn("persist", |order, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
sqlx::query("INSERT INTO orders (tenant_id, product_id, quantity) VALUES ($1, $2, $3)")
.bind(&tenant.tenant_id)
.bind(&order.product_id)
.bind(order.quantity)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(format!("order created for tenant {}", tenant.tenant_id))
});The TenantContext flows through the entire pipeline without any Transition needing
to know how it was extracted.
#5. DB Query Isolation
#Row-Level Isolation (Recommended)
The simplest and most common strategy. Every query includes WHERE tenant_id = $1.
#[async_trait]
impl Transition<(), Vec<Order>> for ListOrders {
type Error = String;
type Resources = ();
async fn run(
&self,
_input: (),
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<Vec<Order>, Self::Error> {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
let orders = sqlx::query_as::<_, Order>(
"SELECT id, product_id, quantity, status FROM orders WHERE tenant_id = $1"
)
.bind(&tenant.tenant_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(orders)
}
}#Schema-per-Tenant
For stronger isolation, use PostgreSQL schemas. Set the search path before queries.
.then_fn("set-schema", |input, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let pool = &bus.require::<DbPool>().0;
// Set search_path for this connection
let schema = format!("tenant_{}", tenant.tenant_id.replace('-', "_"));
sqlx::query(&format!("SET search_path TO {schema}, public"))
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Outcome::next(input)
})#Connection Pool per Tenant (Enterprise)
For maximum isolation, maintain separate connection pools. Use Bus to store
the tenant-specific pool.
#[derive(Debug, Clone)]
pub struct TenantPoolRegistry(pub std::sync::Arc<dashmap::DashMap<String, PgPool>>);
// In bus_injector or a Guard:
let registry = bus.require::<TenantPoolRegistry>();
let tenant = bus.require::<TenantContext>();
if let Some(pool) = registry.0.get(&tenant.tenant_id) {
bus.insert(DbPool(pool.clone()));
}#6. Per-Tenant Rate Limiting
Combine TenantContext with RateLimitGuard to enforce per-tenant request limits.
#Plan-Based Limits
use ranvier_guard::prelude::*;
fn tenant_rate_guard() -> impl Fn(&Bus) -> Option<(u64, u64)> {
|bus: &Bus| {
let tenant = bus.get_cloned::<TenantContext>().ok()?;
let (limit, window_ms) = match tenant.plan {
TenantPlan::Free => (100, 60_000), // 100 req/min
TenantPlan::Pro => (1_000, 60_000), // 1K req/min
TenantPlan::Enterprise => (10_000, 60_000), // 10K req/min
};
Some((limit, window_ms))
}
}#Custom Rate Limit Guard
#[derive(Debug, Clone)]
pub struct TenantRateLimitGuard<T> {
counters: std::sync::Arc<dashmap::DashMap<String, (u64, std::time::Instant)>>,
_marker: std::marker::PhantomData<T>,
}
#[async_trait]
impl<T: Send + Sync + 'static> Transition<T, T> for TenantRateLimitGuard<T> {
type Error = String;
type Resources = ();
async fn run(
&self,
input: T,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<T, Self::Error> {
let tenant = bus.require::<TenantContext>();
let (limit, window) = match tenant.plan {
TenantPlan::Free => (100u64, std::time::Duration::from_secs(60)),
TenantPlan::Pro => (1_000, std::time::Duration::from_secs(60)),
TenantPlan::Enterprise => (10_000, std::time::Duration::from_secs(60)),
};
let key = tenant.tenant_id.clone();
let mut entry = self.counters.entry(key).or_insert((0, std::time::Instant::now()));
// Reset window if expired
if entry.1.elapsed() > window {
entry.0 = 0;
entry.1 = std::time::Instant::now();
}
entry.0 += 1;
if entry.0 > limit {
Outcome::fault("429 Too Many Requests: tenant rate limit exceeded".into())
} else {
Outcome::next(input)
}
}
}#7. Full Server Example
Combining TenantGuard, per-tenant rate limiting, DB isolation, and bus_injector:
use ranvier_http::prelude::*;
use ranvier_guard::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
Ranvier::http()
.bind("0.0.0.0:8080")
.bus_injector({
let pool = pool.clone();
move |parts: &http::request::Parts, bus: &mut Bus| {
bus.insert(DbPool(pool.clone()));
// Inject raw headers for TenantGuard
let headers: Vec<(String, String)> = parts.headers.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
bus.insert(headers);
}
})
// Global guards
.guard(RequestIdGuard::new())
.guard(AccessLogGuard::new())
.guard(CorsGuard::<()>::permissive())
.guard(TenantGuard::new()) // extracts TenantContext
.guard(TenantRateLimitGuard::new()) // per-tenant rate limit
// Routes — all Transitions can bus.require::<TenantContext>()
.post_typed("/api/orders", order_pipeline())
.get("/api/orders", list_orders_pipeline())
.run(())
.await?;
Ok(())
}#8. Audit Trail per Tenant
Combine tenant context with Ranvier's audit hash chain for per-tenant audit logs.
use ranvier_audit::prelude::*;
.then_fn("audit-log", |result, _res, bus| async move {
let tenant = bus.require::<TenantContext>();
let audit = bus.require::<AuditTrail>();
audit.record(AuditEntry {
action: "order.created".into(),
actor: tenant.tenant_id.clone(),
resource: result.clone(),
timestamp: chrono::Utc::now(),
}).await;
Outcome::next(result)
})For regulatory compliance, each tenant's audit trail should be independently verifiable. The audit hash chain ensures immutability regardless of the number of tenants.
#9. Testing Multi-Tenant Isolation
#Unit Test with Bus
#[tokio::test]
async fn test_tenant_isolation() {
let mut bus_a = Bus::new();
bus_a.insert(TenantContext::new("tenant-a", TenantPlan::Pro));
bus_a.insert(DbPool(test_pool().await));
let mut bus_b = Bus::new();
bus_b.insert(TenantContext::new("tenant-b", TenantPlan::Free));
bus_b.insert(DbPool(test_pool().await));
let transition = ListOrders;
let result_a = transition.run((), &(), &mut bus_a).await;
let result_b = transition.run((), &(), &mut bus_b).await;
// Verify each tenant sees only their own data
match (&result_a, &result_b) {
(Outcome::Next(orders_a), Outcome::Next(orders_b)) => {
assert!(orders_a.iter().all(|o| o.tenant_id == "tenant-a"));
assert!(orders_b.iter().all(|o| o.tenant_id == "tenant-b"));
}
_ => panic!("both should succeed"),
}
}#Guard Rejection Test
#[tokio::test]
async fn test_missing_tenant_header() {
let mut bus = Bus::new();
// No headers injected — TenantGuard should reject
bus.insert::<Vec<(String, String)>>(vec![]);
let guard = TenantGuard::<String>::new();
let result = guard.run("input".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Fault(_)));
}
#[tokio::test]
async fn test_valid_tenant_header() {
let mut bus = Bus::new();
bus.insert(vec![
("x-tenant-id".to_string(), "pro-acme".to_string()),
]);
let guard = TenantGuard::<String>::new();
let result = guard.run("input".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Next(_)));
let ctx = bus.get_cloned::<TenantContext>().expect("should be in Bus");
assert_eq!(ctx.tenant_id, "pro-acme");
assert_eq!(ctx.plan, TenantPlan::Pro);
}#Per-Tenant Rate Limit Test
#[tokio::test]
async fn test_free_tenant_rate_limit() {
let guard = TenantRateLimitGuard::<String>::new();
let mut bus = Bus::new();
bus.insert(TenantContext::new("free-user", TenantPlan::Free));
// First 100 requests should pass
for _ in 0..100 {
let result = guard.run("ok".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Next(_)));
}
// 101st should be rejected
let result = guard.run("ok".into(), &(), &mut bus).await;
assert!(matches!(result, Outcome::Fault(_)));
}#See Also
- Guard Patterns Cookbook — Guard composition patterns
- Bus Access Patterns Cookbook — Bus read/write decision guide
- HttpIngress Patterns Cookbook — route registration patterns
- Saga Compensation Cookbook — saga patterns for tenant provisioning