Resource API Keys
How a site-agent resource's API keys are generated, applied, revealed and
rotated — and, where the backend supports it, requested, assigned, limited,
paused, resumed and deleted one by one. The motivating case is inference resources (Envoy AI Gateway keys),
but nothing here is Envoy-specific: the croit-s3 plugin uses the same lifecycle
for S3 access/secret pairs, which is what surfaced the mutable-client_id case
below. The agent-side mechanics build on the
event pub/sub architecture and the
agent queue guide.
Overview
- A resource owns multiple API keys, each independently revealed and rotated from the portal.
- Zero-downtime rotation: rotating one key never disturbs the others, so consumers on a different key keep working.
- The stored key is always a working key. Waldur never hands a member a key the gateway would reject.
- Safe visibility: members read the keys on demand, but they are never plaintext where a database dump or a broad API response could leak them.
- Per-key governance, where the backend supports it: a key can be requested, assigned to a person, given per-component limits, restricted to some models, paused, resumed and deleted — each without touching the resource or its other keys.
Design rationale
The site agent generates the key, not Waldur. Generation (and any
backend-specific format, e.g. the sk- prefix) lives in the site-agent plugin.
Waldur is a management layer, not the source of provider secrets. Crucially, the
agent applies the key to the gateway first, then reports it to Waldur — so
Waldur only ever stores a key that is already live. Minting in Waldur instead
produces phantom keys: generate → store → the apply fails → Waldur holds a key
the gateway never accepted. Requesting a key keeps this: the request creates a
row and a command, never a value.
Waldur stores the key, encrypted, for reveal. For multiple members to read a key later, something durable and member-reachable must hold it. The agent has no inbound server (it dials out only), so a live per-reveal round-trip to the agent would be slow and fail whenever the agent is offline. Waldur holding an encrypted copy keeps reveal a fast, reliable read; the agent remains the source.
Every change is a command the agent acknowledges. Pause, resume, delete, a
settings update and a request all follow rotation's shape: Waldur moves the key
into a transitional state, records the command as pending_action, publishes
it, and settles the key only when the agent reports back. A key that has not been
acknowledged refuses a second command, so two commands can never race at the
agent.
Keys are shared by default, personal when assigned. Two keys are provisioned by default — two independent keys are what make rotation zero-downtime, and they model the operator reality of primary/standby credentials. On a team-shared inference offering that is not enough: usage cannot be attributed to a person, a single key cannot be limited, and a leaked key can only be rotated. So a key may carry an assignee, and then only that person (plus staff and support) can reveal it.
Limits are enforced by Waldur, not the gateway. The Envoy backend's
set_resource_limits is a no-op by design: limits are enforced by Waldur (report
→ pause → block). A per-key limit works the same way — reported per-key usage that
reaches the limit issues a pause for that key. Nothing new is pushed to the
gateway to enforce a limit, and a limit never affects billing.
Limits are monthly. A key's usage is counted per month, like a resource's usage per billing period, and its limits apply to each month afresh. A key Waldur paused for its limit therefore comes back on its own once a new month begins, and so it does when the limit is raised; a key a person paused stays paused.
Deletion is soft. A deleted key keeps its row and the usage it reported, so the resource's per-component totals for the month do not move when a key goes away. Hard deletion would lose usage that belongs in the resource's total.
Governance is a backend capability. Not every key-carrying backend can honour
a per-key command: ceph-s3's pause_resource is a no-op and its client_id
rotates with the secret. The offering declares support with
plugin_options.enable_api_key_provisioning; without it the governance fields
read as null and the governance actions are refused, while reveal and rotation
work as before.
The option is a claim the operator makes about the agent's backend, and nothing
checks it: the agent does not yet report what its backend supports. Turned on
for a backend without per-key commands (ceph-s3, for one), every request, pause,
resume, update or delete errs at the agent and leaves the key Erred. Turn it
on only for a backend whose agent sets supports_resource_api_key_lifecycle.
Model
ResourceApiKey (marketplace/models.py) — many per resource:
| Field | Purpose |
|---|---|
resource |
ForeignKey — a resource owns a collection of keys |
client_id |
the backend's public identifier, e.g. <resource_backend_id>-<n> (one gateway Secret entry) or an S3 access key; unique per resource, and may move on rotation. Blank on a requested key until the agent creates it |
key_ciphertext |
the agent-generated value, Fernet-encrypted (field encryption); dropped when the key is deleted |
issued_at |
when the agent last stored a value: the key's creation or its latest rotation. Unlike modified, a pause, resume or edit leaves it alone, so it is the age of the value in use |
state |
FSM, aligned to resource states (below) |
pending_action |
the command in flight (ResourceApiKeyActions: create, rotate, pause, resume, delete, update); blank once settled, kept on Erred to show which command failed |
user |
optional assignee; only they can reveal the key |
limits |
optional per-component limits, keyed by component type; zero means no limit |
allowed_models |
optional model allowlist; null allows every model |
current_usages |
per-component usage in usage_period, as last reported by the agent |
usage_period |
the month current_usages covers (its first day); usage of an earlier month counts against no limit |
paused_by_limit |
Waldur paused the key because its usage reached a limit; such a key is resumed automatically once under its limits again |
error_message |
agent-reported failure detail when Erred |
modified |
TimeStampedModel; the time the key last changed state or settings — the agent's sweep reads it as "in this state since". Usage reports do not touch it |
The key value is deliberately not on Resource: it never touches the broad
ResourceSerializer, gets its own permission-gated endpoints, and stays out of
admin and reversion history. ResourceSerializer carries only has_api_keys, a
boolean (an Exists annotation on the consumer and provider resource viewsets,
with a per-instance fallback) so the portal can offer key management without
knowing the backend —
offering_type cannot tell these backends apart, they are all site-agent offerings.
client_id is the backend's public identifier, and it is not always stable.
Envoy uses a slot, <resource_backend_id>-<n>, that survives rotation: only the
value behind it changes. The croit-s3 plugin puts the S3 access key there,
which a rotation replaces along with the secret. set_key therefore accepts
an optional client_id; a backend with a stable identifier omits it. A value
already held by another key of the same resource — a deleted one included — is
rejected: usage is attributed by client_id, so a deleted key's identifier is
never handed out again.
States
The FSM reuses the resource state vocabulary so the portal renders it with
the standard StateIndicator (@/core/StateIndicator) — no bespoke badge:
| State | Meaning | Indicator |
|---|---|---|
Creating |
agent is generating + applying the initial key | spinner |
OK |
applied and live at the gateway | green |
Updating |
a rotate, pause, resume or update is in flight; pending_action says which |
spinner |
Paused |
the gateway refuses the key; it keeps its value | — |
Deleting |
the agent is revoking the key | spinner |
Deleted |
revoked; the row stays for its usage, hidden from default listings | — |
Erred |
the agent could not apply the change | red |
stateDiagram-v2
[*] --> Creating: provision / request
Creating --> OK: report_created / set_key
OK --> Updating: rotate / update / pause
Paused --> Updating: resume
Updating --> OK: set_key (rotate) / set_ok (resume, update)
Updating --> Paused: set_paused
OK --> Deleting: delete
Paused --> Deleting: delete
Deleting --> Deleted: set_deleted
Creating --> Erred: set_erred
Updating --> Erred: set_erred
Deleting --> Erred: set_erred
Erred --> Updating: retry rotate / update / pause / resume
Erred --> Creating: retry create
Erred --> Deleting: delete
Creating --> Deleted: delete (no client_id yet)
Erred --> Deleted: delete (no client_id yet)
Erred --> OK: set_key / set_ok
Erred --> Paused: set_paused
Erred --> Deleted: set_deleted
OK --> [*]: resource terminated (rows deleted)
Every acknowledgement names the commands it settles, and is refused otherwise:
set_key settles create and rotate, set_ok settles resume and update,
set_paused settles pause, set_deleted settles delete. So a late or
duplicated report can neither resurrect a paused or deleted key nor settle a
command it does not answer — a rotation report cannot un-pause a key.
set_erred is accepted from any transitional state but never from OK, Paused
or Deleted: a stale failure report must not flip a key that has since settled.
Erred is always recoverable, but only by the command that failed or by a
delete. pending_action stays set on an Erred key, and POST /{uuid}/retry/
sends that command again, with the limits and models the key has now; posting the
same command directly (a pause after a failed pause, a PATCH of limits or
models after a failed update) is a retry too. Any other command is refused with
409: a rotation standing in for a failed pause would leave the key OK and
live, and one standing in for a failed update would show limits and models the
backend never received. A requested key that erred before it had a client_id
never reached the backend, so it can only be requested again (retry) or
deleted. A key that erred before commands were recorded retries a rotation.
A requested key without a client_id — still Creating, or Erred on its
create — holds nothing at the backend, so deleting it publishes no command:
it is Deleted at once, even while its create is still pending. That is the
way out of a request whose command was lost. Should the agent still create the
key afterwards, its set_key is refused with 409, and the agent withdraws the
key it minted from the backend, so nothing is left live that Waldur does not
track.
Once governance is switched off, a failed governed command (pause, update,
delete, create) cannot be retried; an ungoverned one (rotate, resume)
may then take its place, so the key is not stranded.
Flows
sequenceDiagram
participant M as Member portal
participant W as Waldur
participant Q as RabbitMQ
participant A as Site agent
participant B as Backend Secret
Note over M,B: Create (2 keys)
W->>Q: provision event
Q->>A: event
A->>A: generate key-1, key-2
A->>B: write 2 Secret entries
A->>W: POST report_created (per key) -- state OK
Note over M,B: Rotate one key
M->>W: POST keys/{id}/rotate -- Updating (rotate)
W->>Q: command (key id, slim)
Q->>A: command
A->>A: generate new value
A->>B: overwrite that client_id only
A->>W: POST set_key (new value) -- state OK
Note over M,B: Request a key
M->>W: POST keys/ {resource, user, limits} -- Creating (create)
W->>Q: command (key id, limits, models)
A->>B: write a new Secret entry
A->>W: POST set_key (value, client_id) -- state OK
Note over M,B: Pause a key
M->>W: POST keys/{id}/pause -- Updating (pause)
W->>Q: command
A->>B: block that client_id
A->>W: POST set_paused -- state Paused
Note over M,B: Reveal
M->>W: GET keys/{id}/reveal (audited)
W-->>M: {api_key} (decrypted)
Rotation never touches the other keys, so a consumer authenticating with a sibling key keeps working throughout — that is what makes it zero-downtime, and why two keys are provisioned.
Commands (observable type resource_api_key_rotation — the name predates the
other commands and agents subscribe to it by name) carry a slim payload:
resource/key identifiers, client_id, and action, never key material.
create, update and resume also carry the key's limits and
allowed_models, so settings edited while a key was paused are applied when it
resumes. The key only ever travels agent → Waldur, in the report_created /
set_key request body (TLS), and is encrypted on receipt. When a resource is
terminated, its remaining key rows — deleted ones included — are deleted by the
termination callback.
Limits. The agent reports each key's usage so far in a month with
report_usage. When a key is OK, has no command in flight and its usage this
month reaches a non-zero limit, Waldur issues a pause for that key alone, marks
it paused_by_limit and audit-logs it as automatic. The resource and its other
keys keep serving. An Erred key is not paused automatically — its failed
command needs a person to retry it first.
Waldur resumes a key it paused for its limit, again audit-logged as automatic, as soon as its usage is under every limit again:
- a usage report for a new month, since limits start afresh each month;
- a limit raised above the usage, or removed — the
resumecarries the new limits; - the hourly
resume_api_keys_under_limittask, for a month that begins before the agent reports anything for it.
A resume by hand clears the mark: if the key is still over its limit, the next report pauses it again. A key a person paused is never resumed automatically.
API
All endpoints live under /api/marketplace-resource-api-keys/
(ResourceApiKeyViewSet); keys are addressed by their own UUID and filtered per
resource with ?resource_uuid=, and further by state, pending_action,
has_pending_action (false for keys with no command in flight), user_uuid,
offering_uuid and modified_before. A listing leaves out Deleted keys
unless ?state=Deleted asks for them.
Consumer side. The actions marked governance need the offering's
enable_api_key_provisioning; without it they return 400.
GET /andGET /{uuid}/— list/retrieve status (client_id, state,pending_action,modified, error message, assignee, limits, models, usage — no secret). Visible to resource project/customer members and the provider organization.GET /{uuid}/reveal/— return the decrypted value (in the body, never the URL, withCache-Control: no-store). Restricted to consumer-side access — a member of the resource's project or its customer, plus staff/support; a provider-org member (who can reach the write actions) is explicitly excluded, and so are minimal-visibility viewers (as withbackend_metadata). A key with an assignee is revealed only to the assignee (plus staff/support). Only anOKkey is revealed — a transitional or paused key's stored value may not match the gateway. Audited — each reveal emits themarketplace_resource_api_key_revealedevent identifying the key.POST /{uuid}/rotate/— therotatecommand. Available on every backend.POST /{uuid}/resume/— theresumecommand. Available on every backend too, so a key paused before the provider switched governance off can come back.POST /{uuid}/retry/— re-sends the command that left the keyErred(pending_action); governed unless that command isrotateorresume.POST /(governance) — request a key:{resource, user?, limits?, allowed_models?}. Creates aCreatingrow and thecreatecommand; Waldur generates nothing. The assignee must be a member of the resource's project (an organization role alone is not enough), limits must name offering components, and models must be among the offering'sresource_options.options.models.choiceswhen it lists any.PATCH /{uuid}/(governance) — changeuser,limits,allowed_models. An assignee change is Waldur's alone, but audited asmarketplace_resource_api_key_updated, since it decides who may reveal the key. A change to limits or models is theupdatecommand — or, on a paused key, is stored and sent with itsresume. Unassigning ({"user": null}) needs no governance: reveal still honours an assignee set while governance was on, and only this lets someone other than staff lift it.POST /{uuid}/pause/(governance) — thepausecommand.DELETE /{uuid}/(governance) — thedeletecommand;202, since the key is gone only once the agent acknowledges. A requested key with noclient_idyet isDeletedat once, with no command (see States).GET /usage_totals/?resource_uuid=— the resource's reported key usage this month, summed per component, deleted keys included.
Every command is gated on RESOURCE.MANAGE_USERS (project or customer scope),
refused while the resource is terminating/terminated or while the key already
has an unacknowledged command, and emits an audit event when it is issued:
marketplace_resource_api_key_requested, _rotated, _paused, _resumed,
_updated or _deleted. Those events read as requested ("Pause of API key … has
been requested by …"), since the agent has not carried the command out yet; an
agent's failure report emits marketplace_resource_api_key_failed naming the
command and the error.
Provider side (used by the site agent), gated on RESOURCE.MANAGE_API_KEY held
on any of the provider-side scopes the permission is granted at — the offering
itself (OFFERING.MANAGER, which is what a site agent runs as), the offering's
customer (CUSTOMER.OWNER), or that customer's ServiceProvider
(CUSTOMER.MANAGER). The list of keys is likewise visible to the provider
organization, but reading a value is not: reveal stays consumer-side only.
POST /report_created/— the agent pushes a key it created on its own at provisioning; Waldur encrypts, stores, and the row landsOK. Idempotent per(resource, client_id), but refused for a key that has since been paused or deleted or has another command in flight, and once the resource itself is terminating/terminated. Not for a requested key: that row has noclient_idto match on, soreport_createdwould add a second row and leave the requested oneCreating. A requested key is reported withset_keyon its own UUID.POST /{uuid}/set_key/— the agent pushes a created or rotated value; Waldur encrypts, stores, transitions toOK. Takes an optionalclient_idfor backends whose public identifier rotates with the secret; a requested key must send the one it was given.POST /{uuid}/set_ok/— acknowledgesresumeorupdate.POST /{uuid}/set_paused/— acknowledgespause.-
POST /{uuid}/set_deleted/— acknowledgesdelete; the value is dropped, the row and usage kept. -
POST /{uuid}/set_erred/— the agent reports that a command failed →Erred, audited asmarketplace_resource_api_key_failed. POST /{uuid}/report_usage/(governance) —{usages: {component_type: value}, billing_period?}: the key's usage so far in that month (any day of it; the current month when omitted). A report for the month the key holds is merged intocurrent_usages; one for a later month replaces it. Only the latest month is kept, so a report for an earlier one is refused with409, and one for a future month with400. May trigger a limit pause, or the resume of a key paused for its limit. Accepted for a deleted key, so usage up to the deletion still counts.
The acknowledgements (set_key, set_ok, set_paused, set_deleted,
set_erred) are not gated on enable_api_key_provisioning: a command already in
flight still settles if the provider switches governance off.
Reconciliation sweep contract. A command is published once; if the agent
misses it, the key waits in its transitional state. An agent that re-drives keys
stuck in Updating (listing state=Updating&modified_before=…) must replay the
command named by pending_action, not assume a rotation: Updating also covers
pause, resume and update, and a rotation replayed over a lost pause would
install a new secret that set_key then refuses. Keys stuck in Creating and
Deleting are found the same way; a replayed create that lands after the
original one is refused by set_key and withdrawn by the agent. A sweep written
before this change must be updated before an offering turns on
enable_api_key_provisioning.
Encryption at rest
This is one of several encrypted columns; see Field encryption for the general feature (what is encrypted, key configuration, rotation, backups).
Keys are encrypted with Fernet via waldur_core.core.encryption
(encrypt_value / decrypt_value / is_encrypted), keyed by
settings.FIELD_ENCRYPTION_KEY. FIELD_ENCRYPTION_KEY is a separate setting
from SECRET_KEY: leaking Django settings must not, by itself, unlock encrypted
DB fields.
Rotating the encryption key. FIELD_ENCRYPTION_KEY_FALLBACKS (a
comma-separated list) holds previous keys so the app can be re-keyed without
downtime, using MultiFernet: encryption always uses the primary
FIELD_ENCRYPTION_KEY, but decryption is attempted against the primary and
every fallback. To rotate:
- Generate a new Fernet key, set it as
FIELD_ENCRYPTION_KEY, and move the previous key intoFIELD_ENCRYPTION_KEY_FALLBACKS. Existing rows still decrypt (via the fallback); new writes use the new primary. - Run
waldur reencrypt_fields, which rewrites every stored token under the new primary. Waiting for rows to be re-saved on their own does not work here: a key is only rewritten when it happens to be rotated, so there is no point at which you could tell the old key had become unnecessary. - Drop the old key from
FIELD_ENCRYPTION_KEY_FALLBACKS.
waldur reencrypt_fields --dry-run reports the same counts without writing, and in
particular how many rows no configured key can decrypt. That is worth checking
on its own: such rows are invisible until someone calls reveal and gets a 409, and
the only fix is to restore the key that wrote them.
When no dedicated key is configured, the key is derived from SECRET_KEY (with
a startup warning). That derived key always remains an implicit last-resort
decrypt fallback, so a deployment that starts without a dedicated key can
introduce FIELD_ENCRYPTION_KEY later without losing the rows written before
the switch — no manual fallback entry or re-encrypt migration needed. (This
costs nothing at rest: an attacker holding SECRET_KEY and a dump can decrypt
those pre-switch rows regardless of the server's fallback list.)
| Threat | Protection |
|---|---|
| Database dump / backup theft | Values are opaque Fernet tokens |
Casual SELECT / SQL-injection read |
Ciphertext, not plaintext |
| Over-broad API serialization | Keys live off ResourceSerializer, behind gated endpoints |
| Application-server compromise | Not protected — the process can decrypt |
Defense-in-depth against at-rest exposure, not a secrets manager.
Usage and billing
Billing stays per resource: a resource's usage is what the agent reports for the resource, and rotating, adding, pausing or deleting a key does not change it. A key limit never affects an invoice.
Per-key usage (current_usages, reported with report_usage) is a breakdown of
that total for one month — it is what limits are enforced against and what lets
usage be attributed to an assignee. Summed over all of a resource's keys, deleted
ones included, it should add up to the resource's usage in that month
(usage_totals), which is why deletion is soft. Only a key's latest month is
kept: the resource's own usage history stays in its component usages.
How each backend gets there is plugin-specific and documented with the plugin.
The Envoy AI Gateway tags requests with a per-key x-client-id; per-key numbers
depend on the usage shipper emitting them per client_id. croit-s3 meters the S3
user directly, so keys never enter usage at all — and it does not declare
enable_api_key_provisioning.