Skip to main content
Cloacina Documentation
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Configure PostgreSQL Schema-Based Multi-Tenancy

Configure PostgreSQL Schema-Based Multi-Tenancy

Goal

After completing this guide you will have a working PostgreSQL multi-tenant setup where each tenant’s workflows run in an isolated database schema, with optional per-tenant database credentials for defense-in-depth security.

Prerequisites

  • Cloacina added to your project (cloacina = "0.7.0" in Cargo.toml)
  • A running PostgreSQL server
  • For per-tenant credentials: an admin user with CREATEDB and CREATEROLE privileges

1. Create per-tenant executors with DefaultRunner::with_schema

Each call to DefaultRunner::with_schema provisions a PostgreSQL schema (if it does not already exist) and returns an executor scoped to that schema:

use cloacina::runner::DefaultRunner;

let database_url = "postgresql://user:pass@localhost/cloacina";

let tenant_a = DefaultRunner::with_schema(database_url, "tenant_a").await?;
let tenant_b = DefaultRunner::with_schema(database_url, "tenant_b").await?;

All workflow state for tenant_a is fully isolated from tenant_b.

2. Provision tenants with DatabaseAdmin

Use DatabaseAdmin to create a schema and a dedicated database user in one step. Passing an empty password triggers secure auto-generation:

use cloacina::database::{Database, DatabaseAdmin, TenantConfig};

let admin_db = Database::new(
    "postgresql://admin:admin_pass@localhost/cloacina",
    "cloacina",
    10,
);
let admin = DatabaseAdmin::new(admin_db);

let creds = admin.create_tenant(TenantConfig {
    schema_name: "tenant_secure".to_string(),
    username: "secure_user".to_string(),
    password: "".to_string(), // auto-generate
})?;

// creds exposes: username, password, schema_name, connection_string

Store the returned TenantCredentials in a secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager) and pass the connection string when creating the executor:

let executor = DefaultRunner::with_schema(
    &creds.connection_string,
    &creds.schema_name,
).await?;

To tear down a tenant and its database user:

admin.remove_tenant(&creds.schema_name, &creds.username)?;

Why per-tenant credentials?

  • Database-level isolation – each tenant can only access their own schema
  • Audit compliance – PostgreSQL logs attribute operations to the correct user
  • Independent credential rotation – rotate one tenant without affecting others

3. Verify tenant isolation

A minimal check that two tenants cannot see each other’s executions:

let tenant_a = DefaultRunner::with_schema(database_url, "test_tenant_a").await?;
let tenant_b = DefaultRunner::with_schema(database_url, "test_tenant_b").await?;

let result_a = tenant_a.execute_async("test_workflow", Context::new()).await?;
let result_b = tenant_b.execute_async("test_workflow", Context::new()).await?;

assert_ne!(result_a.execution_id, result_b.execution_id);

For full integration-testing patterns, see Testing Workflows.

Alternative: SQLite file-based tenancy

If you do not need PostgreSQL, you can isolate tenants with one SQLite file per tenant:

let executor = DefaultRunner::new("sqlite:///data/tenant_a.db").await?;

Each file is a self-contained database, so isolation is guaranteed by the file system. DatabaseAdmin and per-tenant credentials are not available for SQLite.

Migrate an existing single-tenant deployment

If you already run a single-tenant deployment against the public schema, you can move to schema-based tenancy without starting over. Two approaches:

Option 1: Move existing data into a named schema

Move the existing tables into a dedicated schema, then address that schema as a tenant. Run this in a transaction:

BEGIN;
CREATE SCHEMA legacy_tenant;

ALTER TABLE pipeline_executions SET SCHEMA legacy_tenant;
ALTER TABLE task_executions SET SCHEMA legacy_tenant;
ALTER TABLE contexts SET SCHEMA legacy_tenant;
-- ... repeat for all Cloacina tables

COMMIT;

Then point the existing application at the new schema, and give new tenants their own schemas:

let legacy = DefaultRunner::with_schema(db_url, "legacy_tenant").await?;
let new_tenant = DefaultRunner::with_schema(db_url, "new_customer").await?;

Option 2: Run side-by-side

Leave the existing single-tenant runner on the public schema and add new tenants alongside it in their own schemas:

// Existing single-tenant runner (public schema)
let legacy = DefaultRunner::new(db_url).await?;

// New multi-tenant runners
let tenant_a = DefaultRunner::with_schema(db_url, "tenant_a").await?;
let tenant_b = DefaultRunner::with_schema(db_url, "tenant_b").await?;

Schema names must follow the schema naming rules.