Skip to content

Audit HTTP API

This is the reference for the audit chain HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, the closed Problem.code taxonomy, and the deliberate probe-oracle defences that shape the status code mapping. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative (the hash-chain construction, per-Domain residency, pseudonymisation pepper, SIEM fan-out, threat model) see ../../contexts/audit/index.md.

The audit tag spans two parallel surfaces with an identical shape:

  • Per-Domain chains under /v1/domains/{domainId}/audit/... — one hash chain per Domain, gated by the auditor relation on that Domain.
  • The platform-residency chain under /v1/platform/audit/... — a single chain carrying privileged actions owned by no Domain (Cloud lifecycle, platform-scope Label definitions, the Invitation ExpirePending sweep), gated by the auditor relation on the fixed platform object.

Operations

Per-Domain audit chain

MethodPathOperation IDReBAC gateAudit relationNotes
GET/v1/domains/{domainId}/audit/entriesListAuditEntriesdomain#auditor(none — read surface)Cursor-paginated. 404 only after the authz gate passes (no Domain-id oracle).
GET/v1/domains/{domainId}/audit/entries/{seq}GetAuditEntrydomain#auditor(none — read surface)403 collapses onto 404 to avoid a chain-length probe oracle.
POST/v1/domains/{domainId}/audit/verifyVerifyAuditChaindomain#auditor(none — verification surface)Divergence is 200 { ok: false, divergent_seq, expected_hash, observed_hash }, not a 5xx.
POST/v1/domains/{domainId}/audit/erase-identityEraseIdentityFromAuditdomain#auditoraudit.erase-identity (self-audit)Idempotent on subject_pseudonym. Returns 202 even on a no-op.

Platform-residency audit chain

MethodPathOperation IDReBAC gateAudit relationNotes
GET/v1/platform/audit/entriesListPlatformAuditEntriesplatform#auditor(none — read surface)Cursor-paginated over the single platform-residency chain.
GET/v1/platform/audit/entries/{seq}GetPlatformAuditEntryplatform#auditor(none — read surface)403 collapses onto 404 to avoid a chain-length probe oracle.
POST/v1/platform/audit/verifyVerifyPlatformAuditChainplatform#auditor(none — verification surface)Divergence is 200 { ok: false, divergent_seq, expected_hash, observed_hash }, not a 5xx.
POST/v1/platform/audit/erase-identityEraseIdentityFromPlatformAuditplatform#manageaudit.erase-identity (self-audit)Gated on manage (a write), not auditor — the erasure path drops a PII row. Idempotent on subject_pseudonym; returns 202 even on a no-op.

All eight operations may surface as 501 audit_not_provisioned if the composition root has not wired the audit query service / authorizer / pepper resolver onto the handler. The 501 is a build signal, not a runtime fault — the kind dev wires every collaborator and never returns it.

Probe-oracle defences

Audit data is sensitive: each chain is residency-scoped (per-Domain or platform), the row contents identify principals and resources, and the chain length itself (number of audit rows on a chain) leaks workload signal. Both surfaces therefore make three deliberate trade-offs that depart from the "regular" Problem.code mapping used elsewhere:

  1. The single-entry read collapses 403 onto 404. A caller who lacks the auditor relation receives the same 404 body as for an unknown seq — on both GetAuditEntry and GetPlatformAuditEntry. The seq id is monotonic from 1, so a 403 would leak the chain length and let an attacker probe for the existence of arbitrary rows.
  2. 404-after-403 on the per-Domain list and verify surfaces. An unknown domainId only surfaces as 404 after the authorisation gate has run, so the endpoint cannot be used as a Domain-id oracle. (The platform-residency list and verify surfaces address a single fixed chain and so carry no path id to probe.)
  3. Divergence is data, not an error. A tampered chain is the point of having a verifier — the handler returns 200 { ok: false, ... } so divergence triggers an incident playbook while the 5xx channel stays reserved for genuine infrastructure faults (database unreachable, decode error on a malformed row). This holds for both VerifyAuditChain and VerifyPlatformAuditChain.

These trade-offs are documented in the operation descriptions on the spec; they are NOT bugs to "fix" in a future audit-API revision.

Path & query parameters

