Appearance
Approvals HTTP API
This is the reference for the Approvals HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, 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. For the domain model — the aggregate, the closed state machine, the policy value object, and the break-glass semantics — see the bounded-context reference ../../contexts/approvals.md.
An Approval is a generic dual-control record: a proposer opens it in the proposed state, the owning Domain's Approval Policy decides whether the action is gated, and a matching rule routes it to pending-approval to await a decision. An approver who is not the proposer moves it to approved or rejected; an emergency approver may force it to approved through the break-glass override; and the background sweeper moves an undecided proposal past its deadline to expired. The surface adds five operations: a cursor-paginated list, a single read, and the three decision verbs. The carrying OpenAPI tag is approvals.
The queue carries four source families, named by the kind field on every row: approval, credential_assignment, cloud_assignment, and sink_enablement. The kind selects the gate. An approval row authorises against the owning Domain — approve and reject resolve the domain#approve / domain#reject permissions (each owner + approver in schema/authz.zed), and break-glass resolves the stricter domain#emergency_approver relation. Every other row authorises against the object it spends — assign on the cloudcredential:<uuid>, the cloud:<uuid> or the sink:<uuid>, the permission that carries the authority to hand that resource out.
A sink_enablement row is the request to let one Project deliver telemetry to a Domain's sink. Approving it writes the sink#uses edge that makes the sink deliverable for that Project; rejecting it grants nothing. The row carries project_id (the consuming Project) and no materialised field: an enablement tracks no materialisation column, so the graph edge the approval writes is the whole binding.
GetApproval and the three decision verbs authorise the caller before they act, on an object the row read supplies. ListApprovals runs no entry gate at all: any authenticated principal may call it, and a per-row check filters the page down to the rows the caller may see.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Body cap |
|---|---|---|---|---|---|
| GET | /v1/approvals | ListApprovals | no entry gate; per-row domain#read (approval) or assign on the spent object (assignment) | approval.list (granted) | n/a |
| GET | /v1/approvals/{id} | GetApproval | domain#read or assign on the spent object, by kind (AFTER an unavoidable pre-authz row read) | approval.read (granted) | n/a |
| POST | /v1/approvals/{id}/approve | ApproveApproval | domain#approve or assign on the spent object, by kind (AFTER the row read, AFTER the self-approval guard) | approval.approve | 8 KiB |
| POST | /v1/approvals/{id}/reject | RejectApproval | domain#reject or assign on the spent object, by kind (AFTER the row read) | approval.reject | 8 KiB |
| POST | /v1/approvals/{id}/break-glass | BreakGlassApproval | domain#emergency_approver on the owning Domain (AFTER the row read); an assignment row is refused with 409 | approval.break_glass | 8 KiB |
body_cap = 8 KiB(MaxApprovalRequestBodyBytesininternal/transport/http/v1/approvals/wiring.go) is enforced before the JSON decoder runs on the decision bodies; an over-cap body surfaces as413 request_body_too_large. The two bodyless verbs (ApproveApproval, and the emptyGetApproval/ListApprovalsreads) carry no request body.ListApprovals.limitis clamped at the handler to[1, 200]with default50.status,kind,domain_id, andcloud_idare optional filters;statusaccepts oneApprovalStatevalue andkindoneApprovalKindvalue, each rejected with a400when the value falls outside its vocabulary.ListApprovalsanswers200with an emptyitemsarray when the per-row filter drops everything, including for a caller who holds no grant at all. There is no403on the list path other thancursor_binding_mismatch.ListApprovals.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.ApproveApproval,RejectApproval, andBreakGlassApprovalmust read the persistence row before the ReBAC check because the id on the path encodes neither the row's kind nor its gate object — the gate target is unknowable until the row is read.- Self-approval is forbidden, and the guard runs first. On
ApproveApprovalthe proposer-equals-caller check is evaluated before the ReBAC check and before any write, so a proposer who also holdsdomain#approveis still refused with403 self_approval_deniedand nothing is persisted. - The application service records the lifecycle audit row and appends the matching
approval_*outbox event in a single transaction; the break-glass reason value is routed to a PII-safe channel by its field name only and never crosses onto the audit hash chain or the outbox payload. Seeinternal/identity/approvals/services/.
Lifecycle and the self-approval rule
The approval state is a stored column advanced only by the application service's transition rules:
| From | Verb | To | Effect |
|---|---|---|---|
| (new) | propose | proposed → pending-approval or approved | Opens the approval; an empty or non-matching policy auto-approves in place, a matching rule holds it at pending-approval. |
pending-approval | approve | approved | Records the decision and the deciding principal. |
pending-approval | reject | rejected | Closes the proposal with the operator reason. |
pending-approval | break-glass | approved | Emergency override; forces the decision past the normal approver gate. |
pending-approval | expire | expired | The background sweeper flips an undecided proposal past its deadline. |
- A verb attempted from any source state other than the one above returns
409 illegal_transition. The five lifecycle states (proposed,pending-approval,approved,rejected,expired) are a closed roster for anapprovalrow. The sixthApprovalStatemember,revoked, is reached only by an assignment row, whose lifecycle the Credential Assignment and Cloud Assignment contexts own. - Self-approval is forbidden. The proposer may not approve their own proposal —
self_approval_deniedis returned with403so the proposal/approval split stays a true separation-of-duties control. - Break-glass requires the
domain#emergency_approverrelation and areasonof at least sixteen characters; a shorter value is rejected with400 invalid_break_glass_reasonbefore any state change.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetApproval / ApproveApproval / RejectApproval / BreakGlassApproval | id (path) | string (uuid) | yes | Approval UUID. Malformed → 400 invalid_approval_id. |
| ListApprovals | status (query) | string | no | One ApprovalState value (proposed, pending-approval, approved, rejected, expired, revoked). Outside the vocabulary → 400 invalid_status. |
| ListApprovals | kind (query) | string | no | One ApprovalKind value (approval, credential_assignment, cloud_assignment, sink_enablement). Outside the vocabulary → 400 invalid_kind. |
| ListApprovals | domain_id (query) | string (uuid) | no | Narrows the scan to one Domain. Malformed → 400 invalid_domain_id. |
| ListApprovals | cloud_id (query) | string (uuid) | no | Narrows the scan to the assignment rows targeting one Cloud. An approval or sink_enablement row names no Cloud and never matches. Malformed → 400 invalid_cloud_id. |
| ListApprovals | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListApprovals | 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:
- Decision (reject):
RejectApprovalRequest(a single non-emptyreasonstring,1..1024characters; recorded on the decision as an operator audit string). - Decision (break-glass):
BreakGlassRequest(a singlereasonstring,16..1024characters; its value is PII recorded by field NAME only on the audit caveat context and routed to a PII-safe sink, never onto the contract boundary verbatim). - Response:
Approval(single),ApprovalList(paged, with an optional nullablenext_cursor). - Embedded:
ApprovalState(closed enum:proposed,pending-approval,approved,rejected,expired,revoked),ApprovalKind(closed enum:approval,credential_assignment,cloud_assignment,sink_enablement), and the read-onlyApprovalPolicyprojection on the owning Domain.
ApproveApproval carries no request body. The three decision verbs return 200 with the updated Approval projection.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The 403 permission-denied 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_approval_id | 400 | Get / Approve / Reject / BreakGlass | Approval {id} was not a valid UUID. |
invalid_domain_id | 400 | List | domain_id filter was not a valid UUID. |
invalid_cloud_id | 400 | List | cloud_id filter was not a valid UUID. |
invalid_status | 400 | List | status filter was outside the ApprovalState vocabulary. |
invalid_kind | 400 | List | kind filter was outside the ApprovalKind vocabulary. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
invalid_body | 400 | Reject / BreakGlass | Body could not be read or did not parse as the expected schema. |
invalid_decision_reason | 400 | Reject | reason was empty or whitespace-only. |
invalid_break_glass_reason | 400 | BreakGlass | reason was shorter than the sixteen-character emergency-justification floor. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
self_approval_denied | 403 | Approve | The caller is the proposer of this approval and may not approve their own proposal. |
permission_denied | 403 | Get / Approve / Reject / BreakGlass | The caller lacks the relation the row's kind-resolved gate requires. |
cursor_binding_mismatch | 403 | List | Cursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). |
approval_not_found | 404 | Get / Approve / Reject / BreakGlass | No Approval with the given {id}. |
illegal_transition | 409 | Approve / Reject / BreakGlass | The row is not in a state from which the verb is legal, or break-glass was called on an assignment row. |
request_body_too_large | 413 | Reject / BreakGlass | Body exceeded the 8 KiB Approval decision ceiling. |
approvals_not_provisioned | 501 | every operation | The composition root has not wired the Approval 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; theListApprovals/GetApproval/ApproveApproval/RejectApproval/BreakGlassApprovaloperations and theApproval/ApprovalList/ApprovalPolicy/ApprovalState/RejectApprovalRequest/BreakGlassRequestschemas.../../contexts/approvals.md— the bounded-context reference: the aggregate, the closed state machine, the Approval Policy value object, the break-glass semantics, the audit relation catalog, and the lifecycle events.../../../internal/transport/http/v1/approvals/— the transport-tier implementation: the five handlers, the closedProblem.codetaxonomy, the body-cap and limit-clamp constants, and the per-kind gate resolution and the per-row visibility filter on the list path.../../../schema/authz.zed— ReBAC schema; thedomaindefinition declares theapprove,reject, andemergency_approverrelations this surface gates anapprovalrow on, and thecloudcredential/clouddefinitions declare theassignpermission it gates an assignment row on.authz.md— thePermissionDeniedshape returned on 403.