Appearance
Cloud Credential Assignments HTTP API
This is the reference for the Credential Assignment request, list, and revoke HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, lifecycle outbox event, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract. The transport-tier implementation lives in internal/transport/http/v1/credentialassignments/, and the lifecycle rules every transition obeys are owned by the application service in internal/provisioning/credentialassignment/.
A Credential Assignment binds one Cloud Credential to one consuming Project. The binding is request-and-approve: a request opens in the requested state and is materialised — wired into the ReBAC graph as a cloudcredential#uses tuple — only when a second principal moves it to approved. For the ReBAC model behind the gates below — the cloudcredential#uses relation, the dual-write tuple sync, and the state machine — see the explanation page ../../contexts/provisioning/rebac.md.
The approve and reject decisions are not taken on this surface. They are taken on the dual-control queue at POST /v1/approvals/{id}/approve and /reject, which accepts a Credential Assignment id and dispatches it to the same application service. See ../api/approvals.md for the decide contract and its per-kind gates.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/projects/{id}/credential-assignments | RequestCredentialAssignment | Project deploy | credential_assignment.request | CredentialAssignmentRequested | 8 KiB |
| GET | /v1/projects/{id}/credential-assignments | ListCredentialAssignments | Project read | credential_assignment.list | (none) | n/a |
| POST | /v1/credential-assignments/{id}/revoke | RevokeCredentialAssignment | Cloud Credential assign, else Project deploy | credential_assignment.revoke | CredentialAssignmentRevoked | 8 KiB |
body_cap = 8 KiB(MaxCredentialAssignmentRequestBodyBytesininternal/transport/http/v1/credentialassignments/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as413 request_body_too_large.ListCredentialAssignmentscarries no request body, so the cap does not apply.RequestCredentialAssignmentis gated by a single ReBAC check on the consuming Project'sdeploypermission (admin + maintainer + parent->manageon theprojectdefinition), the same composite the Cloud Assignment request gates on. A Project maintainer may therefore open a request.RevokeCredentialAssignmentresolves the assignment row to reach both the bound Cloud Credential and the consuming Project, then runs a two-party check. The Cloud Credential'sassignpermission (owner + assigneron thecloudcredentialdefinition) is tried first; on its denial the consuming Project'sdeploypermission is tried. Either grants, and only a caller denied by both is refused, so the party that holds the credential and the party that spends it can each hand the binding back. Owning the parent Cloud is not sufficient for the first leg —assigndoes not derive fromparent.- The two revoke legs are unaudited individually. A single denial row is written when both fail, naming the Cloud Credential as the object and
deployas the missing relation; on success the application service records which of the two relations granted, so the audit trail names the party that revoked. Thecloudcredential#usestuple is narrow-deleted the same way whichever party acted. ListCredentialAssignmentsruns a single top-levelreadcheck on the owning Project and applies no per-row filter on top: the path scopes every row to that one Project, so the top-level grant already decides the whole page.next_cursoris set whenever the persistence layer returned a full page.ListCredentialAssignments.cursoris opaque and HMAC-signed, bound to the per-(caller, pepper) pseudonym. A tampered or unknown-version envelope surfaces as400 invalid_cursor; a cursor minted by a different caller surfaces as403 cursor_binding_mismatch.ListCredentialAssignments.limitis clamped at the handler to[1, 200]with default50.- The request and the list are
authz-checked before the persistence write or read, so an unauthorised caller receives403without the existence side-channel a "load-then-check" flow would leak. The revoke cannot: the path id encodes neither gate object, so it reads the row first and accepts the narrow404-versus-403side-channel that leaves. - The audit relations above are the verb strings the transport handler stamps on its
application/problem-side audit rows; the application service additionally records a domain-layer audit row per granted transition — see../../contexts/provisioning/rebac.md.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| RequestCredentialAssignment / ListCredentialAssignments | id (path) | string (uuid) | yes | Project identifier. UUIDv7, non-zero. Malformed → 400 invalid_project_id. The ProjectID parameter component. |
| RevokeCredentialAssignment | id (path) | string (uuid) | yes | Credential Assignment identifier. UUIDv7, non-zero. Malformed → 400 invalid_credential_assignment_id. The CredentialAssignmentID parameter component. |
| ListCredentialAssignments | cursor (query) | string | no | Opaque HMAC-signed continuation token from a prior next_cursor. Tampered → 400 invalid_cursor; replayed by another caller → 403 cursor_binding_mismatch. |
| ListCredentialAssignments | limit (query) | integer | no | [1, 200], default 50. The handler clamps out-of-range values rather than rejecting them. |
Request schemas
CredentialAssignmentRequest
Body for POST /v1/projects/{id}/credential-assignments. additionalProperties is false.
Exactly one of the two fields below is required. Supplying both is rejected with 400 ambiguous_credential_target, supplying neither with 400 invalid_body. The by-Cloud form and its two extra preconditions are documented in ../api/credential-assignments.md.
| Field | Type | Required | Notes |
|---|---|---|---|
cloud_credential_id | string (uuid) | one of | Identifier of the Cloud Credential to bind to the Project. Must be a non-zero UUID; a malformed value is rejected with 400 invalid_cloud_credential_id. |
cloud_id | string (uuid) | one of | Identifier of a Cloud whose newest eligible credential the server auto-selects and binds. Must be a non-zero UUID; a malformed value is rejected with 400 invalid_cloud_id. |
CredentialAssignmentDecisionRequest
Body for POST /v1/credential-assignments/{id}/revoke. additionalProperties is false.
| Field | Type | Required | Notes |
|---|---|---|---|
reason | string | yes | Decision rationale recorded on the lifecycle outbox event as an operator-supplied audit string. minLength: 1, maxLength: 1024. An empty or whitespace-only value is rejected with 400 invalid_decision_reason. |
Response schemas
CredentialAssignmentResponse
The metadata projection of a Credential Assignment. The same shape is returned by RequestCredentialAssignment and RevokeCredentialAssignment, and as each element of a ListCredentialAssignments page, so a client needs only one binding. A queue decision returns the row in the Approval shape instead. Every field is required.
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Credential Assignment identifier (UUIDv7). |
project_id | string (uuid) | Owning Project — the residency pivot the ReBAC gate authorises against. |
cloud_credential_id | string (uuid) | The Cloud Credential bound to the Project by this assignment. |
state | CredentialAssignmentState | Lifecycle state — see the enum below. |
materialised | boolean | Whether the binding is currently live. true only while state is approved; false for requested, rejected, and revoked. |
created_at | string (date-time) | Aggregate creation timestamp (UTC). |
updated_at | string (date-time) | Last-modified timestamp (UTC). Bumped by every lifecycle transition. |
CredentialAssignmentList
The page returned by GET /v1/projects/{id}/credential-assignments.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<CredentialAssignmentResponse> | yes | The Project's Credential Assignments in the current page, in creation order. |
next_cursor | string | null | no | Continuation token for the next page. null or omitted at end-of-stream. HMAC-signed by the server. |
CredentialAssignmentState
Closed enum. The state is a stored column advanced only by the application service's transition rules.
| Value | Meaning |
|---|---|
requested | Opening state — the assignment has been asked for and awaits a decision. |
approved | A reviewer accepted the request; the cloudcredential#uses binding is materialised. |
rejected | A reviewer declined the request. Terminal. |
revoked | A previously approved assignment was withdrawn; the binding is torn down. Terminal. |
The legal transitions are requested → approved, requested → rejected, and approved → revoked. Any other transition is rejected with 409 illegal_transition. The application service enforces the rules whichever surface calls it, so a decision taken on the approvals queue obeys the same table. A requested assignment presents in that queue under the wire state pending-approval; the column stored here stays requested.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json, RFC 9457). The 403 authorisation path uses the richer PermissionDenied shape carrying the ReBAC denial reason, relation_path, and request correlation_id.
The Credential Assignment surface adds the closed taxonomy below. Each code maps to exactly one repo / service / transport sentinel and one HTTP status.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_project_id | 400 | Request / List | Path {id} on /v1/projects/{id}/credential-assignments was not a non-zero UUID. |
invalid_credential_assignment_id | 400 | Revoke | Path {id} on /v1/credential-assignments/{id}/revoke was not a non-zero UUID. |
invalid_cloud_credential_id | 400 | Request | Body cloud_credential_id was not a non-zero UUID. |
invalid_cloud_id | 400 | Request | Body cloud_id was not a non-zero UUID. |
ambiguous_credential_target | 400 | Request | Both cloud_credential_id and cloud_id were supplied; exactly one is required. |
invalid_decision_reason | 400 | Revoke | Body reason was empty or whitespace-only. |
invalid_body | 400 | Request / Revoke | Body could not be read, did not parse as the typed request shape, or named neither credential target. |
invalid_cursor | 400 | List | Cursor HMAC verification failed or the version byte was unknown. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
cursor_binding_mismatch | 403 | List | The pagination cursor was minted by a different caller; the per-(caller, pepper) HMAC binding rejected the replay. |
credential_assignment_not_found | 404 | Revoke | No Credential Assignment with the given {id}. |
duplicate_live_assignment | 409 | Request | A live Credential Assignment already exists for the same (Project, Cloud Credential) pair. |
illegal_transition | 409 | Revoke | The assignment is not in the approved state revocation is legal from. |
credential_not_assignable | 422 | Request | The named Cloud Credential is not in an assignable lifecycle state. |
cloud_not_usable_in_project | 422 | Request / Grant | The Project holds no approved Cloud Assignment for the Cloud involved: the Cloud the cloud_id form named, or the Cloud the requested or granted credential belongs to. |
no_eligible_credential_for_cloud | 422 | Request | The cloud_id form named a usable Cloud that has no eligible credential. |
request_body_too_large | 413 | Request / Revoke | Body exceeded the 8 KiB ceiling. |
authz_unavailable | 503 | every operation | The authorization backend is temporarily unavailable. |
credential_assignments_not_provisioned | 501 | every operation | The composition root has not wired the Credential Assignment dependency bundle yet. |
internal | 500 | every operation | Server-side failure path. |
A 403 authorisation refusal (the caller lacks the ReBAC gate for the operation) carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ../api/authz.md. cursor_binding_mismatch is also 403 but uses the plain Problem envelope. The self_approval_denied refusal belongs to the queue rather than this surface — see ../api/approvals.md.
Cross-references
../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; the*CredentialAssignment*operations and theCredentialAssignmentRequest/CredentialAssignmentDecisionRequest/CredentialAssignmentResponse/CredentialAssignmentList/CredentialAssignmentStateschemas.../../../internal/transport/http/v1/credentialassignments/— the transport-tier implementation: the three handlers, the closedProblem.codetaxonomy, and the body-cap and limit-clamp constants.../api/approvals.md— the queue that carries the approve and reject decisions for this family, its per-kind gates, and itsApprovalrow shape.../../../internal/provisioning/credentialassignment/— the Credential Assignment bounded sub-context: the aggregate, the lifecycle transitions, the five domain events, and the application service that owns the credential-assignability and self-approval guards.../../../schema/authz.zed— ReBAC schema; thecloudcredentialdefinition declares theassignpermission and theusesrelation a materialised assignment writes.../../contexts/provisioning/rebac.md— the explanation page: thecloudcredential#usesrelation, the request/approve/reject/revoke state machine, the dual-write tuple sync, and the event-to-tuple mapping.../../how-to/provisioning/assign-a-cloud-credential.md— the operator how-to walking the request → approve → use → revoke flow withplexctl../cloud-credential-pool.md— the Cloud Credentials Custodian reference; the lifecycle of thecloudcredentialaggregate this surface assigns.../api/authz.md— thePermissionDeniedshape returned on a 403 authorisation refusal.