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.
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: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.
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. Emitted by GraphQLcreateAccountServiceKey/updateAccountServiceKey/deleteAccountServiceKeyand RESTPOST/PATCH/DELETE /v1/service-keys(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_*key_prefix— 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.
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.
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, 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.
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
- 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.