Multitenancy (umbral-tenants)
Schema-per-tenant and database-per-tenant multitenancy for Postgres. Split your app into tenant apps (isolated per tenant) and shared apps (one copy in public), worked through a Starknet explorer where blockchain networks are the tenants.
umbral-tenants lets one app serve many tenants with their data isolated, without you threading a tenant id through every query. It uses a schema-per-tenant model: one Postgres database, one schema per tenant, plus a shared public schema for cross-tenant data. A request is mapped to a tenant by its Host subdomain or a header; the rest of that request runs scoped to the tenant, and every tenant-owned table is schema-qualified to the tenant's schema with zero extra round-trips - no SET search_path per request.
Schema-per-tenant is Postgres-only, and registering TenantsPlugin on a non-Postgres pool fails fast at boot - App::build() returns an error naming the offending backend, rather than letting you discover it later inside a migrate. SQLite has no schemas to isolate tenants with. Point UMBRAL_DATABASE_URL at a Postgres.
The running example: a Starknet explorer
Imagine a block explorer API that serves two networks - Sepolia (testnet) and Mainnet. The two share the same code, but their on-chain data must never mix: a Sepolia transaction must be invisible when you're serving Mainnet. Other data - API keys, a blog - is the same for everyone.
So networks are tenants, and your data splits in two:
| Data | Belongs to | Lives in |
|---|---|---|
transaction, address, token | each network (tenant) | the network's schema - sepolia.transaction, mainnet.transaction |
api_key, blog_post | everyone (shared) | public.api_key, public.blog_post |
tenant (the registry of networks) | the framework (always shared) | public.tenant |
That's the whole mental model: tenant data is isolated per tenant; shared data has one copy in public. The rest of this page is how you declare which is which.
Step 1 - split your app into tenant apps and shared apps
The split is by app (plugin), not by model. You group models into plugins, then tell umbral-tenants which plugin names are shared. Everything not listed as shared is a tenant app, and its tables are created in every tenant's schema.
A plugin is just a struct implementing Plugin with a name() and the models it owns:
use umbral::prelude::*;use umbral::migrate::ModelMeta; // ── The TENANT app: per-network data. Named in `tenant_apps`, so its tables// are created in EACH network's schema and isolated per network. ──pub struct ExplorerPlugin;impl Plugin for ExplorerPlugin { fn name(&self) -> &'static str { "explorer" } fn models(&self) -> Vec<ModelMeta> { vec![ ModelMeta::for_::<Transaction>(), ModelMeta::for_::<Address>(), ModelMeta::for_::<Token>(), ] }} // ── A SHARED app: one copy in public, every network sees the same rows. ──pub struct AccessPlugin;impl Plugin for AccessPlugin { fn name(&self) -> &'static str { "access" } fn models(&self) -> Vec<ModelMeta> { vec![ModelMeta::for_::<ApiKey>()] }} // ── Another SHARED app (blog content). ──pub struct ContentPlugin;impl Plugin for ContentPlugin { fn name(&self) -> &'static str { "content" } fn models(&self) -> Vec<ModelMeta> { vec![ModelMeta::for_::<BlogPost>()] }}The models themselves are ordinary #[derive(Model)] structs - nothing tenant-specific on them. What makes Transaction per-network and ApiKey shared is which plugin owns it and whether that plugin is shared:
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "transaction")]pub struct Transaction { // owned by ExplorerPlugin -> per-network pub id: i64, #[umbral(unique)] pub hash: String, pub block_number: i64, pub sender_address: String, pub status: String,} #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "api_key")]pub struct ApiKey { // owned by AccessPlugin (shared) -> public pub id: i64, #[umbral(unique)] pub key: String, pub label: String,}Step 2 - declare which apps are tenant-specific
Register TenantsPlugin and name your tenant apps with tenant_apps. Everything else is shared by default - built-ins (auth, sessions), external plugins, your own shared apps. Here only explorer is per-network:
use umbral_tenants::{TenantsPlugin, MissingTenant}; TenantsPlugin::new() // TENANT apps = per-network. Everything NOT listed here is shared (public), // including built-ins and any plugin you add later. `explorer` is the only // tenant app; `access`, `content`, `tenants` are shared automatically. .tenant_apps(["explorer"]) // Map a request to a network by this header (wins over the subdomain)… .tenant_header("X-Network") // …and/or the left-most Host label under this base // (sepolia.localhost -> tenant key "sepolia"). .subdomain_base("localhost") // A request that maps to no active network falls through to public // (e.g. the bare-domain landing page). Use NotFound to 404 instead. .on_missing_tenant(MissingTenant::FallThroughToPublic)Prefer tenant_apps over shared_apps. With shared_apps([...]) you must enumerate every shared app - including built-ins like auth and sessions. Forget one and it silently becomes a tenant app: its table is created in every tenant schema, and a shared concept (your users!) fragments per tenant. tenant_apps inverts the default to the safe side - an app you forget to classify stays in public, and you'd notice the missing isolation immediately. shared_apps still exists for when you genuinely want to enumerate the shared set; if both are set, tenant_apps wins.
Name apps type-safely. A stringly-typed app label is easy to fat-finger, and a misspelling silently changes the split - dangerous when several devs share a project. Pass the plugin itself instead: .tenant_app(&ExplorerPlugin) reads the name from ExplorerPlugin::name(), so the compiler keeps your declaration and the plugin in sync (and renaming the plugin can't leave a stale string behind). Chain one per tenant app - the example uses this form.
TenantsPlugin::new() .tenant_app(&ExplorerPlugin) // "explorer", straight from ExplorerPlugin::name() .tenant_app(&LedgerPlugin) // add more tenant apps the same wayThe plugin installs its TenantRouter and the per-request resolution middleware for you. Wire everything into the builder:
let app = App::builder() .settings(settings) // UMBRAL_DATABASE_URL=postgres://… .database("default", pool) .plugin( TenantsPlugin::new() .tenant_apps(["explorer"]) // the one per-network app .tenant_header("X-Network") .subdomain_base("localhost") .on_missing_tenant(MissingTenant::FallThroughToPublic), ) .plugin(ExplorerPlugin) // tenant app .plugin(AccessPlugin) // shared app .plugin(ContentPlugin) // shared app .routes(/* … */) .build()?;Step 3 - migrate everything with one command
migrate_schemas (contributed by the plugin) does both phases: the shared apps into public, then the tenant apps into every active network's schema. It's the one command to run after makemigrations.
Generate migrations for every app
cargo run -- makemigrationsOne migration folder per app - migrations/explorer/, migrations/access/, migrations/content/, migrations/tenants/.
Migrate everything - migrate_schemas
cargo run -- migrate_schemasPhase 1 migrates the shared apps into public (via run_shared, so the explorer tenant tables are not created in public - they belong only in each network's schema). Phase 2 migrates the explorer tenant app into every active network's schema. Idempotent - re-run it any time after adding a model.
Provision a network - create_tenant
let plugin = TenantsPlugin::new().tenant_apps(["explorer"]);plugin.create_tenant("Starknet Sepolia", "sepolia", "sepolia.localhost").await?;plugin.create_tenant("Starknet Mainnet", "mainnet", "mainnet.localhost").await?;Each create_tenant inserts the registry row in public, runs CREATE SCHEMA "sepolia", and migrates the tenant apps (explorer) into that schema - so sepolia.transaction, sepolia.address, sepolia.token come into existence. Idempotent on the schema. schema_name is validated as a safe Postgres identifier (Schema::new) before it touches the DB, so an injection-shaped name is rejected, not escaped. After provisioning, migrate_schemas keeps every network current as you add models.
Where do migrations end up? A shared-app migration is applied once, to public. A tenant-app migration is applied to every active network's schema (Phase 2 walks them) - and to a new network's schema the moment you create_tenant it. So a tenant table exists in every network's schema but never in public; a shared table exists only in public. Because migrate_schemas is idempotent, running it after any makemigrations brings every schema up to date.
Step 4 - it just works per network
With the explorer's handlers using the plain ORM (Transaction::objects().fetch().await), routing is automatic - the active network is resolved from the request and every tenant-owned query is scoped to its schema:
# Seed a tx on each network - they accumulate independently:curl -H 'X-Network: sepolia.localhost' localhost:3000/txs/seedcurl -H 'X-Network: mainnet.localhost' localhost:3000/txs/seed # Read back - fully isolated:curl -H 'X-Network: sepolia.localhost' localhost:3000/txs # only Sepolia's txscurl -H 'X-Network: mainnet.localhost' localhost:3000/txs # only Mainnet's txs # Shared data - same under every network, no header needed:curl localhost:3000/blogcurl localhost:3000/apikeysTransaction::objects().fetch() in a handler serving sepolia.localhost reads sepolia.transaction; the same code serving mainnet.localhost reads mainnet.transaction. BlogPost::objects().fetch() always reads public.blog_post. You wrote one set of handlers; isolation is the router's job.
The full, runnable version of everything above is examples/starknet-explorer/ - three plugins (one tenant, two shared), run_shared, two provisioned networks, and routes that demonstrate per-network isolation vs shared data end to end.
Safely presenting tenant data
A few rules keep tenant isolation airtight:
- Tables are the boundary. A tenant-owned query can only ever reach the active tenant's schema - the router never emits another tenant's schema name, so there's no query shape that reads across networks.
- Resolve the tenant from something you trust. The subdomain/header maps to a
Tenantrow bydomain; only an active registered network resolves. Don't let an end user pick an arbitrary schema name. - Decide what an unknown tenant does.
on_missing_tenant(MissingTenant::NotFound)404s an unrecognized network;FallThroughToPublic(the default) serves the bare-domain/public site. Pick per route group as needed. - Shared writes are shared. Anything in a shared app (
api_key,blog_post) is visible to - and writable from - every network's requests. Keep per-network data in a tenant app; only put genuinely cross-tenant data in shared apps.
Cross-boundary relations: a tenant model pointing at a shared one
A tenant-owned model can relate to a shared (public) model - an FK or an M2M<T> from a per-tenant parent to a child in public. For a Starknet explorer, a per-network Transaction could M2M to a shared Label taxonomy: the labels are the same everywhere, but which network's txs carry which label is per-network.
This works because the schema-migrate pins search_path to "<tenant_schema>", public. An unqualified reference resolves against the tenant schema first (tenant tables still shadow same-named public ones), then falls back to public - so a junction's child_id REFERENCES "label"(...) resolves label to public.label instead of failing relation "label" does not exist.
// Child owned by a SHARED app -> lives in public, one row every network sees.#[derive(Model)] #[umbral(table = "label")]struct Label { id: i64, name: String } // registered by a shared plugin // Parent is a TENANT app -> `transaction` + the junction land in each network's// schema; the junction's FK to `label` resolves to `public.label`.#[derive(Model)] #[umbral(table = "transaction")]struct Transaction { id: i64, hash: String, #[sqlx(skip)] #[serde(skip)] labels: M2M<Label>,}Both networks link the same public.label rows, but each network's junction holds only its own links - shared taxonomy, isolated relationships. (This also enables M2M to a model in any other plugin - e.g. umbral-auth's User - not just the tenant case.)
Data migrations across the tenant boundary
The same "<schema>", public search_path lets a hand-authored RunSql data migration in a tenant app read shared public lookups while writing tenant rows. Because the schema-migrate loop applies every op once per tenant schema, the data migration runs per network:
// migrations/explorer/0002_seed_known_tokens.json - a tenant-app data migration.{ "kind": "RunSql", "sql": "INSERT INTO token (contract_address, name, symbol, decimals) \ SELECT contract_address, name, symbol, decimals FROM public.token_catalog", "reverse_sql": "DELETE FROM token"}token (bare) resolves to the current network's schema; public.token_catalog is the shared seed list. migrate_schemas gives each network its own derived rows. A data migration in a shared app runs once in public via the normal migrate.
Database-per-tenant (the stronger-isolation strategy)
Schema-per-tenant keeps one database; database-per-tenant gives each tenant its own database - stronger isolation (separate databases, not schema namespaces) at the cost of one connection pool per tenant. For a Starknet explorer you might keep Mainnet (large, sensitive) in its own database. Opt in with TenantStrategy::Database:
use umbral_tenants::{TenantsPlugin, TenantStrategy}; TenantsPlugin::new() .strategy(TenantStrategy::Database) .tenant_app(&ExplorerPlugin) // the per-network app - shared apps + the registry stay in the app DBSame split, same tenant_apps/tenant_app declaration as schema mode - only the strategy changes. In this mode the TenantRouter routes through db_for_read/db_for_write instead of schema_for_table: a tenant-owned model under an active network resolves to that network's database pool; shared models stay on the default pool; no schema qualification happens (separate databases need none).
You provision each database (creating a Postgres database is an ops step, not something a request transaction can do) and hand the plugin a connected pool. register_tenant_database records the registry row, registers the pool at runtime, and migrates the tenant apps into it:
let mainnet_pool = umbral::db::connect("postgres://…/explorer_mainnet").await?;plugin .register_tenant_database("Starknet Mainnet", "mainnet", "mainnet.localhost", mainnet_pool) .await?;The pool lands in a runtime registry (umbral::db::register_tenant_pool), so pool_for_dispatched resolves it even though it was added after App::build. Each tenant database carries its own umbral_migrations ledger.
Picking a strategy. Schema-per-tenant is the default - cheaper (one pool, zero-round-trip routing), ideal for many tenants. Database-per-tenant is for stronger isolation, per-tenant backup/restore, compliance boundaries, or a few large tenants - at the cost of a pool per tenant.
How resolution works
The plugin's middleware (a Plugin::wrap_router layer) runs per request:
- Extract the tenant key - the configured header (e.g.
X-Network, wins), else theHostsubdomain under the configured base. - Look the
Tenantup bydomainin public (no tenant context yet, so the registry read stays inpublic) - via the ORM, never raw SQL. - If found and active, scope the rest of the request under
RouteContext::new().with_tenant(TenantKey::new(tenant.schema_name)). Otherwise apply theon_missing_tenantpolicy.
Because the scope spans the whole handler - every .await, every ORM call - the TenantRouter sees the active tenant for the entire request.
See also
examples/starknet-explorer/- the complete, live-Postgres-verified version of this page (networks as tenants, tenant + shared plugins,run_shared, per-network isolation).examples/umbral-tenants/- a minimal one-tenant-model variant.- The DatabaseRouter - the routing seam
umbral-tenantsbuilds on. - RunSql data migrations - the boundary-spanning backfill mechanism.
arch.mdanddocs/superpowers/specs/2026-06-16-database-router-foundation-design.mdfor the design rationale.