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:
| Crate | Responsibility |
|---|
heimdall-audit | Core types and the catalog of event/resource/status/source constants |
heimdall-audit-logger | AuditLogger — writes events to TimescaleDB with GeoIP enrichment |
heimdall-rest / heimdall-graphql | REST endpoints and GraphQL queries/mutations for reading and reporting events |
The CreateAuditEvent model
Events are constructed with a builder (heimdall_audit::CreateAuditEvent). Key fields:
| Field | Meaning |
|---|
user_id | The user the event relates to. May be a Heimdall UUID, an external ID (e.g. a Discord snowflake), or None for system events |
event_type | One of the event-type constants below |
resource_type / resource_id | The affected resource (see resource constants) |
actor_id | Who performed the action (differs from user_id for admin actions); parsed as a UUID |
ip_address / user_agent | Request context |
description | Human-readable summary |
metadata | Arbitrary JSON detail (provider, changed fields, etc.) |
status | "success" (default) or "failure" |
error_message | Reason, set when status is failure |
country_code / country_name / city / region | GeoIP-derived location (usually set by the logger) |
source_service | Originating 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
| Slug | Meaning |
|---|
login | User successfully signed in |
login_failed | User failed to sign in |
logout | User signed out |
password_changed | User changed their password |
password_reset_requested | User requested a password reset email |
email_change_requested | User requested an email change (verification sent) |
Two-factor authentication
| Slug | Meaning |
|---|
2fa_enabled | User enabled 2FA |
2fa_disabled | User disabled 2FA |
2fa_verified | User successfully verified 2FA |
2fa_failed | User failed to verify 2FA (wrong code) |
2fa_backup_codes_regenerated | User regenerated 2FA backup codes |
Sessions
| Slug | Meaning |
|---|
session_created | New session was created |
session_revoked | A session was revoked |
all_sessions_revoked | All sessions were revoked |
Account
| Slug | Meaning |
|---|
user_created | User account was created |
user_updated | User profile was updated |
user_deleted | User account was deleted |
deletion_scheduled | Account deletion was scheduled |
deletion_cancelled | Account deletion was cancelled |
OAuth
| Slug | Meaning |
|---|
consent_granted | User granted OAuth consent to an application |
consent_revoked | User revoked OAuth consent from an application |
token_created | OAuth token was created |
token_revoked | OAuth token was revoked |
client_created | OAuth client was created |
client_updated | OAuth client was updated |
client_secret_regenerated | OAuth client secret was regenerated |
client_deleted | OAuth client was deleted |
Admin
| Slug | Meaning |
|---|
user_banned | User was banned by an administrator |
user_unbanned | User was unbanned by an administrator |
role_assigned | Role was assigned to a user |
role_removed | Role was removed from a user |
permission_changed | A permission was changed |
Role management
| Slug | Meaning |
|---|
role_created | A new role was created |
role_updated | A role was updated |
role_deleted | A role was deleted |
role_permission_assigned | A permission was assigned to a role |
role_permission_removed | A permission was removed from a role |
Permission management
| Slug | Meaning |
|---|
permission_created | A new permission was created |
permission_updated | A permission was updated |
permission_deleted | A permission was deleted |
API keys
| Slug | Meaning |
|---|
api_key_created | API key was created |
api_key_updated | API key was updated |
api_key_revoked | API key was revoked |
api_key_regenerated | API key was regenerated (new key, same config) |
Account links
| Slug | Meaning |
|---|
account_linked | External account was linked |
account_reconnected | External account was reconnected (data refreshed) |
account_unlinked | External account was unlinked |
primary_account_changed | Primary account was changed |
Data
| Slug | Meaning |
|---|
data_exported | User exported their personal data |
Reports
| Slug | Meaning |
|---|
activity_reported | User reported suspicious activity |
report_updated | Admin updated a report's status |
Discord bot
| Slug | Meaning |
|---|
bot_command_executed | Discord bot command was executed |
bot_config_changed | Discord bot configuration was changed |
bot_moderation_action | Discord bot performed a moderation action |
bot_permission_denied | Discord bot denied permission to a user |
bot_error | Discord bot encountered an error |
bot_started | Discord bot started |
bot_stopped | Discord bot stopped |
bot_guild_configured | Discord guild was configured |
Storage
| Slug | Meaning |
|---|
file_created | File was created/uploaded (requires storage:write) |
file_downloaded | File was downloaded (requires storage:download) |
file_edited | File metadata was edited (requires storage:edit) |
file_deleted | File was deleted (requires storage:delete) |
Modules (trips, locations, categories, templates)
| Slug | Meaning |
|---|
trip_created | Trip was created |
trip_updated | Trip was updated |
trip_deleted | Trip was deleted |
location_created | Trip location was created |
location_updated | Trip location was updated |
location_deleted | Trip location was deleted |
trip_category_created | Trip category was created |
trip_category_updated | Trip category was updated |
trip_category_deleted | Trip category was deleted |
trip_template_created | Trip template was created |
trip_template_updated | Trip template was updated |
trip_template_deleted | Trip template was deleted |
Devices
| Slug | Meaning |
|---|
device_created | GPS device was created |
device_updated | GPS device was updated |
device_deleted | GPS device was deleted |
Geofences
| Slug | Meaning |
|---|
geofence_created | Geofence was created |
geofence_updated | Geofence was updated |
geofence_deleted | Geofence was deleted |
| Slug | Meaning |
|---|
platform_setting_updated | Platform setting updated by admin (e.g. enabled, two_factor_required, registration_enabled) |
System settings
| Slug | Meaning |
|---|
system_setting_created | System setting was created by admin |
system_setting_updated | System setting was updated by admin |
system_setting_deleted | System setting was deleted by admin |
| Slug | Meaning |
|---|
integration_connected | Platform integration connected (OAuth completed) |
integration_reconnected | Platform integration reconnected (re-authorized with new/updated scopes) |
integration_disconnected | Platform integration disconnected |
integration_token_refreshed | Integration OAuth token automatically refreshed |
integration_token_error | Integration token refresh or validation failed |
integration_status_updated | Integration status updated (e.g. Twitch bot badge toggled) |
integration_stats_refreshed | Integration 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 & Path | Auth / Permission | Purpose |
|---|
GET /v1/users/me/audit | Authenticated user | Current user's own activity log |
GET /v1/users/{user_id}/audit | audit:read | A specific user's audit log (admin) |
GET /v1/admin/audit | audit:read | All audit events across all users (admin) |
POST /v1/internal/audit | System key (X-ID-App-Key) or audit:write | Log an event from an internal app |
POST /v1/users/me/audit/{event_id}/report | Authenticated user | Report one of your events as suspicious |
GET /v1/users/me/audit/reports | Authenticated user | List your submitted reports |
GET /v1/admin/audit/reports | audit:read | List all reports (admin) |
PATCH /v1/admin/audit/reports/{report_id} | audit:write | Update a report's status (admin) |
GraphQL (heimdall-graphql/src/{queries,mutations}/audit.rs)
Queries — myAuditLog, userAuditLog (audit:read), allAuditEvents
(audit:read), auditEvent(id) (audit:read), auditEventReport(auditEventId)
(audit:read).
Mutations — logAuditEvent (system key or audit:write), reportAuditEvent
(authenticated user), myAuditReports, allAuditReports (audit:read),
updateAuditReport (audit:write).
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.)
| # | File | What to add |
|---|
| 1 | crates/heimdall-audit/src/events.rs | The Rust pub const (snake_case slug) |
| 2 | shared/api/src/types/audit.ts | Entry in AuditEventTypes + AuditEventCategories + AuditEventIcons + AuditFilterOptions (and a label in AuditEventLabels) |
| 3 | shared/ui/src/components/AuditIcons/index.tsx | Lucide icon import + iconComponentMap entry (only if introducing a new icon) |
| 4 | platform/backend/messages/en.json | audit.events.* (snake_case key) |
| 5 | platform/backend/messages/de.json | audit.events.* (snake_case key) |
| 6 | platform/id/messages/en.json | activity.events.* (camelCase key) |
| 7 | platform/id/messages/de.json | activity.events.* (camelCase key) |
| 8 | platform/id/src/app/account/activity/page.tsx | getEventLabel 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