Skip to content

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

MethodPathOperation IDReBAC gateAudit relationBody cap
POST/v1/authz/checkPostAuthzCheck(none — the call IS a check)authz.check8 KiB
GET/v1/authz/relation-tuplesListRelationTuplesread on project:<project_id> (gate) + per-row read filterauthz.relation_tuple.listn/a
POST/v1/authz/relation-tuplesCreateRelationTuplemanage on project:<project_id> (parent Project)authz.relation_tuple.create8 KiB
PATCH/v1/authz/relation-tuples/{id}PatchRelationTuplemanage on the OLD tuple's resourceauthz.relation_tuple.update8 KiB
DELETE/v1/authz/relation-tuples/{id}DeleteRelationTuplemanage on the OLD tuple's resourceauthz.relation_tuple.deleten/a
POST/v1/authz/lookup-resourcesPostAuthzLookupResources(none — the call IS an enumeration; same posture as Check)authz.lookup_resources8 KiB
POST/v1/authz/lookup-subjectsPostAuthzLookupSubjects(none — the call IS an enumeration; same posture as Check)authz.lookup_subjects8 KiB
  • body_cap = 8 KiB references the MaxAuthzRequestBodyBytes enforcement applied before the JSON decoder runs (see ../../../internal/transport/http/v1/authz/wiring.goMaxAuthzRequestBodyBytes); an over-cap body surfaces as 413 request_body_too_large.
  • ListRelationTuples.limit query parameter is clamped at the handler to [1, 200] with default 50 (defaultListLimit, minListLimit, maxListLimit).
  • ListRelationTuples.cursor is opaque, HMAC-signed by the server via the CursorCodec port; a tampered cursor surfaces as 400 invalid_cursor.
  • ListRelationTuples.project_id is a REQUIRED filter — relation-tuple administration is scoped per Project. A missing or zero UUID surfaces as 400 invalid_project_id.
  • PatchRelationTuple implements Delete-then-Write semantics — the body carries the FULL replacement tuple. A failure between the two legs surfaces as 500 internal and leaves the OLD binding removed; the caller's idempotent recovery is to re-issue the PATCH against the same id, which then resolves to 404 tuple_not_found so the client can fall back to a fresh POST /v1/authz/relation-tuples.
  • DeleteRelationTuple is idempotent in posture, not in status: the second delete of the same id surfaces as 404 tuple_not_found rather than 204 so a caller racing two deletes can distinguish "I deleted it" from "someone else got there first" without leaking an existence side-channel.
  • PostAuthzCheck returns HTTP 200 for both grants AND denials — denials are policy data, NEVER transport errors. 403 is reserved for callers who are not even authorised to USE the surface (currently the tuple endpoints).

Path & query parameters

OperationParameterTypeRequiredNotes
PatchRelationTuple / DeleteRelationTupleid (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_tuple_id.
ListRelationTuplesproject_id (query)string (uuid)yesOwning Project (UUIDv7). Malformed or zero → 400 invalid_project_id.
ListRelationTuplescursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor.
ListRelationTupleslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
CreateRelationTupleproject_id (query)string (uuid)yesOwning 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.

FieldTypeRequiredNotes
subjectstringyesminLength=1. Object reference of the subject performing the check (e.g. user:0190a8b8-..., serviceaccount:...).
relationstringyesminLength=1. Relation name to evaluate (e.g. read, manage, maintainer). The accepted set is fixed by schema/authz.zed.
resourcestringyesminLength=1. Object reference of the resource the relation is evaluated against (e.g. project:0190a8b8-...).
caveat_contextobjectnoOptional 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.

FieldTypeRequiredNotes
decisionstring (enum: allowed, denied)yesReBAC decision. allowed is the affirmative outcome; denied is a policy denial — NEVER an HTTP error.
relation_patharray<string>noOrdered 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).
reasonstring (enum: granted, out_of_scope, insufficient_relation, caveat_violation, unknown)noMachine-readable denial reason. Set only when decision=denied. The handler emits insufficient_relation for an ErrPermissionDenied and caveat_violation for an ErrCaveatViolation.
correlation_idstringyesCorrelation 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.

