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 can reveal it. Staff and support are no exception: a revealed key is a working credential that spends against its holder's limit, so neither reads one directly. Staff who need a key impersonate a user who may reveal it, and the audit event names them.
A resource under per-key management starts with no keys. Where the offering
manages keys one by one (enable_api_key_provisioning, below) and the agent's
backend can mint a key on request, the agent provisions none. A key can be
requested at any time there, so a replacement is created, clients move to it, and
the old key is deleted. The default pair would only be two shared keys with no
assignee and no limits, which any member of the resource's project or
organization can reveal. Its users request their own keys instead. A restored
resource follows the same rule. Its key rows were deleted when it terminated, so
it comes back with none. Existing resources keep the keys they have when the option is turned
on or off. Wherever either condition does not hold, the pair stays, as on a
backend without per-key commands such as ceph-s3. The agent decides this, so
Waldur's API and models are unchanged.
A key is requested once the resource exists at the backend. The agent mints
a key under the resource's backend_id, so a request, or a retry of a failed
one, is refused with 409 until the resource has one, which it gets when the
agent creates it. The agent cannot act on such a command and would leave the
key Creating until its sweep replayed it, long after the resource was ready.
The assignee can rotate their own key. Whoever is trusted with a key's value is trusted to replace it, so a person who suspects their key has leaked does not have to find a manager while it stays live. Nothing else follows from being the assignee: pausing, resuming, deleting and editing the key stay with whoever manages the resource's API keys. A separate permission for this was considered and dropped — it would be one more thing for a project to grant, to the one person who already holds the secret.
One key per person. A person is the assignee of at most one key on a
resource; a request that would hand them a second is refused with 400, naming
the key they hold. A backend ties a person's own use of the
resource to the key assigned to them — LiteLLM's chat follows it and bills it —
and two keys would leave that two answers. A key holds its assignee until it is
Deleted: one still Deleting may yet fail and stay in service. Keys on other
resources do not count, so someone can hold one key on each of several
resources. The check runs with the resource row locked, so two concurrent
requests cannot both pass; there is no database constraint, so keys assigned
before the rule keep their assignees.
A key's assignee is fixed when the key is requested. It stays until the key
is deleted, and no edit sets, changes or clears it. A key changes hands by
requesting a key for the new holder and deleting the old one, and someone who
left the project is cut off by deleting their key. The key's limits and its usage
so far this month belong to the key, not to whoever holds it, so a key that
changed hands would charge one person's spending to another's allowance and
report it as theirs. Unassigning would also hand a still-working secret to
everyone who can reveal the resource's unassigned keys. An unassigned key stays
shared: to give someone a personal key, request one for them. Without key
management a key can be neither requested nor deleted, so an assignee set while
it was on stays, and a manager can still rotate the key. An edit could change
the assignee in 8.1.3, so one that still names user is refused with 400 rather
than silently dropped.
Deleting a user's account would clear the assignee of their keys, so the keys
are withdrawn first (revoke_api_keys_of_deleted_user, on the user's
pre_delete), audited as automatic: "Its assignee's account was deleted."
Where the offering manages keys, each is deleted — a requested key not created
yet is dropped, and a command in flight gives way to the deletion, so its late
acknowledgement is refused. Without key management, where a key cannot be
deleted, it is rotated, so the departed person's copy stops working. A key that
can take neither, such as one of a resource being terminated, has its stored
value dropped, so nobody reveals it. Deactivating an account changes nothing.
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 per calendar period. A key takes the same per-period limits as its resource's spending limits — day, week, month, quarter and year, calendar periods in UTC, and a total that never resets — so a research group can give each member a daily and a monthly share of the group's budget, or a one-off allowance. A key Waldur paused for a limit comes back on its own once that period resets, and so it does when the limit is raised; a key a person paused stays paused. A total never resets, so a key it paused comes back only when the limit is raised or removed.
Which limits a key is offered is the provider's call. Only a key's usage
per day can reach a day or week limit, and only the usage of every month can
reach a quarter or year one, so periods other than month and total need an
agent that reports each key's usage per day. Waldur cannot tell what the agent reports,
so the offering declares it, next to enable_api_key_provisioning:
plugin_options.api_key_limits— consumers may set limits on keys. On unless set tofalse; off, no new key limit is accepted.plugin_options.api_key_daily_usage— the agent reports each key's usage per day (daily_usagesinreport_usage). Off unless set; without it a key is offeredmonth, andtotalwhere the component offers it.
Turning either on needs enable_api_key_provisioning. Like that option, the
claim is not checked: turned on for an agent that reports keys monthly, day
and week limits are accepted and never reached. Upgrading set
api_key_daily_usage on every offering whose keys already had usage per day.
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 |
description |
the portal's own note telling the key apart from the resource's other keys (DescribableMixin); carried by no command, so editing it issues none; the agent and the provider still read it when they list the key |
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, set when the key is requested and never changed; only they can reveal the key, and they can rotate it. A person is the assignee of at most one key per resource that is not Deleted |
spending_limits |
optional limits per component type and period, {component_type: {period: amount}} (see Limits) |
allowed_models |
the models the key may call, never null: ["*"] for every model, [] for none, or a list of names (below) |
current_usages |
per-component usage in usage_period, the highest figure the agent reported for the month, which never drops; for a component reported per day, the sum of its days |
usage_period |
the month current_usages covers (its first day); usage of an earlier month counts against no month limit |
usage_history |
each earlier month's usage per component, {"2026-09": {"token_cost": 12.5}}: kept when the next month's usage comes in, and raised, never lowered, by usage reported late for that month; never pruned, as a total limit counts it. Not in the API |
paused_by_limit |
Waldur paused the key because its usage reached a limit; such a key is resumed automatically once under its limits again |
limit_resumes_at |
when every limit that paused the key resets, so that it resumes without a usage report; empty while a reached total holds the key, which then resumes only when that limit is raised or cleared |
models_outdated |
the resource's model selection changed while the key could not take an update; Waldur sends one once the key settles (below) |
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.
Model lists
A key's allowed_models has three forms, and so has its resource's selection
(below):
| Value | Meaning on a key | Meaning on a resource |
|---|---|---|
["*"] |
every model its resource allows | every model of the offering, including ones it adds later |
[] |
no model: the key cannot call anything | no model: none of its keys can call anything |
["a", "b"] |
those models | those models |
"Every model" has a value of its own so that an empty list can mean what it looks like. With null or an empty list standing for every model, the selection that looked most restrictive was the least restrictive one, and a key limited to a model the provider later retired could be widened to all of them by saving it with nothing ticked.
*means every model only as the sole entry; next to another entry it is refused. It is not a pattern:provider/*is just a model name.- A key's names must be among those its resource allows: the resource's own
names, or the offering's
resource_options.options.models.choiceswhen the resource is on every model (any name, when the offering lists none). A name the offering no longer lists is allowed by no resource. - A request that leaves
allowed_modelsout creates a key with[]; an edit that leaves it out does not change it. Null is refused. - A key the agent reports on its own (
report_created) was never restricted at the backend, so it is stored as["*"]. Keys that existed before the list became non-null were migrated from null to["*"]. - On an offering without
enable_api_key_provisioningthe field reads as null, like every governance field.
A backend that cannot restrict a key's models has to refuse every list but
["*"], the empty one included, so that Waldur never shows "No models" for a
key that can call all of them. The Envoy AI Gateway plugin does, so on Envoy a
resource has to stay on every model: any narrower effective list errs the key.
Removing a model from the offering's choices drops it from every effective
list that names it, but ["*"] names nothing: a key whose effective list is
["*"], and every key on Envoy, can call a retired model for as long as the
backend serves it. The provider has to remove it from the backend too.
The resource's selection
A resource option named models of type select_string_multi is the resource's
model selection, on any offering: it takes ["*"], a new resource starts on it,
and an empty one means none. Under enable_api_key_provisioning it narrows
every key of the resource; without it, it narrows nothing, but an offering that
turns key management on later finds its resources already on every model. When
selections start or stop narrowing — key management is turned on, or the option
becomes or stops being a multiple choice — or the provider adds or removes a
model in the option's choices, every key whose effective list changes with it
is sent an update. Turning key management off sends nothing:
its keys take no governed command. A single-choice models option has no room
for * and narrows nothing.
- Effective list. Waldur stores a key's list as chosen and sends the agent
the effective one: the resource's selection, then the key's, less any model
the offering no longer lists. Every model stays
["*"].
| Resource | Key | Sent |
|---|---|---|
["*"] |
["*"] |
["*"] |
["a", "b"] |
["*"] |
["a", "b"] |
["a", "b"] |
["b", "c"] |
["b"] |
[] |
anything | [] |
["*"] |
["a", "c"], with c no longer listed |
["a"] |
The key listing carries both: allowed_models is the key's own list,
effective_models the one a command carries. A sweep that replays a command
from the listing sends effective_models. A key keeps names its resource has
since stopped allowing, or the offering stopped listing, and an edit may send
them back unchanged; it cannot add new ones. A model the provider lists again
is sent again to the keys that kept it.
- Values. Resource-option validation (update_options,
update_options_direct, and the provider's new_options at approval)
accepts a lone * although it is not among the option's choices, and refuses
it next to a name.
- New resources are on ["*"] unless the order chose a selection. Resources
that had no selection, or an empty one, were migrated to ["*"], since an
empty selection used to leave their keys every model. An offering that lists
an "All models" entry among its own choices should drop it; resources holding
it have to be switched to every model by hand.
- A key the agent reports on its own (report_created) is stored as
["*"]; when its resource allows fewer models, Waldur sends it an update
with them at once.
- A change to the selection, by any path that saves the resource's options,
sends an update for every key whose effective list it changes. Requesting
and reporting a key lock the resource row, so a key created while the
selection changes is either seen by the change or created with the new one.
A paused key's resume carries the new list instead, and a deleted one needs
none. A key that cannot take a command — one is in flight, or its last one
failed — is marked models_outdated; once it settles OK, Waldur sends the
update, unless a command carrying its settings (create, update,
resume) went first. The hourly send_outdated_api_key_models task sends
any settled key still marked.
- Rollout. A site agent that predates effective_models replays a stuck
command with the key's own allowed_models, which is wider than what Waldur
sent when the resource is narrowed. Upgrade agents before narrowing resources.
Keys the upgrade finds applied wider than their resource now allows — until
then a selection narrowed only the portal's dialogs, and a model the offering
stopped listing was still sent — are marked
models_outdated by a migration and sent their effective list by the hourly
task. On Envoy those keys then err, within the hour: put such resources on
every model before upgrading, by clearing their selection, which the
migration then turns into ["*"].
The migration rewrites resources, not pending orders: an option order placed
before it with an empty models meant every model, and now means none. Check
pending option orders of such offerings when upgrading.
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, spending_limits} -- Creating (create)
W->>Q: command (key id, spending_limits, 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 where keys are not managed one by one.
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
spending_limits, its limits (the month limits alone, {component_type:
amount}, the shape an agent that knows only monthly limits reads) and its
effective allowed_models (the resource's selection), so
settings edited while a key was paused are applied when it resumes. rotate
carries them too under key management, so a rotated key keeps the settings
Waldur has for it. Without key management Waldur neither shows nor applies a
key's settings, so a rotate there carries none. Agents
from before rotate carried them ignore them and keep the backend's. 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
A key's limit for a component is one amount per period:
1 | |
- No period is offered where the offering turns
api_key_limitsoff. monthis available on every component of the offering, as key limits were monthly before they had periods, whatever the resource's plan charges for it: a LiteLLM cost cap is priced at 0, yet it is what a key's spend is capped on. Withapi_key_daily_usageon, the other periods are available where the component offers them to resources (OfferingComponent.spending_limit_periods, see spending limits); without it, onlytotalis, where the component offers it: a total needs no usage per day.- A key's limit may not exceed the resource's spending limit for the same
period or any longer one — the lowest of them, when the resource has any:
a key of a resource capped at 40 a month takes no day limit above 40. The
provider's per-period
maximumbounds only the resource's limits. A shorter period's limit may not exceed a longer one's. - A limit is reached when usage is equal to it or above it. It must be above
0, unlike a resource's: in the flat
limitsshape agents and older clients read, 0 means no limit. A key is stopped with a pause instead. - An amount the key already holds is accepted again unchanged, even once its period is no longer offered or the resource's limit has dropped below it, so that editing one period never forces a change to another.
- Requesting or editing a key validates its limits again with the resource and
the key locked, against what they hold then: an edit that sends back a held
amount another edit has since lowered, or a cap lowered meanwhile, is
refused with
400.
Usage. The agent reports a key's usage with report_usage: its usage so far
in a month (usages), per day (daily_usages), or both. The month figure of a
component reported per day is the sum of its days. Month limits compare that
figure; day and week sum the days; quarter and year add the month figure to the
days of the earlier months in the period. A key reported only monthly therefore
enforces month limits, and counts no earlier month towards a quarter or a year.
A month figure never drops: a month total lower than the key holds for the
month leaves the higher one, and so does a day corrected downwards (below), so a
limit it reached does not reopen within the month.
A total adds the month figure to usage_history: when a report moves the key on
to a later month, the month it held is kept there, so a total counts every
month the key reported in, monthly or daily, after the daily rows are pruned.
A closed month still takes the usage the agent sends for it once the next month
has started — the minutes after its last report before midnight, or the days of
an outage over the month end: a month total with billing_period set to that
month, or days dated in it, raise its figure in usage_history, and never lower
it. As in the current month, a component with days in that month takes days
only. A late month total counts for a total alone — month limits read the
current month, and quarter and year read days — while late days also count
for the day, week, quarter and year they fall in. Either may pause the key at
once, when it brings a limit to its amount. The agent should therefore send one last report for a month
after it ends; without one, a total misses the usage after the month's last
report. Closed months are accepted as far back as daily usage is: from the
start of the current year, or of the previous month when that is earlier;
an older one is refused with 409.
Keys from before totals start with the history their stored daily rows give:
the months before the one they hold, back to the start of the previous year at
most, and none for a key reported only monthly. A total is the key's own: a key
requested again starts from zero, and a rotated one keeps its usage.
An agent that switches to daily reporting mid-month must report every day of
the month so far, as for a resource's daily usage: the
days replace the month total from then on, and a month total for that component
is refused for the rest of the month. The month figure never drops within a
month all the same: a month total reported before the first daily amounts stays
as a floor, so a limit it reached does not reopen before the days catch up, and
a day corrected downwards counts as reported until the month turns.
Enforcement. When a key is OK, has no command in flight and a report brings
its usage to a limit, Waldur issues a pause for that key alone, marks it
paused_by_limit, records in limit_resumes_at when every reached limit resets
(none, while a reached total holds it),
and audit-logs it as automatic, naming the limits ("Its usage reached the day
limit of token_cost."). The resource and its other keys keep serving. A key over a limit
while a command is in flight is checked again when the agent acknowledges that
command: if it settles OK, it is paused at once rather than serving until the
next report. A limit reached later while the key is paused moves limit_resumes_at.
A pause the agent reports as failed leaves the key Erred and still served at
the backend. The retry_failed_api_key_limit_pauses task, every five minutes,
issues that pause again, audited as automatic, for every such key that is
still over a limit, on an offering with key management and a resource that
is not terminating. It backs off while the pause keeps failing: the first
retry comes five minutes after the failure, and each one after waits twice as
long after the last failure — 10 minutes, 20, 40 — up to six hours. The key
counts its retries in limit_pause_retries, and a new automatic pause starts
the count again. A key under its limits again by then is left Erred for a
person to retry or delete. Any other 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:
- within five minutes of
limit_resumes_at, by theresume_api_keys_after_limit_resettask, without a usage report; - a usage report that brings it under every limit, such as the first of a new month;
- a limit raised above the usage, or removed — the
resumecarries the new limits; - the hourly
resume_api_keys_under_limittask, which checks every key paused for its limit,limit_resumes_ator not.
None of these resumes a key while its resource is paused or downscaled for
the resource's own spending limits (usage_limit_restriction): the
resource's usage is over its limit, so none of its keys may spend. The tasks
resume the key once the resource's restriction lifts.
A resume by hand clears the mark, but does not override the limit: if the key is still over it, it is paused again once the agent acknowledges the resume. A key a person paused is never resumed automatically.
Daily usage rows. A key's status reads only the daily rows its own limits
reach back to: from the start of the week for a day or week limit, of the
quarter or year for those. A key with month or total limits only, or none,
reads none. The daily prune_api_key_daily_usage task deletes the rows dated
before the previous year; a total reads usage_history, which is kept.
Audit. Requesting a key with limits, and every edit or command that changes
them, carries old_spending_limits and new_spending_limits in the event
context, as a resource's spending limits event does.
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,description, state,pending_action,modified, error message, assignee, limits, models as chosen and effective, usage — no secret).spending_limit_statuslists each limit withused,limit,resets_at(nullfor a total) andreached, andlimitsthe month limits alone. 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; 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. Staff and support are held to the same rules: outside the resource's project and organization they get403, and inside it they reveal what any member could. Staff reveal any other key by impersonating its assignee or, for an unassigned key, a member of the project or its organization. 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. Under impersonation it reads "… has been revealed to user, impersonated by staff", and its context carriesuser_impersonator_uuidanduser_impersonator_username. Staff and support still list every key and read its details, without the secret.POST /{uuid}/rotate/— therotatecommand. Available on every backend, and to the key's assignee as well as to whoever holdsRESOURCE.MANAGE_CONSUMER_API_KEY.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. The key's assignee can retry a failedrotate; a retry of any other command is refused to them with403.POST /(governance) — request a key:{resource, description?, user?, spending_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) and must not already hold a key on the resource (see One key per person above), limits follow Limits, and models must be among those the resource allows.["*"], which stands for every model the resource allows, is always accepted, and a request withoutallowed_modelscreates a key with[], no model (model lists).PATCH /{uuid}/(governance) — changespending_limits,allowed_models.spending_limitsreplaces every limit of the key, and a null amount clears its period.limits, the flat monthly shape ({component_type: amount}, zero meaning no limit), is still accepted in its place: it replaces the month limits and keeps the other periods. Its amounts are rounded up to the cent, wherespending_limitsrefuses more than two decimal places; an amount the key already holds is kept as it is. A request that sends both is refused with 400. The change is theupdatecommand — or, on a paused key, is stored, audited asmarketplace_resource_api_key_updatedand sent with itsresume. An edit that namesuser,nullincluded, is refused with 400 onuseron every offering, with or without key management: the key is left as it was, and nothing is published or audited (see A key's assignee is fixed when the key is requested above).PATCH /{uuid}/withdescriptionalone — on every offering, governed or not, and in every state butDeleted, a command in flight or a failed one included: the description is Waldur's own note, so it is saved at once without a command, andstate,pending_actionandmodifiedstay as they were (resource_api_keys.set_description). Sent withspending_limitsorallowed_models, it is saved in the same transaction as theirupdate, and refused with it. Same permission as any edit; not audited, as it changes nothing at the backend.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_CONSUMER_API_KEY (project or customer
scope). permissions.yaml grants it to the organization owner, the project
manager and the project administrator: the two project roles differ on the
project's team and settings, not on its resources, and every key action is a
resource action. A project member manages no keys.
rotate, and a retry of a failed rotation, also admit the key's assignee
without that permission, under the conditions reveal holds them to: still a
member of the resource's project or its customer, and not a minimal-visibility
viewer. An assignee who no longer reaches the key at all — they left the
project and hold no role on its customer — gets 404, since the key is not
listed for them; one who still lists it some other way (through the provider
organization, or as global support) gets 403. Whether the caller is the
assignee, and which command a retry repeats, are checked with the key locked. An
unassigned key gives ordinary members nothing: it is rotated by a manager.
Three things stay closed to the assignee:
- Their rotation never stands in for another command that failed. Once
governance is off a manager may rotate a key whose
pausefailed, but for the assignee that would undo a pause meant for them, so it is refused with403. - They cannot rotate an
OKkey whose usage has reached any one of its limits, whatever its period — day, week, month, quarter, year or total (409). A usage report pauses only a settled key, so a rotation in flight would hold the limit pause off for the very person the limit is set for. A manager can still rotate it, and the key is paused once the agent acknowledges the rotation. This holds only where limits are enforced: not once governance is off, and not for a retry of a failed rotation, since anErredkey is not paused for its limit and the retry is what settles it. - A personal access token cannot act as the assignee: its scopes delegate role permissions, and being a key's assignee is not one, so a token limited to reading would otherwise be able to replace the key.
A rotation in flight also makes the key refuse a manager's pause or delete
until the agent acknowledges it. Each rotation is audited, and deleting the key
ends its assignee's rotations.
Every command is refused while the resource is terminating/terminated or while
the key already has an unacknowledged command, and emits an audit event naming
whoever issued it — a manager, the assignee, or Waldur itself for a limit pause
or resume:
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?, daily_usages?: [{type, date, amount}]}.usagesis 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, each component taking the higher of the figure it held and the one reported; one for a later month replaces it. One for an earlier month raises that month's figures inusage_historythe same way, for a total limit, and leavescurrent_usagesalone; a month before the start of the current year — or of the previous month, when that is earlier — is refused with409, and a future month with400.daily_usagesare amounts per UTC date with at most two decimal places, as for a resource's daily usage (usagestakes any number), so an agent must round sub-cent amounts to the cent before sending them. A date reported again replaces its amount, a future date or the same component and date twice is refused with400, and days of a month before the one the key holds count for the limits of the periods they fall in, not for its month figure. A report holds at most 1000 daily items, and a date before the start of the current year — or of the previous month, when that is earlier, so that the last days of December can still be sent in January — is refused with400: no limit reads it. Once a component has daily amounts in a month, a month total for it is refused with400. A report is accepted or refused whole: one bad daily item refuses theusagessent with it too. 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 with the key's effective_models, 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.
Upgrading from RESOURCE.MANAGE_USERS
Until it had its own permission, key management was gated on
RESOURCE.MANAGE_USERS, held by the organization owner alone. A data migration
gives the new permission to every role that held the old one, and adds it to
every personal access token scoped to the old one, so a custom role or an
existing token keeps managing keys. Roles listed in custom-roles.yaml are the
exception: import_roles replaces their whole permission set on every
deployment, so the migration's addition is dropped again. A deployment that
grants RESOURCE.MANAGE_USERS for this purpose, in custom-roles.yaml or
through permissions-override.yaml, has to grant
RESOURCE.MANAGE_CONSUMER_API_KEY there instead. Likewise, an override that
keeps key management from a role with drop_permissions: [RESOURCE.MANAGE_USERS]
has to drop RESOURCE.MANAGE_CONSUMER_API_KEY instead, or the role gains it.
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.