Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/projects/{id}/credential-assignmentsRequestCredentialAssignmentProject deploycredential_assignment.requestCredentialAssignmentRequested8 KiB
GET/v1/projects/{id}/credential-assignmentsListCredentialAssignmentsProject readcredential_assignment.list(none)n/a
POST/v1/credential-assignments/{id}/revokeRevokeCredentialAssignmentCloud Credential assign, else Project deploycredential_assignment.revokeCredentialAssignmentRevoked8 KiB
  • body_cap = 8 KiB (MaxCredentialAssignmentRequestBodyBytes in internal/transport/http/v1/credentialassignments/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as 413 request_body_too_large. ListCredentialAssignments carries no request body, so the cap does not apply.
  • RequestCredentialAssignment is gated by a single ReBAC check on the consuming Project's deploy permission (admin + maintainer + parent->manage on the project definition), the same composite the Cloud Assignment request gates on. A Project maintainer may therefore open a request.
  • RevokeCredentialAssignment resolves the assignment row to reach both the bound Cloud Credential and the consuming Project, then runs a two-party check. The Cloud Credential's assign permission (owner + assigner on the cloudcredential definition) is tried first; on its denial the consuming Project's deploy permission 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 — assign does not derive from parent.
  • The two revoke legs are unaudited individually. A single denial row is written when both fail, naming the Cloud Credential as the object and deploy as the missing relation; on success the application service records which of the two relations granted, so the audit trail names the party that revoked. The cloudcredential#uses tuple is narrow-deleted the same way whichever party acted.
  • ListCredentialAssignments runs a single top-level read check 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_cursor is set whenever the persistence layer returned a full page.
  • ListCredentialAssignments.cursor is opaque and HMAC-signed, bound to the per-(caller, pepper) pseudonym. A tampered or unknown-version envelope surfaces as 400 invalid_cursor; a cursor minted by a different caller surfaces as 403 cursor_binding_mismatch.
  • ListCredentialAssignments.limit is clamped at the handler to [1, 200] with default 50.
  • The request and the list are authz-checked before the persistence write or read, so an unauthorised caller receives 403 without 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 narrow 404-versus-403 side-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

OperationParameterTypeRequiredNotes
RequestCredentialAssignment / ListCredentialAssignmentsid (path)string (uuid)yesProject identifier. UUIDv7, non-zero. Malformed → 400 invalid_project_id. The ProjectID parameter component.
RevokeCredentialAssignmentid (path)string (uuid)yesCredential Assignment identifier. UUIDv7, non-zero. Malformed → 400 invalid_credential_assignment_id. The CredentialAssignmentID parameter component.
ListCredentialAssignmentscursor (query)stringnoOpaque HMAC-signed continuation token from a prior next_cursor. Tampered → 400 invalid_cursor; replayed by another caller → 403 cursor_binding_mismatch.
ListCredentialAssignmentslimit (query)integerno[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.

FieldTypeRequiredNotes
cloud_credential_idstring (uuid)one ofIdentifier 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_idstring (uuid)one ofIdentifier 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.

FieldTypeRequiredNotes
reasonstringyesDecision 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.

FieldTypeNotes
idstring (uuid)Credential Assignment identifier (UUIDv7).
project_idstring (uuid)Owning Project — the residency pivot the ReBAC gate authorises against.
cloud_credential_idstring (uuid)The Cloud Credential bound to the Project by this assignment.
stateCredentialAssignmentStateLifecycle state — see the enum below.
materialisedbooleanWhether the binding is currently live. true only while state is approved; false for requested, rejected, and revoked.
created_atstring (date-time)Aggregate creation timestamp (UTC).
updated_atstring (date-time)Last-modified timestamp (UTC). Bumped by every lifecycle transition.

CredentialAssignmentList

The page returned by GET /v1/projects/{id}/credential-assignments.

FieldTypeRequiredNotes
itemsarray<CredentialAssignmentResponse>yesThe Project's Credential Assignments in the current page, in creation order.
next_cursorstring | nullnoContinuation 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.

ValueMeaning
requestedOpening state — the assignment has been asked for and awaits a decision.
approvedA reviewer accepted the request; the cloudcredential#uses binding is materialised.
rejectedA reviewer declined the request. Terminal.
revokedA 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.

CodeStatusWhereMeaning
invalid_project_id400Request / ListPath {id} on /v1/projects/{id}/credential-assignments was not a non-zero UUID.
invalid_credential_assignment_id400RevokePath {id} on /v1/credential-assignments/{id}/revoke was not a non-zero UUID.
invalid_cloud_credential_id400RequestBody cloud_credential_id was not a non-zero UUID.
invalid_cloud_id400RequestBody cloud_id was not a non-zero UUID.
ambiguous_credential_target400RequestBoth cloud_credential_id and cloud_id were supplied; exactly one is required.
invalid_decision_reason400RevokeBody reason was empty or whitespace-only.
invalid_body400Request / RevokeBody could not be read, did not parse as the typed request shape, or named neither credential target.
invalid_cursor400ListCursor HMAC verification failed or the version byte was unknown.
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403ListThe pagination cursor was minted by a different caller; the per-(caller, pepper) HMAC binding rejected the replay.
credential_assignment_not_found404RevokeNo Credential Assignment with the given {id}.
duplicate_live_assignment409RequestA live Credential Assignment already exists for the same (Project, Cloud Credential) pair.
illegal_transition409RevokeThe assignment is not in the approved state revocation is legal from.
credential_not_assignable422RequestThe named Cloud Credential is not in an assignable lifecycle state.
cloud_not_usable_in_project422Request / GrantThe 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_cloud422RequestThe cloud_id form named a usable Cloud that has no eligible credential.
request_body_too_large413Request / RevokeBody exceeded the 8 KiB ceiling.
authz_unavailable503every operationThe authorization backend is temporarily unavailable.
credential_assignments_not_provisioned501every operationThe composition root has not wired the Credential Assignment dependency bundle yet.
internal500every operationServer-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