Skip to main content

Audit Events (Developer Guide)

This guide covers the write side of the audit log: when a change must emit an audit event, and how to emit it correctly. For the read side - scopes, read surfaces, permissions and the AuditEvent field list - see Audit Log; for the operator view see Admin · Audit Log.

The rule

Security- and lifecycle-relevant actions emit an audit event in the same change that ships them. An audit trail that the documentation promises but the code never writes is a compliance defect, not a missing nice-to-have.

Treat "does this need an audit event?" as a standing review question, alongside RBAC gating and GraphQL ↔ REST parity.

When to emit

Emit when an action is security-relevant, tenant-visible, or irreversible:

CategoryExamples
Authentication & identityLogin, failed login, MFA enable/disable, OAuth grant
CredentialsAPI-key create/revoke, channel connection added/removed
Permission structureRole create/update/delete, member role assignment
Destructive / GDPRData erasure, retention purges, account deletion
Platform operationsAdmin actions, feature-flag flips, plan changes

Do not emit for:

  • Reads and list queries - the audit log records changes, not access.
  • High-volume telemetry (chat messages, stream events, metrics). The audit log is a security record with a compression policy, not an analytics stream.
  • Purely internal bookkeeping with no security or tenant-visible consequence.

How to emit

The canonical API lives in apps/api/src/db/audit.rs:

crate::db::audit::emit(&tsdb.0, crate::db::audit::AuditEventFields {
account_id: Some(account_id),
user_id: actor_user_id,
event_type: crate::db::audit::event_types::ACCOUNT_ROLE_CREATED,
scope: crate::db::audit::AuditScope::Account,
ip_address: origin.ip_address.as_deref(),
user_agent: None,
country: origin.country.as_deref(),
city: origin.city.as_deref(),
metadata: serde_json::json!({ "role_id": role_id, "slug": slug }),
})
.await;

Use db::audit::emit, never insert_audit_event, in new code. emit is best-effort by design: it logs a tracing::warn! and swallows the error, because a failed audit insert must never fail - or roll back - the user-facing mutation that triggered it.

Catalog first, call site second

Every emit references a constant from db::audit::event_types. Add the constant to the catalog before the call site; never inline a string literal. The catalog is the single source of truth that the docs and the admin event-type filter are checked against.

Naming is domain:action (account:role_created, user:login, system:feature_flag_updated), where domain matches the row's scope. The two legacy GDPR types (youtube_member_erasure, chat_pii_erasure) stay un-namespaced on purpose - the admin filter pins those exact strings, so renaming them is a coordinated change, not a drive-by.

Scope is explicit, never inferred

scope is set by the writer on every row and is NOT NULL with no default, so a writer that forgets fails closed. Never infer scope at read time from which of account_id / user_id happens to be populated - that inference leaks authorization boundaries between tenants.

ScopeUse for
AuditScope::UserPersonal security events, readable self-only
AuditScope::AccountTenant actions, gated on account audit-log:read
AuditScope::SystemPlatform-operator / cross-account actions

One event, one scope. An account-context action with an actor lives only in the account log (the actor is shown via user_id); it is never additionally mirrored into that user's personal log.

Actor, target, metadata

user_id is always the actor, never the target. The target and any context go in metadata (role_id, target_user_id, platform, …). Never put secrets, tokens, credentials, or raw PII in metadata - audit rows are readable by every account member holding audit-log:read.

Some of those id keys (target_user_id, member_id, from_account_id, to_account_id, target_account_id, role_id) are resolved to display names at read time - see Read-time UUID → name resolution.

Origin enrichment

Resolve the request origin instead of hand-building it:

  • GraphQL - crate::graphql::audit_origin_from_ctx(ctx)
  • REST - AuditOrigin::resolve(state.geoip.as_ref(), crate::routes::auth::extract_client_ip(req))

GeoIP is fail-open: country / city stay None when GeoIP is disabled or the IP is private. Never block or fail a mutation on origin resolution.

How the client IP is resolved

Both helpers end up in lo_api::client_ip::resolve, which reads the headers in one of two orders depending on who is calling:

CallerPrecedence
First-party server hop (a valid X-Internal-Token)X-Lumio-Client-IPX-Forwarded-For (first hop) → X-Real-IPCF-Connecting-IP → peer address
Everything else (a direct browser/client request)CF-Connecting-IPX-Forwarded-For (first hop) → X-Real-IP → peer address

Almost no audit event is emitted for a request the end user made directly against the API. The browser talks to a Next.js route handler or server component, which then calls the API server-to-server - so from the API's point of view the request peer is the web/admin/id service. Those services resolve the user's origin (cf-connecting-ipx-real-ip → first hop of x-forwarded-for, in apps/{web,admin,id}/src/lib/client-ip.ts) and forward it on that internal hop.

The forwarded value has to outrank CF-Connecting-IP on that hop. When the internal call itself travels through Cloudflare - a public LUMIO_API_URL - the edge stamps CF-Connecting-IP with the web server's own egress IPv4/IPv6, and preferring it wrote the web server's address into every audit_events.ip_address and every personal Account-Activity row instead of the user's (ZAF-1208).

