Skip to main content

Audit Log

Overview

The audit log is Lumio's tamper-evident record of security- and administration-relevant actions. Every row in the audit_events table carries an explicit scope column — user, account, or system — set by the writer and never inferred at read time. Scope partitions the log into three audiences, each with its own read surface, so a reader only ever sees the events meant for them.

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

Scope isolation is enforced by each reader: the user reader returns only the caller's scope=user rows, the account reader returns only the caller's account's scope=account rows, and system rows are only available through the operator-only admin view.

Overlap rule: an account-context action that has an actor lives only in the account log (where the actor is shown), never additionally in the user log; personal-security events live only in the user log. An event is written to exactly one scope.

Permission

Reading a single account's audit log requires the account-scope audit-log:read permission. In the default roles it is held by Owner and Administrator only — Moderator and Viewer do not carry it.

This account permission shares its string with an admin-scope audit-log:read that gates the operator/system log, but the two are distinct grants enforced by different guards (the account guard checks the account role; the admin guard checks the admin role). Holding the account permission never unlocks the system log, and vice-versa. The self-service user log needs no permission at all — only a first-party session.

Reading the log

Your personal log (user scope)

Your own scope=user rows. This is self-service — it needs no RBAC permission — but it does require a first-party principal: a logged-in session or your own API key. A popout, overlay, widget, or extension token cannot read it. Current writers record successful logins, failed login attempts tied to a known user, OAuth grant consent, and your personal API-key (lm_usr_*) create/revoke.

  • GraphQL: myAuditLog(filter: AuditLogFilter!): AuditLogPage!
  • REST: GET /v1/me/audit-log

Your account's log (account scope)

Tenant actions on the active account. Current emitters write role and permission changes, member-role assignment, channel-connection add/remove rows, and account-scoped GDPR erasure rows. The account filter list also includes account-owned API-key lifecycle event types, but current app flows do not emit those rows yet. Requires the account-scope audit-log:read permission (Owner and Administrator by default). Returns only the active account's rows.

  • GraphQL: accountAuditLog(filter: AuditLogFilter!): AuditLogPage!
  • REST: GET /v1/account/audit-log

Operator log (system scope)

Operator and cross-account rows. This is the admin surface, gated by the admin-scope audit-log:read; see Admin → Audit Log.

  • GraphQL: adminAuditLog(filter)
  • REST: GET /v1/admin/audit-log

Filtering and pagination

Both the user and account readers share the same filter and paginated response shape, exposed identically on GraphQL and REST (same fields, permission, validation and errors).

REST query paramGraphQL filter fieldMeaning
pagepagePage number (default 1).
limitlimitPage size (REST default 50, clamped 1..=200).
typeeventTypeExact event-type match.
fromdateFromOnly events at/after this instant (RFC-3339; an unparseable value is ignored).
todateToOnly events at/before this instant (RFC-3339; an unparseable value is ignored).

A page (AuditLogPage) carries items, total, page, and limit; the REST response additionally includes total_pages.

Each AuditEvent exposes id, accountId, userId, eventType, scope, ipAddress, userAgent, country, city, metadata (a JSON string), and createdAt. The country / city fields are filled by the optional GeoIP service and are empty when it is disabled or cannot resolve the client IP.

Event catalog

Every audit event type is a wire string. Most catalog entries use scope:action naming; the two GDPR erasure strings keep their historical underscore names.

