Roles & Permissions
Overview
The Roles & Permissions module implements a granular Role-Based Access Control (RBAC) system. Every account starts with 3 default roles (Owner, Administrator, Moderator), and custom roles can be created. The registry defines 133 account-scope permissions across 31 categories, plus 105 admin-scope permissions across 38 categories and 10 user-scope permissions, all using a resource:action naming convention (e.g., chat:read, overlays:edit). Permissions are enforced at the GraphQL layer via PermissionGuard and at the REST layer via Auth::require_permission(). Permission checks are cached in Redis for performance.
CRUD authority is always granular - create / edit / delete, never a lumped resource:write. (chat:write is the one write action in the registry, and it is a verb - "send a chat message" - not a CRUD grant.) Roles also cannot carry wildcards; see Wildcards.
Architecture
Backend
- RBAC Registry (
crates/lo-auth/src/rbac.rs) -- All permission constants organized into three modules:global(admin-scope),account(per-account), anduser(user-scope, cross-account - the Ideas Hub set). Default role definitions with their permission sets. Plan-based limits. This is a single file, not a directory. - Permission Guards --
lo_graphql::PermissionGuardfor GraphQL resolvers,Auth::require_permission()for REST endpoints; both resolve against the active account.AdminPermissionGuard/require_admin_permission()andrequire_user_permission()cover the other two scopes. - Permission Cache -- Permissions are cached in Redis per user/account pair. The cache is invalidated on member role change and member removal via
lo_api::middleware::auth::invalidate_permission_cache(). - Database -- Roles live in
account_roles, their permissions in theaccount_role_permissionsjoin table. Default roles are created on account creation. - WebSocket -- Roles have no channel of their own, but account permissions gate the WebSocket channels:
channel_gate_forincrates/lo-websocket/src/gate.rsmapsevents→events:read,chat→chat:read,spotify→spotify:read,automations→automations:read,history→history:read;ext-storagetakesextension-store:readin the account that owns the install;ext-install-logstakesbot-modules:readorautomations:readaccording to the install's extension category.
Default Roles
Defined as ROLE_OWNER / ROLE_ADMIN / ROLE_MODERATOR in rbac.rs.
| Role | Slug | Color | System | Sort | Description |
|---|---|---|---|---|---|
| Owner | owner | #f59e0b | Yes | 0 | Full account access - every account permission, listed explicitly. Cannot be deleted or assigned to another member. |
| Administrator | administrator | #ef4444 | No | 1 | Full access except account deletion and plan changes (enforced via the owner_id check, not by permissions). |
| Moderator | moderator | #22c55e | No | 2 | Chat moderation and event monitoring. |
In the admin app, Add Member requires an account-owned role and offers the account's roles through adminAccountRoles / GET /v1/admin/accounts/\{id\}/roles (accounts:read). The picker hides the Owner/system role and leaves the role unselected until the operator chooses one; adding the member requires accounts:edit.
API
GraphQL Queries
| Query | Returns | Permission |
|---|---|---|
roles | [Role!]! | roles:read |
role(id: UUID!) | RoleDetail | roles:read |
availablePermissions | [AccountPermission!]! | roles:read |
myPermissions | [String!]! | AuthGuard |
Role carries permissionCount and memberCount for the list view; RoleDetail carries the full permissions: [String!]! array. AccountPermission is { key, label, category } - the role-editor catalog.
myPermissions returns the permissions of the current auth context: fresh account-role permissions from the database for a JWT user, and exactly the token's own scope for a scoped API key or popout token (never a ["*"] sentinel). Its REST twin is GET /v1/tokens/me.
GraphQL Mutations
| Mutation | Args | Returns | Permission |
|---|---|---|---|
createRole | input: CreateRoleInput! | RoleDetail! | roles:edit |
updateRole | input: UpdateRoleInput! | RoleDetail! | roles:edit |
deleteRole | id: UUID! | RoleMutationResult! | roles:delete |
CreateRoleInput is { name!, description, color, permissions! }; UpdateRoleInput is { id!, name, description, color, permissions }. Neither accepts sortOrder - sort order is assigned by the system.
Role creation derives the slug from the name (lowercased, spaces replaced with -) and assigns a random pastel color when none is supplied.
REST Endpoints
All paths live under /v1/roles. Bodies are snake_case and mirror the GraphQL inputs.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/roles | roles:read | List roles for the account |
GET | /v1/roles/{id} | roles:read | Get a single role with its permissions |
POST | /v1/roles | roles:edit | Create a role (name, description, color, permissions) |
PATCH | /v1/roles/{id} | roles:edit | Update role name, description, color, or permissions |
DELETE | /v1/roles/{id} | roles:delete | Delete a role (system roles cannot be deleted) |
GET | /v1/roles/permissions | roles:read | Catalog of available account permissions (key, label, category) |
Wildcards
createRole and updateRole validate every submitted permission string against the account registry (lo_auth::rbac::account::get_all_account_permissions()), identically on GraphQL and REST, rejecting anything unknown with Invalid permission: <key>. The registry contains no :* keys, so a wildcard can never be persisted into an account role. Wildcard grants (resource:*, *:*) exist only on config-provisioned System keys. System-key-only grants such as bots:internal are not in the account registry either, so they are rejected here as unknown permissions.
Permissions
Admin-Scope Permissions
105 permissions, exhaustively enumerated by all_admin_permissions() in rbac.rs (pinned by all_admin_permissions_returns_expected_count); the category is derived from the segment before the :. admin:access is only the dashboard-entry gate for the admin app - every element inside is additionally gated on its own resource:action. audit:ingest is a system-key-only grant in effect; the ingest endpoint requires a System auth context and does not accept a human admin role grant.
| Category | Permissions |
|---|---|
admin | admin:access, admin:privacy-erase, admin:privacy-objection-create, admin:privacy-objection-delete, admin:privacy-erase-withdraw |
users | users:read, users:edit, users:delete |
accounts | accounts:read, accounts:edit, accounts:delete, accounts:overrides-read, accounts:overrides-edit |
billing | billing:read, billing:edit |
support | support:read, support:edit, support:verify |
features | features:read, features:edit |
plans | plans:read, plans:create, plans:edit, plans:delete |
subscriptions | subscriptions:read, subscriptions:edit |
coupons | coupons:read, coupons:create, coupons:edit, coupons:delete |
audit | audit:read, audit:ingest |
audit-log | audit-log:read |
csp-reports | csp-reports:read |
devtools | devtools:read |
platforms | platforms:read, platforms:edit |
providers | providers:read, providers:edit |
feature-flags | feature-flags:read, feature-flags:edit |
se-tokens | se-tokens:read, se-tokens:delete |
bot-commands | bot-commands:read, bot-commands:create, bot-commands:edit, bot-commands:delete |
bot-control | bot-control:read, bot-control:edit |
bot-connections | bot-connections:read, bot-connections:create, bot-connections:delete |
developer-limits | developer-limits:read, developer-limits:edit |
developer-verification | developer-verification:read, developer-verification:edit |
developer-funds | developer-funds:release-deletion, developer-funds:settle |
discord-guilds | discord-guilds:read, discord-guilds:delete |
extension-review | extension-review:read, extension-review:edit |
extension-purchases | extension-purchases:read |
contract-declarations | contract-declarations:read, contract-declarations:edit |
oauth-clients | oauth-clients:read, oauth-clients:create, oauth-clients:edit, oauth-clients:delete |
system-keys | system-keys:read, system-keys:create, system-keys:delete |
account-apikeys | account-apikeys:read, account-apikeys:edit, account-apikeys:delete, account-apikeys:rotate |
system-connections | system-connections:read, system-connections:edit, system-connections:delete |
admin-roles | admin-roles:read, admin-roles:create, admin-roles:edit, admin-roles:delete |
copyright | copyright:read, copyright:edit, copyright:delete, copyright:moderate |
obs | obs:read, obs:edit |
bot-modules | bot-modules:read, bot-modules:edit |
user-roles | user-roles:read, user-roles:create, user-roles:edit, user-roles:delete |
ideas | ideas:moderate_read, ideas:moderate_status, ideas:moderate_edit, ideas:moderate_delete, ideas:moderate_comment |
crawler | crawler:scope-read, crawler:scope-edit, crawler:watchlist-read, crawler:watchlist-create, crawler:watchlist-delete |
Several action strings, including bot-commands:read, copyright:read, obs:read, and se-tokens:read, are declared in both the global and account modules. The same string can therefore exist at system and account scope; handlers use the appropriate guard for the resource.
Account Permissions
133 permissions in 31 categories, sourced from account::get_all_account_permission_infos() (pinned by account_permission_infos_has_expected_count) - the single source of truth behind the role-editor picker (GET /v1/roles/permissions / availablePermissions) and the create/update validation registry.
| Category | Permissions |
|---|---|
| Events | events:read, events:create, events:userinfo |
| Overlays | overlays:read, overlays:create, overlays:edit, overlays:delete, overlays:access-read, overlays:access-grant, overlays:access-revoke |
| Spotify | spotify:read, spotify:playback, spotify:volume, spotify:queue, spotify:playlist, spotify:device, spotify:worker |
| Chat | chat:read, chat:write, chat:userinfo, chat:delete, chat:ban, chat:timeout, chat:notes, chat:raid, chat:refresh_user, chat:poll, chat:prediction, chat:automod-read, chat:automod-allow, chat:automod-deny, chat:pin, chat-bots:read, chat-bots:edit |
| Connections | connections:read, connections:create, connections:edit, connections:delete |
| Settings | settings:read, settings:edit |
| Account | account:read, account:edit, account:delete |
| Plan | plan:read, plan:edit |
| Members | members:read, members:create, members:edit, members:delete |
| Roles | roles:read, roles:edit, roles:delete |
| Login Assignments | login-assignments:read, login-assignments:create, login-assignments:delete |
| Uploads | uploads:read, uploads:create, uploads:delete |
| Rewards | rewards:read, rewards:create, rewards:edit, rewards:delete |
| Tokens | tokens:read, tokens:create, tokens:edit, tokens:delete |
| API Keys | apikeys:read, apikeys:create, apikeys:edit, apikeys:delete |
| Automations | automations:read, automations:create, automations:edit, automations:delete, automations:execute |
| OBS | obs:read, obs:edit, obs:delete |
| Copyright | copyright:read, copyright:edit, copyright:delete, copyright:vote, copyright:report, copyright:recommend |
| Bot Modules | bot-modules:read, bot-modules:edit |
| Bot Commands | bot-commands:read, bot-commands:create, bot-commands:edit, bot-commands:delete |
| Bot Connections | bot-connections:read, bot-connections:create, bot-connections:delete |
| StreamElements | se-tokens:read, se-tokens:create, se-tokens:delete |
| Extension Developer | extension-dev:read, extension-dev:create, extension-dev:edit, extension-dev:delete, extension-dev:publish, extension-dev:analytics, extension-dev:payouts |
| Extension Store | extension-store:read, extension-store:install, extension-store:uninstall, extension-store:configure, extension-store:review |
| Extension Data | extension-data:read, extension-data:edit |
| Widgets | widgets:read, widgets:create, widgets:edit, widgets:delete, widgets:access-read, widgets:access-grant, widgets:access-revoke |
| Sounds | sounds:read, sounds:create, sounds:edit, sounds:delete, sounds:play |
| Stream History | history:read, history:share, history:export, history:delete, history:revenue-read, history:revenue-edit, history:keywords-edit |
| Setting Presets | presets:share |
| Public Stats | public-stats:read, public-stats:edit |
| Audit Log | audit-log:read |
Adding an account permission means adding it to get_all_account_permission_infos() and seeding it for the owner role in a migration; the registry and both pickers then follow automatically.
User-Scope Permissions
10 permissions in rbac::user, granted through global user roles rather than account roles. They cover the Ideas Hub: ideas:read, ideas:create, ideas:edit, ideas:delete, ideas:vote, ideas:comment_read, ideas:comment_create, ideas:comment_edit, ideas:comment_delete, ideas:comment_vote. See Ideas Hub › Permissions.
Plan-Based Limits
Plan limits live in the canonical plans table (seeded rows; apps/api/src/db/plans.rs), not a hard-coded Rust table (ZAF-1092). These are defaults; the numeric limits below can be overridden per account via the account_limits table, except max_commands, which reads the plan row directly (no per-account override). 0 = unlimited.
| Limit | Free | Pro |
|---|---|---|
| Max overlays | 1 | 5 |
| Max storage | 100 MB | 1 GB |
| Max upload size | 10 MB | 50 MB |
| Max integrations | 2 | 10 |
| Channel data retention (chat, events, chatter profiles, moderation) | 365 days | 1095 days |
| Max bot commands | 25 | 200 |
An Enterprise tier once existed, but its plan row was removed (migration 20260415000009_drop_enterprise_plan) and its accounts were moved to Pro; nothing resolves to it at runtime.
Capability toggles such as custom bot, OBS remote, and Copyright Manager are feature flags plus plan_features rows, not plan-limit columns - the boolean columns that once held them were dropped from account_limits when feature flags took over. See Billing.
Database
| Table | Database | Description |
|---|---|---|
account_roles | PostgreSQL | id, account_id, name, slug, description, is_default, is_system, color, sort_order, created_at, updated_at. UNIQUE(account_id, name) and UNIQUE(account_id, slug). |
account_role_permissions | PostgreSQL | (role_id, permission) join table - one row per granted permission, ON DELETE CASCADE |
account_memberships | PostgreSQL | Links users to accounts with a nullable role_id FK to account_roles |
admin_roles / admin_role_permissions | PostgreSQL | Admin-scope roles and their grants |
user_roles / user_role_permissions | PostgreSQL | User-scope roles and their grants |
Data Flow
- On account creation, the default roles (Owner, Administrator, Moderator) are inserted into
account_roleswith their predefined permission sets written toaccount_role_permissions. - The account owner is assigned the Owner role automatically.
- When a user makes an API request, the auth middleware resolves their permissions:
- Check Redis cache for the user/account pair.
- If not cached, query the user's role and its permissions from PostgreSQL.
- Cache the result in Redis.
- GraphQL resolvers check permissions via
PermissionGuard::new("resource:action"). - REST endpoints check permissions via
auth.require_permission("resource:action"). - When a member's role is changed or a member is removed, the Redis cache is invalidated for that user/account pair.
Frontend gating mirrors the backend: <Gate permission="resource:action"> hides controls in both the dashboard and the admin app, but it is a UX layer - the backend guard is the authority.
Key Files
| Path | Description |
|---|---|
crates/lo-auth/src/rbac.rs | Permission registry (global / account / user), default roles |
crates/lo-graphql/src/guard.rs | PermissionGuard, AdminPermissionGuard, AuthGuard, SystemGuard::new(grant) (System key and a named grant — never type-only) |
crates/lo-api/src/extractors.rs | Auth extractor with require_permission() |
crates/lo-api/src/middleware/auth.rs | Permission cache and invalidate_permission_cache() |
crates/lo-websocket/src/gate.rs | Permission gates for WebSocket channels |
apps/api/src/graphql/roles.rs | GraphQL role queries and mutations |
apps/api/src/routes/roles.rs | REST role endpoints |