Skip to main content

Audit Event System

Overview

Heimdall records security-relevant and operational actions as audit events. Every sign-in, permission change, OAuth grant, integration connect, file operation, trip edit, and admin action is captured in a single, queryable activity log that powers both the user-facing activity timeline (Heimdall ID) and the admin audit console (backend dashboard).

Audit events are stored in the AuditEvent TimescaleDB hypertable (see Databases & Caching). Using TimescaleDB gives the audit log time-based auto-partitioning suited to a high-volume, append-only, time-series workload, while relational data (users, the AuditEventReport table) stays in PostgreSQL. A single read query therefore joins across both pools.

Events are enriched with GeoIP location (country code, country name, city, region) at write time when an IP address is available, so the activity log can show where an action originated.

The system is implemented across three crates:

CrateResponsibility
heimdall-auditCore types and the catalog of event/resource/status/source constants
heimdall-audit-loggerAuditLogger — writes events to TimescaleDB with GeoIP enrichment
heimdall-rest / heimdall-graphqlREST endpoints and GraphQL queries/mutations for reading and reporting events

The CreateAuditEvent model

Events are constructed with a builder (heimdall_audit::CreateAuditEvent). Key fields:

FieldMeaning
user_idThe user the event relates to. May be a Heimdall UUID, an external ID (e.g. a Discord snowflake), or None for system events
event_typeOne of the event-type constants below
resource_type / resource_idThe affected resource (see resource constants)
actor_idWho performed the action (differs from user_id for admin actions); parsed as a UUID
ip_address / user_agentRequest context
descriptionHuman-readable summary
metadataArbitrary JSON detail (provider, changed fields, etc.)
status"success" (default) or "failure"
error_messageReason, set when status is failure
country_code / country_name / city / regionGeoIP-derived location (usually set by the logger)
source_serviceOriginating service (api, id, backend, policies, discord_bot, twitch_bot)

Three constructors exist: CreateAuditEvent::new(user_id, event_type), CreateAuditEvent::system_event(event_type) (no user, for bot_started etc.), and CreateAuditEvent::with_optional_user(opt, event_type).

Event categories & catalog

Event types are defined as pub const strings in crates/heimdall-audit/src/events.rs (92 constants), grouped by category. The slug (string value) is what is persisted in the database; the camelCase mirror lives in shared/api/src/types/audit.ts.

Authentication

SlugMeaning
loginUser successfully signed in
login_failedUser failed to sign in
logoutUser signed out
password_changedUser changed their password
password_reset_requestedUser requested a password reset email
email_change_requestedUser requested an email change (verification sent)

Two-factor authentication

SlugMeaning
2fa_enabledUser enabled 2FA
2fa_disabledUser disabled 2FA
2fa_verifiedUser successfully verified 2FA
2fa_failedUser failed to verify 2FA (wrong code)
2fa_backup_codes_regeneratedUser regenerated 2FA backup codes

Sessions

SlugMeaning
session_createdNew session was created
session_revokedA session was revoked
all_sessions_revokedAll sessions were revoked

Account

SlugMeaning
user_createdUser account was created
user_updatedUser profile was updated
user_deletedUser account was deleted
deletion_scheduledAccount deletion was scheduled
deletion_cancelledAccount deletion was cancelled

OAuth

SlugMeaning
consent_grantedUser granted OAuth consent to an application
consent_revokedUser revoked OAuth consent from an application
token_createdOAuth token was created
token_revokedOAuth token was revoked
client_createdOAuth client was created
client_updatedOAuth client was updated
client_secret_regeneratedOAuth client secret was regenerated
client_deletedOAuth client was deleted

Admin

SlugMeaning
user_bannedUser was banned by an administrator
user_unbannedUser was unbanned by an administrator
role_assignedRole was assigned to a user
role_removedRole was removed from a user
permission_changedA permission was changed

Role management

SlugMeaning
role_createdA new role was created
role_updatedA role was updated
role_deletedA role was deleted
role_permission_assignedA permission was assigned to a role
role_permission_removedA permission was removed from a role

Permission management

SlugMeaning
permission_createdA new permission was created
permission_updatedA permission was updated
permission_deletedA permission was deleted

API keys

SlugMeaning
api_key_createdAPI key was created
api_key_updatedAPI key was updated
api_key_revokedAPI key was revoked
api_key_regeneratedAPI key was regenerated (new key, same config)
SlugMeaning
account_linkedExternal account was linked
account_reconnectedExternal account was reconnected (data refreshed)
account_unlinkedExternal account was unlinked
primary_account_changedPrimary account was changed

