Skip to content

Platform audit log

This is the authoritative bounded-context reference for the Platform Audit Log that ships under internal/audit and exposes through /v1/domains/{domainId}/audit/... for Domain-scoped chains and /v1/platform/audit/... for the platform-residency chain. The reference is split into five focused pages plus this entry point.

The Platform Audit Log is the forensic substrate of last resort. When a post-incident investigator asks "who did what, when, and from where?" inside a plexsphere deployment, every privileged action — ReBAC grants and denials, bootstrap-token issuance / consumption / revocation, signing-service Sign and rotation transitions, IdP-binding edits, Domain / Project lifecycle changes, Cloud lifecycle changes, Invitation create / read / revoke / accept and the expiry sweep, Identity listing reads, Label Registry definition and assignment edits, group-membership changes, approval-workflow transitions, and the future Secret-Store and Session-issuance paths — lands here as one row on a hash chain in PostgreSQL: a per-Domain chain when the action is owned by a Domain, or the single platform-residency chain when it is platform-scoped and no Domain owns it (Cloud lifecycle in full, platform-scope Label definitions, and the Invitation expiry sweep). Every row is mirrored to long-term object storage and recoverable end-to-end through the per-Domain and platform read endpoints this reference pins.

Pages

  • Storage topology — the two-store split (Postgres primary, object-store mirror) and the canonical-byte encoder pin the chain hash is computed over.
  • Hash chain and residency model — the three-state chain machine, the per-Domain advisory lock, and the fan-out rule for cross-Domain decisions.
  • Retention and right to erasure — the retention matrix mapping every data-protection invariant to the seam that enforces it, plus the pseudonymise-from-inception erasure flow.
  • Read access and OpenAPI surface — the domain#auditor ReBAC rule and the four read operations (ListAuditEntries, GetAuditEntry, VerifyAuditChain, EraseIdentityFromAudit).
  • Threat model, production wiring, and boundaries — the four attacker shapes the substrate defends against, the composition root that wires the surface, and the explicit "what this context is NOT" boundary against the downstream consumers.

See also

  • ../../contributing/layout.md — the bounded-context map row that locates internal/audit (and its chain/, archiver/, erasure/, query/, repo/ sub-packages) inside the codebase and enumerates the downstream consumer stories that depend on this substrate.
  • ../../architecture/overview.md — the "Forensic substrate — Platform Audit Log" callout that shows the two-store split (Postgres primary + object-store mirror) on the high-level component diagram and cross-links back to this reference.
  • ../../../README.md#platform-audit-log — the canonical subsystem specification: every operator action on plexsphere itself, tamper-evident, captures the full approval workflow, and is separate from the node-side audit data ingested from plexd.
  • ../approvals.md — the Approval Workflow sub-context, one of the decision surfaces that writes onto the per-Domain hash chain documented here: each approve / reject / expire / break-glass decision records an approval.* relation row through this context's Sink, while the break-glass reason value is held off the chain on a PII-safe channel.
  • ../../../README.md#data-protection--audit-retention — the data-protection invariants: write-once, default 7-year retention, right-to-erasure via deterministic pseudonymisation (not deletion), PII minimisation, per-Domain data residency. These are enforced at the persistence boundary in this context, not by operator convention.
  • ../../../README.md#storage-topology — the Postgres + object-store split for the Platform Audit Log; Loki carries node-side audit separately (the no-node-audit-on-platform-chain depguard rule keeps the two streams structurally separate).
  • ../../../api/openapi/plexsphere-v1.yaml — OpenAPI 3.1 spec; ListAuditEntries, GetAuditEntry, VerifyAuditChain, and EraseIdentityFromAudit operations plus the AuditEntry, AuditEntryProof, AuditChainVerifyResult, and AuditEraseIdentityRequest schemas live there.
  • ../../../schema/authz.zed — the ReBAC schema. domain#auditor is the relation the read surface enforces; the comment block above the relation pins the DECISION to reuse it rather than introduce a parallel audit_reader relation.
  • ../../../internal/audit/doc.go — the package-level pin of the same DECISION on the Go side, so a reader chasing the read-relation from code finds the rationale without leaving the file.

Ubiquitous language

The terms below travel together across the Go code, the SQL migration, the OpenAPI contract, the audit-row vocabulary, the structured-log attributes, the Prometheus metric label values, and operator-facing tooling. Names are preserved verbatim in error messages and audit-row payloads so a reader chasing a string from a log line finds it in the source without translation.

