Skip to content

Credentials and authorization

Two credential kinds (cluster API keys and proxy-minted user sessions), the OAuth login flow, and the authorization matrix.

Updated View as Markdown

The proxy accepts exactly two kinds of credential, and they are not interchangeable. A cluster API key is what a daemon puts in its configuration: opaque, long-lived, bound to one cluster, scoped by capability. A user session is what a human gets from logging in: a proxy-minted JWT, not bound to any cluster, whose permissions are resolved per cluster at request time.

The credential is spent at the proxy. It is stripped before the request is forwarded, and the broker sees only the cell secret. See Deploying queen_proxy.

How a credential is read

From the Authorization header first, then, for some surfaces, from the session cookie:

  1. Authorization is read and a Bearer prefix stripped. A value that starts with qk_ is an API key; anything else is treated as a session token.

  2. If there is no usable Authorization header, the session cookie is considered. An explicit header always wins over the cookie: a token is chosen per request, a cookie merely rides along.

  3. A cookie whose value starts with qk_ is ignored, not promoted. Nothing mints an API key into a cookie, and a browser-attached credential must never widen what a caller can do.

Cluster API keys

Property Value
Format qk_<env>_<43 base64url characters>
Entropy 32 bytes from the OS CSPRNG
Stored as sha256 hex in api_keys.key_hash; the plaintext is shown once
Bound to One cluster, permanently
Scopes Any non-empty subset of produce, consume, admin, read
Revocation api_keys.revoked_at; the lookup filters on revoked_at IS NULL

Two checks run on every request. The hash must resolve to a live key, and that key’s cluster must be the cluster the request resolved to:

  • Unknown or revoked hash → 401 {"code":"unauthorized"}.
  • A valid key belonging to a different cluster → 403 with key/cluster mismatch.

Revocation propagates two ways: the mutating SQL function fires the invalidation notification, which drops the cached entry immediately, and the positive cache entry expires after 30 seconds anyway. A revoked key can never be resurrected by the pxdb-outage grace window, because a clean “no such row” writes a negative entry that replaces the expired positive one.

An API key can never be an operator, and it can never be retargeted: x-queen-act-cluster is refused for API keys with a 403 rather than being silently ignored. A producer that believes it is writing to cluster B while the proxy routes it to A is the worse failure.

User sessions

A session is a JWT the proxy itself mints and verifies. Claims are {sub, iss, iat, exp, jti, role, cluster?}, where sub is the user id.

Aspect Behaviour
Signing Ed25519 when QUEEN_PROXY_JWT_ED25519_PEM is set, HS256 from QUEEN_PROXY_JWT_SECRET otherwise. Ed25519 wins when both are present
Public key Published at GET /.well-known/jwks.json
Lifetime QUEEN_PROXY_JWT_TTL_S, default 86400
cluster claim Absent on a session cookie
role claim A placeholder (viewer) on a session cookie
Revocation The jti goes on the revoked_tokens deny-list at logout, checked on every verify

The two blanks are the point. A session token grants nothing on its own: because it carries no cluster and its role is a placeholder, the effective role is looked up live in cluster_roles for the cluster the request resolved to. Deleting a membership row takes effect within the role cache’s 30-second TTL, without touching the token.

A token that does carry a cluster claim is trusted for that cluster and rejected for any other (403 token not valid for this cluster). Nothing mints such a token today; the check exists so a future scoped token cannot be retargeted.

Three caches bound how fast a revocation lands: the deny-list at 60 seconds, the per-cluster role at 30 seconds, and the operator bit at 30 seconds. When the deny-list lookup itself errors (a pool or query failure, not a clean miss), the default is to fail open: a transient pxdb blip must not 401 every session, and the token still passed signature and expiry. QUEEN_PROXY_REVOCATION_STRICT=true inverts that for deployments that would rather lose availability.

queen_session=<jwt>; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400

Domain is appended when QUEEN_PROXY_COOKIE_DOMAIN is set, and Secure when either that domain is configured or the request arrived with X-Forwarded-Proto: https.

SameSite=Lax blocks the cookie from cross-site fetch and XHR, which is what lets the dashboard call the API with plain same-origin requests and no token plumbing. Lax does not withhold the cookie from a top-level navigation, so one more check closes that gap: on API paths, a request whose Sec-Fetch-Mode is navigate is treated as having no credential. Without it, a hostile page could navigate a logged-in browser to GET /api/v1/pop/queue/orders and lease a tenant’s messages on the cookie alone.

The document surface (the dashboard shell itself) does accept the cookie on a navigation, because serving static bytes has no state to forge. Non-browser clients send no Sec-Fetch-* headers and are unaffected either way, as is any request carrying an Authorization header.

POST /auth/logout deny-lists the presented jti first, then clears the cookie. Clearing alone would only stop that browser from sending a token which stays valid for the rest of its TTL.

One scope limit: GET /auth/session-token mints a short bearer for the SPA with its own jti and a 15-minute lifetime, and those are not tracked by the deny-list. A logout does not kill an already-issued short bearer; it runs out. The mint itself checks the deny-list first, so a logged-out cookie cannot produce a new one.

The login flow

