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:
| 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 (93 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 |
ws_identity_override | A system API key relayed a WebSocket connection under an impersonated user identity (?userId= override) — logged with both identities: user_id is the impersonated user, actor_id is the system key |
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 |
Platform settings
| 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 |
Platform integrations
| 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.
Audit actor attribution & trust matrix
An audit event's actor_id (who performed the action) is a security-relevant
field: it must always be the authenticated principal, never a
client-forgeable value from the request body. The ingestion endpoints —
GraphQL logAuditEvent, REST POST /v1/internal/audit, and the WebSocket
CreateAuditEvent message — all funnel the decision through one shared, pure
rule (heimdall_audit::resolve_ingestion_actor) so attribution is enforced
identically everywhere.
The rule takes three inputs: the authenticated subject (auth.user_id() —
Some only for a user-token caller), the subject of a verified
X-ID-App-Assertion (if one was presented and passed verification), and the
caller-supplied payload actor_id (never trusted directly):
| Caller | Payload actor_id | Result |
|---|---|---|
| User-token | omitted | actor = the authenticated subject |
| User-token | equals the subject | actor = the subject |
| User-token | anything else (incl. a self-minted assertion's subject, or a non-UUID) | rejected — a user-token caller can only ever be its own actor; any presented assertion is ignored entirely |
Service + valid X-ID-App-Assertion | omitted | actor = the assertion subject |
Service + valid X-ID-App-Assertion | equals the assertion subject | actor = the assertion subject |
Service + valid X-ID-App-Assertion | anything else / non-UUID | rejected |
| Bare service (no user token, no assertion) | omitted | actor = NULL (a pure service event) |
| Bare service | present | rejected — a service caller may not assert an arbitrary user actor without a signed assertion |
Key properties:
- A present-but-invalid
X-ID-App-Assertionis a hard401rejection, never silently ignored (seeX-ID-App-Assertion). source_serviceis always derived from the authenticated caller (the verified assertion'sapp_id, or the system API key's name) — never taken from the request body.- Non-UUID actors (a Discord moderator snowflake, a service name) are never
silently dropped: the logger sets
actor_id = NULLand preserves the original string inmetadata.actor_ref(heimdall_audit_logger::merge_actor_ref; the WS path does the same for a rejected/forged payload actor), so the identity stays queryable for forensics. - Token-less logout/session/consent-revoke audit is written server-side,
not from the browser: per-app login is logged at the OAuth token exchange, and
logout/consent-revoke by their own GraphQL mutations. The one genuinely
token-less webapp write (
backend'slogout-auditroute) authenticates by minting anX-ID-App-Assertion. - WS-ingested events are geo-enriched the same as any other, via
AuditLogger::with_geoip.
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 runsgeoip.lookup(ip)and populatescountry_code,country_name,city, andregionbefore the insert. - Forward client IP and User-Agent on every call (via
with_request_contextor theip_address/user_agentarguments on the typed helpers). - Log failures explicitly with a reason.
with_failure(reason)setsstatus = "failure"anderror_message; typed helpers likelog_login_failedandlog_2fa_failedtake areasonargument. - Use provider slugs (lowercase, e.g.
"discord", not"Discord"). - Use
events::CONSTANTfromheimdall_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. Thelog()method returns the insertedAuditEventfor the few callers that need it (e.g. the internal logging endpoint). - Source resolution. The persisted
source_serviceis taken from, in order: the event's ownsource_service, the logger'swith_source(...), then the default"api"(heimdall-audit-logger/src/lib.rs,AuditLogger::log). Webapps set this from theX-Source-Servicerequest header (see below).
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.
Source & device attribution from webapps
Two request headers carry per-app context into every audit write. Both are read in
crates/heimdall-rest/src/auth_context.rs::request_info_from_request and land on
RequestInfo, which flows into AuditLogger:
| Header | Rust extractor | Effect |
|---|---|---|
X-Source-Service | extract_source_service | Populates AuditEvent.source_service. Falls back to "api" when absent (heimdall-audit-logger's AuditLogger::log). |
X-Original-User-Agent | extract_user_agent | Preferred over the standard User-Agent header when non-empty — populates AuditEvent.user_agent (the device shown in the activity timeline). |
X-Source-Service: config-first, sent on every request
ApiClientConfig (shared/api/src/client.ts) has an optional sourceService field. The
GraphQL and REST transports (shared/api/src/clients/{graphql,rest}.ts) both send
options.sourceService ?? config.sourceService as X-Source-Service whenever it resolves
to a value — i.e. every call made through a config built with sourceService set
carries the header by default, not only calls that pass sourceService explicitly in
their per-call options (a per-call sourceService still overrides the config default for
that one request).
Each app's getApiConfig() (platform/{id,backend,policies}/src/lib/api/index.ts) sets
sourceService: getSourceService(). getSourceService(), defined in each app's
src/lib/config.ts, resolves process.env.SOURCE_SERVICE || "<app>" — "id" for
platform/id, "backend" for platform/backend, "policies" for platform/policies
(see SOURCE_SERVICE in
Configuration). The net effect: every GraphQL/REST call a webapp makes through
getApiConfig() — including login, 2FA, and logout mutations — is now attributed to that
app's own source_service instead of falling back to the generic "api" source.
SSO login: source + real browser device on the token exchange
backend and policies authenticate end users via Heimdall itself, using NextAuth's
HeimdallProvider (src/lib/auth/heimdall-provider.ts in each app) configured with the
standard OAuth Authorization Code + PKCE flow. The token/discovery/userinfo legs of that
flow are performed server-to-server by openid-client, which by default sends its own
openid-client/<version> User-Agent and no X-Source-Service — so without extra wiring,
the Rust /v1/oauth/token endpoint's per-app login audit event would record
source_service = "api" and device "Unknown" / the openid-client UA instead of the real
app and browser.
HeimdallProvider closes this gap via its httpOptions (NextAuth's
custom.setHttpOptionsDefaults hook, which openid-client evaluates per outgoing request):
- A static
X-Source-Service: backend/X-Source-Service: policiesheader, from thesourceServicepassed intoHeimdallProvider({ ..., sourceService: getSourceService() })in each app'ssrc/lib/auth.ts. - An
X-Original-User-Agentheader defined as a getter, so it re-resolves per request: it readsgetAuthRequestUserAgent()from anAsyncLocalStorage(src/lib/auth/request-context.ts) populated with the end user's real browserUser-Agent. The store is seeded by the wrapped NextAuth route handler (src/app/api/auth/[...nextauth]/route.ts), which reads the incoming request'sUser-Agentand runs the rest of the NextAuth handler (including the awaited server-to-server openid-client calls) insideauthRequestContext.run(...).
Together, this makes the Rust API's per-app login audit event (emitted at
/v1/oauth/token) record the actual webapp (backend/policies) as source_service and
the end user's actual browser as user_agent, instead of "api" /
openid-client/<version>.
The Heimdall ID app does not need the heimdall-provider.ts half of this mechanism: it
is the identity provider, so end users authenticate on it directly (via its own social-login
NextAuth providers, e.g. Discord/Twitch/Google), not through a server-to-server exchange
against itself. Its source_service comes from the config-first X-Source-Service header
described above, sent by its own GraphQL/REST calls.
It does use the same AsyncLocalStorage pattern for the device half, in
platform/id/src/lib/request-context.ts: authHandler (src/lib/auth.ts, exported as the
/api/auth/[...nextauth] route) extracts the browser User-Agent and client IP from the
incoming request and runs the NextAuth handler inside runWithRequestContext(...). The
signIn/jwt callbacks and the signOut event read them back with getRequestContext() —
next/headers is not available in all of those contexts — and forward them on the login/logout
API calls they make, so those audit events record the real device and IP. Because the store is
bound to the in-flight request, concurrent logins cannot observe each other's device.
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).
Filtering & pagination
Both surfaces accept the same filters (see AuditEventQuery):
page(1-indexed, default 1) andlimit(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, defaultdesc), andisReported
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 usescamelCase(auto-converted). The persisted event-type slug is always the snake_case string (e.g.password_changed). Keep types in sync across RustGqlXxxstructs → shared API types → frontend interfaces.
See also
- Roles & Permissions — the
audit:read/audit:writepermissions that gate audit access - Databases & Caching — the
AuditEventTimescaleDB hypertable and the dual-pool architecture