Skip to content

Cloud Credential Assignments HTTP API

This is the reference for the Credential Assignment request + decision 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.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/projects/{id}/credential-assignmentsRequestCredentialAssignmentProject admin OR maintainercredential_assignment.requestCredentialAssignmentRequested8 KiB
GET/v1/projects/{id}/credential-assignmentsListCredentialAssignmentsProject read (top-level + per-row filter)credential_assignment.list(none)n/a
POST/v1/credential-assignments/{id}/approveApproveCredentialAssignmentCloud Credential assign (+ self-approval denial)credential_assignment.approveCredentialAssignmentMaterialisedn/a
POST/v1/credential-assignments/{id}/rejectRejectCredentialAssignmentCloud Credential assigncredential_assignment.rejectCredentialAssignmentRejected8 KiB
POST/v1/credential-assignments/{id}/revokeRevokeCredentialAssignmentCloud Credential assigncredential_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. ApproveCredentialAssignment and ListCredentialAssignments carry no request body, so the cap does not apply.
  • RequestCredentialAssignment is gated by a dual ReBAC check on the parent Project: admin is checked first, maintainer only if admin is denied, so a granted admin costs one round-trip. Either relation is sufficient — a Project maintainer may open a request.
  • ApproveCredentialAssignment, RejectCredentialAssignment, and RevokeCredentialAssignment resolve the parent assignment row to reach the bound Cloud Credential, then gate on that credential's assign permission (owner + assigner on the cloudcredential definition). Owning the parent Cloud is not sufficient — assign does not derive from parent.
  • ApproveCredentialAssignment carries one extra gate: the approving principal must not be the principal that requested the assignment. A self-approval is rejected with 403 self_approval_denied so every assignment is decided by a second party.
  • ListCredentialAssignments runs a single top-level read check on the owning Project, then layers a per-row read visibility filter on the creation-ordered persistence window — the response page is the subset the caller is authorised to see. next_cursor is set whenever the persistence layer returned a full page regardless of how many rows the per-row filter dropped.
  • 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.
  • Each transition is 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 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.
ApproveCredentialAssignment / RejectCredentialAssignment / 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.

FieldTypeRequiredNotes
cloud_credential_idstring (uuid)yesIdentifier 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.

CredentialAssignmentDecisionRequest

Body for POST /v1/credential-assignments/{id}/reject and POST /v1/credential-assignments/{id}/revoke. additionalProperties is false. ApproveCredentialAssignment takes no body.

FieldTypeRequiredNotes
reasonstringyesDecision rationale recorded on the lifecycle outbox event as an approver- or 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 all four mutating operations and as each element of a ListCredentialAssignments page, so a client needs only one binding. 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>yesCredential Assignments in the current page, in creation order, filtered to the subset the caller may read.
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.

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_id400Approve / Reject / RevokePath {id} on /v1/credential-assignments/{id}/… was not a non-zero UUID.
invalid_cloud_credential_id400RequestBody cloud_credential_id was not a non-zero UUID.
invalid_decision_reason400Reject / RevokeBody reason was empty or whitespace-only.
invalid_body400Request / Reject / RevokeBody could not be read or did not parse as the typed request shape.
invalid_cursor400ListCursor HMAC verification failed or the version byte was unknown.
invalid_limit400Listlimit query parameter was non-numeric (out-of-range values are clamped, not rejected).
unauthenticated401every operationRequest carries no authenticated principal.
self_approval_denied403ApproveThe caller is the principal that requested this assignment and may not approve their own request.
cursor_binding_mismatch403ListThe pagination cursor was minted by a different caller; the per-(caller, pepper) HMAC binding rejected the replay.
credential_assignment_not_found404Approve / Reject / RevokeNo Credential Assignment with the given {id}.
duplicate_live_assignment409RequestA live Credential Assignment already exists for the same (Project, Cloud Credential) pair.
illegal_transition409Approve / Reject / RevokeThe requested lifecycle transition is not legal from the assignment's current state.
credential_not_assignable422RequestThe named Cloud Credential is not in an assignable lifecycle state.
request_body_too_large413Request / Reject / RevokeBody exceeded the 8 KiB ceiling.
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. self_approval_denied and cursor_binding_mismatch are also 403 but use the plain Problem envelope.

Cross-references