Skip to main content

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 four default roles:

RoleSlugColoris_systemis_defaultNotes
Ownerowner#f59e0btruetrueAll 118 registry permissions, listed explicitly in ROLE_OWNER. Cannot be edited or deleted.
Administratoradministrator#ef4444falsetrueEverything except account:delete, plan:read, plan:edit, and extension-dev:payouts.
Moderatormoderator#22c55efalsetrueChat moderation, event monitoring, read access (45 permissions).
Viewerviewer#6b7280falsetrueRead-only (11 permissions) — see Complete Permissions Reference.

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 four at insert time.

Role Properties

PropertyTypeDescription
slugstringMachine-readable identifier. Stable, lowercase, used in code checks. Never changes.
namestringHuman-readable display name. Editable on custom roles, translated for default roles.
is_systemboolCannot be edited or deleted. Only the Owner role.
is_defaultboolCannot be deleted. All four default roles have this set.
colorstringHex 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",
"viewer": "Viewer"
}
}
}

Permission Format

All permissions follow the resource:action format. Examples:

  • events:read, events:create
  • chat:ban, chat:timeout, chat:notes
  • automations:execute

Rules

  1. Always granular. Every resource needs at minimum read plus granular create/edit/delete.
  2. Domain-specific actions are added when CRUD is insufficient (e.g., automations:execute, chat:ban).
  3. Never use wildcards. No resource:* / *:* account-level permissions. Account actions are always granular verbs — there is no coarse :manage action; management surfaces are split into discrete permissions (for example overlays: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 currently holds 123 account permissions across 29 categories: Events, Overlays, Spotify, Chat, Connections, Settings, Account, Plan, Members, Roles, Login Assignments, Uploads, Rewards, Tokens, Automations, OBS, Copyright, Bot Modules, Bot Commands, Bot Connections, StreamElements, Extension Developer, Extension Store, Extension Data, Widgets, Sounds, Stream History, Public Stats, Audit Log. (Crawler control moved to admin scope in ZAF-725 — 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

PermissionLabel
events:readRead Events
events:createCreate Events
events:userinfoEvent User Info

Overlays (7)

PermissionLabel
overlays:readRead Overlays
overlays:createCreate Overlays
overlays:editEdit Overlays
overlays:deleteDelete Overlays
overlays:access-readRead Overlay Access
overlays:access-grantGrant Overlay Access
overlays:access-revokeRevoke Overlay Access

Widgets (7)

PermissionLabelOwnerAdminModViewer
widgets:readView Widgetsxxxx
widgets:createCreate Widgetsxx
widgets:editEdit Widgetsxx
widgets:deleteDelete Widgetsxx
widgets:access-readRead Widget Accessxx
widgets:access-grantGrant Widget Accessxx
widgets:access-revokeRevoke Widget Accessxx

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.

PermissionLabelOwnerAdminModViewer
extension-data:readRead Extension Dataxxxx
extension-data:editEdit Extension Dataxx

Spotify (7)

PermissionLabelOwnerAdminModViewer
spotify:readRead Spotifyxxx
spotify:playbackSpotify Playbackxxx
spotify:volumeSpotify Volumexx
spotify:queueSpotify Queuexxx
spotify:playlistSpotify Playlistsxxx
spotify:deviceSpotify Devicesxxx
spotify:workerSpotify Workerxx

Chat (11)

PermissionLabelOwnerAdminModViewer
chat:readRead Chatxxx
chat:writeWrite Chatxxx
chat:userinfoChat User Infoxxx
chat:deleteDelete Chat Messagesxxx
chat:banBan Chat Usersxxx
chat:timeoutTimeout Chat Usersxxx
chat:notesChat User Notesxxx
chat:raidCancel Raidsxxx
chat:pollEnd Pollsxxx
chat:predictionEnd Predictionsxxx
chat:refresh_userRefresh User Profilexxx

Connections

PermissionLabel
connections:readRead Connections
connections:createCreate Connections
connections:editEdit Connections
connections:deleteDelete Connections

Settings (2)

PermissionLabelOwnerAdminModViewer
settings:readRead Settingsxx
settings:editEdit Settingsxx

Members (4)

PermissionLabelOwnerAdminModViewer
members:readRead Membersxxx
members:createCreate Invitesxx
members:editEdit Membersxx
members:deleteDelete Membersxx

Roles (3)

PermissionLabelOwnerAdminModViewer
roles:readRead Rolesxxx
roles:editEdit Rolesxx
roles:deleteDelete Rolesxx

Uploads

PermissionLabel
uploads:readRead Uploads
uploads:createCreate Uploads
uploads:deleteDelete Uploads

Sounds (5)

PermissionLabelOwnerAdminModViewer
sounds:readRead Soundsxxxx
sounds:createUpload Soundsxx
sounds:editEdit Soundsxx
sounds:deleteDelete Soundsxx
sounds:playPlay Soundsxxx

Stream History (4)

PermissionLabelOwnerAdminModViewer
history:readRead Stream Historyxxx
history:shareShare History Reportsxx
history:exportExport History Reportsxxx
history:deleteDelete Stream Historyxx

Gated behind the feature:stream_history feature flag.

Public Stats (2)

PermissionLabelOwnerAdminModViewer
public-stats:readRead Public Stats Settingsxxx
public-stats:editEdit Public Stats Settingsxx

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 in pub mod global and are gated by AdminPermissionGuard / require_admin_permission. See Admin-Scope Permissions → Crawler.

Audit Log (1)

PermissionLabelOwnerAdminModViewer
audit-log:readRead Audit Logxx

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

PermissionLabel
rewards:readRead Rewards
rewards:createCreate Rewards
rewards:editEdit Rewards
rewards:deleteDelete Rewards

Tokens

Popout tokens (OBS browser-source docks).

PermissionLabel
tokens:readRead Tokens
tokens:createCreate Tokens
tokens:editEdit Tokens
tokens:deleteDelete Tokens

API Keys

Personal user API keys (lm_usr_…) — a dedicated family, separate from popout tokens.

PermissionLabel
apikeys:readRead API Keys
apikeys:createCreate API Keys
apikeys:editEdit API Keys
apikeys:deleteDelete API Keys

Automations (6)

PermissionLabelOwnerAdminModViewer
automations:readRead Automationsxxx
automations:createCreate Automationsxx
automations:editEdit Automationsxx
automations:deleteDelete Automationsxx
automations:executeExecute Automationsxxx

Account (3)

PermissionLabelOwnerAdminModViewer
account:readRead Accountxx
account:editEdit Accountxx
account:deleteDelete Accountx

Plan (2)

PermissionLabelOwnerAdminModViewer
plan:readView Plan & Billingx
plan:editChange Planx

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.

PermissionLabel
login-assignments:readRead Login Assignments
login-assignments:createCreate Login Assignments
login-assignments:deleteDelete 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 and Viewer carry none. Work performed inside a developer team is additionally checked against team-scope permissions (see Team RBAC below).

PermissionLabelOwnerAdminModViewer
extension-dev:readView Developer Dashboardxx
extension-dev:createCreate Extensionsxx
extension-dev:editEdit Extensionsxx
extension-dev:deleteDelete Extensionsxx
extension-dev:publishPublish Extensionsxx
extension-dev:analyticsView Extension Analyticsxx
extension-dev:payoutsManage Extension Payoutsx

Extension Store (5)

PermissionLabelOwnerAdminModViewer
extension-store:readBrowse Extension Storexxxx
extension-store:installInstall Extensionsxx
extension-store:uninstallUninstall Extensionsxx
extension-store:configureConfigure Extensionsxx
extension-store:reviewReview Extensionsxxxx
PermissionLabelOwnerAdminModViewer
copyright:readView Copyrightxx
copyright:editEdit Copyright Listsxx
copyright:deleteDelete Copyright Entriesxx
copyright:voteVote on Songsxxx
copyright:reportCreate Copyright Reportsxxx
copyright:recommendRecommend Songsxxx

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

PermissionLabelOwnerAdminModViewer
obs:readView OBS Configxxx
obs:editEdit OBS Configxx
obs:deleteDelete OBS Configxx
bot-modules:readView Bot Modulesxxx
bot-modules:editEdit Bot Modulesxx
bot-commands:readView Bot Commandsxxxx
bot-commands:createCreate Bot Commandsxx
bot-commands:editEdit Bot Commandsxx
bot-commands:deleteDelete Bot Commandsxx
bot-connections:readView Bot Connectionsxxx
bot-connections:createCreate Bot Connectionsxx
bot-connections:deleteDelete Bot Connectionsxx
se-tokens:readView SE Tokensxx
se-tokens:createCreate SE Tokensxx
se-tokens:deleteDelete SE Tokensxx

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:

PermissionLabel
ideas:readRead Ideas
ideas:createCreate Ideas
ideas:editEdit Own Ideas
ideas:deleteDelete Own Ideas
ideas:voteVote on Ideas
ideas:comment_readRead Comments
ideas:comment_createCreate Comments
ideas:comment_editEdit Own Comments
ideas:comment_deleteDelete Own Comments
ideas:comment_voteVote on Comments

Admin-scoped moderation permissions:

PermissionLabel
ideas:moderate_readRead All Ideas (Admin)
ideas:moderate_statusChange Idea Status
ideas:moderate_editEdit Any Idea
ideas:moderate_deleteDelete Any Idea
ideas:moderate_commentDelete 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

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 constants
  • pub 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 and GET/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 global module as admin::ACCESS
  • Auto-injected into every admin role at creation time (both REST and GraphQL mutation)
  • Never removed by the permission diff algorithm

admin:privacy-erase

GDPR-Art-17 erasure tooling. It gates two surfaces, each with matching REST and GraphQL entry points:

SurfaceRESTGraphQL
YouTube member-badge erasure — clears cached YouTube member-channel records across all accountsDELETE /v1/admin/privacy/youtube/member/{member_channel_id}eraseYoutubeMemberData
Chat-subject erasure — erases a chat subject's stored messages and profile dataPOST /v1/admin/privacy/chat/eraseeraseChatSubjectData

Both 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. 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 the erasure (event_type = "youtube_member_erasure" for the member path).

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).

PermissionLabel
crawler:scope-readRead Crawler Scope
crawler:scope-editEdit Crawler Scope
crawler:watchlist-readRead Crawler Watchlist
crawler:watchlist-createAdd Crawler Watchlist Channels
crawler:watchlist-deleteRemove 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).

