Appearance
Authz HTTP API
This is the reference for the /v1/authz HTTP surface. It maps each operation to its OpenAPI schema, the per-call ReBAC permission gate, the audit-row taxonomy, and the closed Problem.code set the handlers emit. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative (SpiceDB schema, zedtoken consistency flow, audit contract, caveat redaction) see ../../contexts/identity/rebac.md; for the sibling cursor-paginated surface (and the canonical Problem.code style) see ./projects.md.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Body cap |
|---|---|---|---|---|---|
| POST | /v1/authz/check | PostAuthzCheck | (none — the call IS a check) | authz.check | 8 KiB |
| GET | /v1/authz/relation-tuples | ListRelationTuples | read on project:<project_id> (gate) + per-row read filter | authz.relation_tuple.list | n/a |
| POST | /v1/authz/relation-tuples | CreateRelationTuple | manage on project:<project_id> (parent Project) | authz.relation_tuple.create | 8 KiB |
| PATCH | /v1/authz/relation-tuples/{id} | PatchRelationTuple | manage on the OLD tuple's resource | authz.relation_tuple.update | 8 KiB |
| DELETE | /v1/authz/relation-tuples/{id} | DeleteRelationTuple | manage on the OLD tuple's resource | authz.relation_tuple.delete | n/a |
| POST | /v1/authz/lookup-resources | PostAuthzLookupResources | (none — the call IS an enumeration; same posture as Check) | authz.lookup_resources | 8 KiB |
| POST | /v1/authz/lookup-subjects | PostAuthzLookupSubjects | (none — the call IS an enumeration; same posture as Check) | authz.lookup_subjects | 8 KiB |
body_cap = 8 KiBreferences theMaxAuthzRequestBodyBytesenforcement applied before the JSON decoder runs (see../../../internal/transport/http/v1/authz/wiring.go—MaxAuthzRequestBodyBytes); an over-cap body surfaces as413 request_body_too_large.ListRelationTuples.limitquery parameter is clamped at the handler to[1, 200]with default50(defaultListLimit,minListLimit,maxListLimit).ListRelationTuples.cursoris opaque, HMAC-signed by the server via theCursorCodecport; a tampered cursor surfaces as400 invalid_cursor.ListRelationTuples.project_idis a REQUIRED filter — relation-tuple administration is scoped per Project. A missing or zero UUID surfaces as400 invalid_project_id.PatchRelationTupleimplements Delete-then-Write semantics — the body carries the FULL replacement tuple. A failure between the two legs surfaces as500 internaland leaves the OLD binding removed; the caller's idempotent recovery is to re-issue the PATCH against the same id, which then resolves to404 tuple_not_foundso the client can fall back to a freshPOST /v1/authz/relation-tuples.DeleteRelationTupleis idempotent in posture, not in status: the second delete of the same id surfaces as404 tuple_not_foundrather than204so a caller racing two deletes can distinguish "I deleted it" from "someone else got there first" without leaking an existence side-channel.PostAuthzCheckreturns HTTP 200 for both grants AND denials — denials are policy data, NEVER transport errors.403is reserved for callers who are not even authorised to USE the surface (currently the tuple endpoints).
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| PatchRelationTuple / DeleteRelationTuple | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed → 400 invalid_tuple_id. |
| ListRelationTuples | project_id (query) | string (uuid) | yes | Owning Project (UUIDv7). Malformed or zero → 400 invalid_project_id. |
| ListRelationTuples | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor. |
| ListRelationTuples | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| CreateRelationTuple | project_id (query) | string (uuid) | yes | Owning Project (UUIDv7). Malformed or zero → 400 invalid_project_id. The manage gate fires against project:<project_id> BEFORE the existence check so an unauthorised caller cannot probe project ids via the 403/404 timing differential. |
Schemas
RebacCheckRequest
Body for POST /v1/authz/check. Triple addressing (subject, relation, resource) mirrors the canonical ReBAC tuple shape; the optional caveat_context carries the field NAMES the caveat program reads — never values.
| Field | Type | Required | Notes |
|---|---|---|---|
subject | string | yes | minLength=1. Object reference of the subject performing the check (e.g. user:0190a8b8-..., serviceaccount:...). |
relation | string | yes | minLength=1. Relation name to evaluate (e.g. read, manage, maintainer). The accepted set is fixed by schema/authz.zed. |
resource | string | yes | minLength=1. Object reference of the resource the relation is evaluated against (e.g. project:0190a8b8-...). |
caveat_context | object | no | Optional set of caveat field NAMES the caveat program reads. additionalProperties: true for forward-compatibility, but the contract requires NAMES-only payloads. |
RebacCheckResponse
Result of POST /v1/authz/check. decision is the machine-readable outcome; relation_path is set only on allowed; reason is set only on denied. correlation_id is always set and pairs the response with the matching audit entry emitted by internal/audit.
| Field | Type | Required | Notes |
|---|---|---|---|
decision | string (enum: allowed, denied) | yes | ReBAC decision. allowed is the affirmative outcome; denied is a policy denial — NEVER an HTTP error. |
relation_path | array<string> | no | Ordered sequence of relations the authorizer traversed. Set only when decision=allowed. Empty array when the decision was reached without a relation lookup (typically a direct-binding allowance). |
reason | string (enum: granted, out_of_scope, insufficient_relation, caveat_violation, unknown) | no | Machine-readable denial reason. Set only when decision=denied. The handler emits insufficient_relation for an ErrPermissionDenied and caveat_violation for an ErrCaveatViolation. |
correlation_id | string | yes | Correlation id that pairs this decision with the matching audit entry. Sourced from the request's X-Correlation-Id (or X-Request-Id) header. |
RelationTuple
Hydrated projection of a relation tuple. The shape is shared by CreateRelationTuple, PatchRelationTuple, and ListRelationTuples so clients only need one binding.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Surrogate identifier (UUIDv7). The composition-root adapter stamps a deterministic UUID derived from (subject, relation, resource, caveat_name) so a subsequent PATCH / DELETE by id resolves to the same SpiceDB row. |
subject | string | yes | Object reference of the subject side. |
relation | string | yes | Relation name (e.g. maintainer, read). |
resource | string | yes | Object reference of the resource side. |
caveat_context | object | no | Optional set of caveat field NAMES the tuple binds. NAMES only — values never cross the contract boundary. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). The composition-root adapter owns the bookkeeping; SpiceDB itself does not surface a per-tuple timestamp. |
RelationTupleCreateRequest
Body for POST /v1/authz/relation-tuples. Field set mirrors the relation-tuple aggregate's NewRelationTuple invariants. The handler authorises the call against project:<project_id>#manage BEFORE the existence check and BEFORE writing to SpiceDB.
| Field | Type | Required | Notes |
|---|---|---|---|
subject | string | yes | minLength=1. Object reference of the subject side. |
relation | string | yes | minLength=1. Relation name. |
resource | string | yes | minLength=1. Object reference of the resource side. |
caveat_context | object | no | Optional set of caveat field NAMES. The conventional caveat_name key (when present) carries the caveat function name to attach to the tuple; all other keys are NAMES-only. |
RelationTuplePatchRequest
Body for PATCH /v1/authz/relation-tuples/{id}. The handler implements Delete-then-Write semantics, so the body is the FULL replacement tuple — there are no partially-mutable fields.
| Field | Type | Required | Notes |
|---|---|---|---|
subject | string | yes | minLength=1. New object reference of the subject side. |
relation | string | yes | minLength=1. New relation name. |
resource | string | yes | minLength=1. New object reference of the resource side. |
caveat_context | object | no | Optional set of caveat field NAMES the new tuple binds. NAMES only — values never cross the contract boundary. |
RelationTupleList
Page of relation tuples returned by GET /v1/authz/relation-tuples. Per-row visibility is layered on top of the persistence-level page — rows the caller cannot read are filtered out, so the items array is the subset the caller is authorised to see. len(items) < limit is NOT a reliable end-of-stream signal; consult next_cursor instead.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<RelationTuple> | yes | Relation tuples in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation. Absent or null at end-of-stream. |
LookupResourcesRequest
Body for POST /v1/authz/lookup-resources. Enumerates every resource of resource_type the subject can reach via relation.
| Field | Type | Required | Notes |
|---|---|---|---|
subject | string | yes | Object reference of the subject (e.g. user:<uuid>). |
relation | string | yes | Relation to evaluate (e.g. read). |
resource_type | string | yes | Object type to enumerate; items are <resource_type>:<id>. |
caveat_context | object | no | Caveat field NAMES only — values never cross the boundary. |
LookupSubjectsRequest
Body for POST /v1/authz/lookup-subjects. The dual: every subject of subject_type that can reach resource via relation.
| Field | Type | Required | Notes |
|---|---|---|---|
subject_type | string | yes | Object type to enumerate; items are <subject_type>:<id>. |
relation | string | yes | Relation to evaluate (e.g. read). |
resource | string | yes | Object reference whose authorised subjects are listed. |
caveat_context | object | no | Caveat field NAMES only — values never cross the boundary. |
RebacLookupResponse
Result of both lookup operations. The full materialised id set — an empty items array is a normal answer, never an error. No cursor: the production authorizer drains SpiceDB's pagination internally.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<string> | yes | Object references (<type>:<id>) in the authorizer's insertion order; sort client-side if needed. |
correlation_id | string | yes | Pairs the lookup with the matching internal/audit entry. |
ReBAC contract
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| PostAuthzCheck | (the call IS a check; no transport-side gate) | resolved principal | n/a | n/a — denials surface as 200 { decision: denied, reason }, NEVER as 403. |
| PostAuthzLookupResources | (the call IS an enumeration; no transport-side gate) | resolved principal | n/a | n/a — an empty items array is the answer, NEVER a 403. |
| PostAuthzLookupSubjects | (the call IS an enumeration; no transport-side gate) | resolved principal | n/a | n/a — an empty items array is the answer, NEVER a 403. |
| ListRelationTuples | read (BEFORE persistence read) | resolved principal | project:<project_id> | 403 PermissionDenied + audit row relation=authz.relation_tuple.list, outcome=permission_denied |
| ListRelationTuples (per-row) | read | resolved principal | each row's resource | row filtered out; ordinary policy denials NOT counted; transport flakes counted into the page-level audit row's authz_errors caveat field |
| CreateRelationTuple | manage (BEFORE existence check) | resolved principal | project:<project_id> (parent) | 403 PermissionDenied + audit row relation=authz.relation_tuple.create, outcome=permission_denied |
| PatchRelationTuple | manage (on OLD tuple's resource) | resolved principal | <old_tuple.resource> | 403 PermissionDenied + audit row relation=authz.relation_tuple.update, outcome=permission_denied |
| DeleteRelationTuple | manage (on OLD tuple's resource) | resolved principal | <old_tuple.resource> | 403 PermissionDenied + audit row relation=authz.relation_tuple.delete, outcome=permission_denied |
PatchRelationTuple's gate fires against the OLD tuple's parent resource, NOT the proposed new shape. The Patch is conceptually "replace the binding the existing id points at"; the caller proves they manage the existing binding's home, not the rewritten target. Mirrors the projects-CRUD posture where Patch gates against the existing aggregate (see ../../../internal/transport/http/v1/authz/relation_tuples_patch.go — DECISION: block).
The 403 body on this surface is a PermissionDenied schema (NOT a Problem with code: permission_denied) — established by the ReBAC platform contract and reused here verbatim. The richer body carries reason (currently always insufficient_relation from this surface), detail, and correlation_id.
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in ../../../internal/transport/http/v1/authz/errors.go. The Origin column names the layer (handler / transport / authorizer / reader) so a future maintainer knows where to grep when the code changes.
| HTTP status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_body | handler | Body was not valid JSON, carried unknown fields, or failed to decode against the per-operation request schema. |
| 400 | invalid_triple | handler | Body was decoded but at least one of subject / relation / resource was empty. |
| 400 | invalid_project_id | handler | Required project_id query parameter was missing, malformed, or a zero UUID (Create / List). |
| 400 | invalid_tuple_id | handler | Path {id} was not a non-zero UUID (Patch / Delete). |
| 400 | invalid_limit | handler | List limit query parameter out of range [1, 200]. |
| 400 | invalid_cursor | handler | List cursor query parameter was tampered or malformed (the CursorCodec.Decode rejected it). |
| 401 | unauthenticated | handler | No resolved principal in the request context (no auth, or authn.KindUnknown). |
| 403 | (PermissionDenied) | transport | ReBAC denied the operation on the tuple endpoints (separate schema, NOT Problem). PostAuthzCheck NEVER emits 403 for policy denials. |
| 404 | project_not_found | handler | Create against a project_id that has no aggregate (after the manage gate has passed; gates fire BEFORE the existence check to close the timing side-channel). |
| 404 | tuple_not_found | reader | Patch / Delete against an id the RelationTupleReader.GetByID cannot resolve. Also the canonical 204→404 posture for an idempotent double-delete. |
| 413 | request_body_too_large | handler | Body exceeded the 8 KiB ceiling (MaxAuthzRequestBodyBytes). |
| 500 | internal | handler / authorizer | Unexpected error: SpiceDB transport flake (PostAuthzCheck), tuple write/delete failed, lookup failed, cursor encode failed, surrogate id generation failed, or a Patch's write leg failed AFTER the delete leg succeeded. The detail is generic; the underlying error text NEVER leaks to the wire. |
| 501 | authz_not_provisioned | handler | The composition root has not wired the required Deps (e.g. Deps.Authz, Deps.TupleReader, Deps.ProjectExists). Production wiring always provisions these; the 501 is a defensive guard for partial test fixtures. |
Every Problem detail on this surface carries the (PX-0045, REQ-xxx) trailer so reviewers can grep production logs back to the originating requirement.
Audit & outbox contract
Every successful mutation writes exactly one mutator audit row (emitted by the production Authorizer when it Writes / Deletes SpiceDB tuples). Read paths (PostAuthzCheck, ListRelationTuples) write a transport-local audit row through Deps.Sink because the Authorizer does NOT emit on Check. This is in addition to whatever audit row the production Authorizer already emits internally on every Check — the transport row carries the wire-side correlation_id so operators can join the two on (subject, relation, object, timestamp).
List denials/refusals are audit-only — they do NOT consume an outbox slot. Per-row denials inside ListRelationTuples are silent; per-row transport flakes are NOT individually audited but are counted into the page-level audit row's caveat_context.authz_errors field.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| PostAuthzCheck | allowed | authz.check | granted | (none) |
| PostAuthzCheck | denied (policy) | authz.check | permission_denied | (none) |
| PostAuthzCheck | denied (caveat) | authz.check | caveat_violation | (none) |
| PostAuthzCheck | 4xx invariant (invalid body / triple / 413) | authz.check | invariant_violation | (none) |
| PostAuthzCheck | 5xx (transport flake) | authz.check | internal_error | (none) |
| ListRelationTuples | success | authz.relation_tuple.list (page-level row carrying item_count + optional authz_errors) | granted | (none) |
| ListRelationTuples | 403 (gate) | authz.relation_tuple.list | permission_denied | (none) |
| ListRelationTuples | 5xx | authz.relation_tuple.list | internal_error | (none) |
| CreateRelationTuple | success | authz.relation_tuple.create | granted | RelationTupleCreated (emitted by the Authorizer) |
| CreateRelationTuple | 403 | authz.relation_tuple.create | permission_denied | (none) |
| CreateRelationTuple | 4xx invariant (invalid body / triple / 413 / project_not_found) | authz.relation_tuple.create | invariant_violation | (none) |
| CreateRelationTuple | 5xx | authz.relation_tuple.create | internal_error | (none) |
| PatchRelationTuple | success | authz.relation_tuple.update | granted | RelationTupleUpdated |
| PatchRelationTuple | 403 | authz.relation_tuple.update | permission_denied | (none) |
| PatchRelationTuple | 4xx invariant | authz.relation_tuple.update | invariant_violation | (none) |
| PatchRelationTuple | 5xx (delete leg) | authz.relation_tuple.update | internal_error (caveat phase=delete) | (none) |
| PatchRelationTuple | 5xx (write leg, after delete succeeded) | authz.relation_tuple.update | internal_error (caveat phase=write) | (none) |
| DeleteRelationTuple | success (204) | authz.relation_tuple.delete | granted | RelationTupleDeleted |
| DeleteRelationTuple | 403 | authz.relation_tuple.delete | permission_denied | (none) |
| DeleteRelationTuple | 404 (already gone) | (no transport-local row; the reader returned ErrTupleNotFound BEFORE any audit emission site) | n/a | (none) |
| DeleteRelationTuple | 5xx | authz.relation_tuple.delete | internal_error | (none) |
Audit rows on this surface stamp caveat_context with NAMES-only caveat-field projections via caveatFieldNames (see ../../../internal/transport/http/v1/authz/helpers.go). Values are deliberately NOT captured so a Check that forwards a secret context value to SpiceDB does not leak the secret into the audit chain. Mutator rows additionally stamp tuple_id, tuple_subject, and tuple_object to make ex-post audit triage trivial without joining back to SpiceDB.
A nil Deps.Sink degrades silently — the transport-layer audit row is dropped while the security gate still fires; sink errors are NOT propagated to the caller (a flaky audit backend cannot turn a successful read into a 5xx) but they ARE made loud via a slog.WarnContext breadcrumb.
Cross-references
./projects.md— sibling reference for the/v1/projectssurface; canonical example of the cursor /ProblemCodestyle this doc mirrors. TheMaxAuthzRequestBodyBytesceiling, the[1, 200]limitclamp, and the HMAC-signed cursor envelope are all carried over from that contract verbatim.../../contexts/identity/rebac.md— bounded-context narrative for the ReBAC layer: SpiceDB schema walkthrough, zedtoken consistency flow, CEL caveats, audit contract, and the dual-write outbox that backs theRelationTupleCreated/RelationTupleUpdated/RelationTupleDeletedevents.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate. TheRebacCheckRequest/RebacCheckResponse/RelationTuple/RelationTupleList/RelationTupleCreateRequest/RelationTuplePatchRequestschemas live under#/components/schemas.../../../internal/transport/http/v1/authz/— handler package; per-handler files (check.go,relation_tuples_create.go,relation_tuples_list.go,relation_tuples_patch.go,relation_tuples_delete.go); closedProblem.codetaxonomy and audit constants inerrors.go; ports andDepswiring inwiring.go;principalSubject/ NAMES-only caveat projection inhelpers.go.- RFC 9457 — Problem Details for HTTP APIs; the
Problembody format every error path on this surface emits (except the dedicatedPermissionDenied403 schema).