Appearance
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 theauditorrelation 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 theauditorrelation on the fixed platform object.
Operations
Per-Domain audit chain
| Method | Path | Operation ID | ReBAC gate | Audit relation | Notes |
|---|---|---|---|---|---|
| GET | /v1/domains/{domainId}/audit/entries | ListAuditEntries | domain#auditor | (none — read surface) | Cursor-paginated. 404 only after the authz gate passes (no Domain-id oracle). |
| GET | /v1/domains/{domainId}/audit/entries/{seq} | GetAuditEntry | domain#auditor | (none — read surface) | 403 collapses onto 404 to avoid a chain-length probe oracle. |
| POST | /v1/domains/{domainId}/audit/verify | VerifyAuditChain | domain#auditor | (none — verification surface) | Divergence is 200 { ok: false, divergent_seq, expected_hash, observed_hash }, not a 5xx. |
| POST | /v1/domains/{domainId}/audit/erase-identity | EraseIdentityFromAudit | domain#auditor | audit.erase-identity (self-audit) | Idempotent on subject_pseudonym. Returns 202 even on a no-op. |
Platform-residency audit chain
| Method | Path | Operation ID | ReBAC gate | Audit relation | Notes |
|---|---|---|---|---|---|
| GET | /v1/platform/audit/entries | ListPlatformAuditEntries | platform#auditor | (none — read surface) | Cursor-paginated over the single platform-residency chain. |
| GET | /v1/platform/audit/entries/{seq} | GetPlatformAuditEntry | platform#auditor | (none — read surface) | 403 collapses onto 404 to avoid a chain-length probe oracle. |
| POST | /v1/platform/audit/verify | VerifyPlatformAuditChain | platform#auditor | (none — verification surface) | Divergence is 200 { ok: false, divergent_seq, expected_hash, observed_hash }, not a 5xx. |
| POST | /v1/platform/audit/erase-identity | EraseIdentityFromPlatformAudit | platform#manage | audit.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:
- The single-entry read collapses 403 onto 404. A caller who lacks the
auditorrelation receives the same404body as for an unknownseq— on bothGetAuditEntryandGetPlatformAuditEntry. The seq id is monotonic from1, so a403would leak the chain length and let an attacker probe for the existence of arbitrary rows. - 404-after-403 on the per-Domain list and verify surfaces. An unknown
domainIdonly surfaces as404after 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.) - 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 bothVerifyAuditChainandVerifyPlatformAuditChain.
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
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| every per-Domain operation | domainId (path) | string (uuid) | yes | Owning 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 / GetPlatformAuditEntry | seq (path) | integer (int64, ≥1) | yes | Per-chain monotonic sequence number assigned at append time; sequences start at 1 (genesis row) and never reset. |
| ListAuditEntries / ListPlatformAuditEntries | cursor (query) | string | no | Opaque HMAC-signed continuation. Cursors are bound to the addressed chain; replaying a cursor minted for a different chain → 400 cursor_invalid. |
| ListAuditEntries / ListPlatformAuditEntries | limit (query) | integer | no | [1, 200], default 50. Server-side clamp protects the read replica from an unbounded LIMIT. |
| ListAuditEntries / ListPlatformAuditEntries | subject (query) | string (^[0-9a-f]{64}$) | no | Filter 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 / ListPlatformAuditEntries | relation (query) | string | no | SpiceDB relation label to filter on. |
| ListAuditEntries / ListPlatformAuditEntries | object_type (query) | string | no | SpiceDB object type (e.g. domain, node, cloud, platform). |
| ListAuditEntries / ListPlatformAuditEntries | object_id (query) | string | no | Opaque object identifier within object_type. |
| ListAuditEntries / ListPlatformAuditEntries | reason (query) | AuditReason enum | no | ReBAC decision reason filter. |
| ListAuditEntries / ListPlatformAuditEntries | from / to (query) | string (date-time) | no | RFC 3339 brackets on occurred_at. to < from → 400. |
| ListAuditEntries / ListPlatformAuditEntries | correlation_id (query) | string | no | Inbound 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 + optionalnext_cursor). - Read with proof:
AuditEntryProof(the entry plusprev_hash,entry_hash, and the canonical bytes the chain hashed; off-line verification recomputessha256(prev_hash || sha256(canonical_bytes))and compares againstentry_hash). - Verification:
AuditChainVerifyRequest(optionalfrom_seq/to_seqsegment),AuditChainVerifyResult(ok+ onfalsethe offendingdivergent_seq,expected_hash,observed_hash). - Erasure:
AuditEraseIdentityRequest(carriesidentity_id),AuditEraseIdentityResponse(echoes the derivedsubject_pseudonymanderased_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.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_domain_id | 400 | every per-Domain operation | Malformed domainId UUID. |
cursor_invalid | 400 | List (per-Domain / platform) | HMAC verification failed, or the cursor was minted for a different chain. |
subject_invalid | 400 | List (per-Domain / platform) | subject not 64-character hex. |
range_invalid | 400 | List / Verify (per-Domain / platform) | to < from, or to_seq < from_seq, or negative seq. |
seq_invalid | 400 | GetAuditEntry / GetPlatformAuditEntry | seq < 1. |
identity_id_invalid | 400 | EraseIdentityFromAudit / EraseIdentityFromPlatformAudit | Malformed identity_id or empty body. |
not_found | 404 | List / Verify / Erase (per-Domain) | Domain id not resolved (post-authz). |
not_found | 404 | GetAuditEntry / GetPlatformAuditEntry | Unknown seq, cross-chain probe attempt, OR missing auditor relation — all three collapse onto the same body for probe-oracle defence. |
audit_not_provisioned | 501 | every operation | The composition root has not wired the audit service onto this build. |
internal | 500 | every operation | Persistence-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
../../contexts/audit/index.md— bounded-context entry point.../../contexts/audit/chain.md— the hash chain construction and per-Domain residency contract.../../contexts/audit/erasure.md— retention and right-to-erasure narrative the/erase-identityendpoint implements.../../contexts/audit/access.md— theauditorReBAC relation and the read-side authz model.../../contexts/audit/threat-model.md— the probe-oracle defences this surface implements and the attacker model they assume.../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; the per-Domain*Audit*operations, the*PlatformAudit*operations, and theAuditEntryList/AuditEntryProof/AuditChainVerifyRequest/AuditChainVerifyResult/AuditEraseIdentityRequest/AuditEraseIdentityResponse/AuditReasonschemas (shared by both surfaces).../../../internal/audit/— the bounded-context implementation: hash chain, pseudonym pepper, SIEM fan-out, and the erasure flow.../../../internal/transport/http/v1/handlers/— the transport-tier audit handlers:audit_list.go/audit_get.go/audit_verify.go/audit_erase.gofor the per-Domain chain, and theaudit_platform_*.gofiles for the platform-residency chain.