Skip to main content

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.

typeScopeWritten byTrigger
chat_pii_erasureaccount / systemChat-PII erasure (Chat)Account dissolution, or an on-request data-subject erasure
youtube_member_erasureaccount / systemYouTube member-badge erasure (Member Badges)An on-request member-channel erasure
account:role_createdaccountRole management (Roles)A new role is created
account:role_updatedaccountRole managementA role's name/description/color/permissions change
account:role_deletedaccountRole managementA non-system role is deleted
account:member_role_assignedaccountMember managementA member is assigned a different role
account:member_removedaccountMember managementA member is removed from the account
account:invite_created / account:invite_revoked / account:invite_acceptedaccountMember managementAn account invite is created, revoked, or accepted (invite email/code never stored)
account:dissolvedaccountAccount settingsAn account is irreversibly dissolved
account:connection_addedaccountChannel connections (Connections)A channel connection is authorized (OAuth callback / exchange)
account:connection_removedaccountChannel connectionsA channel connection is disconnected
account:history_shared_link_created / account:history_shared_link_revoked / account:history_shared_link_extendedaccountStream 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_deletedaccountStream historyA stored stream-history session is deleted (GDPR Art. 17); session_id in metadata
user:loginuserAuth handlers (REST + GraphQL exchangeToken) and apps/id ingestA web, native, or GraphQL-exchange login succeeds
user:login_faileduserAuth handlers (REST + GraphQL exchangeToken)Provider-token validation or PKCE verification fails
user:oauth_granteduserapps/id ingestA signed-in user grants or reconnects OAuth access
user:api_key_created / user:api_key_updated / user:api_key_revokeduserUser API-key managementA 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_updatedsystemAdmin feature/provider controlsA global feature flag or provider kill-switch changes
system:plan_created / system:plan_updated / system:plan_deletedsystemAdmin plan managementA billing plan is created, edited, or deleted
system:plan_feature_updatedsystemAdmin plan managementA plan↔feature mapping changes
system:account_plan_changedsystemAdmin account controls, or Stripe billing webhookAn admin reassigns an account's plan, or a Stripe subscription event changes it ("via":"stripe" in metadata, actor NULL)
system:account_feature_overridesystemAdmin account controlsAn admin sets or clears an account feature override
system:account_deletedsystemAdmin account controlsA 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_revokedsystemAdmin credential managementA system API key (is_system=true) is minted or revoked (never full_key/key_hash)
system:connection_upserted / system:connection_deletedsystemAdmin credential managementA 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_deletedsystemAdmin user-role managementA cross-account user role is created, edited, or deleted
system:user_role_assigned / system:user_role_unassignedsystemAdmin user-role managementA cross-account user role is assigned to or removed from a user
system:user_permission_override_set / system:user_permission_override_removedsystemAdmin user-role managementA per-user permission override is set or removed
system:account_permission_override_set / system:account_permission_override_removedsystemAdmin account controlsA 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:deletedsystemAdmin developer-application managementA 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:deletedsystemAdmin admin-role managementAn 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:unassignedsystemAdmin admin-role managementAn 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_deletedsystemAdmin bot-connection managementA 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_deletedsystemAdmin user managementA 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_deletedsystemAdmin user managementA 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_deletedsystemAdmin developer managementA 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_changedsystemAdmin developer managementA 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_setsystemAdmin developer managementA platform operator sets a developer's revenue split (developer-limits:edit); developer_id + split in metadata
system:developer_limits_updatedsystemAdmin developer managementA 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_reviewedsystemAdmin developer managementA 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_changedsystemAdmin user managementA 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_setsystemAdmin user managementA 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_setsystemAdmin user managementA 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_deletedsystemAdmin account controlsA platform operator deletes an account's channel / bot connection (accounts:edit); target_account_id + platform in metadata
system:account_login_connection_deletedsystemAdmin account controlsA 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:

ScopeAudienceExample
userThe individual user, self-onlyPersonal-security rows keyed to the authenticated user
accountAn account's members, RBAC-gated to that accountRole/permission change, member-role assignment, channel connection
systemPlatform operatorsCross-account GDPR erasure and platform-operator rows

Each scope has its own reader: usermyAuditLog / GET /v1/me/audit-log (self-only, a first-party session or the user's own API key, no permission); accountaccountAuditLog / 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.)

note

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 / GraphQL eraseChatSubjectData, gated by admin: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, or platform + 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 through POST /v1/internal/audit-ingest; the Rust POST /v1/auth/token/exchange handler emits the native PKCE redemption path directly; the GraphQL exchangeToken mutation 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 /auth handlers and the GraphQL exchangeToken mutation (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 personal lm_usr_* API key, via GraphQL createUserApiKey / updateUserApiKey / deleteUserApiKey and REST POST / PATCH / DELETE /v1/api-keys. Metadata carries only the non-secret api_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 GraphQL adminUpdateFeatureFlag / adminUpdateFeatureFlagDetail, REST PATCH /v1/admin/feature-flags/{id} / PUT /v1/admin/feature-flags/{id}/detail, and the platform provider kill-switches — GraphQL adminSetProviderEnabled / adminSetProviderSubtypeEnabled / adminToggleProvider / adminToggleProviderSubtype and REST PATCH /v1/admin/providers/{platform}[/{subtype}]/toggle and …/enabled, which flip the same platform:* flags. The provider paths add "via": "provider" plus the platform (and subtype) to the metadata; enabled is 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 GraphQL adminCreatePlan / adminUpdatePlan / adminDeletePlan and REST POST /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 GraphQL adminSetPlanFeature and REST PUT /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 GraphQL adminUpdateAccountPlan and REST PATCH /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 GraphQL adminSetAccountFeature / adminClearAccountFeature and REST PUT /v1/admin/accounts/{id}/features / DELETE /v1/admin/accounts/{id}/features/{feature_id}. The action field is set or clear.

    { "account_id": "<uuid>", "feature_id": "<uuid>", "action": "set", "enabled": true }
  • system:account_deleted — a platform operator deletes an account, via GraphQL adminDeleteAccount and REST DELETE /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-scope account: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 GraphQL adminSetAccountPermissionOverride / adminRemoveAccountPermissionOverride and REST PUT /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 metadata account_id (never the key column). Remove emits only when a row was actually removed. The set path additionally records granted and the operator's optional reason; 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 typeScope
user:mfa_enableduser
user:mfa_disableduser

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.