The audit log is a tamper-evident, append-only record of security- and compliance-relevant actions. Step 0.7c shipped the store + writer; Step 6.6a adds the HTTP read API. The signed WORM export is Step 6.6b.
AuditEvent(rag_core.types) — an immutable (frozen) record:id,tenant_id,principal_id,action(dot-namespaced, e.g.corpus.route),resource,outcome(allowed/denied/error),trace_context,metadata,timestamp.AuditStore(rag_core.spi.audit_store) — append-only SPI:append(event),events(),verify_chain(),health().NoopAuditStore— in-memory reference impl that links events into a SHA-256 hash chain: each entry's hash issha256(prev_hash + event_json)(genesis sentinel for the first).verify_chain()recomputes every link and returns False if any event or stored hash was altered.AuditWriter(rag_core.audit) — facade:write(event)appends to the store and emits a structuredaudit.eventlog line;.storeexposes the backing store for the read API.
from rag_core.audit import AuditWriter
from rag_core.spi.noop import NoopAuditStore
from rag_core.types import AuditEvent, AuditOutcome
writer = AuditWriter(NoopAuditStore())
writer.write(
AuditEvent(
tenant_id=ctx.tenant_id,
principal_id=ctx.principal.id,
action="corpus.route",
resource="corpus-a",
outcome=AuditOutcome.allowed,
trace_context=ctx.trace,
)
)GET /v1/audit — the calling tenant's own events, newest-first. Auth via
Authorization / X-Tenant-Id headers (a tenant never sees another tenant's
events).
| Param | Default | Meaning |
|---|---|---|
limit |
100 | page size, clamped to [1, 1000] |
action |
— | exact-match filter (e.g. corpus.route) |
outcome |
— | allowed / denied / error |
Response (AuditListResponse): { tenant_id, events: [AuditEvent…], returned, total, chain_verified }. chain_verified is the whole-log integrity result at
read time.
GET /v1/audit/verify — whole-log hash-chain integrity (the chain spans all
tenants, so this is global). Response (AuditVerifyResponse): { ok, event_count }.
Both return 404 when the read API is disabled (cfg.audit.enabled = false)
and 401 without tenant credentials.
curl -H "X-Tenant-Id: acme" -H "X-Principal-Id: alice" localhost:8000/v1/audit
curl -H "X-Tenant-Id: acme" -H "X-Principal-Id: alice" localhost:8000/v1/audit/verifyPOST /v1/audit/export returns a self-verifying AuditExport bundle of the
calling tenant's events — the artifact you archive to immutable (WORM)
storage (e.g. S3 Object Lock). Two independent integrity checks:
content_hash— SHA-256 over the canonical events (pins the exact set + order; recomputed on verify, so tampering is caught even without the secret).signature— HMAC-SHA256 overf"{timestamp}.{content_hash}", present whencfg.audit.export_secretis set (elsenull— content-hashed but unsigned).
chain_verified attests the source log's whole-log hash chain was intact at
export time. Verify a bundle offline with AuditExporter:
from rag_core.audit_export import AuditExporter
from rag_core.types import AuditExport
result = AuditExporter("my-secret").verify(AuditExport.model_validate_json(blob))
# result.reason ∈ {ok, unsigned, no_secret, content_mismatch, signature_mismatch}ragctl audit drives the whole round-trip in-process (seed → export → verify):
ragctl audit --secret demo-secret --out audit.json # export the whole log
ragctl audit --verify audit.json --secret demo-secret # verify a bundle offlineaudit:
enabled: true # expose GET /v1/audit + /v1/audit/verify (default on)
export_secret: ${AUDIT_EXPORT_SECRET} # HMAC key for the WORM export (empty = unsigned)Events are always recorded into the store; enabled only gates the HTTP read
surface, and export_secret only controls whether export bundles are signed.
- One shared store.
build_appcreates a singleAuditWriterover a hash-chain store, exposes it onapp.state.audit_store/audit_writer, and hands the same writer to the corpus router — so the events the writers append are exactly what the read API serves. The default store isNoopAuditStore(in-memory); inject a durable one viabuild_app(audit_writer=…). - Tenant scoping at the boundary.
GET /v1/auditfiltersstore.events()byctx.tenant_idin the handler — cross-tenant events never leave. Chain verification is whole-log (the chain is one global sequence). - What gets audited today. Every
/v1/querywrites acorpus.routeevent via the corpus router. Expanding coverage (ACL / PII / ingest decisions) is a follow-up; the store + chain + read API are in place for it.
Implement a durable AuditStore (Postgres, S3, an append-only ledger):
- Subclass
rag_core.spi.audit_store.AuditStore. append(event)must persist immutably and extend the hash chain (sha256(prev_hash + event.model_dump_json())).events()returns insertion order (oldest-first);verify_chain()recomputes the chain (consider incremental verification for large logs).- Inject via
build_app(audit_writer=AuditWriter(MyStore())).