Rustango docs
← Cookbook

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) — extracts acme from acme.cookbook-test.local then DB-loads the matching Org.
  • §5.68 HeaderResolver::default() — reads X-Org header 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 the Org row + applies tenant-scoped migrations.
  • §5.73 TenantPools::pool_for_org(&org) — returns a tenant-scoped pool whose search_path lands 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_users columns aren't enough:
    1. JSONB data column — stuff sparse attributes into user.data["display_name"]. Zero migration, no override. Right answer for preferences / onboarding flags.
    2. Sibling UserProfile model with FK — typed, indexable extras without touching rustango_users. Define a regular #[derive(Model)] with #[rustango(fk = "rustango_users")], then cargo run -- makemigrations && cargo run -- migrate. Works on any project, including ones already in production.
    3. Cli::user_model::<AppUser>() — extras inline on rustango_users itself (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>() on Cli (or Builder). init-tenancy and Builder::migrate then write the bootstrap migration with your CREATE TABLE rustango_users columns. Caveats:
      • Idempotent: only takes effect when the bootstrap JSON doesn't already exist. On a cargo rustango new --template tenant project you must rm migrations/0001_rustango_*.json before cargo run -- init-tenancy.
      • Both framework User and AppUser register in inventory — subsequent makemigrations may emit redundant ops touching rustango_users; review the JSON.
      • Validation (validate_tenant_user_schema) panics at init-tenancy time on wrong table name or a missing required column. → option 3 covered by tenancy::bootstrap::tests::user_model_override_* and tenancy::auth::tests::validate_* in the framework crate. → see docs/manage.md "Custom user model" for the full step-by-step recipe.

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_perm check uses, so what you see is what has_perm enforces.
  • 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:

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, --generate prints + 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.