Skip to main content

Audit Log (SD-2a)

The audit subsystem records who did what to which entity, plus the request context the action was performed under. Rows live in audit_events and are written by two paths:
  1. Successful mutations — handlers call audit_log(session, descriptor, entity_key, old, new, request, …) before committing the domain change. The helper stages a row in audit_outbox; the AuditOutboxPromoter worker moves it into audit_events on a 1-second tick. Crash recovery is safe because the outbox row commits atomically with the domain mutation.
  2. Denied authorization (401/403) — the AuditMiddleware writes a synthetic entity_type='access_denied' row directly via a short-lived session. The middleware is registered as the OUTERMOST wrap of HMACAuthMiddleware, so short-circuit responses still flow back through it and request.state.auth (populated by HMAC pre-raise) provides the actor.

Schema

audit_events:

Append-only guarantee

Postgres enforces append-only via the audit_events_append_only trigger. UPDATE/DELETE on audit_events raise an exception; DELETE on audit_outbox is also blocked.

Redaction tiers

Defined in src/qtg/infrastructure/audit/redaction.py:
  • Full (***redacted***): hmac_secret, api_secret, private_key, seed, mnemonic, kms_alias, access_token, … (substring match).
  • Partial (hong***dong): names, emails, phones — adaptive slice so a 3-character Korean name reveals 2 + … + 1 and a long email reveals 4 + … + 3.
  • No redact: wallet addresses, amounts, asset codes, status strings — forensic value outweighs privacy cost.

Retention (manual procedure)

The system never deletes audit rows automatically. Operators run the manual procedure when storage requires reclamation:
  1. Choose a cutoff timestamp C (e.g. 6 months prior).
  2. Postgres: audit_events lives in the network-scoped schema (mainnet or testnet), not public, so qualify every statement — a plain psql session has no search_path and will report the relation as missing. Run SET search_path TO mainnet, public; first, or qualify inline. Drop the trigger temporarily — DROP TRIGGER audit_events_prevent_delete ON <network>.audit_events; — then DELETE FROM <network>.audit_events WHERE created_at < :C; Restore it immediately after: CREATE TRIGGER audit_events_prevent_delete BEFORE DELETE ON <network>.audit_events FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();. Run the same pair on <network>.audit_outbox for promoted_at < :C rows if needed.
  3. Capture the row count and operator who performed the cleanup in the incident log. Future automation should call a dedicated CLI; until then the manual procedure is intentional so no scheduled job can quietly tamper with the audit surface.

Query API

GET /v3/audit/events is available to admin and operator roles. The query itself emits a meta-audit row (entity_type='audit_log', action='query').

Pending work

  • Handler-side audit_log() calls: descriptor registration is centralized in src/qtg/infrastructure/audit/bootstrap_descriptors.py; per-handler body invocations land incrementally. The AST lint test tests/qtg/test_audit_log_call_coverage.py fails when a registered descriptor has no matching handler call.
  • Separate DB role (qtg_audit_reader) — deferred to v0.2.0.