Skip to content

Multi-tenant

Put queen_proxy in front of a broker: per-tenant credentials, quotas and metering, and the path from an empty cell to a tenant pushing through it.

Updated View as Markdown

queen_proxy is a second Rust binary that stands in front of a broker and turns it into something you can hand to other people. Both are Apache-2.0, so the multi-tenant service is yours to run: for internal teams, for customers, or under a product of your own.

The broker already scopes queues by tenant on its own, behind QUEEN_TENANCY_HEADER (broker-native tenancy). What the proxy adds is the reason to trust that header: it spends the client’s credential at the edge, strips it, and injects the tenant itself. Around that it holds what a shared broker has no place holding. Per-cluster API keys and human logins (credentials), plan limits on rate, size and count (quotas), a route classifier that forwards nothing it cannot name (endpoints), and usage counted from the per-item statuses the broker actually returned (metering).

The shape

A cell is one proxy, one broker, one PostgreSQL for the messages. The proxy needs a second PostgreSQL of its own, pxdb, carrying the tenants, clusters, keys, plans and usage of the whole fleet. The proxy reads it through a 30-second cache, which is what keeps a pxdb outage from stopping the data plane.

Three nouns carry the model:

  • Tenant: the organisation. Owns users and clusters.
  • Cluster: the tenant-visible Queen. One hostname, one plan, one queue namespace.
  • Cell: one physical stack, on one failure domain.

A cluster lives on exactly one cell and never spans two. Every limit rests on that: a cluster’s traffic can only arrive at the one proxy fronting its cell, so the counters are plain in-process state, exact with no Redis and nothing to coordinate. One proxy per cell is the assumption the code is written against.

Routing is a DNS label. The proxy reads the cluster slug from the first label of the Host header, so acme-prod.eu1.example.com is the cluster acme-prod, and one wildcard record covers every cluster on the cell.

Bring up a cell

  1. Mint the cell secret. The broker holds an HMAC secret; the cell row holds a token signed with it. The same string does not go in two places.

    CELL_JWT_SECRET=$(openssl rand -hex 32)
    b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
    header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
    claims=$(printf '{"sub":"queen-proxy","role":"admin"}' | b64url)
    sig=$(printf '%s.%s' "$header" "$claims" | openssl dgst -binary -sha256 -hmac "$CELL_JWT_SECRET" | b64url)
    CELL_SECRET="$header.$claims.$sig"

    The admin role is required, because the proxy forwards queue and consumer-group deletions. There is no exp on purpose: nothing refreshes this value, so rotate it by minting a new one.

  2. Run the broker, on a network policy that admits the proxy and nobody else.

    JWT_ENABLED=true JWT_ALGORITHM=HS256 JWT_SECRET="$CELL_JWT_SECRET" QUEEN_TENANCY_HEADER=true QUEEN_KV_TRUSTED_PROXY=true ./bin/queen

    The second of those two is not optional and the broker exits 1 without it. The tenant header is opaque and validated against nothing, and key/value state is addressable purely by name, so a forged header reads and writes another tenant’s state knowing only that name. Queues survive a forged header because a caller must still name a queue and an ack carries a lease id it cannot guess; the KV has no such id. Since the KV is part of the engine on every cell and no flag can take it away, the requirement is unconditional: setting QUEEN_KV_TRUSTED_PROXY=true is you stating that step 3 is what supplies the header and strips the client’s. If that is not true of your deployment, do not run with the header.

  3. Run the proxy. PXDB_HOST is the only variable it refuses to start without, and the six migrations that create the queen_proxy schema are embedded in the binary and applied at boot. Generate QUEEN_PROXY_JWT_SECRET once and reuse it: sessions are signed with it, so changing it logs every human out.

    SESSION_SECRET=$(openssl rand -hex 32)
    docker run -d --name queen-proxy -p 6711:6711 -e PXDB_HOST=10.0.3.20 -e PXDB_PASSWORD=change-me -e QUEEN_PROXY_JWT_SECRET="$SESSION_SECRET" -v queen-proxy-spool:/app/spool ghcr.io/queen-mq/queen-proxy:latest
    curl -s http://127.0.0.1:6711/healthz
    {"enforce":false,"status":"ok","tenant_header":true}

    How to run it in production, with Docker or Kubernetes, and why it stays on one replica: Proxy.

  4. Register the cell. No SQL function creates one: cells are fleet infrastructure. base_url must be http://, an assertion that broker traffic is cell-internal with TLS terminated at the proxy edge.

    psql -h 10.0.3.20 -U postgres -d queen_proxy -v cell_secret="$CELL_SECRET" <<'SQL'
    INSERT INTO queen_proxy.cells (slug, region, base_url, class, cell_secret)
    VALUES ('eu1-shared-a', 'eu1', 'http://10.0.3.11:6632', 'shared', :'cell_secret');
    SQL
  5. Point a wildcard DNS record at the proxy, so every cluster slug on this cell resolves to it.

