Reveal
Plaintext for a named operator — its own scope, mandatory attribution, audited before the response.
Operators editing credentials through the Internal Admin API see only masks — which is right until the day a key might be wrong and the only cure would be a blind overwrite. POST /internal/v1/admin/credentials/{credential_id}/reveal is the deliberate exception: decrypt one record and show it to a human.
curl -s -X POST "$CREDS_BASE/internal/v1/admin/credentials/$CRED_ID/reveal" \
-H "Authorization: Bearer $REVEAL_TOKEN" \
-H "X-Admin-Actor: panel:alice"
The response carries the decrypted credentials, the open configuration, the owner coordinates, and revealed_at.
The four deliberate restrictions
- Its own scope.
provider-credentials.reveal— not implied byadmin(edit ≠ see) nor byinvoke(machines resolve, humans reveal). The auth-server registry grants it separately, to the panel only; see Service Plane Tokens. - A named operator, mandatorily. Unlike other admin calls, a missing
X-Admin-Actoris a400. A reveal leaves nothing behind except its audit event; anonymous, that event would say only "some client read a secret" — worthless. Anonymous plaintext disclosure is rejected by construction. - Audited before answered. The
credential.revealedevent (field names only, never values) is committed in its own transaction before the response is sent — the trace exists even if the response never arrives. The event is also exempt from audit retention purge: reveals are rare, human, and worth keeping. - Its own scarce budget. Default 10/min per operator (Rate Limits) — far below the admin 60/min, so a stolen panel session cannot walk the whole store at pagination speed.
Semantics worth knowing
- Works on records of any level and any status including
disabled— a suspected-broken key is inspected exactly when it is off. Soft-deleted records are a404: deletion means gone. - It is a
POST, not aGET: a reveal is an audited action, not a cacheable resource. The id stays in the path; values travel only in the body, so no secret ever lands in an access log. - A ciphertext that will not decrypt is a
500 CREDENTIAL_DECRYPTION_FAILED, itself audited ascredential.decryption_failed— that response is a fire alarm about the keyring, not a retry candidate.
Worked example, including the audit event it leaves: cookbook scenario 08.