FieldTypeRequiredNotes
idstring (uuid)yesSurrogate 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.
subjectstringyesObject reference of the subject side.
relationstringyesRelation name (e.g. maintainer, read).
resourcestringyesObject reference of the resource side.
caveat_contextobjectnoOptional set of caveat field NAMES the tuple binds. NAMES only — values never cross the contract boundary.
created_atstring (date-time)yesAggregate 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.

FieldTypeRequiredNotes
subjectstringyesminLength=1. Object reference of the subject side.
relationstringyesminLength=1. Relation name.
resourcestringyesminLength=1. Object reference of the resource side.
caveat_contextobjectnoOptional 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.

FieldTypeRequiredNotes
subjectstringyesminLength=1. New object reference of the subject side.
relationstringyesminLength=1. New relation name.
resourcestringyesminLength=1. New object reference of the resource side.
caveat_contextobjectnoOptional 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.

FieldTypeRequiredNotes
itemsarray<RelationTuple>yesRelation tuples in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque 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.

FieldTypeRequiredNotes
subjectstringyesObject reference of the subject (e.g. user:<uuid>).
relationstringyesRelation to evaluate (e.g. read).
resource_typestringyesObject type to enumerate; items are <resource_type>:<id>.
caveat_contextobjectnoCaveat 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.

FieldTypeRequiredNotes
subject_typestringyesObject type to enumerate; items are <subject_type>:<id>.
relationstringyesRelation to evaluate (e.g. read).
resourcestringyesObject reference whose authorised subjects are listed.
caveat_contextobjectnoCaveat 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.