Why a private header and not just X-Forwarded-For

X-Forwarded-For is the header every reverse proxy in the chain feels entitled to rewrite, so it cannot be relied on to carry a value end to end. The staging Caddy rewrites it explicitly:

reverse_proxy lumio-api:3000 {
header_up X-Forwarded-For \{http.request.header.CF-Connecting-IP\}
}

header_up <field> <value> replaces the field - +field would append - so whatever apps/web forwarded is discarded and overwritten with CF-Connecting-IP, which on the web→API hop is the web container's own egress address. Forwarding X-Forwarded-For alone is therefore not enough: the value never survives to the API.

So the first-party services send the origin under a private name as well, X-Lumio-Client-IP (lo_api::client_ip::CLIENT_IP_HEADER, mirrored as CLIENT_IP_HEADER in each app's lib/client-ip.ts). No proxy or CDN rewrites a non-standard header, so it arrives intact - the fix holds with no infrastructure change, and keeps holding if the proxy in front of the API is swapped. X-Forwarded-For is still sent next to it, for rate-limit bucketing, request logging, and hops that reach the API with no rewriting proxy in between (local dev, direct container-to-container networking). Use clientIpHeaders(ip) rather than setting either header by hand.

For a direct request the order is reversed on purpose: CF-Connecting-IP is set by the edge and a client cannot forge it, while X-Forwarded-For is client-settable. X-Lumio-Client-IP is ignored entirely outside the trusted-internal branch, so an outside client cannot use it to forge its own audit origin. That branch does not widen the trust boundary either — TrustedInternalCaller is only inserted on a constant-time match against the configured X-Internal-Token secret. Anonymous rate-limit bucketing is deliberately not routed through this helper and stays pinned to CF-Connecting-IP (lo_api::middleware::rate_limit::build_rate_key), so a forged header can never mint rate-limit buckets.

Where the rows live

Audit events are written to the TimescaleDB pool (state.tsdb / lo_graphql::health::TsdbPool), not the primary Postgres pool. The audit_events hypertable and its migrations live in apps/api/tsdb_migrations/, not apps/api/migrations/.

Retention is anonymise-in-place, never drop: a native TimescaleDB scheduled job (anonymise_old_audit_events, defined in tsdb_migrations/20260823000002_anonymise_audit_events_retention.up.sql) nulls ip_address / user_agent / country / city on rows older than 12 months and keeps the aggregate row indefinitely. So do not put anything in metadata that must disappear at 12 months - only the four network columns are anonymised; metadata is kept for the life of the row (and must never carry secrets or raw PII regardless). See Audit Log → Retention.

Events that originate outside the API (apps/id ingest)

Some user-scope security events happen in apps/id (NextAuth, TypeScript), not in the Rust API. Today apps/id emits web login and OAuth grant rows; the ingest endpoint also whitelists 2FA lifecycle rows, but no current product flow calls it for MFA. The Rust API is the single audit writer, so apps/id does not write audit_events itself - it posts each event server-to-server to POST /v1/internal/audit-ingest after the auth event, and the endpoint writes the row via insert_audit_event.

The user-scope login events apps/id cannot ingest are emitted in-process from the Rust /auth handlers instead, because they need a subject apps/id does not have at the NextAuth callback:

  • user:login for the native (PKCE) flow - POST /v1/auth/authorize returns only an auth code; the subject/JWT is minted later when the native app redeems the code against POST /v1/auth/token/exchange, directly against the API. That redemption handler is the single authoritative user:login emit point for the native flow (the web flow stays on apps/id ingest); metadata.method = "pkce".
  • user:login for the GraphQL exchangeToken mutation (ZAF-1023) — graphql/auth.rs exchange_token emits user:login inline on success with metadata.method = "graphql_exchange". This is the GraphQL path's only login-success writer and must not be removed: exchangeToken has no first-party caller (apps/id, web, and admin all use REST /auth/token), so the apps/id audit-ingest that covers the REST twin's success (see below) never observes it.
  • user:login_failed - a rejected OAuth credential has no resolved user at the NextAuth callback (bad credentials are rejected upstream by the provider). The Rust REST exchange_token / authorize handlers and the GraphQL exchangeToken mutation (ZAF-1023, at parity) emit it on a failed provider-token validation, and token_exchange emits it on a PKCE mismatch. The subject is resolved from the existing login connection for the (unverified) provider identity when one exists - so a failed attempt against a known user lands in that user's personal-security log - otherwise the row is unattributed (user_id = NULL), never leaked into an arbitrary user's log. These emit via the in-process best-effort emit, not the ingest endpoint. Both paths capture the request User-Agent (the GraphQL context grows a UserAgent datum for this).

This is the one sanctioned use of the internal ingest endpoint, and the reasons it deviates from the in-process emit pattern:

  • Auth is a System key with the narrow audit:ingest grant. Every non-System principal is rejected 403 (anonymous 401) so no public/user route can forge audit rows; a System key without the grant is 403 too. Provision apps/id's key with audit:ingest (or audit:* - a bare *:* does not grant it).
  • The endpoint forces the shape. It whitelists the five user-scope catalog types above and forces scope = user, user_id = <subject>, account_id = NULL; a caller cannot widen scope, attach a tenant, or write an account:* / system:* type.
  • It uses insert_audit_event, not emit, on purpose. Here the audit write is the request's whole purpose (there is no user-facing mutation to protect), so the endpoint propagates a failed insert to apps/id as a real error rather than silently swallowing it. This is the deliberate exception to the "emit, never insert_audit_event" rule above - do not copy it into an in-process emitter.

The apps/id call sites live in apps/id/src/auth.ts (NextAuth signIn callback) via the best-effort helper emitUserAuditEvent in apps/id/src/lib/audit.ts. The helper posts to the ingest endpoint with apps/id's System key (env LUMIO_AUDIT_INGEST_KEY, an lm_sys_* key with audit:ingest), forwards the end-user's IP and user-agent (the request peer is the id service, not the acting user), and never blocks or fails the auth flow - a timeout or non-2xx response is logged and swallowed.

LUMIO_AUDIT_INGEST_KEY is carried by apps/id/.env.example and dev-stack/staging/.env.id.example, and read through getAuditIngestKey() in apps/id/src/lib/config.ts.

Provisioning the ingest key

LUMIO_AUDIT_INGEST_KEY must hold an lm_sys_* System key that carries the audit:ingest grant. Mint one from the admin app under System → System-API-Schlüssel: audit:ingest is part of the System-key assignable-scope registry (lo_auth::rbac::system_key::get_assignable_permission_infos()), so the admin create dialog offers it and both create paths accept it. Set the resulting secret as LUMIO_AUDIT_INGEST_KEY in apps/id's environment. Until it is set the apps/id trail below is not written - the fail-loud signals in the next paragraph are what make that state visible.

Fail-loud when the ingest key is unset. A missing/misconfigured LUMIO_AUDIT_INGEST_KEY disables the whole apps/id audit trail - every user-scope security event is silently dropped while the app still looks healthy, a DSGVO Art. 5(2)/32 accountability defect. To make that state observable without ever blocking login, apps/id surfaces it two ways:

  • Log once per process. When the key is unset and NODE_ENV === "production", emitUserAuditEvent emits exactly one console.error ([audit] LUMIO_AUDIT_INGEST_KEY is unset in production - user-scope security events are being dropped.) for the process lifetime, then returns. Local dev with no key stays silent, and a per-call skip for a missing subject user_id stays silent (it is a normal skip, not an operator condition).
  • Health field. GET /api/health on apps/id returns audit_ingest_configured (a boolean — Boolean(process.env.LUMIO_AUDIT_INGEST_KEY), the key value is never printed), so the disabled state is pollable/alertable, not just log-greppable.

Wired today (both carry a resolved Lumio user_id):

  • user:login - successful web login, after the provider token is exchanged for a Lumio JWT; the subject is decoded from the issued JWT's sub claim.
  • user:oauth_granted - an already-authenticated user links/reconnects a provider connection (OAuth consent); the subject is decoded from the session JWT.

Not yet wired from apps/id:

  • user:mfa_enabled / user:mfa_disabled - no 2FA/MFA feature exists in the product yet; the catalog constants and ingest whitelist are reserved for when it ships.

Profile email-change event (in-process, ZAF-913)

Editing the email address on the acting user's own profile is the classic account-takeover vector (email change → password-reset link → takeover), so it is a personal-security event worth recording:

  • user:email_changed (AuditScope::User) - emitted in-process from updateMe (GraphQL) and PATCH /v1/users/me (REST) when a profile update actually changes the stored email. Both handlers read the pre-update address and emit only when it differs from the new one, so a no-op re-submit of the same address writes nothing. Actor = the acting user (user_id), no tenant account_id. The old/new address is PII and is never written to metadata — the row carries only a had_previous_email boolean (first-time set vs. change of an existing address). Best-effort, so a failed audit insert never rolls back the profile update. Same event type / scope / metadata on both protocols (parity); the profile edit is already gated to a first-party user session (a popout token cannot reach it - ZAF-469), so there is no anonymous/token-actor variant.

Token-refresh reconnect events (in-process, ZAF-754)

The Token Refresh Worker (apps/api/src/workers/token_refresh.rs) is the automatic producer of the reconnect signal. When a refresh fails terminally (invalid_grant / unauthorized_client / HTTP 400/401) it flips reconnect_required false→true and, on that single transition (services::reconnect::notify_reconnect_required), emits:

  • account:connection_reconnect_required (AuditScope::Account) for a channel or bot connection - actor is the account, metadata carries platform
    • connection_type.
  • user:login_reconnect_required (AuditScope::User) for a login connection - subject is the owning user.

There is no HTTP request behind the worker, so no IP/GeoIP origin is attached. The same transition also sends the lo_email reconnect email. These have no GraphQL/REST counterpart (the worker is the only trigger), so protocol parity does not apply.

Active-account switch (ZAF-908)

  • user:active_account_switched (AuditScope::User) - the acting user switched (or cleared) their session's active_account_id. This is identity-relevant: it changes the RBAC scope every subsequent request on that session runs under, and re-mints the session cookie (with the same session_id, so revocation still reaches it - see features/sessions.md). The actor is the user; the from/to account ids and a cleared flag ride in metadata (both accounts are the user's own - no secret). account_id is NULL: a personal switch is not a tenant action, so it stays out of the switched-to account's audit-log:read log (an account admin must not be able to enumerate who is switching into their account).
  • Emit sites - GraphQL updateMe (apps/api/src/graphql/auth.rs) and its REST twin PATCH /v1/users/me (apps/api/src/routes/users.rs), on the same condition that re-mints the cookie (active_account_id set, or clear_active_account). Same event type, scope and metadata on both protocols (parity). Both use the standard best-effort in-process emit.

