Skip to content

06 — Multi-Tenancy

On PostgreSQL, Cloacina isolates tenants by schema: each tenant gets its own schema, so there is no cross-tenant access and no tenant filtering in your code. You opt in with a single runner constructor — the workflow code is unchanged.

with_schema creates (or reuses) a tenant’s schema, runs migrations inside it, and scopes every read and write to that schema. The same workflow runs against each tenant in complete isolation.

use cloacina::runner::DefaultRunner;
use cloacina::executor::{WorkflowExecutor, WorkflowStatus}; // WorkflowExecutor brings `execute` into scope
use cloacina::{task, workflow, Context, TaskError};
use serde_json::json;
use std::collections::HashMap;
#[workflow(
name = "customer_processing",
description = "Process customer data in isolated tenant environment"
)]
pub mod customer_processing {
use super::*;
#[task]
pub async fn process_customer_data(
context: &mut Context<serde_json::Value>,
) -> Result<(), TaskError> {
let tenant_id = context
.get("tenant_id")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
context.insert("processed_records", json!(1250))?;
context.insert("processing_completed", json!(true))?;
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = "postgresql://cloacina:cloacina@localhost:5432/cloacina";
let mut tenant_runners = HashMap::new();
for tenant_id in ["acme_corp", "globex_inc", "initech"] {
// One schema per tenant — created on first use.
let runner = DefaultRunner::with_schema(database_url, tenant_id).await?;
tenant_runners.insert(tenant_id.to_string(), runner);
}
for (tenant_id, runner) in &tenant_runners {
let mut context = Context::new();
context.insert("tenant_id", json!(tenant_id))?;
let result = runner.execute("customer_processing", context).await?;
if matches!(result.status, WorkflowStatus::Completed) {
let records = result
.final_context
.get("processed_records")
.and_then(|v| v.as_i64())
.unwrap_or(0);
println!("Tenant {} completed: {} records", tenant_id, records);
}
}
for (_tenant_id, runner) in tenant_runners {
runner.shutdown().await?;
}
Ok(())
}

Each tenant’s tables live under its own schema (acme_corp.task_executions, globex_inc.task_executions, …), so no query can cross tenants. This needs PostgreSQL — see Database backends.

Provisioning a tenant with dedicated credentials

Section titled “Provisioning a tenant with dedicated credentials”

For stronger isolation, the Database Admin API provisions a tenant with its own PostgreSQL user (auto-generated password) that can only see its own schema. Connect a normal runner with the returned connection string.

use cloacina::database::{Database, DatabaseAdmin, TenantConfig};
use cloacina::runner::DefaultRunner;
let admin_db = Database::new(admin_database_url, "cloacina", 10);
let admin = DatabaseAdmin::new(admin_db);
let tenant_config = TenantConfig {
schema_name: "tenant_demo".to_string(),
username: "demo_user".to_string(),
password: String::new(), // Auto-generate secure password
};
let credentials = admin.create_tenant(tenant_config).await?;
// Runner scoped to the tenant's dedicated credentials.
let tenant_runner = DefaultRunner::new(&credentials.connection_string).await?;

The dedicated user gives database-level access control and a per-tenant audit trail. For the full design rationale, see Multi-tenancy.

A single with_schema call gives each tenant an isolated PostgreSQL schema, and the Admin API adds dedicated credentials on top. Next, react to external events.