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.
| Scope | Audience | Example events |
|---|---|---|
user | The individual user, self-only | Personal-security rows keyed to the authenticated user |
account | An account's members, gated to that account | Role/permission changes, member-role assignment, channel connections |
system | Platform operators | Cross-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 param | GraphQL filter field | Meaning |
|---|---|---|
page | page | Page number (default 1). |
limit | limit | Page size (REST default 50, clamped 1..=200). |
type | eventType | Exact event-type match. |
from | dateFrom | Only events at/after this instant (RFC-3339; an unparseable value is ignored). |
to | dateTo | Only 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 type | Scope | Current writer |
|---|---|---|
chat_pii_erasure | account / system | Account dissolution and admin chat-subject erasure |
youtube_member_erasure | account / system | Admin YouTube member-channel erasure |
account:role_created | account | Role creation |
account:role_updated | account | Role edits, including permission changes |
account:role_deleted | account | Custom role deletion |
account:member_role_assigned | account | Member role assignment |
account:member_removed | account | A member was removed from the account (target user in metadata) |
account:invite_created | account | An account invite was created (invite email and code are never stored in metadata) |
account:invite_revoked | account | An account invite was revoked/deleted |
account:invite_accepted | account | An invite was accepted; actor is the joining user, scoped to the account joined (invite code never stored) |
account:dissolved | account | An account was irreversibly dissolved (CASCADE delete); actor is the owner |
account:connection_added | account | Channel OAuth completion |
account:connection_removed | account | Channel disconnect |
account:connection_reconnect_required | account | A channel/bot token refresh failed terminally (dead grant); the Token Refresh Worker auto-flagged reconnect_required |
account:connection_credentials_upserted | account | Per-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_deleted | account | Per-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_out | account | Broadcaster hides a channel from the public Stats app (is_public → false) |
public_stats:channel_opted_in | account | Broadcaster re-lists a channel on the public Stats app (is_public → true) |
account:popout_token_created / account:popout_token_updated / account:popout_token_deleted | account | Popout-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_revoked | account | Overlay 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_extended | account | Shared-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_revoked | account | Widget 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_revoked | account | Per-overlay access grant (upsert — resulting role in metadata) / revoke; target_user_id in metadata |
account:widget_access_granted / account:widget_access_revoked | account | Per-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_extended | account | Stream-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_deleted | account | A stored stream-history session is deleted (GDPR Art. 17) via deleteChannelHistory / DELETE /v1/history/reports/{id}; session_id in metadata |
account:login_connection_removed | account | An 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_revoked | account | An 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_accepted | account | Extension-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_deleted | account | A 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_revoked | user | 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) |
user:login | user | Successful 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_failed | user | Rejected 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_granted | user | Provider link/reconnect consent by an authenticated user (emitted by apps/id via audit-ingest) |
user:login_reconnect_required | user | A login-connection token refresh failed terminally; the Token Refresh Worker auto-flagged reconnect_required |
user:active_account_switched | user | The 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:logout | user | The 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_revoked | user | The 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_changed | user | The 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_removed | user | A 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_submitted | user | An 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_deleted | user | A 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_deleted | user | Developer-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_removed | user | A 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_accepted | user | Developer-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_disabled | user | Catalogued 2FA lifecycle types; current product flows do not emit them |
system:feature_flag_updated | system | Admin global feature-flag toggles and platform provider kill-switches |
system:plan_created / system:plan_updated / system:plan_deleted | system | Admin plan lifecycle (create / edit / delete) |
system:plan_feature_updated | system | Admin plan↔feature mapping changes |
system:account_plan_changed | system | An 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_override | system | Admin sets or clears a per-account feature override |
system:account_deleted | system | A 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_created | system | Admin mints a system API key (is_system=true); the permission set is registry-validated and a bounded expires_at is enforced |
system:system_key_revoked | system | Admin revokes a system API key (only the key id in metadata; never full_key/key_hash) |
system:connection_upserted | system | Admin creates/updates a system OAuth (login-provider) connection; the raw client_secret and its AES-256-GCM ciphertext are never stored |
system:connection_deleted | system | Admin deletes a system OAuth connection |
system:user_role_created / system:user_role_updated / system:user_role_deleted | system | Admin cross-account user-role lifecycle (create / edit / delete) |
system:user_role_assigned / system:user_role_unassigned | system | Admin assigns/removes a cross-account user role for a user (target user in metadata) |
system:user_permission_override_set / system:user_permission_override_removed | system | Admin sets/removes a per-user permission override (target user in metadata) |
system:account_permission_override_set / system:account_permission_override_removed | system | A 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:deleted | system | Operator 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:deleted | system | Operator 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:unassigned | system | Operator 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_deleted | system | Global (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_deleted | system | A 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_deleted | system | A 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_deleted | system | A 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_changed | system | Operator 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_set | system | A 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_updated | system | A 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_reviewed | system | A 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_changed | system | A 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_set | system | A 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_set | system | A 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_deleted | system | A 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_deleted | system | A 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 place — ip_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.