Skip to main content

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:

SurfaceRouteScopeWho
Platform-wide audit/ops/auditCross-org (every org + platform-level events)SuperAdmin only
Per-client audit tab/ops/clients/[id]Audit tabSingle orgSuperAdmin (AM sees a subset)
Customer-facing feedclient portalCaller's own orgClient 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.tsAuditLog, table audit_log) carries:

ColumnMeaning
idUUID primary key
user_idActor user id — nullable; null for system / automated actions
org_idClient (org) context — nullable for platform-level events
actionDotted action verb, e.g. conversation.create, queue.resolve, impersonation.start
resource_typeEntity acted upon, e.g. conversation, expert_queue_item, organization
resource_idId of that entity (nullable)
payloadJSONB diff / context (confidence, risk level, actorRole, actorName, etc.)
ip_addressClient IP (nullable)
created_atTimestamp (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 / MFAauth.mfa.enrolled, auth.mfa.verified, auth.mfa.login_success, auth.mfa.login_failure, auth.mfa.disabled, auth.mfa.recovery_codes_regenerated
  • User adminadmin.user.updated, admin.user.role_updated, admin.user.removed, admin.user.password_reset_initiated
  • Impersonationimpersonation.start, impersonation.force_end
  • Conversations / messagesconversation.create, conversation.close, conversation.assign_agent, conversation.status_change, message.send, message.internal_note
  • Expert queuequeue.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)
  • Draftsdraft.release, draft.discard, draft.regenerate
  • Knowledge basekb.document.uploaded, kb.document.approved, kb.document.rejected, kb.document.edited, kb.document.deleted, kb.document.hard_deleted, kb.document.restored, kb.search.executed
  • Channelschannel.slack.event, channel.email.inbound, channel.whatsapp.message, channel.telegram.message, channel.teams.message, plus WhatsApp pairing events
  • Integrations / Haystackintegration.telegram.connected, haystack.paused, haystack.reactivated, haystack.*.blocked_paused
  • Org lifecycle / billingorg.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 flagsfeature_flag.toggle (also recorded in the per-flag feature_flag_audit table)

admin.user.removed survives hard-deletes. audit_log.user_id is nullable with no FK to users, so a removal event (and its actor reference) persists even after the user row is hard-deleted. See api/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

SurfaceEndpointMatching
Platform-wide (SA)GET /admin/audit-logAll 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 tabAuditLogService.listForOrg(orgId)Same org-match brackets, single org
Customer-facing feedGET /audit-logorg_id from the caller's JWT only
  • The platform-wide endpoint (AdminAuditLogController) is guarded by JwtAuthGuard + PlatformRolesGuard with @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 returns actor_name / actor_user_id as null, so internal Expert/AM identities never reach client users. Only the SA/AM Ops surfaces (listForOrg and /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_id null) keep actor_name: null and render by actor_role instead.
  • Only valid UUID-shaped ids are queried, so legacy "system" actor strings or test seeds never throw invalid 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:

FilterMaps toNotes
ClientorgIdDropdown of all orgs (getSuperadminOrgs); "All clients" = unfiltered
Actor IDactorUserIdExact user id match
ActionactionExact action match, e.g. message.send
EntityentityTypeExact resource_type match, e.g. conversation
From / Tofrom / todatetime-local, sent as ISO-8601; bounds created_at
Rowslimit25 / 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, id tiebreak, base64-encoded). Previous/Next walk a cursor stack; a total count 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.