Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/projects/{id}/credential-assignmentsRequestCredentialAssignmentproject#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-assignmentsListCredentialAssignmentsproject#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}/revokeRevokeCredentialAssignmentcloudcredential#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 (MaxCredentialAssignmentRequestBodyBytes in internal/transport/http/v1/credentialassignments/wiring.go) is enforced before the JSON decoder runs on the request and decision bodies; an over-cap body surfaces as 413 request_body_too_large.
  • ListCredentialAssignments.limit is clamped at the handler to [1, 200] with default 50.
  • ListCredentialAssignments.cursor is opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as 400 invalid_cursor and a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. The page renders in creation order.
  • RequestCredentialAssignment runs the project#deploy gate before the persistence write, then delegates to the Credential Assignment application service, which records the requested row and appends the CredentialAssignmentRequested outbox event in a single transaction.
  • ListCredentialAssignments runs a single project#read check 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.
  • RevokeCredentialAssignment must 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: assign on the Cloud Credential is tried first, deploy on the Project only if it is denied. A single denial row is written when both fail, recording the Cloud Credential as the object and deploy as 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:

FromVerbToEffect
(new)requestrequestedOpens the assignment; binding not yet live.
requestedapproveapprovedMaterialises the binding (materialised: true). Reached through the approvals queue.
requestedrejectrejectedCloses an unapproved request. Reached through the approvals queue.
approvedrevokerevokedTears 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 from requested; revocation is legal only from approved.
  • The service enforces the transition rules whichever surface calls it, so a queue decision on a terminal row is refused with the same 409 a revoke on one would be.
  • A requested assignment presents in the queue under the wire state pending-approval; the two spellings name the same condition and the column stored here stays requested.
  • The materialised flag is true only while the assignment is in the approved state; false for requested, rejected, and revoked.
  • 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_by principal and the queue returns 403 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 with 409 duplicate_live_assignment. A Cloud Credential that is not in an assignable lifecycle state is rejected with 422 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

OperationParameterTypeRequiredNotes
RequestCredentialAssignment / ListCredentialAssignmentsid (path)string (uuid)yesOwning Project UUIDv7. The shared ProjectID parameter component. Malformed → 400 invalid_project_id.
RevokeCredentialAssignmentid (path)string (uuid)yesCredential Assignment UUIDv7. The shared CredentialAssignmentID parameter component. Malformed → 400 invalid_credential_assignment_id.
ListCredentialAssignmentscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListCredentialAssignmentslimit (query)integernoClamped 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 of cloud_credential_id or cloud_id, both UUIDs; mutually exclusive).
  • Decision: CredentialAssignmentDecisionRequest (a single non-empty reason string, 1..1024 characters; recorded on the revoke outbox event as an audit string).
  • Response: CredentialAssignmentResponse (single), CredentialAssignmentList (paged, with optional nullable next_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.

CodeStatusWhereMeaning
invalid_project_id400Request / ListProject {id} was not a non-zero UUID.
invalid_credential_assignment_id400RevokeAssignment {id} was not a non-zero UUID.
invalid_cloud_credential_id400Requestcloud_credential_id was not a non-zero UUID.
invalid_cloud_id400Requestcloud_id was not a non-zero UUID.
ambiguous_credential_target400RequestBoth cloud_credential_id and cloud_id were supplied; exactly one is required.
invalid_body400Request / RevokeBody could not be read, did not parse, or supplied neither cloud_credential_id nor cloud_id.
invalid_decision_reason400Revokereason was empty or whitespace-only.
invalid_cursor400ListHMAC verification or structural decode failed.
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403ListCursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay).
credential_assignment_not_found404RevokeNo Credential Assignment with the given {id}.
duplicate_live_assignment409RequestA live 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 Credential Assignment ceiling.
credential_assignments_not_provisioned501every operationThe composition root has not wired the Credential Assignment dependency bundle yet.
internal500every operationServer-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 the CredentialAssignmentRequest / CredentialAssignmentDecisionRequest / CredentialAssignmentResponse / CredentialAssignmentList / CredentialAssignmentState schemas.
  • ../../../internal/transport/http/v1/credentialassignments/ — the transport-tier implementation: the three handlers, the closed Problem.code taxonomy, 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 its Approval row shape.
  • ../../../schema/authz.zed — ReBAC schema; the project definition declares the read and deploy permissions and the cloudcredential definition the assign permission 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 — the PermissionDenied shape returned on 403.