Data

SlugMeaning
data_exportedUser exported their personal data

Reports

SlugMeaning
activity_reportedUser reported suspicious activity
report_updatedAdmin updated a report's status

Discord bot

SlugMeaning
bot_command_executedDiscord bot command was executed
bot_config_changedDiscord bot configuration was changed
bot_moderation_actionDiscord bot performed a moderation action
bot_permission_deniedDiscord bot denied permission to a user
bot_errorDiscord bot encountered an error
bot_startedDiscord bot started
bot_stoppedDiscord bot stopped
bot_guild_configuredDiscord guild was configured

Storage

SlugMeaning
file_createdFile was created/uploaded (requires storage:write)
file_downloadedFile was downloaded (requires storage:download)
file_editedFile metadata was edited (requires storage:edit)
file_deletedFile was deleted (requires storage:delete)

Modules (trips, locations, categories, templates)

SlugMeaning
trip_createdTrip was created
trip_updatedTrip was updated
trip_deletedTrip was deleted
location_createdTrip location was created
location_updatedTrip location was updated
location_deletedTrip location was deleted
trip_category_createdTrip category was created
trip_category_updatedTrip category was updated
trip_category_deletedTrip category was deleted
trip_template_createdTrip template was created
trip_template_updatedTrip template was updated
trip_template_deletedTrip template was deleted

Devices

SlugMeaning
device_createdGPS device was created
device_updatedGPS device was updated
device_deletedGPS device was deleted

Geofences

SlugMeaning
geofence_createdGeofence was created
geofence_updatedGeofence was updated
geofence_deletedGeofence was deleted

Platform settings

SlugMeaning
platform_setting_updatedPlatform setting updated by admin (e.g. enabled, two_factor_required, registration_enabled)

System settings

SlugMeaning
system_setting_createdSystem setting was created by admin
system_setting_updatedSystem setting was updated by admin
system_setting_deletedSystem setting was deleted by admin

Platform integrations

SlugMeaning
integration_connectedPlatform integration connected (OAuth completed)
integration_reconnectedPlatform integration reconnected (re-authorized with new/updated scopes)
integration_disconnectedPlatform integration disconnected
integration_token_refreshedIntegration OAuth token automatically refreshed
integration_token_errorIntegration token refresh or validation failed
integration_status_updatedIntegration status updated (e.g. Twitch bot badge toggled)
integration_stats_refreshedIntegration channel statistics refreshed

Resource types

Each event may reference a resource via resource_type. Resource constants live in crates/heimdall-audit/src/resources.rs:

auth, user, session, oauth_client, oauth_consent, oauth_token, api_key, role, permission, account_link, audit_report, platform, system_setting, storage_file, discord_bot, discord_guild, discord_command, discord_channel, discord_member, integration.

Status

Defined in crates/heimdall-audit/src/status.rs: success (default) and failure.

How events are logged

Events are written through AuditLogger (heimdall-audit-logger), shared by heimdall-rest, heimdall-graphql, and heimdall-scheduler.

let logger = AuditLogger::with_geoip(&tsdb_pool, &geoip)
.with_source_opt(req_info.source());
logger.log_login_with_app(user_id, ip, user_agent, provider, app).await;

Key behaviours (also codified in AGENTS.md):

  • Always construct with AuditLogger::with_geoip(pool, geoip), never ::new(). The GeoIP service is required so events are location-enriched. When an IP is present, the logger runs geoip.lookup(ip) and populates country_code, country_name, city, and region before the insert.
  • Forward client IP and User-Agent on every call (via with_request_context or the ip_address / user_agent arguments on the typed helpers).
  • Log failures explicitly with a reason. with_failure(reason) sets status = "failure" and error_message; typed helpers like log_login_failed and log_2fa_failed take a reason argument.
  • Use provider slugs (lowercase, e.g. "discord", not "Discord").
  • Use events::CONSTANT from heimdall_audit::events — never hardcode the string literals.
  • Fire-and-forget. Most helpers call log_fire_and_forget, which logs the event and swallows DB errors (tracing::error! only) so an audit write failure never breaks the user-facing request. The log() method returns the inserted AuditEvent for the few callers that need it (e.g. the internal logging endpoint).
  • Source resolution. The persisted source_service is taken from, in order: the event's own source_service, the logger's with_source(...), then the default "api". Webapps set this from the X-Source-Service header.