PermissionLabel
csp-reports:readRead 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.

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 constants
  • pub mod global {} — Admin-scope permission constants
  • pub mod team {} — Team-scope permission constants

Team Permissions (18)

All team permissions follow the team-resource:action naming convention.

PermissionLabel
team-extensions:createCreate Extensions
team-extensions:editEdit Extensions
team-extensions:deleteDelete Extensions
team-extensions:publishPublish Extensions
team-extensions:analyticsView Extension Analytics
team-versions:createCreate Versions
team-versions:editEdit Versions
team-secrets:readRead Secrets
team-secrets:editEdit Secrets
team-testers:readRead Testers
team-testers:inviteInvite Testers
team-testers:removeRemove Testers
team-members:readRead Members
team-members:inviteInvite Members
team-members:editEdit Member Roles
team-members:removeRemove Members
team-settings:readRead Settings
team-settings:editEdit Settings
team-payouts:readRead Payouts
team-payouts:editEdit Payouts

Default Team Roles

Every new developer team is seeded with three roles:

RoleSlugis_systemis_defaultNotes
Ownerteam-ownertruefalseAll 20 team permissions. Cannot be deleted.
Adminteam-adminfalsetrueAll permissions except team-settings:edit, team-payouts:edit, team-members:remove.
Memberteam-memberfalsetrueRead-only access plus extension development basics (create, edit, create versions, read secrets/settings/members, view analytics).

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

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 in crates/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 in rbac.rs::account). System admins can also act (their admin role has the permission via is_system = true).
  • AuthGuard — any-authenticated operations, no specific permission required.
  • lo_graphql::check_team_permission(ctx, team_id, permission) — team-scope operations (the permission lives in rbac.rs::team). Called inside the resolver body, not as a #[graphql(guard = ...)]. The REST twin is require_team_permission(state, user_id, team_id, permission) in apps/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)

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:

  1. The permission string — the exact same resource:action constant on both (use the rbac.rs constant, never a string literal, so a rename can't drift one side).
  2. The guard type / scope — account-scope (PermissionGuardrequire_permission), admin-scope (AdminPermissionGuardrequire_admin_permission), user/first-party-scope (AuthGuard + require_first_party_user() ↔ the REST first-party check), team-scope (check_team_permissionrequire_team_permission), or an explicit ownership check where the resource is owned by the caller. A UserPermission/user-scope check where an account PermissionGuard was required — or a bare authenticated gate where an ownership check was needed — is a guard-type mismatch even when the permission string matches.
  3. 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 and broadcast request, then runs the check against the same AuthContext the REST/GraphQL layers see.

ChannelGate is not permission-only — the variants are:

VariantMeaning
Permission("resource:action")Caller must reach the account ID in the channel name and hold the permission
AccountScopedCaller 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
AuthenticatedAny non-anonymous identity
PublicAnyone
PublicWhenFeature("flag")Anyone, as long as the named system flag is on; otherwise FEATURE_DISABLED
OverlayToken / WidgetTokenRequires that token type, with a matching overlay_id / widget_id
ExtensionOwnerRequires that the subscriber owns the extension in the channel ID
UnknownDefault arm — always rejects

Current channel mapping:

Channel typeGateFeature flag (channel_feature_for)
overlayOverlayToken
eventsPermission("events:read")
spotifyPermission("spotify:read")
chatPermission("chat:read")feature:multichat
automationsPermission("automations:read")feature:automation
historyPermission("history:read")feature:stream_history
ext-storagePermission("extension-store:read")feature:extensions
ext-install-logsPermission("bot-modules:read")feature:bot_module_extensions
soundsAccountScopedfeature:sounds
widgetWidgetTokenfeature:widgets
ext-logsExtensionOwnerfeature:extension_development
presenceAuthenticated
login-assignmentsPublic
ideasPublicWhenFeature("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.

A third map, channel_broadcast_permission_for, governs client publishes and deliberately uses a different permission than the subscribe gate — eventsevents:create, chatchat:write, automationsautomations:execute. Every other channel returns None, meaning clients cannot broadcast on it at all; server-side WsServer::broadcast_raw remains the only publisher.

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:

pub struct CachedPermissions {
pub global: Vec<String>, // Admin-scope permissions
pub account: Vec<String>, // Account-scope permissions
pub user: Vec<String>, // User-scope permissions (Ideas Hub etc.)
}

Three caches exist, one per scope boundary:

CacheKeyTTLInvalidator
Account + admin + user, per active accountlumio:perms:{user_id}:{account_id}300 sRedisClient::invalidate_permissions()
User-scope only, account-independentlumio:user_perms:{user_id}300 sRedisClient::invalidate_user_permissions()
Team scopelumio:team_perms:{team_id}:{user_id}300 sRedisClient::invalidate_team_permissions()

Invalidate whenever a role assignment, a role's permission set, or a permission override changes.

Key Files

FilePurpose
crates/lo-auth/src/rbac.rsPermission constants (account / global / team / user), get_all_account_permission_infos() registry, default roles, all_admin_permissions()
crates/lo-auth/src/context.rsAuthContext::has_permission() + require_permission() + require_admin_permission() + require_user_permission()
crates/lo-graphql/src/guard.rsPermissionGuard (account-scope) + AdminPermissionGuard (admin-scope) for GraphQL resolvers
crates/lo-websocket/src/gate.rschannel_gate_for / channel_feature_for / channel_broadcast_permission_for
crates/lo-cache/src/client.rsCachedPermissions + Redis cache/invalidation (account, user, team)
apps/api/src/graphql/roles.rsGraphQL availablePermissions picker + role create/update validation
apps/api/src/graphql/admin.rsGraphQL adminRoles* queries/mutations + adminAssignablePermissions (admin scope)
apps/api/src/routes/roles.rsREST GET /v1/roles/permissions picker + validation
apps/api/src/routes/admin.rsREST admin-roles + admin-permissions + account permission overrides
apps/api/src/db/admin_roles.rsDB helpers for admin role CRUD
apps/web/src/contexts/permission-context.tsxPermissionProvider + usePermissions() + useHasPerm()
apps/web/src/components/gate.tsx<Gate> declarative permission component
apps/web/src/components/permission-error-boundary.tsxPage-level access denial
apps/web/src/lib/translate-role.tsuseTranslateRole() hook for default role names