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:
| Category | Examples |
|---|---|
| Authentication & identity | Login, failed login, MFA enable/disable, OAuth grant |
| Credentials | API-key create/revoke, channel connection added/removed |
| Permission structure | Role create/update/delete, member role assignment |
| Destructive / GDPR | Data erasure, retention purges, account deletion |
| Platform operations | Admin 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.
| Scope | Use for |
|---|---|
AuditScope::User | Personal security events, readable self-only |
AuditScope::Account | Tenant actions, gated on account audit-log:read |
AuditScope::System | Platform-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:
| Caller | Precedence |
|---|---|
First-party server hop (a valid X-Internal-Token) | X-Lumio-Client-IP → X-Forwarded-For (first hop) → X-Real-IP → CF-Connecting-IP → peer address |
| Everything else (a direct browser/client request) | CF-Connecting-IP → X-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-ip → x-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:loginfor the native (PKCE) flow -POST /v1/auth/authorizereturns only an authcode; the subject/JWT is minted later when the native app redeems the code againstPOST /v1/auth/token/exchange, directly against the API. That redemption handler is the single authoritativeuser:loginemit point for the native flow (the web flow stays on apps/id ingest);metadata.method = "pkce".user:loginfor the GraphQLexchangeTokenmutation (ZAF-1023) —graphql/auth.rsexchange_tokenemitsuser:logininline on success withmetadata.method = "graphql_exchange". This is the GraphQL path's only login-success writer and must not be removed:exchangeTokenhas 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 RESTexchange_token/authorizehandlers and the GraphQLexchangeTokenmutation (ZAF-1023, at parity) emit it on a failed provider-token validation, andtoken_exchangeemits 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-effortemit, not the ingest endpoint. Both paths capture the request User-Agent (the GraphQL context grows aUserAgentdatum 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:ingestgrant. Every non-System principal is rejected403(anonymous401) so no public/user route can forge audit rows; a System key without the grant is403too. Provision apps/id's key withaudit:ingest(oraudit:*- 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 anaccount:*/system:*type. - It uses
insert_audit_event, notemit, 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, neverinsert_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.
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",emitUserAuditEventemits exactly oneconsole.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 subjectuser_idstays silent (it is a normal skip, not an operator condition). - Health field.
GET /api/healthon apps/id returnsaudit_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'ssubclaim.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 fromupdateMe(GraphQL) andPATCH /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 tenantaccount_id. The old/new address is PII and is never written tometadata— the row carries only ahad_previous_emailboolean (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,metadatacarriesplatformconnection_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'sactive_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 samesession_id, so revocation still reaches it - seefeatures/sessions.md). The actor is the user; the from/to account ids and aclearedflag ride inmetadata(both accounts are the user's own - no secret).account_idis NULL: a personal switch is not a tenant action, so it stays out of the switched-to account'saudit-log:readlog (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 twinPATCH /v1/users/me(apps/api/src/routes/users.rs), on the same condition that re-mints the cookie (active_account_idset, orclear_active_account). Same event type, scope and metadata on both protocols (parity). Both use the standard best-effort in-processemit.
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: GraphQLlogout(refreshToken)/logoutSession, RESTPOST /v1/auth/logout.metadatacarries the endedsession_id(when the deleted row is known) and themethod(refresh_token|session). For the refresh-token flow the actor and session id come from the deleted session row (delete_sessionnowRETURNING id, user_id), so an expired-but-authentic logout still records the row;logoutSessiontakes them from the JWT claims.user:session_revoked- the user revoked session(s) from session management: GraphQLdeleteSession(id)/deleteAllOtherSessions, RESTDELETE /v1/users/me/sessions/{id}/DELETE /v1/users/me/sessions.metadatadistinguishes 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 GraphQLcreateAccountServiceKey/updateAccountServiceKey/deleteAccountServiceKey/rotateAccountServiceKeyand RESTPOST/PATCH/DELETE /v1/service-keys/POST /v1/service-keys/{id}/rotate(protocol parity). Account-scoped (the key is a tenant credential, unlike the personaluser:api_key_*events); the actor is the member who performed the action. Metadata carries onlyservice_key_idand the short, non-secretlm_svc_*prefix -key_prefix, orold_key_prefix+new_key_prefixon_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/_revokedandaccount:widget_access_granted/_revoked. Grant is an upsert: the resultingrolein metadata covers both first-grant and role-change (no pre-read, no separate*_changedtype). - History report share links (
lm_share_*, ZAF-780) —account:history_shared_link_created/_extended/_revoked. The samelm_share_*credential lifecycle as the overlay share link, keyed to a history session/report instead of an overlay (create/revoke carry thelm_share_*token_prefix, extend carries the new expiry). Metadata is the non-secretsession_id/link_id(+expires_at/token_prefix) - never the token string/hash or the argon2password_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 thesession_idonly. 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 gatedpresets:share. Both areaccount-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-secretpreset_id,kind(chat|events|music) andname- 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, gateconnections:create).save_credentials/saveAppCredentialswrite AES-256-GCMclient_id/client_secretintoapp_credentials; this is the credential level, distinct from the token levelaccount:connection_added. Metadata:platform,credentials_id(row id),updated(insert vs update) - never theclient_id/client_secret(plain or ciphertext). The DELETE counterpart isaccount:connection_credentials_deleted(ZAF-981, gateconnections:delete):delete_credentials/deleteAppCredentialsremove the row and, on the same path, tear down the associated channel connection - so that path emits bothaccount:connection_removed(when a connection row was removed) andaccount: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 isplatformonly. - Developer OAuth clients -
oauth_client:created/_updated/_deleted(AuditScope::System, gateoauth-clients:{create,edit,delete}). Metadata:client_id_ref(the DB row id, not the OAuthclient_idstring),name,redirect_uris,scopes, and (update)changed_fields- never the OAuthclient_idstring orclient_secret. - Operator admin roles -
admin_role:created/_updated/_deleted/_assigned/_unassigned(AuditScope::System, gateadmin-roles:{create,edit,delete}). Theadmin_rolestable is separate fromuser_roles(whosesystem:user_role_*events already emit). Metadata:role_id,role_name,permissions(the grantedresource:actionlist — auditing the grant is the point),changed_fields(update); assign/unassign addtarget_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, gatebot-connections:{create,delete}). The nil-UUID global bot token, written byset_discord_bot_token(manual token) andexchange_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_tokenhas full REST↔GraphQL parity. - Operator identity destruction -
system:user_deleted(gateusers:delete) andsystem:user_login_connection_deleted(AuditScope::System). The login-identity removal has full REST↔GraphQL parity —admin_delete_user_login_connection(GraphQL) andDELETE /v1/admin/users/{id}/login-connections/{provider}(routes::admin::admin_delete_login_connection, gateusers:edit) emit the same event. Both emit only on a real deletion. Metadata:target_user_id, plusproviderfor the login-connection case - never email/handle/tokens. - Login-identity removal -
account:login_connection_removed(AuditScope::Account,remove_login_assignment, gatelogin-assignments:delete; metadataprovider+target_user_id) anduser:login_connection_removed(AuditScope::User, self-servicedelete_login_connection/disconnectLoginConnection, first-party-gated ZAF-469; metadataprovider+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 GraphQLcreateUserApiKeyand RESTPOST /v1/api-keys(protocol parity).user:api_key_updated- a key is renamed (label only). Emitted by GraphQLupdateUserApiKeyand RESTPATCH /v1/api-keys/{id}(protocol parity).user:api_key_revoked- a key is deleted. Emitted by GraphQLdeleteUserApiKeyand RESTDELETE /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 GraphQLrotateUserApiKeyand RESTPOST /v1/api-keys/{id}/rotate(protocol parity); the rotate paths pre-read the key so the row can carry the old prefix. Metadata addsold_key_prefix+new_key_prefixalongsideapi_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, andresubmission(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/verificationare 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 viaadmin_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); metadatatarget_user_id+ ahad_previous_emailflag - never the old/new address.system:user_account_creation_override_set- metadatatarget_user_id+override(allow|deny|default).system:user_max_accounts_override_set- GraphQL-only (admin_update_user_max_accounts_override);patch_userREST does not acceptmax_accounts, so a GraphQL-only emitter is correct parity. Metadatatarget_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}). Metadatatarget_account_id+platform; emitted only on a real deletion.system:account_login_connection_deleted- GraphQL-only (admin_delete_account_login_connection, gateaccounts:edit); no REST twin exists for the account-level login-connection delete (the RESTadmin_delete_login_connectiontargets a single user via the already-auditedsystem:user_login_connection_deleted). Metadatatarget_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 gatedaccounts:edit. Metadataaccount_id, apathdiscriminator (upsert|resolved), and the numeric limit fields that were submitted (max_setting_presets,max_overlays, …) - never the free-textnotes.system:user_setting_preset_deleted- operator deleted one of a user's setting presets (adminDeleteUserSettingPreset/DELETE /v1/admin/setting-presets/{id}, gateusers:edit); emitted only on a real deletion. Metadatatarget_user_id,preset_id, andkind(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, targetaccount_id, targetuser_id(personal only;nullfor service), and the resultingpermissions.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 addsold_key_prefix+new_key_prefixto the samekind/api_key_id/account_id/user_idids.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 oneWHERE id = ANY($1)query per kind - never N+1.audit_eventslives in TimescaleDB, but the named entities live in primary Postgres, so the resolver takes the primary pool (state.db/ctx.data::<PgPool>()), notstate.tsdb. - Kinds & columns:
user→users.display_name,account→accounts.name(the human-facing account name the admin surfaces already render),role→account_roles.name. - Allowlist: actor
user_id, plus metadata keystarget_user_id/member_id(user),from_account_id/to_account_id/target_account_id(account), androle_id(account role). Other metadata ids are not resolved.role_idalso appears on thesystem: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_nameslist of{ id, kind, display_name }(resolvedNamesin GraphQL) for exactly the entities that event references, de-duplicated. Present and identical on GraphQLAuditEvent+AdminAuditEventand RESTAuditEventResponse. The raw UUIDs are never removed -resolved_namesis 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
- Add the
event_types::*constant with adomain:actionname matching its scope. - Emit it via
db::audit::emitat every protocol that can trigger the action. - Set
scopeexplicitly; put the target inmetadata; keep secrets out. - Add an emitter test and, for a new scope or reader, an isolation test.
- Document the event in Audit Log (and Admin · Audit Log if operators see it).
- If the admin event-type filter enumerates types, extend it - a filter option with no emitter is a documented lie.