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.

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.

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) flowPOST /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.

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 tokensaccount: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. Emitted by GraphQL createAccountServiceKey / updateAccountServiceKey / deleteAccountServiceKey and REST POST / PATCH / DELETE /v1/service-keys (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_* key_prefix — never the full key or its hash.
  • Overlay access tokensaccount: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 tokensaccount:widget_token_created (create + duplicate) / _rotated / _revoked.
  • Per-resource accessaccount: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.

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 credentialsaccount: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 clientsoauth_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 rolesadmin_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 connectionssystem: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 destructionsystem: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 removalaccount: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.

Per the no-secret rule, metadata carries only api_key_id and the short, non-secret lm_usr_* key_prefix — 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_atnever 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, labelnever 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_setGraphQL-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_deletedGraphQL-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.

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.

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.