AuditLogger exposes typed helpers per category (e.g. log_login_with_app, log_logout_with_details, log_2fa_enabled, log_session_created, log_user_updated, log_consent_granted, log_role_assigned, log_api_key_created, log_integration_connected, log_platform_setting_updated, the Discord-bot sync helpers, etc.). Use log_login_with_app() and log_logout_with_details() to capture app context.

Reading & reporting events

Audit events are exposed over both REST and GraphQL. Read access for other users' or all events requires the audit:read permission; mutating reports requires audit:write.

REST (heimdall-rest/src/handlers/audit.rs)

Method & PathAuth / PermissionPurpose
GET /v1/users/me/auditAuthenticated userCurrent user's own activity log
GET /v1/users/{user_id}/auditaudit:readA specific user's audit log (admin)
GET /v1/admin/auditaudit:readAll audit events across all users (admin)
POST /v1/internal/auditSystem key (X-ID-App-Key) or audit:writeLog an event from an internal app
POST /v1/users/me/audit/{event_id}/reportAuthenticated userReport one of your events as suspicious
GET /v1/users/me/audit/reportsAuthenticated userList your submitted reports
GET /v1/admin/audit/reportsaudit:readList all reports (admin)
PATCH /v1/admin/audit/reports/{report_id}audit:writeUpdate a report's status (admin)

GraphQL (heimdall-graphql/src/{queries,mutations}/audit.rs)

QueriesmyAuditLog, userAuditLog (audit:read), allAuditEvents (audit:read), auditEvent(id) (audit:read), auditEventReport(auditEventId) (audit:read).

MutationslogAuditEvent (system key or audit:write), reportAuditEvent (authenticated user), myAuditReports, allAuditReports (audit:read), updateAuditReport (audit:write).

Filtering & pagination

Both surfaces accept the same filters (see AuditEventQuery):

  • page (1-indexed, default 1) and limit (default 20, max 100)
  • eventType, resourceType, status (success/failure)
  • sourceService (e.g. api, id, backend, discord_bot)
  • startDate / endDate (ISO 8601)
  • Admin-only on the "all events" endpoints: userId, userSearch (by ID, username, or email), sortField (event_type, user_id, status, created_at), sortDirection (asc/desc, default desc), and isReported

Reports

Users can flag an event as suspicious. A report's reason must be one of not_me, suspicious, unknown_device, unknown_location, other, and its status is one of pending, reviewed, resolved, dismissed. Submitting a report itself logs an activity_reported event; a status update logs report_updated. Confirmation and status-update emails are sent to the reporting user. The AuditEventReport table lives in PostgreSQL, while the referenced AuditEvent lives in TimescaleDB — handlers query both pools.

Adding a new event type (contributor guide)

Audit events are synchronized across 5 layers / 8 files. Adding a new event type means updating all of them so the Rust backend, shared TS client, UI icons, and both webapps' i18n stay in sync. (Reference: the "New Audit Event Checklist" in AGENTS.md.)

#FileWhat to add
1crates/heimdall-audit/src/events.rsThe Rust pub const (snake_case slug)
2shared/api/src/types/audit.tsEntry in AuditEventTypes + AuditEventCategories + AuditEventIcons + AuditFilterOptions (and a label in AuditEventLabels)
3shared/ui/src/components/AuditIcons/index.tsxLucide icon import + iconComponentMap entry (only if introducing a new icon)
4platform/backend/messages/en.jsonaudit.events.* (snake_case key)
5platform/backend/messages/de.jsonaudit.events.* (snake_case key)
6platform/id/messages/en.jsonactivity.events.* (camelCase key)
7platform/id/messages/de.jsonactivity.events.* (camelCase key)
8platform/id/src/app/account/activity/page.tsxgetEventLabel hardcoded map entry (snake_case → camelCase)

If you introduce a new category (not just a new event), also update the category filter maps: filterLabelMap in the backend audit page, filterLabels in the ID activity page, and the filter translation strings in all four message files.

Note on field conventions: Rust uses snake_case; GraphQL/TypeScript uses camelCase (auto-converted). The persisted event-type slug is always the snake_case string (e.g. password_changed). Keep types in sync across Rust GqlXxx structs → shared API types → frontend interfaces.

See also