Session-lifecycle events (ZAF-911)

Ending or revoking a session is a personal security action on the user's own credential - "log out everywhere" is exactly what the audit log exists to record. Both events are AuditScope::User, keyed to the acting user with no tenant account_id, and go through the shared user-scope helpers so REST and GraphQL write the same shape:

  • REST - crate::routes::audit::emit_user_audit(state, req, actor_user_id, event_type, metadata)
  • GraphQL - crate::graphql::emit_user_audit(ctx, actor_user_id, event_type, metadata)

Catalog constants (db::audit::event_types):

  • user:logout - the user ended one of their own sessions via a logout flow: GraphQL logout(refreshToken) / logoutSession, REST POST /v1/auth/logout. metadata carries the ended session_id (when the deleted row is known) and the method (refresh_token | session). For the refresh-token flow the actor and session id come from the deleted session row (delete_session now RETURNING id, user_id), so an expired-but-authentic logout still records the row; logoutSession takes them from the JWT claims.
  • user:session_revoked - the user revoked session(s) from session management: GraphQL deleteSession(id) / deleteAllOtherSessions, REST DELETE /v1/users/me/sessions/{id} / DELETE /v1/users/me/sessions. metadata distinguishes a single revoke (session_id, revoked_all: false) from a bulk revoke (count, revoked_all: true).

