#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

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