TermDefinitionCode anchor
AuditEntryThe aggregate root of one row on the per-Domain hash chain. Carries (domain_id, seq, subject_pseudonym, relation, object, reason, relation_path, caveat_context, correlation_id, zedtoken, recorded_at, prev_hash, entry_hash). The shape is frozen — this context changes no field, no ordinal, no method on the aggregate root inherited from the upstream emitter contract.internal/audit/entry.go
ReasonThe frozen four-element ReBAC decision-reason taxonomy: granted, out_of_scope, insufficient_relation, caveat_violation. Persisted as the smallint reason column (ordinals 1-4); a fifth value is a breaking schema change, not a switch-statement extension.internal/audit/entry.go
SinkThe narrow port every privileged code path emits decisions through (Record(ctx, entry) error). The default slogSink ships in internal/audit/sink.go; the production binary replaces it with the hash-chained adapter wired by cmd/plexsphere/audit_factory_prod.go on top of the ChainStore / Pepper / DomainResolver ports. The port shape is the contract that lets every emitter remain unaware of which sink is wired.internal/audit/sink.go, internal/audit/ports.go
PseudonymThe 32-byte deterministic projection sha256(pepper(domain_id) ‖ subject_id) that every chain row references in place of subject plaintext. Erasure removes the plaintext lookup; the pseudonym (and therefore the chain row) survives untouched.internal/audit/pseudonym.go
PepperThe per-Domain secret bound to pseudonym derivation, resolved through the Pepper port. Pepper rotation breaks forensic continuity (existing rows would reference an unreachable pseudonym) and is out of scope for this context — see Threat model, production wiring, and boundaries; pepper rotation belongs to the downstream tenant-side erasure UX work.internal/audit/pseudonym.go
DomainResolverThe port that maps (Subject, Object) on an Entry onto the anchor that owns the resulting chain row. Dual residency: a Domain-scoped action lands on its Domain's chain (cross-Domain decisions land in EACH affected Domain's chain), a platform-scoped action lands on the single platform-residency chain at the reserved platform anchor, and an object whose residency cannot be determined fails closed.internal/audit/ports.go
CanonicalBytesThe deterministic length-prefixed binary projection of an Entry produced by audit/chain.Canonical. Magic prefix PXA1; field order is the canonical-encoder pin and never changes within a generation. The hash chain consumes these bytes — any drift in the encoder retroactively invalidates every downstream row.internal/audit/chain/canonical.go
EntryHashsha256(prev_hash ‖ sha256(canonical_bytes)). The 32-byte hash that anchors row N to row N-1; the genesis row's prev_hash is 32 zero bytes. Stored in audit_log_entry.entry_hash and re-derived row-by-row by the verifier.internal/audit/chain/hash.go
Chain headThe per-Domain (next_seq, head_hash) tuple. audit_log_chain_head carries one row per Domain, advanced under the per-Domain advisory lock by every successful append.internal/audit/repo/append.go, 0011_audit_log.sql
SeqThe per-Domain monotonic, gap-free sequence number assigned at append time. seq=1 is the genesis row; sequences never reset. Density is a tamper-evidence invariant — the verifier reads gaps as evidence of corruption.0011_audit_log.sql
Names-only caveatThe structural rule that caveat_context carries caveat parameter NAMES only, never bound values. Enforced at three layers: the SQL audit_log_entry_caveat_names_only CHECK, the []string Go type, and the OpenAPI x-plexsphere-names-only extension policed by the plexsphere-audit-caveat-names-only Spectral rule.0011_audit_log.sql, tools/openapi/.spectral.yaml
Tamper quarantineThe sibling audit_tamper_quarantine row recording a verifier-detected divergence at (domain_id, divergent_seq) with both expected and observed hashes. Lives outside the live chain — see the file-header DECISION block on 0011_audit_log.sql for why this is a separate table.0011_audit_log.sql
ArchiverThe pull-drain background worker that fans archived_at IS NULL rows out to audit/<domain_id>/<seq:020d>.json.zst on the per-Domain bucket and stamps archive_etag on success. Idempotent: re-running on an already-uploaded (domain_id, seq) preserves the etag.internal/audit/archiver/worker.go, internal/audit/archiver/upload.go
CursorThe opaque, HMAC-signed pagination token returned by ListAuditEntries. Carries (domain_id, seq, version, mac) in 41 bytes; cross-Domain smuggle surfaces as audit.ErrCursorInvalid.internal/audit/query/cursor.go
Reconcile probeThe boot-time + /readyz-mounted check that verifies every Domain's chain end-to-end. Failure flips /readyz to 503 and records a tamper_detected row in the quarantine table.internal/platform/bootstrap/audit_reconcile.go
AuditRelationEraseIdentityThe canonical Relation string the right-to-erasure handler stamps on the self-audit row it appends after purging an audit_subject_pii mapping: audit.erase-identity. Stable so audit consumers can filter by relation.internal/audit/erasure/service.go