Emit is gated on an actual change. A logout that matched no session, a single revoke of a not-found id, and a bulk revoke that removed zero rows all write nothing - a no-op is not a security event.

No-secret rule. metadata carries only the non-secret session_id / count / method / revoked_all - never the refresh token, the session token_hash, or any PII (every reader of the user log is the user themselves, but the discipline is the same as every other emitter).

Parity. The refresh-token logout / POST /v1/auth/logout pair and the deleteSession / deleteAllOtherSessions GraphQL mutations and their DELETE /v1/users/me/sessions[...] REST twins emit identical event type, scope and metadata on both protocols. logoutSession is a GraphQL-only convenience (the REST logout is refresh-token driven); it emits the same user:logout event tagged method: "session", documented at the call site.

Credential & access lifecycle events (ZAF-772)

The token / overlay / widget credential lifecycle and the per-resource access grant/revoke are emit-worthy credential and permission-structure changes (AGENTS.md §Audit Logging). All are AuditScope::Account, keyed to the acting account with the actor recorded, and go through the shared account-scope helpers so REST and GraphQL stay byte-for-byte in parity:

  • REST - crate::routes::audit::emit_account_audit(state, req, account_id, actor, event_type, metadata)
  • GraphQL - crate::graphql::emit_account_audit(ctx, account_id, actor, event_type, metadata)

Catalog constants (db::audit::event_types):

  • Popout tokens - account:popout_token_created / _updated / _deleted (a soft-revoke via update carries "revoked":true; no separate revoke type).
  • Account service keys (lm_svc_*) - account:service_key_created / _updated (label rename) / _revoked / _rotated. Emitted by GraphQL createAccountServiceKey / updateAccountServiceKey / deleteAccountServiceKey / rotateAccountServiceKey and REST POST / PATCH / DELETE /v1/service-keys / POST /v1/service-keys/{id}/rotate (protocol parity). Account-scoped (the key is a tenant credential, unlike the personal user:api_key_* events); the actor is the member who performed the action. Metadata carries only service_key_id and the short, non-secret lm_svc_* prefix - key_prefix, or old_key_prefix + new_key_prefix on _rotated (which regenerates the secret in place and returns the new full key once) — never the full key or its hash.
  • Overlay access tokens - account:overlay_token_rotated / _revoked.
  • Shared-overlay links (lm_share_*) - account:shared_link_created / _revoked / _extended (extending credential validity is a security-relevant lifecycle change).
  • Widget access tokens - account:widget_token_created (create + duplicate) / _rotated / _revoked.
  • Per-resource access - account:overlay_access_granted / _revoked and account:widget_access_granted / _revoked. Grant is an upsert: the resulting role in metadata covers both first-grant and role-change (no pre-read, no separate *_changed type).
  • History report share links (lm_share_*, ZAF-780) — account:history_shared_link_created / _extended / _revoked. The same lm_share_* credential lifecycle as the overlay share link, keyed to a history session/report instead of an overlay (create/revoke carry the lm_share_* token_prefix, extend carries the new expiry). Metadata is the non-secret session_id / link_id (+ expires_at / token_prefix) - never the token string/hash or the argon2 password_hash.
  • History session erasure (ZAF-780) - account:history_session_deleted, a destructive GDPR Art. 17 deletion of a stored stream session and its data (history:delete). Metadata is the session_id only. Kept distinct from the chat-PII (chat_pii_erasure) and YouTube-member (youtube_member_erasure) erasure events, which erase different subject data on different surfaces.
  • Setting-preset account sharing (ZAF-1287 / ZAF-622 §19) - account:setting_preset_shared / account:setting_preset_unshared. Sharing a personal preset with an account makes the owner's saved view (whose payload can hold viewer names) visible to every member, so it is a tenant-visible disclosure gated presets:share. Both are account-scoped: shared is keyed to the account it is shared into; un-shared is keyed to the account it was shared into (fires only when a share was actually removed). Reachable on both GraphQL (shareSettingPreset / unshareSettingPreset) and REST (POST / DELETE /v1/setting-presets/{id}/share) → identical type/scope/metadata. Metadata is the non-secret preset_id, kind (chat | events | music) and name - never the preset payload.