OperationParameterTypeRequiredNotes
every per-Domain operationdomainId (path)string (uuid)yesOwning Domain (UUIDv7). Shared AuditDomainID parameter component. Malformed → 400 invalid_domain_id. The platform-residency operations carry no Domain path id — they address a single fixed chain.
GetAuditEntry / GetPlatformAuditEntryseq (path)integer (int64, ≥1)yesPer-chain monotonic sequence number assigned at append time; sequences start at 1 (genesis row) and never reset.
ListAuditEntries / ListPlatformAuditEntriescursor (query)stringnoOpaque HMAC-signed continuation. Cursors are bound to the addressed chain; replaying a cursor minted for a different chain → 400 cursor_invalid.
ListAuditEntries / ListPlatformAuditEntrieslimit (query)integerno[1, 200], default 50. Server-side clamp protects the read replica from an unbounded LIMIT.
ListAuditEntries / ListPlatformAuditEntriessubject (query)string (^[0-9a-f]{64}$)noFilter by subject pseudonym (64 lowercase hex). Plaintext subject ids are NEVER accepted — pseudonymisation is the caller's responsibility and happens at the sink boundary.
ListAuditEntries / ListPlatformAuditEntriesrelation (query)stringnoSpiceDB relation label to filter on.
ListAuditEntries / ListPlatformAuditEntriesobject_type (query)stringnoSpiceDB object type (e.g. domain, node, cloud, platform).
ListAuditEntries / ListPlatformAuditEntriesobject_id (query)stringnoOpaque object identifier within object_type.
ListAuditEntries / ListPlatformAuditEntriesreason (query)AuditReason enumnoReBAC decision reason filter.
ListAuditEntries / ListPlatformAuditEntriesfrom / to (query)string (date-time)noRFC 3339 brackets on occurred_at. to < from400.
ListAuditEntries / ListPlatformAuditEntriescorrelation_id (query)stringnoInbound request correlation id propagated from the transport layer.

All filters are AND-composed in the persistence layer; they never short-circuit each other. The per-Domain and platform-residency list surfaces accept an identical query-parameter set.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

  • List: AuditEntryList (page items + optional next_cursor).
  • Read with proof: AuditEntryProof (the entry plus prev_hash, entry_hash, and the canonical bytes the chain hashed; off-line verification recomputes sha256(prev_hash || sha256(canonical_bytes)) and compares against entry_hash).
  • Verification: AuditChainVerifyRequest (optional from_seq / to_seq segment), AuditChainVerifyResult (ok + on false the offending divergent_seq, expected_hash, observed_hash).
  • Erasure: AuditEraseIdentityRequest (carries identity_id), AuditEraseIdentityResponse (echoes the derived subject_pseudonym and erased_at).
  • Filter enum: AuditReason.

For the field-level shapes refer to api/openapi/plexsphere-v1.yaml under components/schemas/.

Erasure semantics

EraseIdentityFromAudit drops the audit_subject_pii row for the per-Domain pseudonym derived from identity_id, then appends an audit.erase-identity self-audit entry to the same chain so the erasure event is itself auditable. EraseIdentityFromPlatformAudit performs the identical operation against the platform-residency chain. In both cases the hash chain remains verifiable: rows reference the pseudonym, and the pseudonym is preserved — only the plaintext mapping is removed.

Both endpoints are idempotent on subject_pseudonym. A second call after a successful erasure is a no-op on the audit_subject_pii table (the row is already gone) and may emit a second self-audit entry. The 202 status reflects the idempotent contract: a successful erasure has been recorded, even if the underlying PII row was already absent. Callers MAY safely retry after a partial network failure.

The two erasure operations differ in one respect: the ReBAC gate. EraseIdentityFromAudit gates on the Domain's auditor relation, while EraseIdentityFromPlatformAudit gates on platform#manage — the erasure is a write, and manage is the platform-scope admin grant. Using auditor (a read-side relation) there would let any read-permitted auditor delete PII rows.

Error taxonomy

All error responses use the shared Problem envelope (application/problem+json). The 403 path on the operations that DO emit it (ListAuditEntries, VerifyAuditChain, EraseIdentityFromAudit, ListPlatformAuditEntries, VerifyPlatformAuditChain, EraseIdentityFromPlatformAudit) uses the richer PermissionDenied shape; GetAuditEntry and GetPlatformAuditEntry deliberately do not — see "Probe-oracle defences" above.

CodeStatusWhereMeaning
invalid_domain_id400every per-Domain operationMalformed domainId UUID.
cursor_invalid400List (per-Domain / platform)HMAC verification failed, or the cursor was minted for a different chain.
subject_invalid400List (per-Domain / platform)subject not 64-character hex.
range_invalid400List / Verify (per-Domain / platform)to < from, or to_seq < from_seq, or negative seq.
seq_invalid400GetAuditEntry / GetPlatformAuditEntryseq < 1.
identity_id_invalid400EraseIdentityFromAudit / EraseIdentityFromPlatformAuditMalformed identity_id or empty body.
not_found404List / Verify / Erase (per-Domain)Domain id not resolved (post-authz).
not_found404GetAuditEntry / GetPlatformAuditEntryUnknown seq, cross-chain probe attempt, OR missing auditor relation — all three collapse onto the same body for probe-oracle defence.
audit_not_provisioned501every operationThe composition root has not wired the audit service onto this build.
internal500every operationPersistence-layer failure, authz-check crash, canonical-encode crash.

A 403 (where emitted) carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ../api/authz.md.

Cross-references