Event typeScopeCurrent writer
chat_pii_erasureaccount / systemAccount dissolution and admin chat-subject erasure
youtube_member_erasureaccount / systemAdmin YouTube member-channel erasure
account:role_createdaccountRole creation
account:role_updatedaccountRole edits, including permission changes
account:role_deletedaccountCustom role deletion
account:member_role_assignedaccountMember role assignment
account:member_removedaccountA member was removed from the account (target user in metadata)
account:invite_createdaccountAn account invite was created (invite email and code are never stored in metadata)
account:invite_revokedaccountAn account invite was revoked/deleted
account:invite_acceptedaccountAn invite was accepted; actor is the joining user, scoped to the account joined (invite code never stored)
account:dissolvedaccountAn account was irreversibly dissolved (CASCADE delete); actor is the owner
account:connection_addedaccountChannel OAuth completion
account:connection_removedaccountChannel disconnect
account:connection_reconnect_requiredaccountA channel/bot token refresh failed terminally (dead grant); the Token Refresh Worker auto-flagged reconnect_required
account:connection_credentials_upsertedaccountPer-account OAuth app credentials (client_id/secret) created/updated via saveAppCredentials / PUT /v1/connections/credentials/{platform} (connections:create); the credential level, distinct from account:connection_added (token level). Metadata: platform, credentials_id, updated — never the client_id/client_secret
account:connection_credentials_deletedaccountPer-account OAuth app credentials removed via deleteAppCredentials / DELETE /v1/connections/credentials/{platform} (connections:delete); the DELETE counterpart of account:connection_credentials_upserted. The same call also disconnects the channel, which emits account:connection_removed. Fires only on a real removal. Metadata: platform only — never the client_id/client_secret
public_stats:channel_opted_outaccountBroadcaster hides a channel from the public Stats app (is_public → false)
public_stats:channel_opted_inaccountBroadcaster re-lists a channel on the public Stats app (is_public → true)
account:popout_token_created / account:popout_token_updated / account:popout_token_deletedaccountPopout-token lifecycle (create / update / delete). A soft-revoke via update carries "revoked":true. Metadata is the non-secret token_id + lm_pop_* prefix only
account:overlay_token_rotated / account:overlay_token_revokedaccountOverlay access-token rotate / revoke. Metadata carries overlay_id + token ids + the lm_overlay_* prefix; never the token string
account:shared_link_created / account:shared_link_revoked / account:shared_link_extendedaccountShared-overlay link (lm_share_*) lifecycle; create/revoke carry the lm_share_* prefix, extend carries the new expiry. Never the token string/hash
account:widget_token_created / account:widget_token_rotated / account:widget_token_revokedaccountWidget access-token lifecycle (create / duplicate / rotate / revoke). Metadata is widget_id only — the token id/prefix are not surfaced by the helpers and the full token is never stored
account:overlay_access_granted / account:overlay_access_revokedaccountPer-overlay access grant (upsert — resulting role in metadata) / revoke; target_user_id in metadata
account:widget_access_granted / account:widget_access_revokedaccountPer-widget access grant (upsert — resulting role in metadata) / revoke; target_user_id in metadata
account:history_shared_link_created / account:history_shared_link_revoked / account:history_shared_link_extendedaccountStream-history report share link (lm_share_*) lifecycle; create/revoke carry the lm_share_* prefix, extend carries the new expiry. Metadata is session_id + link_id; never the token string/hash or the argon2 password_hash
account:history_session_deletedaccountA stored stream-history session is deleted (GDPR Art. 17) via deleteChannelHistory / DELETE /v1/history/reports/{id}; session_id in metadata
account:login_connection_removedaccountAn account member's login assignment was removed for a provider (removeLoginAssignment / DELETE /v1/accounts/login-assignments/{provider}, login-assignments:delete); provider + target_user_id in metadata
account:extension_access_granted / account:extension_access_revokedaccountAn extension owner grants/revokes another account's direct access to their extension (grantExtensionAccess / revokeExtensionAccess + POST / DELETE /v1/developer/extensions/{id}/access[/{grant_id}], extension-dev:edit + ownership). Row account_id = the extension-owner (acting) account; extension_id, target_account_id, grant_type, grant_id in metadata
account:extension_access_invite_created / account:extension_access_invite_revoked / account:extension_access_invite_acceptedaccountExtension-access invite lifecycle ({create,delete}ExtensionAccessInvite / acceptExtensionAccessInvite + POST / DELETE / .../accept REST). Metadata extension_id, invite_id, grant_type, grant_id, max_uses, expires_at — never the invite_code/code
account:se_token_created / account:se_token_deletedaccountA StreamElements token is created/upserted or deleted (createSeToken / deleteSeToken + POST / DELETE /v1/se-tokens[/{id}], se-tokens:{create,delete} + feature:streamelements); metadata token_id, platform, label — never the JWT, its ciphertext, or the masked token_hint
user:api_key_created / user:api_key_updated / user:api_key_revokeduserA 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)
user:loginuserSuccessful login — native (PKCE) redemption emits in-process from the Rust /auth/token/exchange handler (method: pkce) and the GraphQL exchangeToken mutation emits inline (method: graphql_exchange, its only success writer — no first-party caller for apps/id ingest to cover); the web flow is emitted by apps/id via audit-ingest
user:login_faileduserRejected login — the Rust REST /auth handlers and the GraphQL exchangeToken mutation (at parity) emit on failed provider-token validation or a PKCE mismatch; the subject is the resolved user, or NULL when the credential maps to no known Lumio user
user:oauth_granteduserProvider link/reconnect consent by an authenticated user (emitted by apps/id via audit-ingest)
user:login_reconnect_requireduserA login-connection token refresh failed terminally; the Token Refresh Worker auto-flagged reconnect_required
user:active_account_switcheduserThe user switched or cleared their session's active account (updateMe / PATCH /v1/users/me); from/to account ids in metadata, account_id NULL so it stays out of the target account's tenant log
user:logoutuserThe user ended their own session — GraphQL logout / logoutSession, REST POST /v1/auth/logout. Metadata: ended session_id (when known) + method (refresh_token / session)
user:session_revokeduserThe user revoked session(s) from session management — GraphQL deleteSession / deleteAllOtherSessions, REST DELETE /v1/users/me/sessions/{id} / /sessions. Metadata distinguishes single (session_id, revoked_all:false) from bulk (count, revoked_all:true)
user:email_changeduserThe acting user changed the email on their own profile (updateMe / PATCH /v1/users/me) — an account-takeover vector; emits only on a real change, and metadata carries only a had_previous_email indicator, never the address itself
user:login_connection_removeduserA user removed one of their own login connections (disconnectLoginConnection / DELETE /v1/login-connections/{id}, first-party gated ZAF-469); provider + connection_id in metadata
developer:verification_submitteduserAn approved developer submitted or resubmitted their own KYC verification (submitDeveloperVerification / POST /v1/developer/verification, first-party gated ZAF-469); verification_id, developer_id, developer_type, status, resubmission in metadata — never the submitted KYC/PII fields
developer:team_created / developer:team_deleteduserA developer creates/deletes their own extension-developer team (create/deleteDeveloperTeam + POST / DELETE /v1/developer/teams[/{id}]). Self-only (myAuditLog); team_id (+ slug on create) in metadata — never the team name PII
developer:team_role_created / developer:team_role_updated / developer:team_role_deleteduserDeveloper-team custom-role lifecycle ({create,update,delete}DeveloperTeamRole + POST / PATCH / DELETE /v1/developer/teams/{id}/roles[/{rid}], team-settings:edit); team_id, role_id, role_slug, permissions, changed_fields in metadata
developer:team_member_role_changed / developer:team_member_removeduserA developer changes a team member's role or removes them (updateDeveloperTeamMemberRole / removeDeveloperTeamMember + REST twins, team-members:{edit,remove}); team_id, member_id, target_user_id, role_id in metadata. The acting developer's self-only log records it (§A.1)
developer:team_invite_created / developer:team_invite_revoked / developer:team_invite_accepteduserDeveloper-team invite lifecycle ({create,delete,accept}DeveloperTeamInvite + REST twins, team-members:invite). Metadata team_id, invite_id, role_id, member_id, max_uses, expires_at — never the invite email or invite_code/code
user:mfa_enabled / user:mfa_disableduserCatalogued 2FA lifecycle types; current product flows do not emit them
system:feature_flag_updatedsystemAdmin global feature-flag toggles and platform provider kill-switches
system:plan_created / system:plan_updated / system:plan_deletedsystemAdmin plan lifecycle (create / edit / delete)
system:plan_feature_updatedsystemAdmin plan↔feature mapping changes
system:account_plan_changedsystemAn account's plan changed — an admin reassignment (operator path), or a Stripe webhook (checkout.session.completed / customer.subscription.updated / customer.subscription.deleted, discriminated by "via":"stripe" in metadata, actor NULL)
system:account_feature_overridesystemAdmin sets or clears a per-account feature override
system:account_deletedsystemA platform operator deleted an account (DELETE /v1/admin/accounts/{id} / adminDeleteAccount); the deleted account id + "reason":"operator_delete" ride in metadata. System-scoped because the CASCADE leaves no account member to read an account-scoped row (owner self-service dissolve stays account:dissolved)
system:system_key_createdsystemAdmin mints a system API key (is_system=true); the permission set is registry-validated and a bounded expires_at is enforced
system:system_key_revokedsystemAdmin revokes a system API key (only the key id in metadata; never full_key/key_hash)
system:connection_upsertedsystemAdmin creates/updates a system OAuth (login-provider) connection; the raw client_secret and its AES-256-GCM ciphertext are never stored
system:connection_deletedsystemAdmin deletes a system OAuth connection
system:user_role_created / system:user_role_updated / system:user_role_deletedsystemAdmin cross-account user-role lifecycle (create / edit / delete)
system:user_role_assigned / system:user_role_unassignedsystemAdmin assigns/removes a cross-account user role for a user (target user in metadata)
system:user_permission_override_set / system:user_permission_override_removedsystemAdmin sets/removes a per-user permission override (target user in metadata)
system:account_permission_override_set / system:account_permission_override_removedsystemA platform operator (accounts:overrides-edit) sets/removes an account-level permission override (PUT / DELETE /v1/admin/accounts/{id}/permission-overrides / adminSetAccountPermissionOverride / adminRemoveAccountPermissionOverride); target account_id + permission (+ granted/reason on set) in metadata
oauth_client:created / oauth_client:updated / oauth_client:deletedsystemOperator developer-application OAuth client lifecycle (oauth-clients:{create,edit,delete}); metadata client_id_ref (DB row id, not the OAuth client_id string), name, redirect_uris, scopes, changed_fields (update) — never the OAuth client_id/client_secret
admin_role:created / admin_role:updated / admin_role:deletedsystemOperator admin-RBAC role lifecycle (admin-roles:{create,edit,delete}) on the admin_roles table (distinct from user_roles); metadata role_id, role_name, permissions, changed_fields (update)
admin_role:assigned / admin_role:unassignedsystemOperator assigns/removes an admin role for a user (admin-roles:edit); metadata role_id + target_user_id. Emits only on a real (non-idempotent) change
system:global_bot_connection_upserted / system:global_bot_connection_deletedsystemGlobal (nil-UUID) bot connection lifecycle (bot-connections:{create,delete}): manual Discord token or OAuth exchange upsert, and delete. Metadata platform, connection_id, bot_username, source (manual_token|oauth_exchange) — never the bot/access/refresh token. The OAuth-exchange path is REST-only
system:user_deletedsystemA platform operator deleted a user and all their data (users:delete, DELETE /v1/admin/users/{id} / adminDeleteUser); target_user_id in metadata
system:user_login_connection_deletedsystemA platform operator removed 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_deletedsystemA platform operator deletes a developer or a developer team (adminDeleteDeveloper / adminDeleteDeveloperTeam + DELETE /v1/admin/developers/{id} / DELETE /v1/admin/developer-teams/{id}, 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_changedsystemOperator developer-team membership admin (admin{Add,Remove}TeamMember / adminUpdateTeamMemberRole + REST twins, developer-limits:edit); team_id, member_id/target_user_id, role_id in metadata
system:developer_revenue_split_setsystemA platform operator sets a developer's revenue split (setDeveloperRevenueSplit / PATCH /v1/admin/developers/{id}/revenue-split, developer-limits:edit); developer_id + split in metadata
system:developer_limits_updatedsystemA platform operator updates a developer OR extension limit — one merged event discriminated by limit_scope ("developer" | "extension") in metadata (adminSetDeveloperLimit / adminSetExtensionLimits + PUT /v1/admin/developers/{id}/limits / PATCH /v1/admin/extensions/{id}/limits, developer-limits:edit); developer_id/extension_id, limit_key/limit_value or changed_fields in metadata
system:developer_limit_request_reviewedsystemA platform operator reviews (approve/deny) a developer limit request (adminReviewLimitRequest / PUT /v1/admin/limit-requests/{id}, developer-limits:edit); request_id, status, developer_id, extension_id, limit_key, has_review_notes (flag) in metadata — never the free-text review_notes
system:user_email_changedsystemA platform operator changes a user's email (adminUpdateUser / PATCH /v1/admin/users/{id}, users:edit); emitted only when the stored email actually changed; target_user_id + had_previous_email (flag) in metadata — never the address. Distinct from the user-self user:email_changed
system:user_account_creation_override_setsystemA platform operator sets a user's account-creation override (adminUpdateUserAccountCreationOverride / PATCH /v1/admin/users/{id}, users:edit); target_user_id + override (allow|deny|default) in metadata
system:user_max_accounts_override_setsystemA platform operator sets a user's max-accounts override (adminUpdateUserMaxAccountsOverride, users:edit) — GraphQL-only (no REST twin accepts max_accounts); target_user_id + max_accounts (int|null) in metadata
system:account_connection_deleted / system:account_bot_connection_deletedsystemA platform operator deletes an account's channel / bot connection (adminDeleteAccountChannelConnection / _bot_connection + DELETE /v1/admin/accounts/{id}/connections/{platform} / .../bot-connections/{platform}, accounts:edit); target_account_id + platform in metadata; only on a real deletion
system:account_login_connection_deletedsystemA platform operator deletes an account's login (identity) connection (adminDeleteAccountLoginConnection, accounts:edit) — GraphQL-only (no REST twin for the account-level delete); target_account_id + provider in metadata; only on a real deletion

Storage

Audit events are persisted in TimescaleDB. See Admin → Audit Log for the operator view, the recorded event types today, and GeoIP location enrichment.

For the web dashboard surfaces, see Audit Log.

Retention

Audit events are kept 12 months in full, after which the network/identifying fields are anonymised in placeip_address, user_agent, country, and city are nulled while the aggregate row (account_id, user_id, type, metadata, created_at, scope) is kept indefinitely for ongoing security analysis and accountability. Audit rows are never dropped: unlike an event-feed or telemetry table, deleting the trail would defeat the compliance record the log exists to provide, so retention is an anonymisation sweep rather than a chunk-drop. The sweep is a native TimescaleDB scheduled job (anonymise_old_audit_events, runs daily) — always on, not gated behind a feature flag. GDPR-erasure event types are handled uniformly: they carry no network PII to begin with, so anonymisation is a no-op for them and their accountability record survives.