No-secret rule (compliance-critical). Metadata carries only non-secret identifiers - token_id, resource ids (overlay_id / widget_id / link_id / session_id), target_user_id, role, expires_at, flags - plus the short, non-secret lm_* token_prefix where the row exposes one. It never carries a full token string, token_hash, AES material, an argon2 password_hash (history share links carry one), or raw PII. The widget rotate/revoke DB helpers surface only the token string (never logged), so those rows carry widget_id only - the helper signatures are deliberately not widened. The apps/api/tests/audit_emitters.rs::zaf749_token_metadata_excludes_secrets and ::zaf780_history_metadata_excludes_secrets regression guards fail if any lm_… value leaks outside token_prefix (or a password/secret key appears).

The platform-operator account delete (DELETE /v1/admin/accounts/{id} / adminDeleteAccount) emits system:account_deleted (AuditScope::System, no tenant account_id, operator as actor, deleted account id in metadata) - the CASCADE leaves no account member to read an account-scoped row, and audit rows live in a separate TimescaleDB the CASCADE does not erase. Owner self-service dissolve stays account:dissolved (Account scope) - the two are distinct events so exactly one emitter fires per path.

The account permission-override set/remove (PUT / DELETE /v1/admin/accounts/{id}/permission-overrides / adminSetAccountPermissionOverride / adminRemoveAccountPermissionOverride, gated by accounts:overrides-edit) emit system:account_permission_override_set / _removed (AuditScope::System, operator as actor, no tenant account_id) via the same emit_system_audit helper the feature-override path uses. A platform operator granting/revoking an account-scoped permission override is a cross-account operator action on the escalation surface hardened in ZAF-748 (only registry-known, non-wildcard permissions are accepted), so it belongs on the operator log. The target account_id and permission ride in metadata - the set path also records granted and the operator's optional reason; never a secret. Remove emits only when a row was actually removed. This is the account-level sibling of the per-user system:user_permission_override_set / _removed events.

Credential & operator-RBAC events (ZAF-886)

These close the ZAF-880 finding #1 gap: credential- and permission-structure tables that lived next to already-audited ones but emitted nothing. Each fires on both protocols through the scope-appropriate shared helper.

  • Per-account app credentials - account:connection_credentials_upserted (AuditScope::Account, gate connections:create). save_credentials / saveAppCredentials write AES-256-GCM client_id/client_secret into app_credentials; this is the credential level, distinct from the token level account:connection_added. Metadata: platform, credentials_id (row id), updated (insert vs update) - never the client_id/client_secret (plain or ciphertext). The DELETE counterpart is account:connection_credentials_deleted (ZAF-981, gate connections:delete): delete_credentials / deleteAppCredentials remove the row and, on the same path, tear down the associated channel connection - so that path emits both account:connection_removed (when a connection row was removed) and account:connection_credentials_deleted (when a credential row was removed), each fail-closed on a real removal, with full REST↔GraphQL parity. The deleted row leaves no id, so its metadata is platform only.
  • Developer OAuth clients - oauth_client:created / _updated / _deleted (AuditScope::System, gate oauth-clients:{create,edit,delete}). Metadata: client_id_ref (the DB row id, not the OAuth client_id string), name, redirect_uris, scopes, and (update) changed_fields - never the OAuth client_id string or client_secret.
  • Operator admin roles - admin_role:created / _updated / _deleted / _assigned / _unassigned (AuditScope::System, gate admin-roles:{create,edit,delete}). The admin_roles table is separate from user_roles (whose system:user_role_* events already emit). Metadata: role_id, role_name, permissions (the granted resource:action list — auditing the grant is the point), changed_fields (update); assign/unassign add target_user_id. Assign emits only on a real (non-idempotent) assignment; unassign/delete only when a row was actually removed.
  • Global bot connections - system:global_bot_connection_upserted / _deleted (AuditScope::System, gate bot-connections:{create,delete}). The nil-UUID global bot token, written by set_discord_bot_token (manual token) and exchange_global_bot_oauth (OAuth exchange). Metadata: platform, connection_id, bot_username (public handle), source (manual_token | oauth_exchange) - never the Discord bot token or the access/refresh token. The OAuth-exchange path is REST-only (no GraphQL exchange mutation exists) - a documented single-protocol emit; set_discord_bot_token has full REST↔GraphQL parity.
  • Operator identity destruction - system:user_deleted (gate users:delete) and system:user_login_connection_deleted (AuditScope::System). The login-identity removal has full REST↔GraphQL parity — admin_delete_user_login_connection (GraphQL) and DELETE /v1/admin/users/{id}/login-connections/{provider} (routes::admin::admin_delete_login_connection, gate users:edit) emit the same event. Both emit only on a real deletion. Metadata: target_user_id, plus provider for the login-connection case - never email/handle/tokens.
  • Login-identity removal - account:login_connection_removed (AuditScope::Account, remove_login_assignment, gate login-assignments:delete; metadata provider + target_user_id) and user:login_connection_removed (AuditScope::User, self-service delete_login_connection / disconnectLoginConnection, first-party-gated ZAF-469; metadata provider + connection_id). The self-service removal is a personal security event (User scope, self-only read); the account-member removal belongs to the tenant log. Both emit only on a real removal.

