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.
Table of contents
- Mounting it
- What each page does
- Hostnames
- Operators
- The audit log
- Provisioning runs
- The three capability levels
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
| Page | What it is for |
|---|---|
| Organizations | Every tenant, with storage mode, host pattern and active state |
| Organizations → Edit | Display name, host pattern, path prefix, port, database URL, branding |
| Hostnames | The extra domains a tenant answers on |
| Operators | Who can sign in to this console |
| Audit log | Every change made through the console |
| Runs | Provisioning 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.
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
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
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
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:
router | router_with_pools | router_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:
| Constructor | Level | What else it takes |
|---|---|---|
router_with_brand_storage | read-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_impersonation | edit | brand storage, the tenant session secret and a handoff URL, so an operator can enter a tenant |
router_full | whatever you pass | all 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.




