Appearance
Credential Assignments HTTP API
This is the reference for the Credential Assignments HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, 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.
A Credential Assignment binds a Cloud Credential to a Project through an approver-gated lifecycle. A requester opens an assignment in the requested state; an approver who is not the requester moves it to approved (which materialises the binding) or rejected; an operator later moves an approved assignment to revoked (which tears the binding down). The surface adds five operations: a request, a cursor-paginated list, and the three decision verbs. The carrying OpenAPI tag is cloud — Credential Assignments sit alongside the Cloud Inventory and Cloud Credentials surfaces.
Every gate authorises against the owning Project: manage is the mutation grant and observe is the lowest-privilege read-equivalent. The ReBAC chain the gates resolve against is the project definition in schema/authz.zed. For the cursor-paginated list idiom this surface inherits, see cloud-credentials.md — Cloud Credentials and Credential Assignments share the same HMAC-signed caller-bound cursor mechanism.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/projects/{id}/credential-assignments | RequestCredentialAssignment | project#manage on the parent Project (BEFORE the persistence write) | credential_assignment.request (on the denial path) | CredentialAssignmentRequested (emitted by the application service) | 8 KiB |
| GET | /v1/projects/{id}/credential-assignments | ListCredentialAssignments | top-level project#observe on the parent Project (BEFORE the persistence read) + per-row project#observe filter | credential_assignment.list (granted, with post-filter cohort size) | (none) | n/a |
| POST | /v1/credential-assignments/{id}/approve | ApproveCredentialAssignment | project#manage on the owning Project (AFTER an unavoidable pre-authz row read) | credential_assignment.approve (on the denial path) | CredentialAssignmentApproved (emitted by the application service) | n/a |
| POST | /v1/credential-assignments/{id}/reject | RejectCredentialAssignment | project#manage on the owning Project (AFTER an unavoidable pre-authz row read) | credential_assignment.reject (on the denial path) | CredentialAssignmentRejected (emitted by the application service) | 8 KiB |
| POST | /v1/credential-assignments/{id}/revoke | RevokeCredentialAssignment | project#manage on the owning Project (AFTER an unavoidable pre-authz row read) | credential_assignment.revoke (on the denial path) | CredentialAssignmentRevoked (emitted by the application service) | 8 KiB |
body_cap = 8 KiB(MaxCredentialAssignmentRequestBodyBytesininternal/transport/http/v1/credentialassignments/wiring.go) is enforced before the JSON decoder runs on the request and decision bodies; an over-cap body surfaces as413 request_body_too_large.ListCredentialAssignments.limitis clamped at the handler to[1, 200]with default50.ListCredentialAssignments.cursoris opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as400 invalid_cursorand a cursor minted by one caller and replayed by another surfaces as403 cursor_binding_mismatch. The page renders in creation order.RequestCredentialAssignmentruns theproject#managegate before the persistence write, then delegates to the Credential Assignment application service, which records therequestedrow and appends theCredentialAssignmentRequestedoutbox event in a single transaction.ListCredentialAssignmentsruns a top-levelproject#observecheck on the parent Project before the persistence read so an unauthorised caller never observes the existence side-channel of the Project's assignment set, then layers a per-rowproject#observefilter so theitemsarray is the subset the caller is authorised to see.ApproveCredentialAssignment,RejectCredentialAssignment, andRevokeCredentialAssignmentmust read the persistence row before the ReBAC check because the assignment id on the path does not encode its owning Project — the gate target is unknowable until the row is read.- The application service does not emit transport-level audit rows for the successful mutations; success is observable through the lifecycle outbox events. The handlers stamp the
credential_assignment.*audit relations on their permission-denied paths so a probe is recorded.
Lifecycle and the self-approval rule
The assignment state is a stored column (CredentialAssignmentState) advanced only by the application service's transition rules:
| From | Verb | To | Effect |
|---|---|---|---|
| (new) | request | requested | Opens the assignment; binding not yet live. |
requested | approve | approved | Materialises the binding (materialised: true). |
requested | reject | rejected | Closes an unapproved request. |
approved | revoke | revoked | Tears the materialised binding down. |
- A verb attempted from any source state other than the one above returns
409 illegal_transition. Approval and rejection are legal only fromrequested; revocation is legal only fromapproved. - The
materialisedflag istrueonly while the assignment is in theapprovedstate;falseforrequested,rejected, andrevoked. - Self-approval is forbidden. The caller who requested an assignment may not approve their own request —
self_approval_deniedis returned with403so the request/approval split stays a true separation-of-duties control. - Opening a second request for the same
(Project, Cloud Credential)pair while an earlier one is still live is rejected with409 duplicate_live_assignment. A Cloud Credential that is not in an assignable lifecycle state is rejected with422 credential_not_assignable.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| RequestCredentialAssignment / ListCredentialAssignments | id (path) | string (uuid) | yes | Owning Project UUIDv7. The shared ProjectID parameter component. Malformed → 400 invalid_project_id. |
| ApproveCredentialAssignment / RejectCredentialAssignment / RevokeCredentialAssignment | id (path) | string (uuid) | yes | Credential Assignment UUIDv7. The shared CredentialAssignmentID parameter component. Malformed → 400 invalid_credential_assignment_id. |
| ListCredentialAssignments | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListCredentialAssignments | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Request:
CredentialAssignmentRequest(a singlecloud_credential_idUUID). - Decision:
CredentialAssignmentDecisionRequest(a single non-emptyreasonstring,1..1024characters; recorded on the reject / revoke outbox event as an audit string). - Response:
CredentialAssignmentResponse(single),CredentialAssignmentList(paged, with optional nullablenext_cursor). - Embedded:
CredentialAssignmentState(closed enum:requested,approved,rejected,revoked).
The CredentialAssignmentResponse carries the resolved id, project_id, cloud_credential_id, state, the derived materialised flag, and the created_at / updated_at lifecycle timestamps. The shape is shared by all five operations so clients only need one binding.
RequestCredentialAssignment returns 201 with a Location header naming the canonical URL of the freshly opened assignment (/v1/credential-assignments/{id}); the three decision verbs return 200 with the updated projection.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason and request correlation_id; the self_approval_denied and cursor_binding_mismatch 403s are plain Problem bodies.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_project_id | 400 | Request / List | Project {id} was not a non-zero UUID. |
invalid_credential_assignment_id | 400 | Approve / Reject / Revoke | Assignment {id} was not a non-zero UUID. |
invalid_cloud_credential_id | 400 | Request | cloud_credential_id was not a non-zero UUID. |
invalid_body | 400 | Request / Reject / Revoke | Body could not be read or did not parse as the expected schema. |
invalid_decision_reason | 400 | Reject / Revoke | reason was empty or whitespace-only. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
self_approval_denied | 403 | Approve | The caller is the requester of this assignment and may not approve their own request. |
cursor_binding_mismatch | 403 | List | Cursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). |
credential_assignment_not_found | 404 | Approve / Reject / Revoke | No Credential Assignment with the given {id}. |
duplicate_live_assignment | 409 | Request | A live assignment already exists for the same (Project, Cloud Credential) pair. |
illegal_transition | 409 | Approve / Reject / Revoke | The assignment is not in a state from which the verb is legal. |
credential_not_assignable | 422 | Request | The named Cloud Credential is not in an assignable lifecycle state. |
request_body_too_large | 413 | Request / Reject / Revoke | Body exceeded the 8 KiB Credential Assignment ceiling. |
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 permission-denied response carries the extended PermissionDenied fields documented in authz.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 five handlers, the closedProblem.codetaxonomy, the metadata projection, the body-cap and limit-clamp constants, and the per-row visibility filter on the list path.../../../schema/authz.zed— ReBAC schema; theprojectdefinition declares themanageandobservepermissions this surface gates on.cloud-credentials.md— the Cloud Credentials read + revoke surface that supplies the Cloud Credential an assignment binds, with the same HMAC-signed caller-bound cursor pagination idiom.clouds.md— the Cloud Inventory CRUD surface that anchors the Cloud a credential belongs to.authz.md— thePermissionDeniedshape returned on 403.