user:oauth_granted is not emitted by the in-API link_provider route - it is emitted by apps/id on provider link/consent via the server-to-server ingest endpoint (see Events that originate outside the API). Wiring a second emit in-API would double-count the same grant.

No-secret rule. As everywhere else, metadata carries only non-secret ids and public handles. The credential/token writers (account:connection_credentials_upserted, oauth_client:*, system:global_bot_connection_upserted) are guarded by apps/api/tests/audit_emitters.rs::zaf886_credential_metadata_excludes_secrets, which fails if a client_id/client_secret/token key or a secret-shaped value (lms_…, cli_…) appears in any stored metadata row.

Personal API-key lifecycle events (ZAF-498)

A user API key (lm_usr_*) is a personal, self-only programmatic credential a member creates in the dashboard (rows in api_keys with is_system = false, bound to one user_id + account_id). Its create/revoke are therefore AuditScope::User personal-security events, not account-scoped - they land in the owner's self-only log with account_id = NULL, exactly like the login events. They go through dedicated user-scope helpers that mirror the account-scope ones:

  • REST - crate::routes::audit::emit_user_audit(state, req, user_id, event_type, metadata)
  • GraphQL - crate::graphql::emit_user_audit(ctx, user_id, event_type, metadata)

Catalog constants (db::audit::event_types):

  • user:api_key_created - a new key is minted. Emitted by GraphQL createUserApiKey and REST POST /v1/api-keys (protocol parity).
  • user:api_key_updated - a key is renamed (label only). Emitted by GraphQL updateUserApiKey and REST PATCH /v1/api-keys/{id} (protocol parity).
  • user:api_key_revoked - a key is deleted. Emitted by GraphQL deleteUserApiKey and REST DELETE /v1/api-keys/{id}; the revoke paths pre-read the key so the row can carry its prefix, and a missing/foreign key collapses to the same not-found before any row is written.
  • user:api_key_rotated - the owner regenerated a key's secret in place (id, label and scopes unchanged; old secret invalidated immediately, new full key returned once). Emitted by GraphQL rotateUserApiKey and REST POST /v1/api-keys/{id}/rotate (protocol parity); the rotate paths pre-read the key so the row can carry the old prefix. Metadata adds old_key_prefix + new_key_prefix alongside api_key_id.

Per the no-secret rule, metadata carries only api_key_id and the short, non-secret lm_usr_* prefixes (key_prefix, or old_key_prefix / new_key_prefix on rotate) - never the full key or its key_hash. Both emit sites are best-effort side-channels that never fail the mutation.

Developer verification submission (ZAF-943)

developer:verification_submitted (AuditScope::User) fires when an approved developer submits (or resubmits) their own KYC identity/company verification via submitDeveloperVerification (GraphQL) or POST /v1/developer/verification (REST). This is a personal identity/credential action on the developer's own identity, so it is User-scoped (self-only read via myAuditLog) with the acting developer as user_id and no tenant account_id. The event name follows the subject-domain precedent (oauth_client:*, admin_role:*). Both protocols emit the same event type, scope and metadata (parity).

  • Metadata: verification_id, developer_id, developer_type (individual | company, inferred from whether a company name was given), status, and resubmission (whether a prior record existed) - never the submitted legal name, company name, address, tax id, trade-register id, or document key (all raw PII/KYC material).
  • Reads do not emit. developerVerificationStatus / GET /v1/developer/verification are reads and write no audit row.

Developer-team, extension-access, SE-token & admin single-site events (ZAF-1022)

Five modules with RBAC-gated mutations on both protocols shipped with zero audit emitters (baseline sweep ZAF-1005 Befund 2); ZAF-1022 wires them, plus a handful of admin single-sites. Scope is set explicitly per surface and never inferred.

