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). This surface adds three operations: a request, a cursor-paginated list, and a revoke. The carrying OpenAPI tag is cloud — Credential Assignments sit alongside the Cloud Inventory and Cloud Credentials surfaces.
The approve and reject decisions are not taken here. 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 this surface calls. See approvals.md for the decide contract.
Two objects carry the gates. The consuming Project authorises opening and listing requests — deploy to request, read to list. The spent Cloud Credential authorises handing the credential out — assign, the permission that also decides the row in the queue. Revocation accepts either: assign on the Cloud Credential, or, when that is denied, deploy on the consuming Project, so the party that holds the credential and the party that consumes it can each hand the binding back. The ReBAC chains resolve against the project and cloudcredential definitions 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#deploy 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 | project#read on the parent Project (BEFORE the persistence read) | credential_assignment.list (granted, with the returned cohort size) | (none) | n/a |
| POST | /v1/credential-assignments/{id}/revoke | RevokeCredentialAssignment | cloudcredential#assign on the spent Cloud Credential, else project#deploy on the consuming 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#deploygate 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 singleproject#readcheck on the parent Project before the persistence read, so an unauthorised caller never observes the existence side-channel of the Project's assignment set. There is no per-row re-check on top: the path scopes every row to that one Project, so the top-level grant already decides the whole page.RevokeCredentialAssignmentmust read the persistence row before the ReBAC check because the assignment id on the path encodes neither the spent Cloud Credential nor the consuming Project — both gate targets are unknowable until the row is read.- The two revoke checks run in order and neither is audited on its own:
assignon the Cloud Credential is tried first,deployon the Project only if it is denied. A single denial row is written when both fail, recording the Cloud Credential as the object anddeployas the missing relation. On success the application service records which relation granted, so the audit trail names the party that revoked. - 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). Reached through the approvals queue. |
requested | reject | rejected | Closes an unapproved request. Reached through the approvals queue. |
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 service enforces the transition rules whichever surface calls it, so a queue decision on a terminal row is refused with the same
409a revoke on one would be. - A
requestedassignment presents in the queue under the wire statepending-approval; the two spellings name the same condition and the column stored here staysrequested. - 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. The service compares the caller against the persisted
requested_byprincipal and the queue returns403 self_approval_denied, so 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.
Requesting by Cloud ID
The request body names its target either directly by cloud_credential_id or indirectly by cloud_id — exactly one is required. With cloud_id, the system auto-selects the most recently issued eligible credential serving that Cloud (active, not revoked, not expired) and runs the standard approval flow on it. Supplying both fields is rejected with 400 ambiguous_credential_target; supplying neither is rejected with 400 invalid_body.
The cloud_id form has one extra precondition on top of the Cloud Assignment requirement both forms share (see below): the Cloud must have at least one eligible credential, otherwise the request is refused with 422 no_eligible_credential_for_cloud.
The Cloud Assignment precondition
Binding a credential to a Project requires the Project to be able to use the Cloud that credential belongs to — an approved Cloud Assignment materialising the cloud#uses binding. Without it the request or grant is refused with 422 cloud_not_usable_in_project.
This holds on every write direction: the cloud_credential_id request form, the cloud_id request form, and the owner-side grant. The reason is the authorization rule the deploy path reads: cloudcredential#use is (uses & parent->use) + owner + assigner, so a Project needs both its Credential Assignment and the Cloud Assignment underneath it. An assignment opened without the Cloud Assignment would bind, be approved, and still be refused when the Project tried to provision with it.
The cloud_id form is checked twice: once against the Cloud the caller named, before auto-select runs, and once against the Cloud the selected credential actually belongs to. Auto-select resolves through the credential-to-Cloud usage join, so those two Clouds are not necessarily the same one, and it is the credential's own Cloud that the deploy-time check reads.
The usability check is evaluated at the latest committed authorization revision, so a Cloud just granted to the Project is immediately usable — a credential request that follows a grant or approval is not refused by a stale read.
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. |
| 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 | Clamped into [1, 200], default 50. An out-of-range value is clamped rather than refused. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Request:
CredentialAssignmentRequest(exactly one ofcloud_credential_idorcloud_id, both UUIDs; mutually exclusive). - Decision:
CredentialAssignmentDecisionRequest(a single non-emptyreasonstring,1..1024characters; recorded on the 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 three operations so clients only need one binding.
RequestCredentialAssignment returns 201 with the freshly opened assignment as its body and no Location header: the aggregate has no by-id read surface, so the header would name a URL that answers 404. RevokeCredentialAssignment returns 200 with the updated projection. A queue decision returns the row in the Approval shape instead; read it back here to see the projection above.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The permission-denied 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason and request correlation_id; the cursor_binding_mismatch 403 is a plain Problem body.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_project_id | 400 | Request / List | Project {id} was not a non-zero UUID. |
invalid_credential_assignment_id | 400 | 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_cloud_id | 400 | Request | 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_body | 400 | Request / Revoke | Body could not be read, did not parse, or supplied neither cloud_credential_id nor cloud_id. |
invalid_decision_reason | 400 | Revoke | reason was empty or whitespace-only. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
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 | 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 | 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 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 three handlers, the closedProblem.codetaxonomy, the metadata projection, and the body-cap and limit-clamp constants.approvals.md— the queue that carries the approve and reject decisions for this family, its per-kind gates, and itsApprovalrow shape.../../../schema/authz.zed— ReBAC schema; theprojectdefinition declares thereadanddeploypermissions and thecloudcredentialdefinition theassignpermission 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.