Onboard a tenant

Nothing over HTTP creates a tenant. Every mutation of tenants, clusters, users, keys and roles is a SQL function in the queen_proxy schema, which is what keeps the audit row and the cache invalidation from being optional. One call does the whole onboarding, and unwinds completely if any part of it raises.

SELECT queen_proxy.bootstrap_tenant(
  'acme',                    -- tenant slug
  'Acme Inc',                -- tenant name
  'acme-prod',               -- cluster slug, and the subdomain
  'free',                    -- plan code
  (SELECT id FROM queen_proxy.cells WHERE slug = 'eu1-shared-a'),
  'ops@acme.example',        -- admin email
  'initial-password',        -- NULL for an OAuth-only admin
  'default'                  -- API key name
);

It returns {tenant_id, cluster_id, user_id, api_key}. The key is plaintext, shown exactly once, and only its sha256 hash is stored; a re-run on the same slugs returns the existing ids with a null key.

That key is everything a client needs. Same routes, same SDKs, same wire format as a bare broker, with the hostname doing the routing:

curl -s -X POST http://acme-prod.eu1.example.com:6711/api/v1/push \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"items":[{"queue":"orders","partition":"cust-42","payload":{"hello":"world"}}]}'

The plan named in that call decides the numbers: requests and messages a second, queues, partitions per queue, parked long polls, payload and batch size, retained bytes and retention window. Four plans ship seeded, and a single cluster is raised above its plan with a jsonb column rather than a forked plan (the data model).

Afterwards, the same schema carries the rest of the lifecycle, one function per operation so the audit row and the cache invalidation are never optional:

-- another human on the tenant, and their role on one cluster
SELECT queen_proxy.create_user(:tenant_id, 'dev@acme.example', NULL, 'google');
SELECT queen_proxy.grant_cluster_role(:cluster_id, 'dev@acme.example', 'admin');

-- a second API key, for a service that should be revocable on its own
SELECT queen_proxy.issue_api_key(:cluster_id, 'ingest-worker', :key_hash, ARRAY['push']);

A password hash of NULL is the OAuth-only user: they exist, they have a role, and they can only arrive through Google.

Human logins with Google

API keys are for programs. People sign in to the dashboard, and the proxy speaks OAuth for that: Google and GitHub, plus local bcrypt passwords. Google needs two variables.

GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
GOOGLE_ALLOWED_DOMAINS=acme.example,acme.io   # optional, comma separated

In the Google Cloud console the authorised redirect URI is <public base>/auth/google/callback, and getting that base right is the one thing that breaks a first setup. The proxy uses QUEEN_PROXY_PUBLIC_URL when it is set, otherwise it rebuilds the URL from the request’s Host and X-Forwarded-Proto. With QUEEN_PROXY_AUTH_HOST on and no public URL set, OAuth is switched off rather than guessed, which is the correct behaviour for a dedicated login host and a confusing one if you did not mean it.

GOOGLE_ALLOWED_DOMAINS restricts who may sign in, by the account’s hosted domain or its email domain. A single entry is also passed to Google as the hd hint, so the account chooser shows that domain first.

What happens when someone arrives is worth knowing exactly, because two of the three outcomes are refusals:

  • The Google identity is already linked to a user: they are logged in.
  • No link, but the verified email matches a user: the identity is linked to that user. An unverified email never links, which is what stops an account being claimed by someone who merely typed the address.
  • Neither: the login is denied, unless QUEEN_PROXY_AUTOPROVISION is on, in which case a user is created with the role in QUEEN_PROXY_DEFAULT_ROLE.

And the part that makes this safe to leave on: a session is not cluster-scoped. The role inside the session token is a placeholder, and the real permission is resolved live from the cluster role rows. A brand-new Google user with no grant_cluster_role can sign in and see nothing at all, so auto-provisioning hands out an account, never an authorisation.

Turn enforcement on

QUEEN_PROXY_ENFORCE defaults to false, and that is shadow mode: every rate and quota decision is computed, logged and metered, and the request proceeds anyway. Size caps and the two push-block quotas are hard regardless of the flag (quotas). Watch a real workload through it, then set it to true and confirm on /healthz, which reports the flag precisely because a boot log survives the process that wrote it.

One proxy per cell, one cell per cluster, one SQL call per tenant, and a 429 contract the SDKs already honour.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close