Developer self-service team (developer:team_*, AuditScope::User) - a developer managing their own team. Consistent with the existing developer:verification_submitted, these are personal-security events on the acting developer's identity: user_id = the acting developer, no tenant account_id, self-only read via myAuditLog. GraphQL (graphql/developer_teams.rs) and REST (routes/developer_teams.rs) emit the same event/scope/metadata via the shared emit_user_audit helpers. Types: developer:team_created, developer:team_deleted, developer:team_role_created / _updated / _deleted, developer:team_member_role_changed, developer:team_member_removed, developer:team_invite_created / _revoked / _accepted. Metadata carries only row/entity ids (team_id, role_id, member_id, invite_id, target_user_id), role_slug, permissions (permission strings), and changed_fields (field names) - never the invite email or invite_code/code (log the invite row id instead), and never the team member's PII. The low-security team rename is intentionally not audited (audit log ≠ analytics stream).

Transparency tradeoff (§A.1, CTO-accepted). Under User scope a member removal or role change done by a non-owner team admin lands in that actor's self-only log; it does not surface in a team-shared or operator log (no team-audit read surface exists in the product). This satisfies DSGVO Art. 5(2) accountability. A team-visible audit read would be a scope extension (a one-way door) - deferred until such a UI is scheduled.

Extension access (account:extension_access_*, AuditScope::Account) — mirrors the account:{overlay,widget}_access_* families. GraphQL (graphql/extension_access.rs) and REST (routes/extension_access.rs) emit via emit_account_audit with the row account_id = the acting (extension-owner) account and the target account in metadata. Types: account:extension_access_granted / _revoked and account:extension_access_invite_created / _revoked / _accepted. Metadata: extension_id, target_account_id, grant_type, grant_id, invite_id, max_uses, expires_at - never the invite invite_code/code.

StreamElements tokens (account:se_token_*, AuditScope::Account) - an account-owned SE credential. GraphQL (graphql/se_tokens.rs) and REST (routes/se_tokens.rs) emit account:se_token_created (upsert) and account:se_token_deleted. Metadata: token_id, platform, label - never the StreamElements JWT, its encrypted_token ciphertext, or the masked token_hint (which still leaks 8 chars).

Operator developer-team administration (system:developer*, AuditScope::System) - platform-operator actions gated on developer-*:edit. GraphQL (graphql/admin_developer_teams.rs) and REST (routes/admin_developer_teams.rs) emit via a local emit_system_audit: the acting operator is user_id, no tenant account_id, the target rides in metadata. Types: system:developer_deleted, system:developer_team_deleted, system:developer_team_member_added / _removed / _role_changed, system:developer_revenue_split_set, system:developer_limits_updated, and system:developer_limit_request_reviewed. system:developer_limits_updated is a single, merged event for developer and extension limits, discriminated by limit_scope: "developer" | "extension" in metadata. The limit-request review metadata carries a has_review_notes presence flag only - never the free-text review_notes.

Admin single-sites (system:*, AuditScope::System) - platform-operator actions on user/account rows, distinct from the user-self events. GraphQL (graphql/admin.rs) and REST (routes/admin.rs) emit:

  • system:user_email_changed - operator changed a user's email via admin_update_user / PATCH /v1/admin/users/{id} (patch_user). Emitted only when the stored email actually changed (a display-name-only edit or a no-op re-submit writes nothing); metadata target_user_id + a had_previous_email flag - never the old/new address.
  • system:user_account_creation_override_set - metadata target_user_id + override (allow | deny | default).
  • system:user_max_accounts_override_set - GraphQL-only (admin_update_user_max_accounts_override); patch_user REST does not accept max_accounts, so a GraphQL-only emitter is correct parity. Metadata target_user_id + max_accounts (int | null).
  • system:account_connection_deleted / system:account_bot_connection_deleted — operator deleted an account's channel / bot connection (admin_delete_account_channel_connection / _bot_connection + DELETE /v1/admin/accounts/{id}/connections/{platform} / .../bot-connections/{platform}). Metadata target_account_id + platform; emitted only on a real deletion.
  • system:account_login_connection_deleted - GraphQL-only (admin_delete_account_login_connection, gate accounts:edit); no REST twin exists for the account-level login-connection delete (the REST admin_delete_login_connection targets a single user via the already-audited system:user_login_connection_deleted). Metadata target_account_id + provider; emitted only on a real deletion.
  • system:account_limits_updated - operator changed an account's numeric limit overrides (account_limits), including the setting-preset quota, via either write path: the full upsert (adminUpsertAccountLimits / PUT /v1/admin/accounts/{id}/limits) or the partial resolved-limits update (adminSetAccountLimits / PUT /v1/admin/accounts/{id}/resolved-limits), both gated accounts:edit. Metadata account_id, a path discriminator (upsert | resolved), and the numeric limit fields that were submitted (max_setting_presets, max_overlays, …) - never the free-text notes.
  • system:user_setting_preset_deleted - operator deleted one of a user's setting presets (adminDeleteUserSettingPreset / DELETE /v1/admin/setting-presets/{id}, gate users:edit); emitted only on a real deletion. Metadata target_user_id, preset_id, and kind (chat | events | music) - never the preset payload (it can hold viewer names the user typed).

Per the no-secret rule, none of these payloads may carry a token, ciphertext, token_hint, invite_code/code, OAuth client_id/client_secret, user email, display name, or free-text review_notes. This is enforced by apps/api/tests/audit_emitters.rs::zaf1022_metadata_excludes_secrets_and_pii, and the User-scope isolation by audit_scoped_reads.rs::developer_team_events_are_user_scoped_and_isolated.