FieldTypeRequiredNotes
itemsarray<string>yesObject references (<type>:<id>) in the authorizer's insertion order; sort client-side if needed.
correlation_idstringyesPairs the lookup with the matching internal/audit entry.

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
PostAuthzCheck(the call IS a check; no transport-side gate)resolved principaln/an/a — denials surface as 200 { decision: denied, reason }, NEVER as 403.
PostAuthzLookupResources(the call IS an enumeration; no transport-side gate)resolved principaln/an/a — an empty items array is the answer, NEVER a 403.
PostAuthzLookupSubjects(the call IS an enumeration; no transport-side gate)resolved principaln/an/a — an empty items array is the answer, NEVER a 403.
ListRelationTuplesread (BEFORE persistence read)resolved principalproject:<project_id>403 PermissionDenied + audit row relation=authz.relation_tuple.list, outcome=permission_denied
ListRelationTuples (per-row)readresolved principaleach row's resourcerow filtered out; ordinary policy denials NOT counted; transport flakes counted into the page-level audit row's authz_errors caveat field
CreateRelationTuplemanage (BEFORE existence check)resolved principalproject:<project_id> (parent)403 PermissionDenied + audit row relation=authz.relation_tuple.create, outcome=permission_denied
PatchRelationTuplemanage (on OLD tuple's resource)resolved principal<old_tuple.resource>403 PermissionDenied + audit row relation=authz.relation_tuple.update, outcome=permission_denied
DeleteRelationTuplemanage (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.goDECISION: 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 statusProblem.codeOriginTrigger
400invalid_bodyhandlerBody was not valid JSON, carried unknown fields, or failed to decode against the per-operation request schema.
400invalid_triplehandlerBody was decoded but at least one of subject / relation / resource was empty.
400invalid_project_idhandlerRequired project_id query parameter was missing, malformed, or a zero UUID (Create / List).
400invalid_tuple_idhandlerPath {id} was not a non-zero UUID (Patch / Delete).
400invalid_limithandlerList limit query parameter out of range [1, 200].
400invalid_cursorhandlerList cursor query parameter was tampered or malformed (the CursorCodec.Decode rejected it).
401unauthenticatedhandlerNo resolved principal in the request context (no auth, or authn.KindUnknown).
403(PermissionDenied)transportReBAC denied the operation on the tuple endpoints (separate schema, NOT Problem). PostAuthzCheck NEVER emits 403 for policy denials.
404project_not_foundhandlerCreate 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).
404tuple_not_foundreaderPatch / Delete against an id the RelationTupleReader.GetByID cannot resolve. Also the canonical 204→404 posture for an idempotent double-delete.
413request_body_too_largehandlerBody exceeded the 8 KiB ceiling (MaxAuthzRequestBodyBytes).
500internalhandler / authorizerUnexpected 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.
501authz_not_provisionedhandlerThe 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.

OperationOutcomeAudit relationAudit outcomeOutbox event
PostAuthzCheckallowedauthz.checkgranted(none)
PostAuthzCheckdenied (policy)authz.checkpermission_denied(none)
PostAuthzCheckdenied (caveat)authz.checkcaveat_violation(none)
PostAuthzCheck4xx invariant (invalid body / triple / 413)authz.checkinvariant_violation(none)
PostAuthzCheck5xx (transport flake)authz.checkinternal_error(none)
ListRelationTuplessuccessauthz.relation_tuple.list (page-level row carrying item_count + optional authz_errors)granted(none)
ListRelationTuples403 (gate)authz.relation_tuple.listpermission_denied(none)
ListRelationTuples5xxauthz.relation_tuple.listinternal_error(none)
CreateRelationTuplesuccessauthz.relation_tuple.creategrantedRelationTupleCreated (emitted by the Authorizer)
CreateRelationTuple403authz.relation_tuple.createpermission_denied(none)
CreateRelationTuple4xx invariant (invalid body / triple / 413 / project_not_found)authz.relation_tuple.createinvariant_violation(none)
CreateRelationTuple5xxauthz.relation_tuple.createinternal_error(none)
PatchRelationTuplesuccessauthz.relation_tuple.updategrantedRelationTupleUpdated
PatchRelationTuple403authz.relation_tuple.updatepermission_denied(none)
PatchRelationTuple4xx invariantauthz.relation_tuple.updateinvariant_violation(none)
PatchRelationTuple5xx (delete leg)authz.relation_tuple.updateinternal_error (caveat phase=delete)(none)
PatchRelationTuple5xx (write leg, after delete succeeded)authz.relation_tuple.updateinternal_error (caveat phase=write)(none)
DeleteRelationTuplesuccess (204)authz.relation_tuple.deletegrantedRelationTupleDeleted
DeleteRelationTuple403authz.relation_tuple.deletepermission_denied(none)
DeleteRelationTuple404 (already gone)(no transport-local row; the reader returned ErrTupleNotFound BEFORE any audit emission site)n/a(none)
DeleteRelationTuple5xxauthz.relation_tuple.deleteinternal_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/projects surface; canonical example of the cursor / ProblemCode style this doc mirrors. The MaxAuthzRequestBodyBytes ceiling, the [1, 200] limit clamp, 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 the RelationTupleCreated / RelationTupleUpdated / RelationTupleDeleted events.
  • ../api/index.md — platform-wide /v1 HTTP surface map.
  • ../../../api/openapi/plexsphere-v1.yaml — authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate. The RebacCheckRequest / RebacCheckResponse / RelationTuple / RelationTupleList / RelationTupleCreateRequest / RelationTuplePatchRequest schemas 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); closed Problem.code taxonomy and audit constants in errors.go; ports and Deps wiring in wiring.go; principalSubject / NAMES-only caveat projection in helpers.go.
  • RFC 9457 — Problem Details for HTTP APIs; the Problem body format every error path on this surface emits (except the dedicated PermissionDenied 403 schema).