Feature: Platform Audit Log
The audit log is h.work's append-only record of every significant action taken across the platform — who did what, to which resource, in which client (org), and when. It backs three surfaces:
| Surface | Route | Scope | Who |
|---|---|---|---|
| Platform-wide audit | /ops/audit | Cross-org (every org + platform-level events) | SuperAdmin only |
| Per-client audit tab | /ops/clients/[id] → Audit tab | Single org | SuperAdmin (AM sees a subset) |
| Customer-facing feed | client portal | Caller's own org | Client users |
This page documents the SuperAdmin platform-wide page (/ops/audit), shipped in #1298 (closes #1238), plus the shared event model that feeds all three surfaces.
Where events come from
Every mutating API action calls AuditService.log() (api/src/common/audit.service.ts), which writes a row to the audit_log table. Per ADR-019 (data-consistency contract) the write is eventual / fire-and-forget:
- It runs after the business transaction commits, outside the RLS tx — an audit failure must never roll back the business write.
AuditService.log()swallows errors (logs and moves on). A dropped audit row is acceptable; a rolled-back business write is not.
Because of this, the audit log is a strong operational forensics record but is not a guaranteed-complete legal ledger — under failure a small number of rows may be missing.
What is recorded
Each row (api/src/common/entities.ts → AuditLog, table audit_log) carries:
| Column | Meaning |
|---|---|
id | UUID primary key |
user_id | Actor user id — nullable; null for system / automated actions |
org_id | Client (org) context — nullable for platform-level events |
action | Dotted action verb, e.g. conversation.create, queue.resolve, impersonation.start |
resource_type | Entity acted upon, e.g. conversation, expert_queue_item, organization |
resource_id | Id of that entity (nullable) |
payload | JSONB diff / context (confidence, risk level, actorRole, actorName, etc.) |
ip_address | Client IP (nullable) |
created_at | Timestamp (timestamptz, indexed DESC) |
Indexes exist on user_id, org_id, (resource_type, resource_id), and created_at DESC.
Event families currently emitted
Actions are namespaced by domain. The set grows over time; representative families:
- Auth / MFA —
auth.mfa.enrolled,auth.mfa.verified,auth.mfa.login_success,auth.mfa.login_failure,auth.mfa.disabled,auth.mfa.recovery_codes_regenerated - User admin —
admin.user.updated,admin.user.role_updated,admin.user.removed,admin.user.password_reset_initiated - Impersonation —
impersonation.start,impersonation.force_end - Conversations / messages —
conversation.create,conversation.close,conversation.assign_agent,conversation.status_change,message.send,message.internal_note - Expert queue —
queue.create,queue.resolve,queue.respond,queue.defer,queue.snooze.resurfaced,queue.reopened,queue.status.update, plus cross-org/no-access denials (queue.resolve.denied_cross_org,queue.respond.denied_no_expert_access) - Drafts —
draft.release,draft.discard,draft.regenerate - Knowledge base —
kb.document.uploaded,kb.document.approved,kb.document.rejected,kb.document.edited,kb.document.deleted,kb.document.hard_deleted,kb.document.restored,kb.search.executed - Channels —
channel.slack.event,channel.email.inbound,channel.whatsapp.message,channel.telegram.message,channel.teams.message, plus WhatsApp pairing events - Integrations / Haystack —
integration.telegram.connected,haystack.paused,haystack.reactivated,haystack.*.blocked_paused - Org lifecycle / billing —
org.restore,org.auto_archive,org.billing_auto_suspend,osa.provision,osa.suspend,osa.restore,org.auto_reply_config.update - Expert access (ADR-007) —
expert.assigned_to_org,expert.unassigned_from_org,expert_access.request_denied_org,expert_access.request_denied_no_grant - Feature flags —
feature_flag.toggle(also recorded in the per-flagfeature_flag_audittable)
admin.user.removedsurvives hard-deletes.audit_log.user_idis nullable with no FK tousers, so a removal event (and its actor reference) persists even after the user row is hard-deleted. Seeapi/src/auth/CLAUDE.md.
Human-readable summaries
formatAuditEntry() (api/src/audit/audit-log.service.ts) turns each row into a sentence shown in the Event column, e.g. "AI agent responded to conversation #a1b2c3d4 with high confidence" or "Expert Jane approved response for conversation #a1b2c3d4". Unmapped actions fall back to a generic Noun verb (#ref) form, so new event types render sensibly before a bespoke summary is added.
Scope & isolation
| Surface | Endpoint | Matching |
|---|---|---|
| Platform-wide (SA) | GET /admin/audit-log | All rows; optional orgId filter matches org_id = :orgId OR resource_type = 'organization' AND resource_id = :orgId (catches actions on the org record itself) |
| Per-client Audit tab | AuditLogService.listForOrg(orgId) | Same org-match brackets, single org |
| Customer-facing feed | GET /audit-log | org_id from the caller's JWT only |
- The platform-wide endpoint (
AdminAuditLogController) is guarded byJwtAuthGuard+PlatformRolesGuardwith@PlatformRoles("superadmin")— SuperAdmin only. The frontend page additionally redirects any non-SuperAdmin to/ops/clients. - Actor identity is never leaked to clients. The customer-facing
list()path returnsactor_name/actor_user_idasnull, so internal Expert/AM identities never reach client users. Only the SA/AM Ops surfaces (listForOrgand/admin/audit-log) resolve actor names.
Actor resolution
After fetching a page of rows, the controller batch-loads users by the distinct set of non-null user_id values and attaches a human-readable actor_name — "Display Name (email)", preferring the profile displayName over the generic name, falling back to just the email (#3008 / #3290). Notes:
- System events (
user_idnull) keepactor_name: nulland render byactor_roleinstead. - Only valid UUID-shaped ids are queried, so legacy
"system"actor strings or test seeds never throwinvalid input syntax for type uuid.
Filter UI (/ops/audit)
The page (frontend/src/app/ops/audit/page.tsx) renders a filter bar over a paginated table:
| Filter | Maps to | Notes |
|---|---|---|
| Client | orgId | Dropdown of all orgs (getSuperadminOrgs); "All clients" = unfiltered |
| Actor ID | actorUserId | Exact user id match |
| Action | action | Exact action match, e.g. message.send |
| Entity | entityType | Exact resource_type match, e.g. conversation |
| From / To | from / to | datetime-local, sent as ISO-8601; bounds created_at |
| Rows | limit | 25 / 50 / 100 (server caps at 100) |
- Columns: Event (summary + raw action) · Client (org name, or "Platform" for null-org events) · Actor (name or role + copyable user id) · Entity (type + copyable id) · When (relative time, full timestamp on hover).
- Pagination is cursor-based (
createdAt,idtiebreak, base64-encoded). Previous/Next walk a cursor stack; atotalcount is shown alongside. - CSV export ("CSV" button) paginates the filtered result set at 100/page up to a 10,000-row cap (warns and truncates beyond that — narrow the filters to export the remainder). Exported columns:
timestamp, org_id, actor_user_id, actor_name, actor_role, action, entity_type, entity_id, summary. Values are escaped against CSV formula injection (a leading=,+,-,@, or tab is prefixed so Excel/Sheets treat them as text).
Retention
There is no automated retention or TTL job for audit_log — rows persist indefinitely until manually pruned. This is deliberate for a forensic record, but it means the table grows unbounded; capacity is an ops concern, not an application one. (If/when a retention policy is added, update this section and add the cleanup job to the multi-pod cron inventory.)
Forensics
For incident forensics — reconstructing who changed what, confirming a kill-switch flip, or tracing an impersonation session — start at /ops/audit, filter by Client and time window, then narrow by Action / Entity. Export to CSV to attach to the incident record. See docs/runbooks/incident-response.md. Impersonation specifically also has its own trail at /ops/impersonation.
Related
- ADR-019 — data-consistency contract (audit is intentionally eventual)
- ADR-007 — expert-access scope
api/src/common/CLAUDE.md—AuditService.log()contractdocs/test-cases/OpsPortal-AuditLogUX-test.md,docs/test-cases/OpsPortal-AuditLogActorResolution-Regression-3008-test.md