Operator API-key management (ZAF-1174)

Platform operators can manage any account's or user's API keys from the admin app - personal lm_usr_* keys (in api_keys) and account service lm_svc_* keys (in account_service_keys) - gated on the admin-scope account-apikeys:* family (AdminPermissionGuard / require_admin_permission). Each operator write is a platform-operator action on someone else's credential, so it is AuditScope::System with the operator as user_id (the actor) and no tenant account_id column - the target ids ride in metadata. These rows never surface in the target account's or the target user's own audit log. GraphQL (graphql/admin.rs) and REST (routes/admin.rs) emit the same event/scope/metadata via the shared emit_system_audit helper.

  • system:api_key_updated - an operator edited a key's label + scopes (adminUpdateApiKey / PATCH /v1/admin/api-keys/{kind}/{id}). Metadata: kind (personal | service), api_key_id, target account_id, target user_id (personal only; null for service), and the resulting permissions.
  • system:api_key_rotated - an operator regenerated a key's secret (adminRotateApiKey / POST /v1/admin/api-keys/{kind}/{id}/rotate). The new full key is returned to the operator exactly once and never stored/logged; metadata adds old_key_prefix + new_key_prefix to the same kind / api_key_id / account_id / user_id ids.
  • system:api_key_revoked - an operator revoked (hard-deleted) a key (adminDeleteApiKey / DELETE /v1/admin/api-keys/{kind}/{id}). Idempotent - a missing key resolves to not-found. Metadata: kind, api_key_id, account_id, user_id (personal only).

Per the no-secret rule, metadata carries only ids and the short, non-secret lm_* prefixes - never the full key, its hash, or PII. These are the operator siblings of the owner-self user:api_key_* (personal) and account:service_key_* (service) events: exactly one emitter fires per path, and an operator action is never mirrored into the target's personal/account log.

Read-time UUID → name resolution (ZAF-1233)

Audit rows store ids, never names - denormalizing a display name into metadata at write time is forbidden (every account member with audit-log:read can read metadata, and the name would go stale). Instead the read surfaces resolve the ids to display names at read time, once per page, so the UI can show "name (uuid)" for every entity a page references - not just the actor.

  • Resolver: db::audit::resolve_referenced_names(primary_pool, &rows). It scans the page's rows for the actor (user_id) plus a fixed metadata key allowlist, collects the distinct ids per entity kind, and runs one WHERE id = ANY($1) query per kind - never N+1. audit_events lives in TimescaleDB, but the named entities live in primary Postgres, so the resolver takes the primary pool (state.db / ctx.data::<PgPool>()), not state.tsdb.
  • Kinds & columns: userusers.display_name, accountaccounts.name (the human-facing account name the admin surfaces already render), roleaccount_roles.name.
  • Allowlist: actor user_id, plus metadata keys target_user_id / member_id (user), from_account_id / to_account_id / target_account_id (account), and role_id (account role). Other metadata ids are not resolved. role_id also appears on the system:user_role_*, admin_role:* and developer-team events, which reference other role tables; those simply miss here and fall back to the raw UUID.
  • Misses are omitted. There is no FK from the retained audit row to these tables - a user/account can be CASCADE-deleted while the audit row is deliberately kept - so a lookup that finds nothing means "deleted/unknown"; the id stays on the wire and the client renders the raw UUID (plus a "deleted" label). Resolution is also fail-open: a resolver query error logs and yields an empty map (via resolve_referenced_names_lenient) rather than blanking the audit page.
  • Wire shape (all three surfaces, GraphQL + REST): each event carries a resolved_names list of { id, kind, display_name } (resolvedNames in GraphQL) for exactly the entities that event references, de-duplicated. Present and identical on GraphQL AuditEvent + AdminAuditEvent and REST AuditEventResponse. The raw UUIDs are never removed - resolved_names is additive enrichment.

Consequently, when you add an emit site whose metadata should render a name, use one of the allowlisted keys above so the read surfaces resolve it for free.

Parity and tests

  • Protocol parity applies. An action reachable on both GraphQL and REST emits the same event type, scope and metadata shape on both. A one-sided emitter is a bug. Where a path exists on only one protocol (for example the OAuth browser-redirect callback, which is REST-only), document why at the call site.
  • Cover the emitter in apps/api/tests/audit_emitters.rs: the row lands with the expected scope, account, actor and metadata.
  • Cover the isolation in apps/api/tests/audit_scoped_reads.rs: a new scope or reader needs a fail-closed negative test proving foreign rows stay invisible.

Adding a new event type - checklist

  1. Add the event_types::* constant with a domain:action name matching its scope.
  2. Emit it via db::audit::emit at every protocol that can trigger the action.
  3. Set scope explicitly; put the target in metadata; keep secrets out.
  4. Add an emitter test and, for a new scope or reader, an isolation test.
  5. Document the event in Audit Log (and Admin · Audit Log if operators see it).
  6. If the admin event-type filter enumerates types, extend it - a filter option with no emitter is a documented lie.