Rustango docs
← Guides

The operator console

A multi-tenant project has two kinds of administrator, and they never mix:

  • Operators run the deployment. They live in the registry, sign in at the apex domain, and can reach every tenant.
  • Tenant users run one tenant. They live in that tenant's database and never see the console.

The operator console is the web interface for the first kind — provisioning tenants, binding hostnames, managing operators, reading the audit trail, and taking a tenant out of service. Nearly everything here is also a manage verb, because an action that exists on only one surface cannot be automated, and one that exists only in a shell cannot be delegated.

The gap worth knowing about: the console can migrate one tenant (POST /orgs/{slug}/migrate), while the CLI's migrate-tenants takes no slug and runs the whole registry. There is no per-tenant migrate on the command line.

The operator console's tenant list — every tenant in the registry, with its storage mode, host pattern and active state, plus actions to provision, migrate and pre-warm

Table of contents


Mounting it

The console is a router you mount; how much it can do depends on what you hand it.

use rustango::tenancy::operator_console::{router, router_with_pools, router_with_provisioning, SessionSecret};
use rustango::tenancy::provision::Provisioner;

// `pools` here is an `Arc<TenantPools<_>>`.

// Read-only: browse tenants, operators and the audit log.
let app = router(registry.clone(), SessionSecret::from_env_or_random());

// …plus editing tenants, managing operators, binding hostnames, pre-warming pools.
let app = router_with_pools(registry.clone(), pools.clone().into_invalidator(), secret);

// …plus provisioning new tenants and running migrations.
let provisioner = Provisioner::new(pools.clone(), registry_url.clone(), "migrations").erased();
let app = router_with_provisioning(
    registry.clone(),
    pools.clone().into_invalidator(),
    provisioner,
    secret,
);

Two conversions are doing work there and neither is optional. pools goes in as Arc<dyn TenantPoolInvalidator>, so it needs .into_invalidator() — the console only ever invalidates pools, and taking the narrow trait is what stops it reaching the rest of them. Provisioner::new closes over the pools, the registry URL and the migrations directory, and .erased() puts it behind Arc<dyn TenantProvisioner>.

In a scaffolded tenant project this is already wired — Cli::new().tenancy().with_tenant_provisioning("migrations") mounts the full version. See Scaffolding.

The console answers on the apex domain (RUSTANGO_APEX_DOMAIN), not on a tenant subdomain: http://localhost:8080/login with apex localhost. A request to 127.0.0.1 is not the apex and will not match.


What each page does

PageWhat it is for
OrganizationsEvery tenant, with storage mode, host pattern and active state
Organizations → EditDisplay name, host pattern, path prefix, port, database URL, branding
HostnamesThe extra domains a tenant answers on
OperatorsWho can sign in to this console
Audit logEvery change made through the console
RunsProvisioning and migration runs, streamed live

Hostnames

A tenant is reachable at its subdomain and, optionally, at extra hostnames you bind to it. One hostname routes to exactly one tenant.

The hostnames page for one tenant — the base host marked as such and undeletable, extra hosts with Park and Remove actions, and a form to add one

Two things worth knowing:

The base host has no delete button. It comes from the tenant's host_pattern column rather than the hostnames table, so there is no row to remove — change it on the tenant's edit page instead. That is structural, not a UI rule: the engine refuses it too, so an operator who guesses the POST gets the same answer as one who reads the page.

Parking keeps the row. Park takes a host out of service without losing the record — useful while DNS propagates, or when retiring a domain you may want back. Serve puts it back.

Hostnames are normalized on the way in: lowercased, no scheme, no port, no path. The stored value is compared byte-for-byte against the Host header, so a value that could never match is refused rather than saved.

From the CLI: list-hosts / add-host / remove-host / set-host-enabled.


Operators

The operators page — the signed-in operator marked "that's you", with a form to add another and the option to generate a password

Operators are deactivated, never deleted: the row stays, so a later "who was this?" still resolves. The console re-reads it on every request, so a deactivation takes effect on the target's next click rather than whenever their cookie expires.

Two things the page will not let you do, for the same reason:

  • Deactivate yourself. Your next request would be rejected.
  • Deactivate the last active operator. That locks everyone out, and only a shell on the registry could undo it.

A generated password is shown once, in the response body — never through a redirect, which would put it in the URL bar, the history, the referrer and every access log in between.

From the CLI: list-operators / set-operator-active.


The audit log

The console's audit log — who did what to which record and when, filterable by entity, id and operation

Every console mutation is recorded: tenant edits, hostname changes, operator management, impersonation, purges, pre-warms. The source column carries operator:<id>:<verb>, so operator activity is separable from tenant-user activity after the fact.

This is the registry's log. A tenant's own history lives in that tenant's admin — mixing them would mean fanning a query across every tenant pool to render one page.

It grows with console use, so trim it on a schedule with audit-cleanup, which sweeps the registry's log and every active tenant's.

From the CLI: audit-log.


Provisioning runs

The provisioning runs page — each run with its kind, tenant, state and timing, linking to the recorded steps

Provisioning a tenant is several steps against a database that may be slow or unreachable, so it is recorded as a run rather than a request that either returns or doesn't. Each run streams its steps live and survives a reload, a reconnect, or being watched from a second pod.

Tenants created from the CLI are recorded too, tagged requested_by = cli, so the history covers both surfaces.

From the CLI: list-runs / show-run.


The three capability levels

Which routes exist depends on which constructor you mounted:

routerrouter_with_poolsrouter_with_provisioning
Browse tenants, operators, audit log✓✓✓
Edit a tenant, manage hostnames✓✓
Add / deactivate operators✓✓
Pre-warm pools✓✓
Decommission a tenant✓✓
Provision a new tenant, run migrations✓
View provisioning runs✓

Routes that are not mounted return 404 rather than 403 — a read-only console does not advertise what it cannot do.

Three more constructors, for wiring rather than capability

The three above are the capability levels. The rest take the same levels and add wiring, so they are not a fourth and fifth rung:

ConstructorLevelWhat else it takes
router_with_brand_storageread-only, or edit if you pass Some(pools)a BoxedStorage for branding uploads, when the default LocalStorage is not where you want them
router_with_impersonationeditbrand storage, the tenant session secret and a handoff URL, so an operator can enter a tenant
router_fullwhatever you passall of the above as Options — None simply does not mount those routes

Server::Builder::serve mounts impersonation for you; a custom mount point opts in by using router_with_impersonation in place of router_with_pools. Reach for router_full when you need a combination the shorthands do not name — it exists so that a new pairing does not need a new positional constructor.

Every operator is fully capable. There are no per-operator permission gates: an operator can reach every tenant and every action the console offers. The access-control boundary is the operator list itself, which is why deactivating one takes effect on their next request.