Route Purpose
GET /auth/login The login page, carrying a validated next
POST /auth/login Email and password, verified against the bcrypt hash in users.password_hash
POST /auth/logout Revoke and clear
GET /auth/me What the browser’s session is: identity, tenant, clusters, operator bit. Cookie only
GET /auth/session-token A 15-minute bearer for the SPA
GET /auth/googleGET /auth/google/callback Authorization code plus OIDC id_token
GET /auth/githubGET /auth/github/callback OAuth2 without OIDC

Both providers are enabled by setting their client id and secret; with those unset the routes have nothing to redirect to.

What the flow enforces:

  • Signed state. A compact HMAC-SHA256 token, not a JWT, carrying the nonce, the validated next and a 300-second expiry. The key comes from QUEEN_PROXY_OAUTH_STATE_SECRET, falling back to the JWT HS secret, falling back to a random per-process key. That last fallback works for a single instance and breaks behind a load balancer: two processes cannot verify each other’s state. Set the variable on any auth host that is not a single process.
  • next is validated, not trusted. It must start with a single /, must not start with // or /\, must not contain ://, and must contain no control characters, so a callback cannot be turned into an open redirect.
  • Verified email only. Google’s email_verified and GitHub’s primary-and-verified email are the only addresses that can link to an existing user. An unverified address never matches an account.
  • A per-IP login throttle, 10 attempts per 60 seconds, answered with 429. The IP comes from X-Forwarded-For (first entry) or X-Real-IP, so the throttle is only as trustworthy as the edge that sets them.
  • A 10-second timeout on every outbound provider call, and Google’s JWKS cached for an hour.

Resolution order on an OAuth callback

  1. A linked identities row for this (provider, provider_id) → log in as that user.

  2. Otherwise, a verified email matching an existing users row → link the identity to that user and log in.

  3. Otherwise, auto-provision, but only when QUEEN_PROXY_AUTOPROVISION=true. The new user is created under the tenant named by QUEEN_PROXY_AUTOPROVISION_TENANT (a slug), with the users and identities rows written in one transaction. With auto-provision on and no valid tenant slug, the callback answers 500.

  4. Otherwise 403 {"code":"not_provisioned"}.

Auto-provision off is the default, and it is the right default for a closed deployment: an OAuth identity that nobody invited gets nothing.

The authorization matrix

Every broker-bound request is classified into one route class (Endpoints a tenant can reach), and the class is matched against the principal. This table is the whole policy:

Route class API key needs User role needs
produce produce admin or producer
consume consume admin or consumer
queue admin admin admin
read read or admin any role, including viewer
gated (streams, traces) produce or consume any role except viewer
operator never only a live operator
blocked never never

Refusals are 403 {"code":"forbidden"}, except for the operator class, which answers the same 404 a blocked route gives, so a tenant admin is never told there is a capability to go looking for.

Note the two asymmetries. An API key with only produce cannot read anything, not even a queue listing, while a viewer session can read everything on the cluster and write nothing. And read is open to every role because there is nothing tenant-external behind it: the read class is the tenant-scoped surface, by construction.

Feature gating happens before authorization: a plan without features.streams gets 403 {"code":"feature_gated"} regardless of the credential.

The operator capability

An operator is a users.is_operator row and QUEEN_PROXY_OPERATOR_ENABLED=true on the cell. Both, resolved per request. With the flag off, an operator-class route 404s before authentication runs at all, the same answer a hard block gives, with no lookup and no way in.

A live operator is admin on every cluster on that cell, membership row or not, and is the only principal that can open the cell-wide surfaces: broker status, buffer state, host and PostgreSQL metrics, maintenance mode, and the Prometheus endpoint. Operator access is logged, sampled to one line per minute with a suppressed count, because a dashboard polls those routes.

Granting it is a psql call to queen_proxy.set_operator(email, true). There is deliberately no HTTP path. See Tenants, clusters and cells.

Acting on a cluster the Host header does not name

The dashboard is served from one hostname for every cluster, so a browser has no DNS label to route by. x-queen-act-cluster supplies the cluster explicitly, as a slug or a cluster UUID:

Credential Result
API key 403. A key is bound to its own cluster
No credential 401
Session with a cluster_roles row on the target Acts with that row’s role
Session without a row 403 no such cluster, or no role on it
Live operator session Acts as admin on any cluster that exists

An unresolvable cluster reference and an unauthorized one return the same 403 with the same message. That is deliberate: distinguishing them would turn the header into a way to enumerate every tenant’s cluster slugs.

Failure reference

Status Code Meaning
401 unauthorized Missing, unknown, revoked, expired or malformed credential
403 forbidden Valid credential, insufficient for this route class or cluster
403 feature_gated The plan does not include this feature
403 cluster_suspended Tenant or cluster suspended or deleting
403 not_provisioned OAuth identity with no account, auto-provision off
404 route_blocked Blocked route, or an operator route without a live operator
421 cluster_unknown The Host label matched no cluster

Bodies are {"error": "<human>", "code": "<machine>"}. The codes are load-bearing: the SDKs branch on them rather than on message text.

  • Quotas and rate limits: the 429 contract and what the SDKs do with it.
  • Isolation: why the operator surfaces are gated the way they are.
  • Broker authentication: the broker’s own JWT, which is a different mechanism from this one and lives on the other side of the proxy.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close