Skip to content

Audit Export

Two things an auditor asks for: a copy of the audit trail for a period, and a statement of what is currently retained. Both are read-only, both are available to the auditor role, and both work over the management API as well as the CLI.

Exporting a range

record-store audit-export export \
  --from 2026-01-01T00:00:00Z \
  --to   2026-02-01T00:00:00Z \
  --format json \
  --out ./audit-2026-01 \
  --endpoint https://management.example.com

The range is [from, to) — inclusive of the start, exclusive of the end. That is what makes consecutive exports tile: January and February together contain every record exactly once, with nothing duplicated at the boundary and nothing lost.

--format is json or csv.

What the directory contains

audit-2026-01/
├── audit.json        the records
├── manifest.json     range, format, export id, who exported it
├── checkpoints.json  the checkpoint roots covering the range
└── SHA256SUMS        digests of the three files above

Check the copy with the tool you already have:

cd audit-2026-01 && sha256sum -c SHA256SUMS

What an export proves, and what it does not

SHA256SUMS establishes that the copy reached you unaltered. It is computed by the client as the bytes arrive, so it covers the transfer.

It does not establish that the log was not edited before the copy was taken. An export is a copy of what the server says the trail contains. Only a checkpoint covering the range — and, beyond that, an external anchor over that checkpoint — turns a copy into evidence about the past.

checkpoints.json states which of the two you have:

{
  "status": "unavailable",
  "reason": "chain_not_enabled",
  "detail": "this deployment does not maintain a tamper-evident audit chain, so no
             checkpoint covers this range. The SHA256SUMS file establishes that this
             copy reached you unaltered; it does not establish that the log was not
             edited before the copy was taken."
}

That file is always written. A missing section would read as "nothing to report"; an explicit unavailable reads as "this was not established", and the two are different claims.

Checkpoints are not implemented yet

Every export from this release reports chain_not_enabled. The field exists so exports taken now stay readable by tooling built later, and so nobody mistakes an unanchored copy for an anchored one.

Formats

json is a single JSON array, streamed. It parses with any JSON reader:

jq '[.[] | select(.result == "denied")] | length' audit-2026-01/audit.json

csv is RFC 4180 with a pinned column order:

timestamp,event_id,principal,credential_id,source_ip,request_id,operation,resource,result,metadata

metadata holds a JSON object in one column. Flattening it into columns would make the column set depend on the data, so two exports of the same deployment could disagree about their own shape.

Bounds

An export never loads the range into memory — records are paged from the store and streamed to disk as they arrive, on both the server and the client. An auditor asking for a year of a busy deployment gets a long download, not an outage.

Every export is recorded

Requesting an export writes an audit record naming who asked and for what:

record-store audit --limit 50 | grep audit.export

The record carries the export id, the range, and the format. It is written when the export is authorized, not when the bytes finish — a transfer that fails midway still represents a range being handed out, and that is the fact worth keeping. If the record cannot be written, the export is refused rather than performed untracked.

One consequence worth knowing: an export's own record lands in the audit trail, so an export whose range includes the present will contain the record of itself.

Retention report

Which buckets have Object Lock, what is currently held, and when each retention expires:

record-store audit-export retention-report --endpoint https://management.example.com
{
  "generated_at": "2026-09-20T07:06:07Z",
  "buckets": [
    { "bucket": "records", "default_retention": { "mode": "compliance",
                                                  "period": { "unit": "days", "value": 2555 } } }
  ],
  "versions": [
    { "bucket": "records", "key": "statement.pdf", "version_id": "…",
      "retention_mode": "COMPLIANCE", "retain_until": "2033-01-01T00:00:00Z",
      "legal_hold": false, "status": "held" }
  ],
  "held_count": 1,
  "truncated": false
}

status distinguishes two things a single list would blur:

status Meaning
held A retention has not elapsed, a legal hold is on, or both
elapsed A lock record exists, but nothing it describes still holds the version

An elapsed entry is still listed. The record is still there, and an auditor asking what is retained wants to see it rather than have it disappear — but it is not counted in held_count, because it no longer protects anything.

generated_at matters: retention is relative to the current time, so a report without its own timestamp cannot be interpreted later.

truncated is true when the scan stopped before the end of the lock table. The report is bounded so it stays readable; truncation is reported rather than silent, because a report that quietly stopped would understate what is retained.

The scan walks the Object Lock table, which holds only locked versions, so a deployment with a million objects and ten locks pays for ten.

See Object Lock for how retention is applied in the first place.

Roles

Route Auditor Storage admin System admin
GET /api/v1/audit/export yes yes yes
GET /api/v1/audit/export/manifest yes yes yes
GET /api/v1/reports/retention yes yes yes

The auditor role is read-only everywhere, so these are reachable with a token that can change nothing. See Audit Log.