Chapter 5 — Multi-tenancy
One comprehensive live test that provisions two tenants in schema
mode, then exercises every resolver against the seeded registry.
Run with DATABASE_URL=... cargo test --test cookbook_chapter05_tenancy -- --test-threads=1.
$ DATABASE_URL=postgres://…/blog \
cargo test --test cookbook_chapter05_tenancy -- --test-threads=1
running 1 test
test provision_two_tenants_then_resolve_and_lazy_pool ... ok
test result: ok. 1 passed; 0 failed; 0 ignored
The admin RBAC and change-password recipes (§5.83–§5.84) are each covered by their own live browser test — see the Verified by pointers on those sections.
- §5.66
SubdomainResolver::new(apex)— extractsacmefromacme.cookbook-test.localthen DB-loads the matchingOrg. - §5.68
HeaderResolver::default()— readsX-Orgheader slug. - §5.70
ChainResolver::new().push(...).push(...)— first hit wins (Subdomain → Header fallback when no subdomain present). - §5.71 schema-per-tenant —
create_tenant_if_missing(...)creates a PG schema named after the slug + INSERTs theOrgrow + applies tenant-scoped migrations. - §5.73
TenantPools::pool_for_org(&org)— returns a tenant-scoped pool whosesearch_pathlands on the tenant's schema. - §5.74
tenancy::migrate_registry / migrate_tenants— registry- scoped vs tenant-scoped migration passes.
→ provision_two_tenants_then_resolve_and_lazy_pool
The tenant Builder + apex/subdomain HTTP host split is exercised
implicitly by the Cli::new().tenancy().run() runserver path —
covered by Chapter 1 §1.5 and the framework's own tenant_admin_live
test.
- §5.77 Extras on tenant users — three escalating options when the
framework's seven
rustango_userscolumns aren't enough:- JSONB
datacolumn — stuff sparse attributes intouser.data["display_name"]. Zero migration, no override. Right answer for preferences / onboarding flags. - Sibling
UserProfilemodel with FK — typed, indexable extras without touchingrustango_users. Define a regular#[derive(Model)]with#[rustango(fk = "rustango_users")], thencargo run -- makemigrations && cargo run -- migrate. Works on any project, including ones already in production. Cli::user_model::<AppUser>()— extras inline onrustango_usersitself (greenfield only).impl rustango::tenancy::TenantUserModel for AppUser {}on a#[derive(Model)] #[rustango(table = "rustango_users")]struct that mirrors all seven required columns plus your extras, then chain.user_model::<AppUser>()onCli(orBuilder). Because yourAppUseris therustango_usersmodel, its columns flow through the samemakemigrations/migrateengine as everything else: the framework's own tables are generated into the project'ssystem/migrations/folder from the compiled models (there is no hand-shipped bootstrap JSON), soAppUser's extra columns land in the generatedCREATE TABLE rustango_users. Caveats:- Only one model may claim
table = "rustango_users". RegisterAppUserinstead of the frameworkUser(don't register both) — two models on the same table makemakemigrationsambiguous. - Validation (
validate_tenant_user_schema) rejects a wrong table name or a missing required column. → option 3 covered bytenancy::auth::tests::validate_*in the framework crate. → see docs/manage.md "Custom user model" for the full step-by-step recipe.
- Only one model may claim
- JSONB
5.78 TenantPoolsConfig — pool tuning
What: Knobs on the database-mode pool builder. Pre-0.27.7 every tenant pool was PgPoolOptions::new().max_connections(N) and nothing else, leaving sqlx defaults to drive timeouts / lifetimes. Apps hitting slow upstreams (vault-resolved DSNs, distant databases) had no way to tune them without bypassing TenantPools entirely.
When: Production tenants that get regular traffic and want sub-second first-request latency; deployments behind PG load balancers with idle_in_transaction_session_timeout; clouds with rotating IAM credentials.
API: tenancy::pools::TenantPoolsConfig.
Recipe:
use rustango::tenancy::TenantPoolsConfig;
use std::time::Duration;
let cfg = TenantPoolsConfig {
max_cached_database_pools: 64,
database_pool_max_connections: 8,
database_pool_min_connections: 1, // keep one warm
database_pool_acquire_timeout: Duration::from_secs(10),
database_pool_idle_timeout: Some(Duration::from_secs(10 * 60)),
database_pool_max_lifetime: Some(Duration::from_secs(30 * 60)),
prewarm_active_tenants: true, // build all on boot
};
let pools = TenantPools::with_config(registry_pool, cfg);
Defaults are conservative: min_connections = 0, prewarm_active_tenants = false. The prewarm-pools manage verb (§1.6b) runs the same warm-up loop one-shot.
Verified by: tests/pools_live.rs + pools::tests::* unit tests.
5.79 RouteConfig — configurable URL prefixes
What: One struct that drives every framework-mounted URL prefix on the tenant admin (login, logout, admin, audit, static, brand). Defaults are the underscore-prefixed __login / __admin / __static__ / __brand__ paths. RouteConfig::friendly() flips them all to underscore-free shapes (/login, /admin, /audit, /_static, /_brand) for projects that prefer Django-style URLs.
When: Apps that want public-facing tenant admins on clean paths instead of the framework's __-prefixed defaults; or apps hosting a tenant admin alongside their own routes that already use /admin/....
API: tenancy::routes::RouteConfig; server::Builder::routes.
Recipe:
use rustango::tenancy::RouteConfig;
let routes = RouteConfig::friendly(); // /login, /admin, /audit, /_static, /_brand
// or pick individually:
// let routes = RouteConfig {
// login_url: "/sign-in".into(),
// admin_url: "/control".into(),
// ..RouteConfig::default()
// };
rustango::server::Builder::new(api_router)
.routes(routes)
.serve()
.await?;
Also exposes session TTLs (tenant_session_ttl, operator_session_ttl, impersonation_ttl) and the basic-auth realm string. The full URL builder audit_full_url() joins admin + audit prefixes for callers (/admin/audit with the friendly default, /__admin/__audit with RouteConfig::legacy()).
Verified by: routes::tests::* (4 unit tests covering defaults, friendly preset, joined audit URL, TTL defaults).
5.80 Operator-as-superuser tenant impersonation
What: From the operator console org-edit page (/orgs/{slug}/edit), an operator can click "Open admin as superuser →" to get an HMAC-signed cross-domain cookie that logs them into that tenant's admin with implicit superuser rights. No password reset, no shadow account. The tenant admin renders a sticky warning banner ("You are impersonating tenant acme as operator admin — [End impersonation]") on every page so the privileged context is visible at all times.
When: Customer support — operator needs to reproduce a tenant-side bug; admin maintenance — fix a malformed model row in a tenant DB; onboarding — sanity-check a freshly-provisioned tenant before handing it over.
API: tenancy::tenant_console::TenantSessionPayload::impersonation; operator_console::router_with_impersonation; admin::Builder::impersonated_by.
Recipe: enabled automatically by server::Builder when both an operator session secret and a tenant session secret are configured. The operator-side form posts to /orgs/{slug}/impersonate; the response sets a slug-pinned tenant cookie with TTL RUSTANGO_OPERATOR_IMPERSONATION_TTL_SECS (default 3600). The cookie payload carries an imp field (operator user id) — distinguishable from native tenant sessions and audit-logged into rustango_audit_log on issue. Clicking "End impersonation" in the banner clears the cookie.
# Optional — override the 1h impersonation cookie TTL:
RUSTANGO_OPERATOR_IMPERSONATION_TTL_SECS=900 # 15 min
Verified by: framework unit tests in tenant_console::tests (5 tests covering the imp claim round-trip + is_impersonation() accessor + TTL defaults).
5.81 Registry-scope filter on tenant admin
What: A tenant_mode() Builder flag on admin::Builder that filters out registry-scoped models (Org, Operator, Permission registry, etc.) from the tenant-side admin sidebar and request resolver. Without it, a tenant superuser would see — and could route to — Org / Operator rows that live in the registry DB; the request would actually resolve those rows out of the tenant pool's search_path fallback, leaking cross-tenant data.
When: Always — server::Builder sets it for you. Only call manually if you're hand-rolling the inner admin router (e.g. mounting the admin alongside an unusual host shape).
API: admin::Builder::tenant_mode; admin::AppState::scope_visible; ModelScope::Registry / Tenant.
Recipe: opt-out only — most apps don't touch this. The check fires both on inventory-walk (sidebar enumeration) and on URL resolution (lookup_model), so a hand-typed /__admin/rustango_orgs URL on the tenant side returns 404 instead of leaking registry rows.
Verified by: admin::urls::tests::* (6 unit tests covering scope filter + admin_prefix Builder variants).
5.82 admin_prefix template variable
What: Every admin Tera template gets {{ admin_prefix }} injected (default /__admin) so links inside _sidebar.html / index.html / form.html / detail.html / list.html / audit_log.html follow the admin URL chosen by RouteConfig. Pre-0.27.9 the templates had hardcoded /__admin/... strings that would 404 if the admin was mounted under a different prefix.
When: Anyone using RouteConfig::friendly() or any custom admin_url. The framework keeps /__admin as the default so apps that don't override RouteConfig see no behavior change.
API: admin::Builder::admin_prefix; admin::helpers::chrome_context.
Recipe: handled automatically when server::Builder::routes(...) flows the prefix through to the inner admin Builder. Custom templates can read {{ admin_prefix }}/<slug> directly.
Verified by: admin::urls::tests::* admin_prefix variants.
5.83 Users / roles / permissions admin pages
What: Five framework auth + RBAC tables exposed in the tenant admin: rustango_users (already had admin config), rustango_roles (already), rustango_role_permissions, rustango_user_roles, rustango_user_permissions. The three junction models carry admin(...) config so list pages show useful columns instead of every field raw.
When: Operators want to inspect or edit role memberships, role-level codename grants, and per-user overrides without reaching for SQL or the assign_role / grant_role_perm / set_user_perm Rust APIs.
API: tenancy::permissions — Models Role, RolePermission, UserRole, UserPermission plus the User model from tenancy::auth.
Plus a Roles & permissions panel rendered on the user detail page (/{admin_url}/rustango_users/{id}):
- Lists each assigned role with a link to its detail page.
- Lists the user's effective codenames — union of role grants + direct grants minus explicit denials. Computed by the same SQL the runtime
has_permcheck uses, so what you see is whathas_permenforces. - Quick links to the four manage-able junction tables for inline editing of memberships, role-level grants, and per-user overrides.
- Hides itself silently when the permission tables haven't been seeded — same posture as the audit-trail panel.
# Bootstrap the perm tables on a fresh tenant (idempotent):
cargo run -- create-user acme alice --password hunter2
# Then visit the user detail page; the panel is automatically there.
# Edit role memberships at /admin/rustango_user_roles
# Edit role-level grants at /admin/rustango_role_permissions
Verified by: tests/admin_user_roles_panel_live.rs::user_detail_page_renders_roles_and_effective_perms (provisions a user with one role granting two codenames, one direct grant, one explicit denial; asserts the panel renders the role + effective grants and that the denial suppresses the role-granted codename); plus tenancy::permissions::admin_config_tests (asserts every junction model carries admin(...) and stays in ModelScope::Tenant).
Out of scope: inline assign/revoke buttons on the user detail panel (currently read-only — manage via junction tables); rustango_permissions catalog as an admin page (it has no Rust Model today; adding one would diff against existing tenants' bootstrap snapshots — needs a schema-aware migration).
5.84 Self-serve change-password page + --generate
What: A self-serve change-password flow on the tenant admin (/__change-password) — the user enters their current password plus a new one and the framework verifies + rotates without operator involvement. Plus a change-password / change-operator-password CLI counterpart and a --generate flag on every password verb.
When: Whenever the user remembers their current password (rotation, periodic refresh, switching from a generated bootstrap password). Operator-driven recovery for locked-out users still uses reset-password / reset-operator-password.
API:
tenancy::routes::RouteConfig::change_password_url(default/__change-password;friendly()→/change-password).admin::Builder::change_password_url— surfaces the link in the standalone admin sidebar; tenant admin Builder threads it through automatically.tenancy::password::generate(length)—OsRng-backed generator over a 58-character unambiguous alphabet (no0/O,1/l/I).
Recipe (tenant admin):
The form is auto-mounted when TenantAdminBuilder::with_session(secret) is wired. The "Change password" link appears in the admin sidebar; the page lives outside the admin URL prefix so it stays a distinct namespace from per-table admin routes.
Recipe (CLI):
# Symmetric — current password verified before rotating:
cargo run -- change-password acme alice
cargo run -- change-operator-password admin
# Operator-driven recovery (no current pw needed):
cargo run -- reset-password acme alice
cargo run -- reset-operator-password admin
# Generate a secure random password — printed once, stored hashed:
cargo run -- create-superuser acme alice --generate
cargo run -- reset-password acme alice --generate
--password and --generate are mutually exclusive on every verb that accepts both.
Verified by:
- 3 unit tests in
tenancy::password::tests(generator length / charset / hash round-trip / uniqueness). - 3 live tests in
tests/manage_change_password_live.rs(CLI round-trip,--generateprints + verifies, mutually-exclusive flags rejected). - 4 live tests in
tests/admin_change_password_ui_live.rs(anonymous → 303 to login; authenticated GET renders form; POST with correct current rotates the hash; POST with wrong current shows error and leaves hash unchanged).
Out of scope: operator-driven password reset on a tenant user via the operator console UI (the reset-password CLI verb already covers this path; UI sugar deferred); password strength enforcement at the form layer (the passwords::strength_score helper exists but isn't wired in).
Password rotation invalidates older sessions: a password_changed_at column on User / Operator is stamped to NOW() on every password change. Session cookies carry an issued-at (iat) claim, and validate_session / require_session reject any cookie whose iat predates password_changed_at — so changing a password logs out every session minted before the change.
Verified by: tenant_console::tests::new_payload_stamps_iat_at_construction_time and the live test admin_change_password_ui_live::session_minted_before_password_rotation_is_rejected.