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 inaudit_events and are
written by two paths:
-
Successful mutations — handlers call
audit_log(session, descriptor, entity_key, old, new, request, …)before committing the domain change. The helper stages a row inaudit_outbox; theAuditOutboxPromoterworker moves it intoaudit_eventson a 1-second tick. Crash recovery is safe because the outbox row commits atomically with the domain mutation. -
Denied authorization (401/403) — the
AuditMiddlewarewrites a syntheticentity_type='access_denied'row directly via a short-lived session. The middleware is registered as the OUTERMOST wrap ofHMACAuthMiddleware, so short-circuit responses still flow back through it andrequest.state.auth(populated by HMAC pre-raise) provides the actor.
Schema
audit_events:
Append-only guarantee
Postgres enforces append-only via theaudit_events_append_only trigger. UPDATE/DELETE on audit_events raise an
exception; DELETE on audit_outbox is also blocked.
Redaction tiers
Defined insrc/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:- Choose a cutoff timestamp
C(e.g. 6 months prior). - Postgres:
audit_eventslives in the network-scoped schema (mainnetortestnet), notpublic, so qualify every statement — a plainpsqlsession has nosearch_pathand will report the relation as missing. RunSET search_path TO mainnet, public;first, or qualify inline. Drop the trigger temporarily —DROP TRIGGER audit_events_prevent_delete ON <network>.audit_events;— thenDELETE 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_outboxforpromoted_at < :Crows if needed. - 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 insrc/qtg/infrastructure/audit/bootstrap_descriptors.py; per-handler body invocations land incrementally. The AST lint testtests/qtg/test_audit_log_call_coverage.pyfails when a registered descriptor has no matching handler call. - Separate DB role (
qtg_audit_reader) — deferred to v0.2.0.