RBAC & Permissions
Lumio uses a role-based access control (RBAC) system with granular permissions. Every authenticated action is checked against the user's assigned role for the active account.
Default Roles
Every new account is created with three default roles:
| Role | Slug | Color | is_system | is_default | Notes |
|---|---|---|---|---|---|
| Owner | owner | #f59e0b | true | true | Every account registry permission, listed explicitly in ROLE_OWNER. Cannot be edited or deleted. |
| Administrator | administrator | #ef4444 | false | true | Everything except account:delete, plan:read, plan:edit, and extension-dev:payouts. |
| Moderator | moderator | #22c55e | false | true | Chat moderation, event monitoring, read access (49 permissions). |
The role rows are seeded by create_default_roles() in apps/api/src/db/roles.rs from the DEFAULT_ROLES slice in crates/lo-auth/src/rbac.rs; is_default is set to true for all three at insert time.
Role Properties
| Property | Type | Description |
|---|---|---|
slug | string | Machine-readable identifier. Stable, lowercase, used in code checks. Never changes. |
name | string | Human-readable display name. Editable on custom roles, translated for default roles. |
is_system | bool | Cannot be edited or deleted. Only the Owner role. |
is_default | bool | Cannot be deleted. All three default roles have this set. |
color | string | Hex color for UI display (e.g., "#f59e0b"). |
Role Name Translation
Default role names are translated via the useTranslateRole() hook in apps/web/src/lib/translate-role.ts. The hook returns a (slug, name) => string function that checks if the slug matches a known default role and returns the localized name; custom roles fall back to their name field.
Translation keys live in apps/web/messages/{en,de}.json under "roles.labels":
{
"roles": {
"labels": {
"owner": "Owner",
"administrator": "Administrator",
"moderator": "Moderator"
}
}
}
Permission Format
All permissions follow the resource:action format. Examples:
events:read,events:createchat:ban,chat:timeout,chat:notesautomations:execute
Rules
- Always granular. Every resource needs at minimum
readplus granularcreate/edit/delete. - Domain-specific actions are added when CRUD is insufficient (e.g.,
automations:execute,chat:ban). - Never use wildcards. No
resource:*/*:*account-level permissions. Account actions are always granular verbs - there is no coarse:manageaction; management surfaces are split into discrete permissions (for exampleoverlays:access-read/overlays:access-grant/overlays:access-revoke).
Account Permissions
The tables below summarize the major account permission categories. The authoritative list of constants lives in crates/lo-auth/src/rbac.rs (under pub mod account {}) - treat that file as the source of truth; categories and counts may shift as features evolve. The registry holds 133 account permissions across 31 categories (pinned by account_permission_infos_has_expected_count): API Keys, Account, Audit Log, Automations, Bot Commands, Bot Connections, Bot Modules, Chat, Connections, Copyright, Events, Extension Data, Extension Developer, Extension Store, Login Assignments, Members, OBS, Overlays, Plan, Public Stats, Rewards, Roles, Setting Presets, Settings, Sounds, Spotify, Stream History, StreamElements, Tokens, Uploads, Widgets. Crawler control belongs to admin scope (see Admin-Scope Permissions → Crawler). For the complete permission-by-role matrix see Complete Permissions Reference.
lo_auth::rbac::account::get_all_account_permission_infos() is the single source of truth for the account permission set - it returns every account permission with its picker label and category. Both role-editor pickers (REST GET /v1/roles/permissions and GraphQL availablePermissions, both guarded on roles:read) map this list, and the create/update validation registry get_all_account_permissions() derives its key set from it. As a result the role editor exposes exactly the set of assignable permissions - there are no "shadow" permissions that validation accepts but the editor hides (ZAF-231).
The admin-app permission-override pickers derive from the same registry (ZAF-430). Both the per-user override picker (/admin/users/[id]) and the per-account override picker (/admin/accounts/[id]) fetch the registry from the API - the admin proxy GET /api/assignable-permissions calls the adminAssignablePermissions GraphQL query (guarded admin:access), which maps get_all_account_permission_infos(). They hold no hardcoded permission arrays, so a new registry key appears in every picker automatically and the admin pickers can never drift from validation. (Before ZAF-430 these two pickers carried hand-maintained copies of the registry that had silently drifted.)
Events
| Permission | Label |
|---|---|
events:read | Read Events |
events:create | Create Events |
events:edit | Edit Events |
events:userinfo | Event User Info |
Overlays (7)
| Permission | Label |
|---|---|
overlays:read | Read Overlays |
overlays:create | Create Overlays |
overlays:edit | Edit Overlays |
overlays:delete | Delete Overlays |
overlays:access-read | Read Overlay Access |
overlays:access-grant | Grant Overlay Access |
overlays:access-revoke | Revoke Overlay Access |
Widgets (7)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
widgets:read | View Widgets | x | x | x |
widgets:create | Create Widgets | x | x | |
widgets:edit | Edit Widgets | x | x | |
widgets:delete | Delete Widgets | x | x | |
widgets:access-read | Read Widget Access | x | x | |
widgets:access-grant | Grant Widget Access | x | x | |
widgets:access-revoke | Revoke Widget Access | x | x |
Extension Data (2)
Gates direct account-actor calls to an installed extension's server functions. Widget-runtime calls made with an Extension Token use the install's own scope and are not checked against these permissions.
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
extension-data:read | Read Extension Data | x | x | x |
extension-data:edit | Edit Extension Data | x | x |
Spotify (7)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
spotify:read | Read Spotify | x | x | x |
spotify:playback | Spotify Playback | x | x | x |
spotify:volume | Spotify Volume | x | x | |
spotify:queue | Spotify Queue | x | x | x |
spotify:playlist | Spotify Playlists | x | x | x |
spotify:device | Spotify Devices | x | x | x |
spotify:worker | Spotify Worker | x | x |
Chat (16)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
chat:read | Read Chat | x | x | x |
chat:write | Write Chat | x | x | x |
chat:userinfo | Chat User Info | x | x | x |
chat:delete | Delete Chat Messages | x | x | x |
chat:ban | Ban Chat Users | x | x | x |
chat:timeout | Timeout Chat Users | x | x | x |
chat:notes | Chat User Notes | x | x | x |
chat:raid | Cancel Raids | x | x | x |
chat:pin | Manage Twitch chat pins | x | x | x |
chat:poll | End Polls | x | x | x |
chat:prediction | End Predictions | x | x | x |
chat:refresh_user | Refresh User Profile | x | x | x |
chat:automod-read | Read AutoMod Queue | x | x | x |
chat:automod-allow | Allow Held Messages | x | x | x |
chat:automod-deny | Deny Held Messages | x | x | x |
The live queue uses chat:automod-read; the session AutoMod log also needs history:read. Allow and deny use separate permissions and the acting member's Twitch login. Popout tokens can carry all three AutoMod permissions for the live chat, but cannot read the session log. AutoMod rows in mergedChatHistory and GET /v1/chat/history/merged require feature:multichat, chat:read and chat:automod-read; history:read permits the account's full chat plan window, otherwise the server returns only holds from the last 24 hours. Members, user and service keys, and popout tokens use that same window rule.
Connections
| Permission | Label |
|---|---|
connections:read | Read Connections and channel live status (channelStatus, GET /v1/channel-status) |
connections:create | Create Connections |
connections:edit | Edit Connections |
connections:delete | Delete Connections |
Settings (2)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
settings:read | Read Settings | x | x | |
settings:edit | Edit Settings | x | x |
Members (4)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
members:read | Read Members | x | x | x |
members:create | Create Invites | x | x | |
members:edit | Edit Members | x | x | |
members:delete | Delete Members | x | x |
Roles (3)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
roles:read | Read Roles | x | x | x |
roles:edit | Edit Roles | x | x | |
roles:delete | Delete Roles | x | x |
Uploads
| Permission | Label |
|---|---|
uploads:read | Read Uploads |
uploads:create | Create Uploads |
uploads:delete | Delete Uploads |
Sounds (5)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
sounds:read | Read Sounds | x | x | x |
sounds:create | Upload Sounds | x | x | |
sounds:edit | Edit Sounds | x | x | |
sounds:delete | Delete Sounds | x | x | |
sounds:play | Play Sounds | x | x | x |
Stream History (7)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
history:read | Read Stream History | x | x | x |
history:share | Share History Reports | x | x | |
history:export | Export History Reports | x | x | x |
history:delete | Delete Stream History | x | x | |
history:revenue-read | Read Estimated Revenue | x | x | |
history:revenue-edit | Edit Revenue Assumptions | x | x | |
history:keywords-edit | Edit Tracked Keywords | x | x |
Gated behind the feature:stream_history feature flag.
Setting Presets (1)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
presets:share | Share Setting Presets | x | x | x |
Only the act of sharing a preset with your account is gated; creating/editing/loading presets is user-scoped (no permission), and reading the shared section needs only account membership. Gated behind the feature:setting_presets feature flag.
Public Stats (2)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
public-stats:read | Read Public Stats Settings | x | x | x |
public-stats:edit | Edit Public Stats Settings | x | x |
Gated behind the feature:public_stats_page feature flag.
Crawler control moved to admin scope (ZAF-725). The five
crawler:*permissions are no longer account permissions - steering the single global stats crawler is a platform-operator action, so they now live inpub mod globaland are gated byAdminPermissionGuard/require_admin_permission. See Admin-Scope Permissions → Crawler.
Audit Log (1)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
audit-log:read | Read Audit Log | x | x |
Reads this account's scope=account audit rows (accountAuditLog / GET /v1/account/audit-log). The identical string in pub mod global {} is a separate admin-scope permission for the system audit log - see Admin-Scope Permissions.
Rewards
| Permission | Label |
|---|---|
rewards:read | Read Rewards |
rewards:create | Create Rewards |
rewards:edit | Edit Rewards |
rewards:delete | Delete Rewards |
Tokens
Popout tokens (OBS browser-source docks).
| Permission | Label |
|---|---|
tokens:read | Read Tokens |
tokens:create | Create Tokens |
tokens:edit | Edit Tokens |
tokens:delete | Delete Tokens |
API Keys
Personal user API keys (lm_usr_…) - a dedicated family, separate from popout tokens.
A personal key, a popout token and a popout session are member-bound: the auth middleware caps their stored scopes to the bound member's current account-role and user-scoped permissions on every request (lo_api::middleware::auth::load_member_permission_ceiling), through the same Redis permission cache resolve_jwt reads. Role changes and role edits already invalidate that cache, so they need no extra hook for credentials. A ceiling that cannot be loaded returns AuthUnavailable (503). Response objects expose the capped list as effective_permissions / effectivePermissions via services::token_permissions::EffectivePermissions. Service and developer keys have no member and are not capped.
| Permission | Label |
|---|---|
apikeys:read | Read API Keys |
apikeys:create | Create API Keys |
apikeys:edit | Edit API Keys |
apikeys:delete | Delete API Keys |
Automations (6)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
automations:read | Read Automations | x | x | x |
automations:create | Create Automations | x | x | |
automations:edit | Edit Automations | x | x | |
automations:delete | Delete Automations | x | x | |
automations:execute | Execute Automations | x | x | x |
Account (3)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
account:read | Read Account | x | x | |
account:edit | Edit Account | x | x | |
account:delete | Delete Account | x |
Plan (2)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
plan:read | View Plan & Billing | x | ||
plan:edit | Change Plan | x |
Both plan permissions are owner-only in the default roles - Administrator carries neither.
User-Level Permissions
The following permission categories are tied to the authenticated user identity rather than to an account resource. Own actions (managing your own login assignments) are allowed regardless of account role. Because they live in the account permission registry, they are still assignable in the account role editor - the picker exposes the complete account registry (ZAF-231).
Sessions
Session management is not a role permission. Every authenticated user can view and revoke their own sessions regardless of account role: sessions / deleteSession / deleteAllOtherSessions (GraphQL) and GET/DELETE /v1/users/me/sessions (REST) select and delete strictly by the acting user's id, carrying AuthGuard plus the first-party check lo_graphql::require_first_party_user() (ZAF-469). The former sessions:read / sessions:delete account constants were removed in ZAF-1094 (ZAF-516) - they lived in the registry and seeded roles but no handler ever enforced them, since a user on their personal profile carries an empty account_permissions list.
Login Assignments (3)
Controls access to login assignment management. Own assignments are always allowed for any authenticated user regardless of role - the permissions below only gate actions performed on behalf of other users.
| Permission | Label |
|---|---|
login-assignments:read | Read Login Assignments |
login-assignments:create | Create Login Assignments |
login-assignments:delete | Delete Login Assignments |
Extension Developer (7)
Extension developer permissions gate access to the developer dashboard, extension CRUD, publishing, analytics, and payouts. They are account-scope permissions and are seeded on the default roles: Owner carries all seven, Administrator carries all but extension-dev:payouts; Moderator carries none. Work performed inside a developer team is additionally checked against team-scope permissions (see Team RBAC below).
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
extension-dev:read | View Developer Dashboard | x | x | |
extension-dev:create | Create Extensions | x | x | |
extension-dev:edit | Edit Extensions | x | x | |
extension-dev:delete | Delete Extensions | x | x | |
extension-dev:publish | Publish Extensions | x | x | |
extension-dev:analytics | View Extension Analytics | x | x | |
extension-dev:payouts | Manage Extension Payouts | x |
Extension Store (5)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
extension-store:read | Browse Extension Store | x | x | x |
extension-store:install | Install Extensions | x | x | |
extension-store:uninstall | Uninstall Extensions | x | x | |
extension-store:configure | Configure Extensions | x | x | |
extension-store:review | Review Extensions | x | x | x |
Copyright (6)
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
copyright:read | View Copyright | x | x | |
copyright:edit | Edit Copyright Lists | x | x | |
copyright:delete | Delete Copyright Entries | x | x | |
copyright:vote | Vote on Songs | x | x | x |
copyright:report | Create Copyright Reports | x | x | x |
copyright:recommend | Recommend Songs | x | x | x |
copyright:moderate is not in this account-role matrix - it is an admin-scope (platform-operator) permission, seeded to the system_admin admin role and not assignable to any tenant role. It governs the global community catalogue (approving/dismissing vote candidates into the cross-account blocked/safe lists); copyright:edit only touches the account's own lists. It was moved out of account scope in ZAF-742 (mirroring the ZAF-725 crawler move) because writing the account-less global catalogue is a platform-operator action.
OBS, Bot Modules, Bot Commands, Bot Connections, StreamElements
| Permission | Label | Owner | Admin | Mod |
|---|---|---|---|---|
obs:read | View OBS Config | x | x | x |
obs:edit | Edit OBS Config | x | x | |
obs:delete | Delete OBS Config | x | x | |
bot-modules:read | View Bot Modules | x | x | x |
bot-modules:edit | Edit Bot Modules | x | x | |
bot-commands:read | View Bot Commands | x | x | x |
bot-commands:create | Create Bot Commands | x | x | |
bot-commands:edit | Edit Bot Commands | x | x | |
bot-commands:delete | Delete Bot Commands | x | x | |
chat-bots:read | View Account Chat Bots | x | x | x |
chat-bots:edit | Edit Account Chat Bots | x | x | |
bot-connections:read | View Bot Connections | x | x | x |
bot-connections:create | Create Bot Connections | x | x | |
bot-connections:delete | Delete Bot Connections | x | x | |
se-tokens:read | View SE Tokens | x | x | |
se-tokens:create | Create SE Tokens | x | x | |
se-tokens:delete | Delete SE Tokens | x | x |
Ideas Hub (User-Scoped)
The Ideas Hub introduces a user-scoped permissions layer that is distinct from both account-scoped and admin-scoped permissions. User-scoped permissions are global (not tied to a specific account) and are assigned via the user_roles and user_role_permissions tables.
Enforcement uses auth.require_user_permission(). The moderation checks use has_moderate_permission(), which accepts BOTH user-scoped and admin-scoped grants.
User-scoped permissions:
| Permission | Label |
|---|---|
ideas:read | Read Ideas |
ideas:create | Create Ideas |
ideas:edit | Edit Own Ideas |
ideas:delete | Delete Own Ideas |
ideas:vote | Vote on Ideas |
ideas:comment_read | Read Comments |
ideas:comment_create | Create Comments |
ideas:comment_edit | Edit Own Comments |
ideas:comment_delete | Delete Own Comments |
ideas:comment_vote | Vote on Comments |
Admin-scoped moderation permissions:
| Permission | Label |
|---|---|
ideas:moderate_read | Read All Ideas (Admin) |
ideas:moderate_status | Change Idea Status |
ideas:moderate_edit | Edit Any Idea |
ideas:moderate_delete | Delete Any Idea |
ideas:moderate_comment | Delete Any Comment |
Admin-scoped moderation permissions are enforced via auth.require_admin_permission() and live in crates/lo-auth/src/rbac.rs::global.
Admin-Scope Permissions
The admin-scope support:verify grant controls the Support Identity card on an admin user detail page. The reveal and check operations, and the recent-check list, use AdminPermissionGuard on GraphQL and require_admin_permission on REST. Revealing or checking a code emits a system-scope audit event; a failed code check is still recorded. The separate users:edit grant controls an operator's 2FA reset, which requires a reason. Neither grant can be supplied by an account role.
Admin-scope permissions gate the Lumio admin panel (/admin) and are entirely separate from account-scope permissions. They are stored in the admin_permissions column of CachedPermissions (not account_permissions) and enforced by require_admin_permission().
Permission Modules
The crates/lo-auth/src/rbac.rs file defines three permission modules:
pub mod account {}- Account-scope permission constants (listed above)pub mod global {}- Admin-scope permission constantspub mod team {}- Team-scope permission constants (see Team RBAC)
Constants in global use the same resource:action format. The canonical list is exposed via:
lo_auth::rbac::all_admin_permissions() -> Vec<(&'static str, &'static str)>
// Returns (permission_string, category_label) pairs
This function is used by the permission picker UI and the REST/GraphQL permission validation.
Admin Roles
Admin roles are managed in the admin_roles / admin_role_permissions / user_admin_roles tables; the seeded roles (system_admin, support, billing, …) and their grants are maintained by migrations such as 20260330000005_feature_flag_phase1 and 20260412000001_seed_admin_refactor_permissions. Unlike account roles, admin roles:
- Are global (not account-scoped)
- Have
is_system: bool- system roles cannot be deleted - Auto-receive
admin:access(the dashboard entry gate) on create and on update - both the REST handler and the GraphQL mutation push it back into the permission list if it is missing - Are CRUD-managed via
adminRoles*GraphQL mutations andGET/POST/PATCH/DELETE /v1/admin/admin-roles
Shared Permissions
Some permission strings (e.g., copyright:read, obs:read, bot-modules:read) are declared in both pub mod account {} and pub mod global {} with identical string values. This is intentional - the same string gates different resources in different contexts:
- Account scope: enforced by
auth.require_permission(account::COPYRIGHT_READ)in account route handlers - Admin scope: enforced by
auth.require_admin_permission(global::COPYRIGHT_READ)in admin route handlers
This follows the precedent set by bot-connections:*.
admin:access
admin:access is the single gate that controls dashboard access. Any user without this permission sees a "No Access" page. It is:
- Always present in the
globalmodule asadmin::ACCESS - Auto-injected into every admin role at creation time (both REST and GraphQL mutation)
- Never removed by the permission diff algorithm
- Not a tenant bypass.
AuthContext::can_access_accountlets only System keys reach a foreign account. An operator session holdingadmin:accessreaches only its own active account on every account-scoped check: WebSocket subscribes, login-assignment writes, and OAuth callbacks/exchanges. Admin features that legitimately span accounts (/v1/admin/*,admin*GraphQL) are gated on their own granular admin permission, such asaccounts:readoraccount-apikeys:rotate. A new operator cross-tenant need gets its own dedicated admin permission key, seeded and checked at that call site, neveradmin:access.
admin:privacy-erase
GDPR-Art-17 erasure tooling. It gates three surfaces, each with matching REST and GraphQL entry points:
| Surface | REST | GraphQL |
|---|---|---|
| YouTube member-badge erasure - clears Lumio's cached YouTube member-channel records across all accounts and erases the member's chat data in the instructing account only | DELETE /v1/admin/privacy/youtube/member/{member_channel_id} | eraseYoutubeMemberData |
Chat-subject erasure - erases a chat subject's stored messages and profile data in the instructing account only (account_id, instruction_reference and reason required) | POST /v1/admin/privacy/chat/erase | eraseChatSubjectData |
Lumio-only erasure - records a person's erasure entry for Lumio Stats and clears Lumio's YouTube member cache; touches no channel data (instruction_reference and reason required) | POST /v1/admin/privacy/lumio/erase | eraseChatSubjectAtLumio |
Record erasure - erases one record (chat message, note, moderation-log entry, AutoMod-log entry, bot-list entry, or one person in one stream report) in the instructing account only (account_id + instruction_reference + reason required) | POST /v1/admin/privacy/records/erase | eraseChatRecord |
Orphaned extension upload sweep - finds (dry run) or queues for deletion (apply) extension upload files no row references | POST /v1/admin/storage/orphaned-extension-uploads/sweep | adminSweepOrphanedExtensionUploads |
The erasure handlers check the permission by hand against the caller's admin_permissions (lo_auth::rbac::global::ADMIN_PRIVACY_ERASE) rather than via a route-level macro; the sweep uses the admin router's require_permission (REST) and AdminPermissionGuard (GraphQL) with the same string. It is seeded on the system_admin role only (migration 20260428000002_seed_admin_privacy_erase) and is not auto-injected into any other admin role. The audit log records each erasure as one system-scoped event (chat_pii_erasure, or youtube_member_erasure for the member path).
admin:privacy-objection-create / admin:privacy-objection-delete
GDPR-Art-21 objection tooling for Lumio's global suppression list (chatter_suppressions). Recording an objection keeps the chatter off every public Stats leaderboard (masked on read, so also on streams published earlier), without erasing anything; channel stream reports, exports, share links and AutoMod logs are not affected. Lifting it names the chatter again on the public leaderboard.
| Permission | REST | GraphQL |
|---|---|---|
admin:privacy-objection-create | POST /v1/admin/privacy/chat/objections | addChatterObjection |
admin:privacy-objection-delete | DELETE /v1/admin/privacy/chat/objections/{platform}/{user_id} | removeChatterObjection |
Both protocols check the permission by hand against the caller's admin_permissions (require_admin_permission, the same guard type as admin:privacy-erase) and share the validation in ChatterObjection::from_parts. Both are seeded on the system_admin role only (migration 20261015120000_create_chatter_suppressions). The events are privacy:chatter_objection_added / privacy:chatter_objection_removed (system scope).
admin:privacy-erase-withdraw
Withdraws one channel's erasure instruction for one chatter: deletes the chatter's row in that account's suppression list (account_chatter_suppressions), so the channel's stream reports frozen afterwards name them again. Nothing the erasure deleted or blanked is restored, and the global chatter_suppressions list is not touched. It is a separate permission from admin:privacy-erase, so an operator role can carry out erasures without being able to undo their suppression.
| Permission | REST | GraphQL |
|---|---|---|
admin:privacy-erase-withdraw | POST /v1/admin/privacy/chat/erase/withdraw | withdrawChannelChatterSuppression |
Both protocols check it by hand against the caller's admin_permissions (require_admin_permission) with the same error code and text (Forbidden: Missing required permission: admin:privacy-erase-withdraw), and share the validation in SuppressionWithdrawal::from_parts. It is seeded on the system_admin role only (migration 20261022120000_admin_privacy_erase_withdraw). The event is privacy:channel_suppression_withdrawn (system scope).
Crawler (Admin Scope)
Platform-operator control of the public stats crawler (scope mode + watchlist).
Declared in pub mod global, surfaced by all_admin_permissions(), and seeded to
the system_admin admin role (migration 20260825000001_crawler_perms_to_admin_scope).
| Permission | Label |
|---|---|
crawler:scope-read | Read Crawler Scope |
crawler:scope-edit | Edit Crawler Scope |
crawler:watchlist-read | Read Crawler Watchlist |
crawler:watchlist-create | Add Crawler Watchlist Channels |
crawler:watchlist-delete | Remove Crawler Watchlist Channels |
Actions are granular (the coarse :manage is disallowed); each mutation emits a
system-scope audit event. Originally (ZAF-706) these were account permissions
seeded to owner, but the Admin app that consumes them gates on admin scope only - so the control was unreachable and every account owner could flip the global
crawl scope via the API. ZAF-725 moved them to admin scope and dropped the
account-side seed.
CSP Reports (Admin Scope)
Platform-operator read of the CSP-violation calibration aggregate (ZAF-1051 —
observability for the ZAF-995 CSP enforce-flip). Declared in pub mod global,
surfaced by all_admin_permissions(), and seeded to the system_admin admin
role (migration 20260904000004_seed_csp_reports_read_permission).
| Permission | Label |
|---|---|
csp-reports:read | Read CSP Violation Reports |
It gates GraphQL cspViolationReports (AdminPermissionGuard) and REST
GET /v1/admin/csp-reports (require_admin_permission) - same guard type,
string, and errors on both. It reads the capped csp_violation_reports
TimescaleDB aggregate (distinct (violated_directive, blocked_host) pairs with
counts; ≤5000 pairs, ≤30 d rolling). The rows carry no viewer identity
(host-only), so this is a read of non-personal calibration data, not an account
resource - admin scope only, never assignable to a tenant role.
Developer Funds (Admin Scope)
developer-funds:release-deletion is declared in lo_auth::rbac::global (DEVELOPER_FUNDS_RELEASE_DELETION), included in all_admin_permissions() (category developer-funds) and seeded to system_admin by 20261025200000_developer_released_balances. It is absent from the account registry.
It gates the release of an unpayable developer balance as a liability before an admin delete. GraphQL adminReleaseFundsAndDeleteUser and adminReleaseFundsAndDeleteDeveloper carry AdminPermissionGuard(DEVELOPER_FUNDS_RELEASE_DELETION); both protocols then call services::developer_funds_release::require_operator(auth, delete_permission), which requires the release permission, the plain delete permission (users:delete for a user, developer-verification:edit for a profile) and an interactive session. The plain deletes keep their own guards and still refuse a positive balance.
developer-funds:settle (DEVELOPER_FUNDS_SETTLE, same category, seeded to system_admin by 20261025210000_developer_funds_settle_permission) gates the settlement of a released balance when the person comes back. GraphQL adminStartReleasedBalanceClaim and adminSettleReleasedBalance carry AdminPermissionGuard(DEVELOPER_FUNDS_SETTLE); both protocols then call services::developer_funds_settlement::require_operator(auth), which requires the permission and an interactive session. It is separate from billing:edit on purpose: it moves money into a developer balance that the normal payout then pays out.
Contract Declarations (Admin Scope)
contract-declarations:read and contract-declarations:edit are declared in lo_auth::rbac::global, included in all_admin_permissions(), and seeded to system_admin by 20261024130102_seed_contract_declarations_permissions. They are absent from the account permission registry: public declarations and the queue contain personal details across tenants.
The queue's GraphQL readers (adminContractDeclarations, adminContractDeclaration) use AdminPermissionGuard("contract-declarations:read"); REST GET /v1/admin/contract-declarations and GET /v1/admin/contract-declarations/\{id\} use require_admin_permission with the same key. Close, match and retention mutations use contract-declarations:edit on both protocols and additionally require an interactive session. The admin sidebar and page require read; each action uses <Gate permission="contract-declarations:edit">.
Signed-in withdrawal uses plan:edit for a plan, extension-store:uninstall for an extension purchase, or only an interactive session for a free-text other contract. Plan cancellation needs plan:edit; extension subscription cancellation needs extension-store:uninstall. Public withdrawal and cancellation need no role, but are rate limited and Turnstile protected. Both protocols call services::contract_declarations for the same validation and result.
Extension Purchases (Admin Scope)
Platform-operator read of paid extension checkouts across accounts (ZAF-2745).
Declared in pub mod global, surfaced by all_admin_permissions(), and seeded
to the system_admin admin role (migration
20261023120001_seed_extension_purchases_read_permission).
| Permission | Label |
|---|---|
extension-purchases:read | Read Extension Purchases |
It gates GraphQL adminUndeliveredExtensionPurchases (AdminPermissionGuard)
and REST GET /v1/admin/extension-purchases/undelivered
(require_admin_permission) - same guard type, string, fields, validation and
errors on both (services::undelivered_purchases). It reads other accounts'
payments, so it is admin scope only and never assignable to a tenant role; the
account-scope string of the same name does not exist.
Dev & Admin Menu (Admin Scope)
Gate for the Dev & Admin menu inside the web app, production included
(ZAF-1668). Declared in pub mod global, surfaced by
all_admin_permissions(), and seeded to the system_admin admin role
(migration 20260930000001_seed_devtools_read_permission). Operators grant it
to further admin roles (e.g. a "Developer" role) in the admin-roles UI.
| Permission | Label |
|---|---|
devtools:read | Open Dev & Admin Menu (web app) |
Only admins — users who can sign in to the admin app — ever hold it: it lives
only in admin roles (which always carry admin:access), and it is absent from
the account and user registries, so no channel role, web-app user, or developer
grant can carry it. The web app shows the menu only when adminPermissions
contains both admin:access and devtools:read.
It is a UI-visibility grant only: the web app reads it from
me { adminPermissions } / REST GET /v1/users/me (admin_permissions), and
both protocols resolve the same user_admin_roles → admin_role_permissions
rows via db::auth::get_user_admin_permissions. No resolver or route is
guarded on it. Everything the menu does is client-side and viewer-local (e.g.
previewing a seasonal look), so it emits no audit event. A future server-side
action behind the menu (e.g. forcing a season for all users) needs its own
granular admin permission, a real AdminPermissionGuard /
require_admin_permission guard on both protocols, and an audit event.
System-Key Assignable-Scope Registry
A System key (lm_sys_*) is a machine credential: is_system = true, not bound
to an account, so any grant it holds is effectively platform-wide - but it reaches
only what its grants allow. It
needs two classes of grant - service grants (what the machine does:
events:*, chat:*, overlays:*, connections:*, the user-scope family,
audit:ingest) and platform-operator grants (the all_admin_permissions()
family, for admin-automation keys).
Both create paths (adminCreateSystemKey and POST /v1/admin/system-keys)
validate every requested permission against one derived registry —
lo_auth::rbac::system_key::get_assignable_permissions() (ZAF-1213), never a
hand-maintained list. It unions the admin, account, and user registries,
removes a two-category deny-list (SYSTEM_KEY_PERMISSION_DENY_LIST), and
adds the system-only grants (SYSTEM_ONLY_PERMISSIONS, see
System-Only Permissions below):
- credential minting -
system-keys:create,apikeys:create/apikeys:edit,tokens:create,admin-roles:create/edit/delete,oauth-clients:create(a key that mints or manages long-lived credentials outlives its purpose and self-escalates); - tenant / platform destruction -
accounts:delete,account:delete,admin:privacy-erase.
Widening the deny-list beyond these two categories needs founder sign-off on ZAF-1169. Validating against the operator registry alone was the ZAF-1213 bug (every account-scope service chip was rejected); validating against nothing was the earlier ZAF-735 hole.
Wildcards (admin:*, *:*) are never registry members, so the mint rejects
them - they stay legitimate only for config-provisioned system keys. There is no
admin:access auto-injection on the system-key mint (a machine credential is not
a dashboard seat, unlike adminCreateRole).
The registry is served for the picker by adminSystemKeyPermissions /
GET /v1/admin/system-key-permissions, guarded on system-keys:read (the same
grant that lists keys) - not admin-roles:read, which the mint operator need
not hold, so guarding on it would render the picker empty.
Audit Ingest (System-Key-Only)
The narrow grant apps/id needs to write user-scope security events
(user:login, user:oauth_granted) to POST /v1/internal/audit-ingest
(ZAF-1212). Declared in pub mod global and surfaced by all_admin_permissions()
so it is derived into the system-key assignable-scope
registry above, which the
system-key mint validates against - that is the sole
supported provisioning path.
| Permission | Label |
|---|---|
audit:ingest | Server-to-Server Audit Ingest (system keys only) |
Unlike every other admin-registry permission it is not a functional
admin-role grant and is never seeded into any admin role (no migration seeds
it). The ingest endpoint's authorize_ingest calls require_system() before
require_permission("audit:ingest"), so only an AuthContext::System (an
lm_sys_* key) can ever write - a human admin who is handed audit:ingest in a
role holds an inert string. This is deliberate: the assignable-scope registry is
the single "what may an operator put on a system key" allowlist, and audit ingest
is a legitimate system-key grant even though it is meaningless on a user/admin
principal. The endpoint is deliberately REST-only (a server-to-server
infrastructure write for one trusted internal caller), so it has no
GraphQL/WebSocket counterpart to keep in parity.
System-Only Permissions (bots:internal)
lo_auth::rbac::system_key::SYSTEM_ONLY_PERMISSIONS holds the grants that exist
only for System keys. They are chained into the assignable-scope registry
(so the mint and its picker offer them - bots:internal shows under category
bots), but they are in none of the account, user or admin registries, so no
role can ever hold one: create_role / update_role reject them as unknown
permission strings, identically on both protocols. A unit test
(bots_internal_is_system_key_only) pins that split.
| Permission | Constant | Guards |
|---|---|---|
bots:internal | system_key::BOTS_INTERNAL | The bot fleet's internal surface (ZAF-1922): GraphQL botChannels, botCommandsForAccount, emitBotEvent, discordBotGuilds, discordBotGuildFeatures, discordBotAllGuildFeatures, updateDiscordGuildInfo, removeDiscordGuildByBot, sendChatMessage; REST POST /v1/chat/message, GET /v1/internal/bot-modules/\{account_id\}/triggers, GET /v1/internal/bot-modules/handlers, GET /v1/internal/automation-nodes/handlers; and the cross-account extension and live surfaces (ZAF-1976): extension server functions on any install (extensionQuery, extensionMutate, extensionExecuteHandler; REST POST /v1/extension-installs/\{install_id\}/\{query,mutate,execute\}/\{function_name\}), draft/testing extension file trees (extensionVersionFiles; REST GET /v1/extensions/\{id\}/versions/\{version\}/files) and bundle bytes (GET /v1/extension-bundles/...), and the WebSocket presence:* and sounds:* channels |
The keys of twitch-bot, kick-bot, youtube-bot, discord-bot,
bot-module-worker and automation-worker carry it (see
Provisioning bot and worker keys);
the apps/id audit-ingest key carries only audit:ingest and reaches none of it.
require_system_permission. AuthContext::require_system_permission(perm)
is require_system() followed by require_permission(perm): a non-System caller
gets Forbidden("System access required"), a System key without the grant gets
MissingPermission. The REST internal feeds share
routes::internal_extension_handlers::require_bots_internal, which returns
403 System key required for internal endpoints for a non-System caller and
403 Missing permission: bots:internal for a System key without the grant.
Rule for new internal System surfaces: check a named grant with
require_system_permission (adding a new entry to SYSTEM_ONLY_PERMISSIONS when
no existing grant fits), never require_system() alone. require_system() is a
type-only gate that every System key passes - including narrow single-grant keys
such as the audit-ingest key - so a type-only gate makes the surface reachable by
every system key ever minted. No AuthContext::System branch is type-only any
more (ZAF-1976): the former cross-account branches (extension function calls on
behalf of any install, draft extension bundles / file trees, WS presence:* and
sounds:*) require bots:internal like the fleet surface, and the GraphQL
SystemGuard takes its grant as an argument (SystemGuard::new(grant)). A
match arm that only labels a System caller (the extension handler's auth role,
the rate-limit skip, the auth-type name) or that refuses it (first-party / session
gates) is not an authorization grant. The tripwire
apps/api/tests/zaf1976_system_grant_gate.rs lists every System arm in
apps/api/src as gated or exempt; a new arm fails it until classified.
Account & User API Keys (Admin Scope)
Platform-operator management of any account's or user's API keys - personal
lm_usr_ keys (in api_keys) and account service lm_svc_ keys (in
account_service_keys) - for support and incident response. Declared in
pub mod global, surfaced by all_admin_permissions(), and seeded to the
system_admin admin role (migration
20260909000001_seed_account_apikeys_operator_permissions). This is a distinct
family from the account-scope apikeys:* that a member uses on their
own keys; the strings differ (account-apikeys:* vs apikeys:*), so holding
one never grants the other.
| Permission | Label |
|---|---|
account-apikeys:read | Read Account/User API Keys |
account-apikeys:edit | Edit Account/User API Keys |
account-apikeys:rotate | Rotate Account/User API Keys |
account-apikeys:delete | Delete Account/User API Keys |
Gated by AdminPermissionGuard (GraphQL adminAccountApiKeys / adminUserApiKeys
/ adminUpdateApiKey / adminRotateApiKey / adminDeleteApiKey) and
require_admin_permission (REST GET /v1/admin/accounts/{account_id}/api-keys,
GET /v1/admin/users/{user_id}/api-keys, PATCH / POST .../rotate /
DELETE /v1/admin/api-keys/{kind}/{id}) - same guard type, string and errors on
both. Scope edits validate against the ZAF-1170 assignable-scope registry for the
key's kind, and a personal key is additionally capped to its owner's own
grantable permissions (a service key's ceiling is the account registry minus
the deny-list), so an operator can never push a key above its own principal. The
rotated plaintext secret is returned exactly once, never listed or logged. Each
write emits a system-scope audit event (system:api_key_updated /
system:api_key_rotated / system:api_key_revoked) with the operator as actor.
Team RBAC (Developer Teams)
Developer teams use a third permission scope - team-scoped - that is separate from both account-scope and admin-scope. Team permissions are defined in crates/lo-auth/src/rbac.rs::team and enforced by the local require_team_permission() in apps/api/src/routes/developer_teams.rs and by lo_graphql::check_team_permission(ctx, team_id, permission) in resolvers. Neither is a route-level guard attribute - call it explicitly at the top of each handler. Permissions are cached in Redis per (team_id, user_id) pair; an empty resolved set means "not a team member" and returns 403 before the permission itself is examined.
Three Modules, Three Contexts
The crates/lo-auth/src/rbac.rs file now defines three permission modules:
pub mod account {}- Account-scope permission constantspub mod global {}- Admin-scope permission constantspub mod team {}- Team-scope permission constants
Team Permissions (18)
All team permissions follow the team-resource:action naming convention.
| Permission | Label |
|---|---|
team-extensions:create | Create Extensions |
team-extensions:edit | Edit Extensions |
team-extensions:delete | Delete Extensions |
team-extensions:publish | Publish Extensions |
team-extensions:analytics | View Extension Analytics |
team-versions:create | Create Versions |
team-versions:edit | Edit Versions |
team-secrets:read | Read Secrets (list a team extension's secret key names) |
team-secrets:edit | Edit Secrets (set/delete a team extension's secrets; manage team developer API keys) |
team-testers:read | Read Testers |
team-testers:invite | Invite Testers |
team-testers:remove | Remove Testers |
team-members:read | Read Members |
team-members:invite | Invite Members |
team-members:edit | Edit Member Roles |
team-members:remove | Remove Members |
team-settings:read | Read Settings |
team-settings:edit | Edit Settings |
team-payouts:read | Read Payouts |
team-payouts:edit | Edit Payouts |
For extension operations, the REST and GraphQL twins call services::developer_extensions::ensure_extension_team_permission (or the version-ID wrapper) after ownership resolution. The check reads current role grants from the database rather than the Redis team-role cache. Missing grants return 403 Missing team permission: <permission> in REST and FORBIDDEN with the same message in GraphQL. An outsider receives a not-found ownership response first. Solo extensions have no team-role requirement; a team-bound developer key is limited to its own team.
| Permission | Extension operations |
|---|---|
team-extensions:edit | Metadata update; icon and screenshot upload; screenshot delete |
team-extensions:delete | Draft deletion and deletion request |
team-extensions:publish | Submit a version for review |
team-extensions:analytics | Analytics |
team-versions:create | Version creation and upload |
team-testers:invite | Tester invite, private access grant, access invite creation |
team-testers:remove | Tester removal, access revoke, access invite deletion |
team-testers:read | Tester, access grant, and access invite lists |
team-versions:edit, team-settings:read, and team-payouts:read|edit remain catalogue keys without a corresponding extension-action guard. The role editor can assign them, but no version-edit or payout endpoint relies on them today.
Default Team Roles
Every new developer team is seeded with three roles:
| Role | Slug | is_system | is_default | Notes |
|---|---|---|---|---|
| Owner | team-owner | true | false | All 20 team permissions. Cannot be deleted. |
| Admin | team-admin | false | true | All permissions except team-settings:edit, team-payouts:edit, team-members:remove. |
| Member | team-member | false | true | Edit extensions, create/upload versions, view analytics and read members/secrets/settings; cannot submit, delete, or manage/list testers and private access. |
Custom team roles can be created via POST /v1/developer/teams/{id}/roles or the createDeveloperTeamRole GraphQL mutation.
Team Permission Cache
Team permissions are cached in Redis similarly to account permissions:
- Cache key:
lumio:team_perms:{team_id}:{user_id} - Invalidation: On role assignment changes, role permission updates, and member removal — on the team-owner routes and on the admin
/v1/admin/developer-teams/{id}/members/{member_id}routes alike (see Invalidation rule)
is_developer Field
The me query (GraphQL) and GET /users/me (REST) expose an isDeveloper / is_developer boolean field. This field is true when the authenticated user has a row in the developer_profiles table OR has the extension_dev_mode admin override enabled. It drives access to the developer dashboard and extension management features.
Guard selection
When writing a GraphQL resolver, pick the right guard:
AdminPermissionGuard::new("resource:action")- admin-scope operations (the permission lives incrates/lo-auth/src/rbac.rs::global). Only users with admin permissions can call these; account-scope role permissions do NOT grant access.PermissionGuard::new("resource:action")- account-scope operations (the permission lives inrbac.rs::account). System admins can also act (their admin role has the permission viais_system = true).AuthGuard- any-authenticated operations, no specific permission required.auth.require_system_permission("resource:action")- internal System-key-only surfaces (the bot fleet, workers). Called in the resolver/handler body; requires a System key and the named grant (see System-Only Permissions). Never gate a System surface onrequire_system()alone.lo_graphql::check_team_permission(ctx, team_id, permission)- team-scope operations (the permission lives inrbac.rs::team). Called inside the resolver body, not as a#[graphql(guard = ...)]. The REST twin isrequire_team_permission(state, user_id, team_id, permission)inapps/api/src/routes/developer_teams.rs.
Backend Enforcement
GraphQL: PermissionGuard and AdminPermissionGuard
GraphQL resolvers use one of two guards from crates/lo-graphql/src/guard.rs.
Account-scope resolvers use PermissionGuard:
#[graphql(guard = "lo_graphql::PermissionGuard::new(\"events:read\")")]
async fn events(&self, ctx: &Context<'_>) -> async_graphql::Result<Vec<Event>> {
// ...
}
Admin-scope resolvers use AdminPermissionGuard:
#[graphql(guard = "lo_graphql::AdminPermissionGuard::new(\"users:edit\")")]
async fn admin_edit_user(&self, ctx: &Context<'_>) -> async_graphql::Result<AdminUser> {
// ...
}
PermissionGuard checks AuthContext::has_permission(), which accepts both admin and account permissions (system admins pass both). AdminPermissionGuard delegates exclusively to auth.require_admin_permission(), which only inspects admin_permissions - account-scope role grants do NOT satisfy it.
REST: require_permission
Every Actix route handler uses require_permission() from crates/lo-auth/src/context.rs:
use lo_auth::rbac::account;
pub async fn list_events(auth: Auth, state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
auth.require_permission(account::EVENTS_READ)
.map_err(from_auth_error)?;
// ...
}
This returns an AuthError::MissingPermission which maps to HTTP 403.
Guard parity (standing in-PR review question)
The live channel-status pair uses the same account permission: GraphQL channelStatus has PermissionGuard::new("connections:read"); REST GET /v1/channel-status calls require_permission("connections:read"). The separate spotifyManualStatus query remains behind spotify:read. A popout token may read channel status only when its own permission list includes connections:read.
Because every feature exposes both GraphQL and REST, a permission that guards one protocol must guard its twin identically. Treat guard parity as a standing review question in every PR - the same class of check as "does this need an audit event?" and "is RBAC seeded for the owner slug?".
For each field, route, or resolver a PR adds or touches, confirm the GraphQL and REST twins agree on all three of:
- The permission string - the exact same
resource:actionconstant on both (use therbac.rsconstant, never a string literal, so a rename can't drift one side). - The guard type / scope - account-scope (
PermissionGuard↔require_permission), admin-scope (AdminPermissionGuard↔require_admin_permission), user/first-party-scope (AuthGuard+require_first_party_user()↔ the REST first-party check), team-scope (check_team_permission↔require_team_permission), or an explicit ownership check where the resource is owned by the caller. AUserPermission/user-scope check where an accountPermissionGuardwas required - or a bare authenticated gate where an ownership check was needed - is a guard-type mismatch even when the permission string matches. - Fail-closed behaviour - both deny by default and surface the same 403/FORBIDDEN error.
The recurring defect class is "REST enforces, GraphQL doesn't" (or the reverse) and "wrong guard type". A one-sided or mismatched guard is a security bug, not a style nit. If a single-protocol path is deliberate, document why at the call site. (ZAF-722 findings #4/#8/#9/A1; standing rule ZAF-738.)
Permission Constants
All permission constants are defined in crates/lo-auth/src/rbac.rs inside pub mod account {}. Use the constants (not string literals) in REST handlers to prevent typos.
WebSocket: channel gate
The WebSocket layer (crates/lo-websocket/src/gate.rs) maps each channel type to a gate. The WsSession calls channel_gate_for(channel_type) on every subscribe request, then runs the check against the same AuthContext the REST/GraphQL layers see. "Reach the account" below means AuthContext::can_access_account: the caller's own account, or a System key. admin:access grants no cross-tenant reach.
ChannelGate is not permission-only - the variants are:
| Variant | Meaning |
|---|---|
Permission("resource:action") | Caller must reach the account ID in the channel name and hold the permission |
AccountScoped | Caller must reach the account ID; no RBAC permission (used where Widget/Overlay tokens must subscribe) |
UserPermission("resource:action") | Checked against the caller's user-scoped permissions; the channel ID is not an account UUID |
Authenticated | Any non-anonymous identity |
Public | Anyone |
PublicWhenFeature("flag") | Anyone, as long as the named system flag is on; otherwise FEATURE_DISABLED |
OverlayToken / WidgetToken | Requires that token type, with a matching overlay_id / widget_id |
ExtensionOwner | Requires that the subscriber owns the extension in the channel ID |
Unknown | Default arm - always rejects |
Current channel mapping:
| Channel type | Gate | Feature flag (channel_feature_for) |
|---|---|---|
overlay | OverlayToken | feature:overlays |
events | Permission("events:read") | feature:events |
spotify | Permission("spotify:read") | feature:music |
chat | Permission("chat:read") | feature:multichat |
automations | Permission("automations:read") | feature:automation |
history | Permission("history:read") | feature:stream_history |
ext-storage | ExtensionStorage (the install's extension token, extension-store:read in the install's account, or an overlay/widget token that renders the install) | feature:extensions |
ext-install-logs | InstallLogs (bot-modules:read or automations:read by install category) | feature:bot_module_extensions |
sounds | AccountScoped | feature:sounds |
widget | WidgetToken | feature:widgets |
ext-logs | ExtensionOwner | feature:extension_development |
presence | PresenceScoped (signed-in members of the owning account; a widget token only for its own widget) | - |
login-assignments | Public | - |
ideas | PublicWhenFeature("system:ideas_hub_public") | system:ideas_hub |
sounds is AccountScoped rather than Permission("sounds:read") on purpose: Widget and Overlay tokens carry an account but no account-level RBAC, so a permission gate would lock them out - while the account check still stops cross-account subscribes.
After RBAC, the same module runs the feature-flag check via the FeatureGate trait (implemented by FeatureService in apps/api/src/websocket/mod.rs). Channels mapped in channel_feature_for reject subscribes for accounts whose plan/account-feature row has the flag off. Adding a new paid-tier channel: extend both functions in one edit.
For overlay and widget the channel suffix is not an account UUID (an overlay key, a widget id), so the feature check resolves the owning account from the lm_overlay_* / lm_share_* / lm_widget_* token instead of parsing the channel id.
There is no client publish path. The WebSocket protocol has no broadcast frame, so a client can only read. Server-side WsServer::broadcast_raw is the only publisher on every channel, and its callers (REST/GraphQL mutations, workers, ingest) carry their own write guards.
A subscribe failure raises a typed error message back to the client: code: "UNAUTHORIZED" for missing permissions and code: "FEATURE_DISABLED" for disabled feature flags. Clients should surface both as user-facing "no access" / "upgrade plan" hints.
Important: when adding a new account-scoped WebSocket channel type, you MUST extend channel_gate_for in the same PR - the default match arm returns ChannelGate::Unknown which rejects the subscribe with UNAUTHORIZED for every caller, including Owners. Forgetting this step ships a broken channel that nobody can use.
Frontend Enforcement
PermissionProvider
The PermissionProvider React context (apps/web/src/contexts/permission-context.tsx) receives the user's permissions from the server layout and makes them available to all client components via hooks.
Gate Component
Declaratively renders children only when the user has the required permission:
<Gate permission="overlays:create">
<Button>Create Overlay</Button>
</Gate>
<Gate> and useHasPerm() resolve against the permission list handed to PermissionProvider; the only wildcard they honour is a literal "*" entry, which no account role can carry.
PermissionErrorBoundary
Wraps entire page content to show a "No Access" fallback for users without the permission:
<PermissionErrorBoundary permission="spotify:read">
<MusicPlayer />
</PermissionErrorBoundary>
Imperative Checks
For use in hooks or event handlers:
const canEdit = useHasPerm("overlays:edit");
See Frontend Permission System for the full API reference.
Redis Cache
Permissions are cached in Redis to avoid database lookups on every request.
CachedPermissions
The CachedPermissions struct in crates/lo-cache/src/client.rs stores permissions split by scope, stamped with the cache generation they were loaded under:
pub struct CachedPermissions {
#[serde(flatten)]
pub stamp: PermissionStamp, // { epoch, ver } at load time
pub global: Vec<String>, // Admin-scope permissions
pub account: Vec<String>, // Account-scope permissions
pub user: Vec<String>, // User-scope permissions (Ideas Hub etc.)
}
| Cache | Key | TTL | Invalidator |
|---|---|---|---|
| Account + admin + user, per active account | lumio:perms:v2:{user_id}:{account_id} | 300 s | exact DEL via invalidate_permission_cache(), or a generation bump |
| User-scope only, account-independent | lumio:user_perms:v2:{user_id} | 300 s | generation bump |
| Team scope | lumio:team_perms:{team_id}:{user_id} | 300 s | RedisClient::invalidate_team_permissions() |
Two generation keys make entries invalid without enumerating them:
| Key | Value | TTL | Bumped by |
|---|---|---|---|
lumio:perms:ver:{user_id} | unique token (UUID v4) | 900 s | lo_api::middleware::auth::invalidate_user_permission_cache() |
lumio:perms:epoch | unique token (UUID v4) | 900 s | lo_api::middleware::auth::invalidate_all_permission_caches() |
resolve_jwt reads lumio:perms:epoch, lumio:perms:ver:{user}, the combined entry and the user-scope entry in one MGET (RedisClient::read_permission_cache). An entry counts as a hit only while its stamp equals both current values (an absent key equals an absent stamp). On a miss the DB load is cached under the stamp read before the load, so an edit committed during the load leaves a stale stamp and the next request misses again. The combined entry is written on a miss only, so its 300 s TTL is a real upper bound.
The generation values are unique tokens, never counters: a counter that expires and is incremented again can repeat an old value and re-validate a stale entry. The generation TTL (900 s) is longer than the entry TTL (300 s), so an entry stamped "key absent" has expired before the key can expire and be set again.
Invalidation rule
TTL is only the backstop. Every write that changes what a cached set would resolve to invalidates in the same code path, after the database write, on both GraphQL and REST. A one-sided invalidation is a security bug: the other protocol leaves a revoked permission usable until the TTL expires.
| Write | Invalidation | Shared helper |
|---|---|---|
Role permission set edited (PATCH /v1/roles/{id} · updateRole) | DEL lumio:perms:v2:{member}:{account} for every member of the role | services::roles::invalidate_cache_for_role |
Role deleted (DELETE /v1/roles/{id} · deleteRole) | same — members are collected before the delete and dropped after it | services::roles::role_member_user_ids + invalidate_permission_cache_for_users |
| Member role changed / member removed / member left | DEL lumio:perms:v2:{user}:{account} | lo_api::middleware::auth::invalidate_permission_cache |
Admin adds or removes an account member (/v1/admin/accounts/{id}/members · adminAddAccountMember / adminRemoveAccountMember) | DEL lumio:perms:v2:{user}:{account} | invalidate_permission_cache |
| Account permission override set/removed (admin) | DEL lumio:perms:v2:{member}:{account} for every member | RedisClient::invalidate_permissions |
User role assigned/removed, user override set/removed (/v1/users/{id}/roles*, /v1/users/{id}/permission-overrides* · assignUserRole / removeUserRole / setUserPermissionOverride / removeUserPermissionOverride) | bump lumio:perms:ver:{user} — every entry of that user, in every account | invalidate_user_permission_cache |
Admin role assigned/unassigned (/v1/admin/admin-roles/{id}/members* · adminAssignUserRole / adminUnassignUserRole) | bump lumio:perms:ver:{user} | invalidate_user_permission_cache |
User role permissions edited or role deleted (PATCH/DELETE /v1/user-roles/{id} · updateUserRole / deleteUserRole) | bump lumio:perms:epoch — the holders include every user without an assignment when the role is the default role, so they cannot be enumerated | invalidate_all_permission_caches |
Admin role permissions edited or role deleted (PATCH/DELETE /v1/admin/admin-roles/{id} · adminUpdateRole / adminDeleteRole) | bump lumio:perms:epoch | invalidate_all_permission_caches |
| Team member role changed / removed (team owner and admin surfaces) | DEL lumio:team_perms:{team}:{user} | RedisClient::invalidate_team_permissions |
An epoch bump makes every active user reload from the database once on their next request. Role-definition edits are rare platform-operator actions, so this costs a handful of indexed queries per active user a few times a month. Invalidation is best-effort: a failed Redis write is logged, and the 300 s TTL bounds the damage.
A role that still has members cannot be deleted (the membership foreign key refuses it), so a delete never leaves a member holding a removed role's grants.
API-key, service-key and developer-key permissions are not cached — they are read from their key row on every request, so editing or revoking a key takes effect immediately. There is no pub/sub permission-invalidation channel: the cache is shared Redis, so a DEL or a generation bump is visible to every replica at once and no in-process permission cache exists to notify.
Key Files
| File | Purpose |
|---|---|
crates/lo-auth/src/rbac.rs | Permission constants (account / global / team / user), get_all_account_permission_infos() registry, default roles, all_admin_permissions() |
crates/lo-auth/src/context.rs | AuthContext::has_permission() + require_permission() + require_admin_permission() + require_user_permission() |
crates/lo-graphql/src/guard.rs | PermissionGuard (account-scope) + AdminPermissionGuard (admin-scope) for GraphQL resolvers |
crates/lo-websocket/src/gate.rs | channel_gate_for / channel_feature_for |
crates/lo-cache/src/client.rs | CachedPermissions, PermissionStamp, read_permission_cache + Redis cache/invalidation (account, user, team) |
apps/api/src/graphql/roles.rs | GraphQL availablePermissions picker + role create/update validation |
apps/api/src/graphql/admin.rs | GraphQL adminRoles* queries/mutations + adminAssignablePermissions (admin scope) |
apps/api/src/routes/roles.rs | REST GET /v1/roles/permissions picker + validation |
apps/api/src/routes/admin.rs | REST admin-roles + admin-permissions + account permission overrides |
apps/api/src/db/admin_roles.rs | DB helpers for admin role CRUD |
apps/web/src/contexts/permission-context.tsx | PermissionProvider + usePermissions() + useHasPerm() |
apps/web/src/components/gate.tsx | <Gate> declarative permission component |
apps/web/src/components/permission-error-boundary.tsx | Page-level access denial |
apps/web/src/lib/translate-role.ts | useTranslateRole() hook for default role names |