API keys for the Model Gateway — who can mint one, what it is scoped to, and how to revoke it
Audience: members of an organisation, and whoever supports them. If you are connecting an agent that runs outside UpsQuad, you want Connecting an external agent to the Model Gateway instead — external agents do not use the keys on this page, and §1 says why.
Read this first — the unit is REQUIRED, and it is what makes the key work
A member API key must be scoped to an org unit you belong to.
unit_idis a required field on the mint request; there is no default and the server will not guess one.That requirement is the whole of #3416, and the reason it is phrased as a hard requirement rather than a nicety is a measurement. Before it landed the unit came from a JWT claim the live Clerk session template does not emit, so every key ever minted through the portal carried an empty unit — and an empty unit is not degraded attribution, it is a
403 binding_unit_unresolvedat gate5/bindingon every single call. Measured on beta-dev 2026-09-23: the plain member key refused, and the same key with a unit id written into its grain admitted, relayed, and recorded withllm_usage_events.governance_outcome = allow. One field was the entire difference.So a key with no unit is not a weaker key. It is a key that cannot complete a call, which is why minting one is now refused up front (
400 UNIT_REQUIRED) at the moment you can still fix it.One key, one unit. If you work in two units, mint two keys — see §3.
Re-verify rather than re-trust: the end-to-end claim above is reproduced in CI by
TestMemberKeyUnit3416_AMintedKeyIsAdmittedAndMeteredAtTheEdge(internal/modelgateway/member_key_unit_3416_test.go), which mints through the production path and drives the production gate chain, withTestMemberKeyUnit3416_AnEmptyUnitIsStillRefusedAtGate5as its control.
1. A key is a MEMBER credential. An agent credential is something else.
This is the distinction the whole page rests on, and it is the answer to "how does my agent get an access key?"
| Caller | Credential | Where it comes from |
|---|---|---|
| A member (you, curl, a CI job acting as you) | uq_key_… API key — this page | the portal's API Keys page / APIKeyService.CreateAPIKey |
| A platform-run agent | short-TTL agent-scoped bearer, minted per step | the runtime; nothing for you to hold |
| An external / third-party agent | an external_agent_clients client id + secret, exchanged for a ~10-minute token at /oauth/token | external agent onboarding §3 |
An API key cannot be made into an agent credential. The scoped_agent_id field still
exists on the mint request, and a key carrying it still stores, but the gateway refuses it at
gate 3/identity with agent_scoped_key_demoted — the uq_key_* deprecation window closed
in T17 (#2919, merged #3068). Read
the switch off the process you are talking to, never off this page:
docker logs upsquad-model-gateway | grep -o 'agent_scoped_keys=[a-z]*'
# agent_scoped_keys=refused <- beta-dev, 2026-09-23
refused is not universal. The estate is deliberately split on this switch and the GKE base
ConfigMap is still accepted pending its own inventory run — scripts/check-model-gateway-exposure.py
prints the per-manifest position on every run, which is the only answer that cannot go stale.
The durable thing a third-party agent holds is therefore its client secret, not a key
from this page. That is not a workaround; it is the design. A ten-minute token that the
holder re-grants is a smaller blast radius than a bearer that never expires, and its
runtime = "external" provenance is stamped onto every ledger and audit row.
2. Who may mint
Clearance L3 or above, server-enforced. apikey.MinMintClearance is the single copy of
that floor (internal/gateway/apikey/mint.go). It is checked in the mint path, so it applies
to the portal, to curl, and to anything else that reaches the RPC.
Three things a minted key takes from the verified caller and never from the request body (#3379):
- the member it is attributed to — if the server cannot say who you are, it refuses
(
CALLER_UNRESOLVED) rather than minting a key it cannot attribute; - the organisation;
- the clearance, taken as the minimum of every verified source and clamped to
[1,5]. A key can never carry more authority than the member who asked for it.
team_id and member_id in the request body are parsed and ignored (they are marked
deprecated in the proto). Sending someone else's is not an error, but it is logged as ignored.
unit_id is the one identity-adjacent field you choose, and the server checks it
(#3416). The mint is refused unless
you hold an active membership of that unit, of kind team, in your own org — read from
org_unit_memberships joined to org_units, inside the request's own RLS-scoped
transaction. That is not a re-opening of #3379's forgery: the wire may only select among
authorities you already have, which is structurally what scoped_agent_id does. Your
role in the unit is not consulted — an ordinary member of a unit is exactly who is
meant to hold a durable credential for it.
The four refusals are distinct on purpose, because each sends you somewhere different:
| Code | HTTP / Connect | What to do |
|---|---|---|
UNIT_REQUIRED | 400 / InvalidArgument | name a unit you belong to |
UNIT_NOT_UUID | 400 / InvalidArgument | unit_id must be a uuid — _default is not a unit |
UNIT_NOT_A_MEMBER | 403 / PermissionDenied | you are not an active member of that unit or it does not exist in your org — the two are deliberately indistinguishable, so that walking uuids tells you nothing |
UNIT_VERIFIER_UNAVAILABLE | 500 / Internal | not your fault: the server could not ask the membership question. Nothing was minted. Escalate, do not retry in a loop |
Rotation (APIKeyService.RotateAPIKey, REST POST /v1/api-keys/rotate) is a mint: the
same L3 floor applies, you must own the key, and the replacement's clearance is capped by
both yours and the old key's. Rotation replaces a credential — it does not re-specify one, so
the scoped agent, the expiry and the unit are carried over from the key being replaced.
The carried unit is re-verified: if you have left the unit since the key was minted, the
rotation is refused UNIT_NOT_A_MEMBER rather than handing you a fresh credential for a unit
you are no longer in. A legacy key minted before #3416 carries no unit at all and refuses
with UNIT_REQUIRED — mint a fresh unit-scoped key instead of rotating a dud into another.
3. What the key is scoped to
| Dimension | Set by | Notes |
|---|---|---|
| Organisation | the verified caller | never empty — migration 237 has a non-empty CHECK |
| Member | the verified caller | the attribution grain; every ledger row carries it |
| Org unit (team) | unit_id on the request, verified against your memberships | required. This is the grain gate 5/binding resolves against, and the key works or does not work on it. Stored in the member_api_keys.team_id column — the name is historical; migration 256 gives it a non-empty CHECK for new rows |
| Clearance | the verified caller, min-of-sources, clamped to [1,5] | gate 7a compares it against each endpoint's min_clearance |
| Agent | scoped_agent_id, optional | can only NARROW a key. Refused at the gateway where agent_scoped_keys=refused — see §1 |
| Expiry | expires_at, optional, RFC 3339 | no default and no maximum. Omitting it mints a key that never expires. Set one |
One key is bound to ONE unit, and that is a design decision rather than a limitation. The gateway routes by model first and only then resolves the binding for (routed endpoint, your unit), so a key pinned to a single endpoint would either refuse every other approved endpoint in your unit or re-implement the binding table inside the credential. The binding table is already the per-endpoint authority, approved per unit. A member of two units mints two keys.
A key is a bearer credential: anyone holding the string is the member it names, at that member's clearance, until it expires or is revoked. Treat it like a password — the platform stores only a SHA-256 hash and cannot show it to you again after the mint response.
4. Minting a key, then making a call
Step 1 — find a unit you belong to. The portal's API Keys page offers your own
memberships; from a shell, any unit id you already know you are in will do. It must be a
team unit, not a pillar.
Step 2 — mint. REST, against the context engine (:8080, :8083 on the devbox):
curl -s -X POST "$CE/v1/api-keys" \
-H "Authorization: Bearer $CLERK_JWT" \
-H 'Content-Type: application/json' \
-d '{"unit_id":"<a team unit you are a member of>",
"expires_at":"2026-12-31T23:59:59Z"}'
# -> 201 {"key_id":"…","raw_key":"uq_key_…","key_prefix":"uq_key_…"}
The same request over Connect is upsquad.auth.v1.APIKeyService/CreateAPIKey with
{"unitId": "…"} — that is the RPC the portal calls, and it enforces the identical rules
(both surfaces go through one Mint, with a parity test that reddens if they ever diverge).
raw_key is shown once. The platform stores a SHA-256 hash and cannot recover it.
Step 3 — call the gateway. The call shape is the same one the external-agent page documents — only the credential differs:
# PUBLIC (live since 2026-09-07, #2912): MG=https://model-gw-beta.upsquad.ai
# DEVBOX LOOPBACK: MG=http://127.0.0.1:8091
curl -s -X POST "$MG/v1/chat/completions" \
-H "Authorization: Bearer $UQ_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"<a model id on an endpoint your unit is bound to>",
"messages":[{"role":"user","content":"hello"}]}'
Your Authorization header is stripped before the request is forwarded and the tenant's
own provider credential is injected. Everything under /v1/ is relayed; the gateway does not
enumerate upstream routes. For dialect inference, translation, the credential-grain walk and
every refusal code, read
external agent onboarding §4 — those are
properties of the gateway, identical for both credential types, and a second copy here
would be the half that goes stale.
Three refusals are specific to this credential and worth recognising:
| Wire | Meaning |
|---|---|
403 no_approved_binding · 5/binding | your unit has no approved binding for the endpoint serving that model. Create one and have the unit's Manager approve it (MG-2.1). This is the expected refusal for a correctly-scoped key aimed at the wrong endpoint |
403 binding_unit_unresolved · 5/binding | your key carries no org unit at all. Since #3416 only a key minted before it can be in this state — mint a new one; rotation will not fix it |
403 agent_scoped_key_demoted · 3/identity | your key names an agent. Use the OAuth path — §1 |
The difference between the first two is the thing to read carefully: no_approved_binding
means the credential is fine and the authorisation is missing; binding_unit_unresolved
means the credential itself cannot be reasoned about.
5. Revoking — and why you never delete
DELETE /v1/api-keys/{id} revokes; it does not remove the row. APIKeyService.RevokeAPIKey
is the same operation. Revocation sets is_active = false and leaves everything else in
place.
That is deliberate. A member_api_keys row holds a hash, never the secret, and it is the
audit record that says this credential existed, who held it, at what clearance and when.
Deleting it destroys the only explanation for the llm_usage_events rows that reference it —
spend with no attributable credential. Revoke; never delete.
Revocation is immediate, and that is newer than it should be. Until
#3370 both revoke surfaces
deactivated the row and left the resolver's 60-second cache entry alone, so a revoked key
kept authenticating for up to a minute. Measured on beta-dev 2026-09-23: revoke at t+0,
is_active = false in the database, and the call at t+1s admitted and relayed; the
call at t+65s finally 401. Both surfaces now evict the cache entry as part of the same
operation (internal/gateway/apikey/revoke.go).
If the eviction itself fails you are told, not congratulated: REST answers
503 REVOKE_CACHE_LIVE and the RPC answers CodeUnavailable, both saying the key is
deactivated but may be honoured for up to 60 seconds. Retry — the revoke is already
applied, retrying is safe, and the retry re-attempts the eviction.
Rotation revokes the old key in the same transaction as it mints the replacement and has always evicted the replaced key's cache entry.
Listing (GET /v1/api-keys, APIKeyService.ListAPIKeys) returns every key in your org,
active and revoked, with the prefix but never the secret.
6. What this page does not claim
- That a key works without an approved binding. Minting is only half of it. A key scoped
to your unit still needs that unit to hold an approved binding to the endpoint serving
the model you ask for (
llm_endpoint_units.approval_status = 'approved'), or the call is refusedno_approved_bindingat the same gate. #3416 fixed the credential, not the grant. - That issuance is discoverable. The API Keys page is a standalone page with no link
from, or reference to, the Model Gateway tab, and it shows no endpoint hostname and no
usage snippet. That half of
#3370 is
upsquad-clientwork and is not delivered here — including the unit selector the mint dialog now needs, sinceunit_idis required and the portal is the surface that knows your memberships. - That the gateway is carrying production tenant traffic. It is not — the hostname is
released for E2E testing under the scoped waiver on
#2912. The standing list of
gates is the query
label:prod-enablement is:open, not a copy on this page. - Anything about spend visibility.
llm_usage_eventsrecords every call and has no consumption surface in either portal — #3009.