The broker can scope queue identity to a tenant itself, in SQL, on every operation. One variable turns it on and one header drives it:
QUEEN_TENANCY_HEADER=truex-queen-tenant: 7f1a9c2e-4b3d-4f8a-9e10-2c5b6d7a8f90This is an operator feature. The header is unauthenticated by design, so it is meaningful only when the broker is unreachable except through something that sets it. Everything below follows from that.
What the flag does
With QUEEN_TENANCY_HEADER=false (the default), a middleware stamps the default tenant
into every request without reading any header at all, the ownership gate short-circuits
to “owned” with no database round trip, and the broker behaves exactly as a broker with
no notion of tenants. That equivalence is deliberate and load-bearing: the open-source
single-tenant path is byte-identical to what it was before tenancy existed.
With it on, one middleware resolves the header once per request and stamps a tenant into
the request extensions. Every handler that resolves a queue by name reads it and threads
it into the SQL name-resolution functions. For several stored procedures it travels as a
_tenant key inside the JSON argument, which is why the SQL layer needs no signature
change to be tenant-aware.
The boot log says so:
QUEEN_TENANCY_HEADER on, native tenant scoping ENABLEDHeader semantics
| Header | Resolved tenant |
|---|---|
| Absent | 00000000-0000-0000-0000-000000000001, the default tenant |
| Present but empty or whitespace | The default tenant |
| A canonical 8-4-4-4-12 hex UUID | That UUID, lowercased |
| Anything else (wrong length, missing dashes, non-hex, non-ASCII) | 400 {"error":"invalid x-queen-tenant header (must be a UUID)"} |
The value is opaque. It is checked for shape and for nothing else: there is no
registry of tenants inside the broker, no list to be a member of, no signature. Shape
validation exists purely so a malformed value cannot reach a ::uuid cast and surface as
a 500 instead of the intended 400.
The default tenant constant is shared with the proxy, which uses the same literal. That is what makes an existing single-tenant deployment continue to see its own data when the flag is switched on.
What changes when it is on
- Queue identity becomes
(tenant, name). Two tenants can each ownorders; they are different queues with different partitions, offsets and consumer-group cursors. - Pid-addressed operations are gated. An ack, a message read, a message delete or a trace lookup that names a partition id verifies that the partition belongs to the request’s tenant before the stored procedure runs. See Isolation for the gate’s properties.
- In-process caches are keyed by
(tenant, queue): lease times, encryption flags, hot-list rings, long-poll wake gates, per-queue metric counters, parked gauges. - Mesh frames carry the tenant, so a cross-broker invalidation or wake targets one tenant’s queue rather than every queue with that name.
- Row deletions are scoped structurally. Retention and the delete paths reach
configuration through
queen.log_partitions.queue_id, a foreign key toqueen.queues(id); there is no name-based join anywhere between data and config, so a name match cannot reach another tenant’s rows even in principle.
What does not change: the JWT access levels. Authentication and tenancy are orthogonal mechanisms. A token’s role decides which routes it may call; the header decides which tenant’s data those calls touch. Nothing binds a token to a tenant.
The header is unauthenticated by design
Put plainly: any client that can open a TCP connection to a tenancy-enabled broker can
set x-queen-tenant to any UUID and operate as that tenant. If JWT is on it still
needs a token with the right access level, but that token is not tied to a tenant, so an
admin token plus an arbitrary header value is full access to an arbitrary tenant’s data.
This is not a defect to be fixed at the broker. The trust boundary is drawn one hop out:
- The gateway sets the header from the cluster it resolved out of the
Hostlabel, usingclusters.broker_tenant_uuid, a value the tenant never sees and cannot influence. - The gateway strips the client’s
Authorizationheader and substitutes the cell secret, so the credential a tenant holds has no meaning to the broker. - The broker’s network policy admits the proxy and nothing else.
The same reasoning is why this header is absent from the client documentation. No SDK sets it, no client-facing page mentions it, and a client that sends it to a proxy has it overwritten.
How the proxy uses it
QUEEN_PROXY_TENANT_HEADER defaults to true, so a proxy injects the header unless you
turn it off. Two paths send it:
- The data plane, on every forwarded request, with the resolved cluster’s
broker_tenant_uuid. - The registry reconciler, when it polls each cell’s
GET /api/v1/resources/queuesto count queues and sum retained bytes. Otherwise it would read the default tenant’s inventory for every cluster.
GET /healthz on the proxy reports the live value of the switch as tenant_header, so
a check can assert on the running process rather than on a boot log that outlives it.
The two flags have to agree. A proxy injecting the header at a broker with tenancy off means every cluster shares the default tenant’s namespace, because the header is simply never read. A broker with tenancy on behind a proxy with injection off means the same thing by the other route. Verify both, on the running processes.
Enabling it on a broker that already holds data
The tenant_id column defaults to the same constant the middleware uses when the header
is absent, so every row that already exists belongs to the default tenant. Turning
the flag on does not hide or move existing data.
What it does mean is that a cluster whose broker_tenant_uuid is a freshly generated
UUID, which is what create_cluster produces, sees an empty broker. Converting an
existing single-tenant deployment into the first cluster of a cell therefore needs one
of two things:
- The first cluster’s
broker_tenant_uuidset to the default constant, so it inherits the existing queues. There is no control-plane function that changes that column; it is a directUPDATEonqueen_proxy.clusters, and it invalidates nothing on its own. Callqueen_proxy.record_operationfor that cluster afterwards, or restart the proxy, or wait out the 30-second cache TTL. - Or the data re-pushed under the new tenant. There is no in-broker facility for re-tenanting existing rows.
Decide this before you create the cluster. After traffic has arrived on both UUIDs you have two namespaces and no tool that merges them.
Surfaces the header does not reach
Some broker responses describe the cell rather than a tenant and ignore the header
entirely: GET /api/v1/status, the system and PostgreSQL analytics, the Prometheus
exposition (whose per-queue series sum across tenants), and all of /streams/v1/*
(which resolves the default tenant internally). The proxy classifies each of them as
operator or blocked for exactly that reason.
Isolation lists them with what each one would expose.
Checklist
-
Set
QUEEN_TENANCY_HEADER=trueon every broker in the cell, and confirm the boot line on each. -
Set
JWT_ENABLED=truewith a token the proxy holds as the cell secret. Tenancy is not authentication; you want both. -
Firewall the broker’s HTTP port to the proxy, and the mesh port to the broker’s peers. See Trust boundaries.
-
Confirm
GET /healthzon the proxy reports"tenant_header": true. -
Test the boundary before you trust it: send a push with one cluster’s tenant UUID, then try to read it back with another’s. The second call must see nothing.