Audit Log
View the audit log for system activity tracking.
Overview
The audit log records privacy-critical and permission-critical actions written
to audit_events. Every row has an explicit scope (user, account, or
system) and an event-type string.
type | Scope | Written by | Trigger |
|---|---|---|---|
chat_pii_erasure | account / system | Chat-PII erasure (Chat) | Account dissolution, or an on-request data-subject erasure |
youtube_member_erasure | account / system | YouTube member-badge erasure (Member Badges) | An on-request member-channel erasure |
account:role_created | account | Role management (Roles) | A new role is created |
account:role_updated | account | Role management | A role's name/description/color/permissions change |
account:role_deleted | account | Role management | A non-system role is deleted |
account:member_role_assigned | account | Member management | A member is assigned a different role |
account:member_removed | account | Member management | A member is removed from the account |
account:invite_created / account:invite_revoked / account:invite_accepted | account | Member management | An account invite is created, revoked, or accepted (invite email/code never stored) |
account:dissolved | account | Account settings | An account is irreversibly dissolved |
account:connection_added | account | Channel connections (Connections) | A channel connection is authorized (OAuth callback / exchange) |
account:connection_removed | account | Channel connections | A channel connection is disconnected |
account:history_shared_link_created / account:history_shared_link_revoked / account:history_shared_link_extended | account | Stream history (History) | A history report share link (lm_share_*) is created, revoked, or extended (session_id + link_id in metadata; never the token string/hash or password) |
account:history_session_deleted | account | Stream history | A stored stream-history session is deleted (GDPR Art. 17); session_id in metadata |
user:login | user | Auth handlers (REST + GraphQL exchangeToken) and apps/id ingest | A web, native, or GraphQL-exchange login succeeds |
user:login_failed | user | Auth handlers (REST + GraphQL exchangeToken) | Provider-token validation or PKCE verification fails |
user:oauth_granted | user | apps/id ingest | A signed-in user grants or reconnects OAuth access |
user:api_key_created / user:api_key_updated / user:api_key_revoked | user | User API-key management | A member mints, renames, or revokes a personal lm_usr_* API key; emitted by GraphQL createUserApiKey/updateUserApiKey/deleteUserApiKey and REST POST/PATCH/DELETE /v1/api-keys (only api_key_id + key_prefix in metadata, never the key or its hash) |
system:feature_flag_updated | system | Admin feature/provider controls | A global feature flag or provider kill-switch changes |
system:plan_created / system:plan_updated / system:plan_deleted | system | Admin plan management | A billing plan is created, edited, or deleted |
system:plan_feature_updated | system | Admin plan management | A plan↔feature mapping changes |
system:account_plan_changed | system | Admin account controls, or Stripe billing webhook | An admin reassigns an account's plan, or a Stripe subscription event changes it ("via":"stripe" in metadata, actor NULL) |
system:account_feature_override | system | Admin account controls | An admin sets or clears an account feature override |
system:account_deleted | system | Admin account controls | A platform operator deletes an account (DELETE /v1/admin/accounts/{id} / adminDeleteAccount); the deleted account id + "reason":"operator_delete" ride in metadata. Owner self-service dissolve is the separate account-scope account:dissolved |
system:system_key_created / system:system_key_revoked | system | Admin credential management | A system API key (is_system=true) is minted or revoked (never full_key/key_hash) |
system:connection_upserted / system:connection_deleted | system | Admin credential management | A system OAuth (login-provider) connection is created/updated or deleted (raw client_secret and ciphertext never stored) |
system:user_role_created / system:user_role_updated / system:user_role_deleted | system | Admin user-role management | A cross-account user role is created, edited, or deleted |
system:user_role_assigned / system:user_role_unassigned | system | Admin user-role management | A cross-account user role is assigned to or removed from a user |
system:user_permission_override_set / system:user_permission_override_removed | system | Admin user-role management | A per-user permission override is set or removed |
system:account_permission_override_set / system:account_permission_override_removed | system | Admin account controls | A platform operator sets or removes an account-level permission override (PUT / DELETE /v1/admin/accounts/{id}/permission-overrides / adminSetAccountPermissionOverride / adminRemoveAccountPermissionOverride); target account_id + permission (+ granted/reason on set) ride in metadata |
oauth_client:created / oauth_client:updated / oauth_client:deleted | system | Admin developer-application management | A developer OAuth client is created, edited, or deleted (oauth-clients:{create,edit,delete}); metadata carries the DB row id (client_id_ref), not the OAuth client_id string, and never the client_secret |
admin_role:created / admin_role:updated / admin_role:deleted | system | Admin admin-role management | An operator admin role is created, edited, or deleted (admin-roles:{create,edit,delete}) on the admin_roles table (distinct from user roles); role_id / role_name / permissions in metadata |
admin_role:assigned / admin_role:unassigned | system | Admin admin-role management | An admin role is assigned to or removed from a user (admin-roles:edit); role_id + target_user_id in metadata |
system:global_bot_connection_upserted / system:global_bot_connection_deleted | system | Admin bot-connection management | A global (nil-UUID) bot connection is upserted (manual Discord token or OAuth exchange) or deleted (bot-connections:{create,delete}); platform / connection_id / bot_username / source in metadata, never the token |
system:user_deleted | system | Admin user management | A platform operator deletes a user and all their data (users:delete, DELETE /v1/admin/users/{id} / adminDeleteUser); target_user_id in metadata |
system:user_login_connection_deleted | system | Admin user management | A platform operator removes one of a user's login identities (adminDeleteUserLoginConnection / DELETE /v1/admin/users/{id}/login-connections/{provider}); target_user_id + provider in metadata |
system:developer_deleted / system:developer_team_deleted | system | Admin developer management | A platform operator deletes a developer or developer team (developer-verification:edit / developer-limits:edit); developer_id / team_id in metadata |
system:developer_team_member_added / system:developer_team_member_removed / system:developer_team_member_role_changed | system | Admin developer management | A platform operator adds/removes a developer-team member or changes their role (developer-limits:edit); team_id, member_id/target_user_id, role_id in metadata |
system:developer_revenue_split_set | system | Admin developer management | A platform operator sets a developer's revenue split (developer-limits:edit); developer_id + split in metadata |
system:developer_limits_updated | system | Admin developer management | A platform operator updates a developer or extension limit — one merged event discriminated by limit_scope (developer|extension) in metadata (developer-limits:edit) |
system:developer_limit_request_reviewed | system | Admin developer management | A platform operator reviews a developer limit request (developer-limits:edit); request_id, status, developer_id, extension_id, limit_key, has_review_notes in metadata — never the free-text review_notes |
system:user_email_changed | system | Admin user management | A platform operator changes a user's email (users:edit), only on a real change; target_user_id + had_previous_email in metadata — never the address. Distinct from the user-self user:email_changed |
system:user_account_creation_override_set | system | Admin user management | A platform operator sets a user's account-creation override (users:edit); target_user_id + override (allow|deny|default) in metadata |
system:user_max_accounts_override_set | system | Admin user management | A platform operator sets a user's max-accounts override (users:edit) — GraphQL-only; target_user_id + max_accounts in metadata |
system:account_connection_deleted / system:account_bot_connection_deleted | system | Admin account controls | A platform operator deletes an account's channel / bot connection (accounts:edit); target_account_id + platform in metadata |
system:account_login_connection_deleted | system | Admin account controls | A platform operator deletes an account's login (identity) connection (accounts:edit) — GraphQL-only; target_account_id + provider in metadata |
The erasure events are hard-delete trails proving after the fact that a
subject's data was removed. The account:role_* events capture role and
permission changes within an account, and account:connection_* events capture
channel connections being added or removed. The user:* rows capture personal
security activity, and the system:* rows capture operator actions on global
entitlements. See Event types for the exact metadata each
emitted event carries.
Scope
Every audit event carries an explicit scope — one of user, account, or
system — stored as a first-class column and set by the writer, never inferred
at read time from which of account_id / user_id is populated. Scope
partitions the log into three audiences:
| Scope | Audience | Example |
|---|---|---|
user | The individual user, self-only | Personal-security rows keyed to the authenticated user |
account | An account's members, RBAC-gated to that account | Role/permission change, member-role assignment, channel connection |
system | Platform operators | Cross-account GDPR erasure and platform-operator rows |
Each scope has its own reader: user → myAuditLog / GET /v1/me/audit-log
(self-only, a first-party session or the user's own API key, no permission);
account → accountAuditLog / GET /v1/account/audit-log (the account-scope
audit-log:read permission); system → the admin log documented on this page
(adminAuditLog / GET /v1/admin/audit-log, the admin-scope audit-log:read).
The two erasure types today span these scopes: the global cross-account row
an operator triggers is system-scoped, while each per-account row is
account-scoped. (The account-dissolution chat sweep writes a single
account-scoped row.)
The top-level scope column is distinct from the "scope" key that appears
inside some events' metadata ("account" / "global" / "per_account"),
which describes the breadth of the erasure operation, not the audit audience.
The column is authoritative for filtering.
Event types
chat_pii_erasure
Written whenever platform_chat_messages rows are hard-deleted for privacy
reasons. Two paths produce it:
-
Account dissolution (
dissolveAccount/POST /v1/accounts/{id}/dissolve) writes one account-scoped event as it purges the dissolved account's chat rows:{ "scope": "account", "account_id": "<uuid>", "deleted_message_count": 0 } -
On-request data-subject erasure (
POST /v1/admin/privacy/chat/erase/ GraphQLeraseChatSubjectData, gated byadmin:privacy-erase) deliberately spans accounts, so it writes one system-scoped event (account_id = null) plus one account-scoped event per affected account:// top-level scope = system; metadata.scope = global{ "scope": "global", "deleted_message_count": 0,"affected_account_ids": ["<uuid>"],"lumio_user_id": "<uuid>" } // or: "platform": "...", "user_id": "..."// top-level scope = account; metadata.scope = per_account{ "scope": "per_account", "lumio_user_id": "<uuid>" }The subject-identity fields (
lumio_user_id, orplatform+user_id) echo whichever identity mode the request supplied.
youtube_member_erasure
Written by the YouTube member-channel erasure
(DELETE /v1/admin/privacy/youtube/member/{member_channel_id} / GraphQL
eraseYoutubeMemberData, gated by admin:privacy-erase), which clears the
member's cached Redis records and their historical chat rows. It also spans
accounts, so it writes one system-scoped event (account_id = null) plus
one account-scoped event per affected account:
// top-level scope = system; metadata.scope = global
{ "scope": "global", "member_channel_id": "UC…",
"erased_key_count": 0, "deleted_message_count": 0,
"affected_account_ids": ["<uuid>"] }
// top-level scope = account; metadata.scope = per_account
{ "scope": "per_account", "member_channel_id": "UC…" }
Both event types record the acting admin in user_id and their request IP in
ip_address (GeoIP-enriched into country / city — see
Location data). The erasure writers do not capture a
user_agent, so that field is always empty for these events.
account:role_* (role & permission changes)
Emitted whenever an account's roles or member-role assignments change, via both
GraphQL (createRole / updateRole / deleteRole / updateMemberRole) and
their REST counterparts (POST /v1/roles, PATCH /v1/roles/{id},
DELETE /v1/roles/{id}, PATCH /v1/accounts/{id}/members/{membership_id}/role).
Both protocols write identical rows. Every event is account-scoped,
carries the acting user_id (the actor), the tenant account_id, and the
request ip_address (GeoIP-enriched). These rows surface only in the account
log — never the actor's personal (user-scoped) log (overlap rule).
-
account:role_created/account:role_updated/account:role_deleted:{ "role_id": "<uuid>", "role_name": "Moderators", "slug": "moderators" } -
account:member_role_assigned:{ "membership_id": "<uuid>", "target_user_id": "<uuid>","role_id": "<uuid>", "role_name": "Moderators" }
The audit insert is best-effort: a failed write is logged and swallowed, so the role mutation itself never fails on the audit side-channel.
account:connection_* (channel connections)
Emitted whenever a channel connection is added or removed. Add
(account:connection_added) happens at OAuth completion, which is REST-only: the
browser-redirect callback (GET /connections/channel/{platform}/callback) and
the JSON exchange (POST /connections/channel/{platform}/exchange). There is no
GraphQL "add connection" mutation to mirror, because authorization is an HTTP
redirect flow. Remove (account:connection_removed) has full parity:
GraphQL disconnectChannel and REST DELETE /v1/connections/channel/{platform}
write identical rows. Every event is account-scoped, carries the tenant
account_id, the request ip_address (GeoIP-enriched), and the platform:
{ "platform": "twitch" }
The acting user_id is recorded on the authenticated paths (exchange, disconnect).
The unauthenticated browser-redirect callback carries no caller identity, so its
actor is null. Like the role events, these rows surface only in the account
log (overlap rule) and the insert is best-effort — a failed write never fails
the connection mutation.
user:* (personal security events)
User-scope rows are personal security events. They always use
account_id = null; when the event can be tied to a known Lumio user, user_id
is the subject who can see the row in My Activity. If a failed login cannot
be mapped to a known Lumio user, the row is still scope=user but has
user_id = null, so it is visible only in the admin audit log.
-
user:login— successful login. apps/id emits the web login path throughPOST /v1/internal/audit-ingest; the RustPOST /v1/auth/token/exchangehandler emits the native PKCE redemption path directly; the GraphQLexchangeTokenmutation emits its own success inline (method: graphql_exchange) — it has no first-party caller, so apps/id ingest never covers it.{ "method": "graphql_exchange", "provider": "twitch", "is_new_user": false }{ "method": "pkce" } -
user:login_failed— rejected login emitted by the Rust REST/authhandlers and the GraphQLexchangeTokenmutation (at parity) when provider-token validation fails or PKCE verification fails.{ "provider": "twitch", "reason": "provider_token_invalid" }{ "reason": "pkce_verification_failed" } -
user:oauth_granted— provider link/reconnect consent from a signed-in user, emitted by apps/id through the ingest endpoint.{ "provider": "youtube", "reconnect": true, "connection_id": "<uuid>" } -
user:api_key_created/user:api_key_updated/user:api_key_revoked— a member mints, renames, or revokes a personallm_usr_*API key, via GraphQLcreateUserApiKey/updateUserApiKey/deleteUserApiKeyand RESTPOST/PATCH/DELETE /v1/api-keys. Metadata carries only the non-secretapi_key_id+key_prefix; never the key or its hash.{ "api_key_id": "<uuid>", "key_prefix": "lm_usr_ab12" }
apps/id forwards the end-user IP and user-agent to the ingest endpoint; the Rust auth handlers capture them from the request. Both paths GeoIP-enrich the IP fail-open.
system:* (platform-operator actions)
Emitted for platform-operator actions on the global entitlement surface,
reachable from the admin app. Every event is system-scoped, carries the acting
admin as user_id and the request ip_address (GeoIP-enriched), and — unlike the
account events — carries no tenant account_id column. A target account (for
the per-account overrides) is recorded in metadata instead, so a system row can
never surface in that account's own audit log; it appears only on this admin
system surface. GraphQL and REST write identical rows, and the insert is
best-effort (a failed write never fails the admin mutation).
-
system:feature_flag_updated— a global feature-flag toggle. Emitted by GraphQLadminUpdateFeatureFlag/adminUpdateFeatureFlagDetail, RESTPATCH /v1/admin/feature-flags/{id}/PUT /v1/admin/feature-flags/{id}/detail, and the platform provider kill-switches — GraphQLadminSetProviderEnabled/adminSetProviderSubtypeEnabled/adminToggleProvider/adminToggleProviderSubtypeand RESTPATCH /v1/admin/providers/{platform}[/{subtype}]/toggleand…/enabled, which flip the sameplatform:*flags. The provider paths add"via": "provider"plus theplatform(andsubtype) to the metadata;enabledis always the resulting flag state.{ "flag_id": "<uuid>", "flag_key": "platform:twitch", "enabled": false,"via": "provider", "platform": "twitch" } -
system:plan_created/system:plan_updated/system:plan_deleted— plan lifecycle, via GraphQLadminCreatePlan/adminUpdatePlan/adminDeletePlanand RESTPOST /v1/admin/plans,PATCH /v1/admin/plans/{id},DELETE /v1/admin/plans/{id}.{ "plan_id": "<uuid>", "slug": "pro", "name": "Pro" } -
system:plan_feature_updated— a plan↔feature mapping change, via GraphQLadminSetPlanFeatureand RESTPUT /v1/admin/plans/{id}/features.{ "plan_id": "<uuid>", "feature_id": "<uuid>", "enabled": true } -
system:account_plan_changed— an admin reassigns an account's plan, via GraphQLadminUpdateAccountPlanand RESTPATCH /v1/admin/accounts/{id}/plan.{ "account_id": "<uuid>", "plan_id": "<uuid>" } -
system:account_feature_override— an admin sets or clears a per-account feature override, via GraphQLadminSetAccountFeature/adminClearAccountFeatureand RESTPUT /v1/admin/accounts/{id}/features/DELETE /v1/admin/accounts/{id}/features/{feature_id}. Theactionfield issetorclear.{ "account_id": "<uuid>", "feature_id": "<uuid>", "action": "set", "enabled": true } -
system:account_deleted— a platform operator deletes an account, via GraphQLadminDeleteAccountand RESTDELETE /v1/admin/accounts/{id}. Emitted only on a real delete; the deleted account id is the target (rides in metadata, not the key column). Audit rows live in TimescaleDB, which the account CASCADE does not erase, so the record persists. Owner self-service dissolve is the separate account-scopeaccount:dissolved.{ "account_id": "<uuid>", "reason": "operator_delete" } -
system:account_permission_override_set/system:account_permission_override_removed— a platform operator (accounts:overrides-edit) grants/revokes an account-level permission override, via GraphQLadminSetAccountPermissionOverride/adminRemoveAccountPermissionOverrideand RESTPUT /v1/admin/accounts/{id}/permission-overrides/DELETE /v1/admin/accounts/{id}/permission-overrides/{permission}. System-scoped because it is a cross-account operator action on a tenant's granted-permission surface; the target account is the metadataaccount_id(never the key column). Remove emits only when a row was actually removed. Thesetpath additionally recordsgrantedand the operator's optionalreason; metadata never carries a secret.{ "account_id": "<uuid>", "permission": "chat:ban", "granted": true, "reason": "temporary escalation" }
Catalogued event types without current emitters
The backend catalog also defines these wire strings so filters and labels can share a single source of truth. Current app flows do not emit them yet, so they only appear if a writer explicitly records them through the central audit writer.
| Event type | Scope |
|---|---|
user:mfa_enabled | user |
user:mfa_disabled | user |
Viewing Logs
Access the audit log via Admin > Audit Log (/audit-log). A filter card at the top exposes:
- Type — a dropdown offering the two erasure type presets
(
chat_pii_erasure,youtube_member_erasure) plus an "all types" entry. Rows with other event types are visible through "all types", but the UI does not offer those values as Type presets. - User ID — free-text.
- Account ID — free-text.
- From / To — date pickers.
Filters apply on Apply; changing a filter alone does not refetch until the button is pressed (or the page changes).
Results render as a table with Time, Type, User ID (truncated to the
first 8 characters), IP, Location (city, country), and a Details
toggle column. Clicking a row expands it to show the raw event metadata JSON
and, when present, the user agent string. The current table does not show the
top-level scope column; use the API-level scope filter when you need to
narrow the admin view by audience.
Paging is 50 events per page with Previous / Next controls and a "page X of Y (N events)" counter.
Location data
The Location column (and the country / city fields on each event) is filled by the optional GeoIP service, which resolves the event's client IP to a coarse country/city via MaxMind GeoLite2. country holds the ISO-3166-1 alpha-2 code (e.g. DE) and city the English city name; the column renders them together as city, country.
Enrichment is fail-open: country / city are left empty whenever GeoIP is disabled, its database is unavailable, or the client IP is missing, private/loopback, on the MaxMind privacy-exclusion list, or simply not found. The IP address itself is still recorded in those cases, and the audit log is fully functional either way.
GeoIP is disabled by default, so out of the box these fields are empty and the column shows nothing. Enabling GeoIP requires a MaxMind license key and, optionally, honours user opt-outs through the Privacy Exclusions API; see the GeoIP configuration for the LUMIO__GEOIP__* settings.
Permissions
Reading the audit log requires the audit-log:read permission. The dashboard
entry itself is gated on admin:access. The seeded system_admin role carries
both permissions explicitly; is_system: true protects that role from edits and
deletion, but does not bypass permission checks. The erasure operations are
separately gated by admin:privacy-erase (see
Roles & Permissions).
This admin page is the operator audit surface and uses the admin-scope
audit-log:read (checked against the caller's admin role). The account- and
user-scoped logs are separate surfaces: accountAuditLog /
GET /v1/account/audit-log is gated by the identically-named account-scope
audit-log:read (a distinct permission checked against the account role), and
myAuditLog / GET /v1/me/audit-log is self-only, gated purely by a first-party
session with no permission. An account grant never unlocks this admin log, and
vice-versa.
Storage
Audit events are persisted in TimescaleDB and queried via the admin API (GET /v1/admin/audit-log, GraphQL adminAuditLog). The admin UI reaches it through its own Next.js proxy route at /api/audit-log.
Each event exposes its scope on both protocols. The admin query accepts an
optional scope filter — GET /v1/admin/audit-log?scope=system or the
scope field on the adminAuditLog filter input — to narrow results to a single
audience (user | account | system); an unrecognized value is ignored. The
admin log is a superuser view, so with no scope filter it returns rows from
every scope. The admin Next.js proxy and UI do not currently forward a scope
filter.