Skip to content

Latest commit

 

History

History
135 lines (105 loc) · 5.46 KB

File metadata and controls

135 lines (105 loc) · 5.46 KB

Audit log — reference

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.

Overview

  • 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 is sha256(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 structured audit.event log line; .store exposes the backing store for the read API.

Usage

Recording

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,
    )
)

Read API (Step 6.6a)

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/verify

WORM signed export (Step 6.6b)

POST /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 over f"{timestamp}.{content_hash}", present when cfg.audit.export_secret is set (else null — 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 offline

Configuration

audit:
  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.

Internals

  • One shared store. build_app creates a single AuditWriter over a hash-chain store, exposes it on app.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 is NoopAuditStore (in-memory); inject a durable one via build_app(audit_writer=…).
  • Tenant scoping at the boundary. GET /v1/audit filters store.events() by ctx.tenant_id in the handler — cross-tenant events never leave. Chain verification is whole-log (the chain is one global sequence).
  • What gets audited today. Every /v1/query writes a corpus.route event via the corpus router. Expanding coverage (ACL / PII / ingest decisions) is a follow-up; the store + chain + read API are in place for it.

Extension points

Implement a durable AuditStore (Postgres, S3, an append-only ledger):

  1. Subclass rag_core.spi.audit_store.AuditStore.
  2. append(event) must persist immutably and extend the hash chain (sha256(prev_hash + event.model_dump_json())).
  3. events() returns insertion order (oldest-first); verify_chain() recomputes the chain (consider incremental verification for large logs).
  4. Inject via build_app(audit_writer=AuditWriter(MyStore())).