Skip to main content

Roles and Permissions Guide

This guide covers everything you need to know when adding new permissions or modifying the roles system in Lumio.


Concepts​

Permission Format​

Permissions follow the resource:action format:

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

Rules:

  • Use granular scopes. Every resource needs at minimum read plus granular create/edit/delete.
  • Add domain-specific actions where the CRUD model is insufficient (e.g., automations:execute, chat:ban).
  • Never use wildcard permissions like resource:* or *:* for account-level permissions. :manage action names are not used for account permissions; choose granular actions.

Role Properties​

PropertyTypeDescription
slugstringMachine-readable identifier (lowercase, stable - used in code checks)
namestringHuman-readable display name (user-editable on custom roles)
is_systemboolCannot be edited or deleted. Only the Owner role is a system role.
is_defaultboolCannot be deleted. All three default roles have this set.
colorstringHex color string, e.g. "#f59e0b"

Default Roles​

Lumio creates three default roles for every new account:

RoleSlugColorNotes
Ownerowner#f59e0bSystem role - every account registry permission, cannot be deleted or edited
Administratoradministrator#ef4444all but four account permissions - everything except account:delete, plan:read, plan:edit, extension-dev:payouts
Moderatormoderator#22c55e49 permissions - chat moderation, event monitoring, read access

The definitions live in ROLE_OWNER / ROLE_ADMIN / ROLE_MODERATOR in crates/lo-auth/src/rbac.rs and are inserted by create_default_roles() (apps/api/src/db/roles.rs), which sets is_default = true on all three.


Adding New Permissions​

Step 1 - Define constants in rbac.rs​

All account-level permission constants live in crates/lo-auth/src/rbac.rs inside the pub mod account {} block.

// crates/lo-auth/src/rbac.rs
pub mod account {
// ... existing permissions ...

/// Read resource data.
pub const RESOURCE_READ: &str = "resource:read";
/// Create new resource entries.
pub const RESOURCE_CREATE: &str = "resource:create";
/// Edit existing resource entries.
pub const RESOURCE_EDIT: &str = "resource:edit";
/// Delete resource entries.
pub const RESOURCE_DELETE: &str = "resource:delete";
}

Use descriptive doc comments - they serve as inline documentation for the permission's purpose.

Step 2 - Add to default roles in rbac.rs​

Update the ROLE_OWNER, ROLE_ADMIN, and ROLE_MODERATOR constants in the same file.

Decision guide:

RoleGets the permission?
OwnerAlways - Owner gets ALL permissions
AdministratorYes, unless it is owner-only (account:delete, plan:read, plan:edit, extension-dev:payouts)
ModeratorRead permissions + feature-specific actions relevant to moderation
pub const ROLE_OWNER: DefaultRole = DefaultRole {
// ...
permissions: &[
// ... existing ...
account::RESOURCE_READ,
account::RESOURCE_CREATE,
account::RESOURCE_EDIT,
account::RESOURCE_DELETE,
],
// ...
};

pub const ROLE_ADMIN: DefaultRole = DefaultRole {
// ...
permissions: &[
// ... existing ...
account::RESOURCE_READ,
account::RESOURCE_CREATE,
account::RESOURCE_EDIT,
account::RESOURCE_DELETE,
],
// ...
};

pub const ROLE_MODERATOR: DefaultRole = DefaultRole {
// ...
permissions: &[
// ... existing ...
account::RESOURCE_READ,
],
// ...
};

Step 3 - Add GraphQL guards​

Every GraphQL resolver that touches the new resource needs a PermissionGuard:

// apps/api/src/graphql/resource.rs
impl ResourceQuery {
#[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:read\")")]
async fn resources(&self, ctx: &Context<'_>) -> async_graphql::Result<Vec<Resource>> {
// ...
}
}

impl ResourceMutation {
#[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:create\")")]
async fn create_resource(
&self,
ctx: &Context<'_>,
input: CreateResourceInput,
) -> async_graphql::Result<Resource> {
// ...
}

#[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:edit\")")]
async fn update_resource(
&self,
ctx: &Context<'_>,
id: Uuid,
input: UpdateResourceInput,
) -> async_graphql::Result<Resource> {
// ...
}

#[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:delete\")")]
async fn delete_resource(
&self,
ctx: &Context<'_>,
id: Uuid,
) -> async_graphql::Result<bool> {
// ...
}
}

Step 4 - Add REST guards​

Every Actix route handler that touches the new resource needs a permission check. Use the constant from rbac.rs rather than a string literal:

// apps/api/src/routes/resource.rs
use lo_auth::rbac::account;

pub async fn list_resources(
auth: Auth,
state: web::Data<AppState>,
) -> Result<HttpResponse, ApiError> {
auth.require_permission(account::RESOURCE_READ)
.map_err(from_auth_error)?;
// ...
}

pub async fn create_resource(
auth: Auth,
state: web::Data<AppState>,
body: web::Json<CreateResourceRequest>,
) -> Result<HttpResponse, ApiError> {
auth.require_permission(account::RESOURCE_CREATE)
.map_err(from_auth_error)?;
// ...
}

Step 5 - Register the permission in the registry​

There is exactly one list to edit: account::get_all_account_permission_infos() in crates/lo-auth/src/rbac.rs. It returns every account permission with the label and category the role editor renders.

// crates/lo-auth/src/rbac.rs — inside get_all_account_permission_infos()
// Resource
p!(RESOURCE_READ, "Read Resource", "Resource"),
p!(RESOURCE_CREATE, "Create Resource", "Resource"),
p!(RESOURCE_EDIT, "Edit Resource", "Resource"),
p!(RESOURCE_DELETE, "Delete Resource", "Resource"),

Everything downstream derives from it and needs no edit:

  • account::get_all_account_permissions() - the validation registry consulted by role create/update on both protocols (apps/api/src/{graphql,routes}/roles.rs). It maps the info list's keys, so a key the editor hides can never be accepted by validation and vice versa (ZAF-231).
  • The web role editor pickers: REST GET /v1/roles/permissions and GraphQL availablePermissions, both guarded on roles:read.
  • The admin override pickers via adminAssignablePermissions (ZAF-430).

Unit tests in both roles.rs files assert the picker set equals the registry set, so a divergence fails CI rather than shipping.

Step 6 - Write a DB migration​

Existing accounts already have their roles in the database. The migration must backfill the new permissions for default roles. New migrations use the reversible format with .up.sql and .down.sql suffixes (YYYYMMDD######_description.up.sql / .down.sql); see apps/api/migrations/ for examples.

-- apps/api/migrations/20260401000001_add_resource_permissions.up.sql
--
-- Add resource permissions to existing default roles.

-- Owner: all permissions
INSERT INTO account_role_permissions (role_id, permission)
SELECT ar.id, perm.permission
FROM account_roles ar
CROSS JOIN (
VALUES
('resource:read'),
('resource:create'),
('resource:edit'),
('resource:delete')
) AS perm(permission)
WHERE ar.slug = 'owner'
ON CONFLICT DO NOTHING;

-- Administrator: all permissions
INSERT INTO account_role_permissions (role_id, permission)
SELECT ar.id, perm.permission
FROM account_roles ar
CROSS JOIN (
VALUES
('resource:read'),
('resource:create'),
('resource:edit'),
('resource:delete')
) AS perm(permission)
WHERE ar.slug = 'administrator'
ON CONFLICT DO NOTHING;

-- Moderator: read only
INSERT INTO account_role_permissions (role_id, permission)
SELECT ar.id, perm.permission
FROM account_roles ar
CROSS JOIN (
VALUES
('resource:read')
) AS perm(permission)
WHERE ar.slug = 'moderator'
ON CONFLICT DO NOTHING;

Note: ON CONFLICT DO NOTHING makes migrations safe to re-run.


Frontend Translations (Critical)​

Translations must be updated in both apps/web/messages/en.json and apps/web/messages/de.json.

Permission labels​

Each permission key must have a translation in the "permissions" namespace:

{
"permissions": {
"resource:read": "Read Resource",
"resource:create": "Create Resource",
"resource:edit": "Edit Resource",
"resource:delete": "Delete Resource"
}
}

Permission category​

Each distinct category value used in get_all_account_permissions() must have a translation under "permissions.categories":

{
"permissions": {
"categories": {
"Resource": "Resource"
}
}
}

Default role names​

Default role display names are translated via "roles.labels" keyed by slug:

{
"roles": {
"labels": {
"owner": "Owner",
"administrator": "Administrator",
"moderator": "Moderator"
}
}
}

Use the useTranslateRole() hook (from apps/web/src/lib/translate-role.ts) when displaying role names - it returns a (slug, name) => string function. Default role slugs are translated; custom roles fall back to their name field unchanged.


Frontend Permission Checks​

Add a permission-gated nav entry in apps/web/src/app/(main)/(app)/shell.tsx:

// apps/web/src/app/(main)/(app)/shell.tsx
{
label: "manage",
items: [
// ...
{
href: "/dashboard/resource",
icon: Box,
translationKey: "resource",
permission: "resource:read",
},
],
},

The shell filters out items where the user lacks the required permission.

Page-level boundary​

Wrap the main page content in <PermissionErrorBoundary> to show a "No Access" fallback for users without the permission:

// apps/web/src/app/(main)/(app)/dashboard/resource/resource-list.tsx
import { PermissionErrorBoundary } from "@/components/permission-error-boundary";
import { Gate } from "@/components/gate";

export function ResourceList() {
return (
<PermissionErrorBoundary permission="resource:read">
<div>
<Gate permission="resource:create">
<Button>Create Resource</Button>
</Gate>
{/* list content */}
</div>
</PermissionErrorBoundary>
);
}

Component-level checks​

For ad-hoc imperative checks (e.g., inside hooks or event handlers):

const canEdit = useHasPerm("resource:edit");

if (canEdit) {
// show edit button
}

useHasPerm resolves as perms.includes("*") || perms.includes(permission). An empty permission array grants nothing - it means the identity has no account-scope grants (for example a user whose active context is their personal profile), so every gate closes. Never hand-roll a permissions.length === 0 escape hatch.

Use <Gate permission="..."> for declarative rendering and useHasPerm("...") for boolean checks. See Frontend Permission System for the full API reference.

Next.js proxy routes​

The frontend never calls the Rust GraphQL endpoint directly. Follow the established proxy pattern:

Browser → Next.js API route → serverGql() → Rust GraphQL
// apps/web/src/app/api/resource/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serverGql } from "@/lib/server-gql";
import { transformKeys } from "@/lib/transform";

const RESOURCES_QUERY = `
query Resources {
resources {
id accountId name createdAt updatedAt
}
}
`;

export async function GET(request: NextRequest) {
try {
const { data } = await serverGql<{ resources: unknown[] }>(
request,
RESOURCES_QUERY,
);
return NextResponse.json({
data: (data.resources || []).map(transformKeys),
});
} catch (e) {
const status = (e as Error & { status?: number }).status ?? 500;
return NextResponse.json(
{ error: (e as Error).message },
{ status },
);
}
}

Complete Permissions Reference​

Every account-scope permission in account::get_all_account_permission_infos(), with its role-editor label, picker category, and the default roles that carry it. 129 entries - Owner holds every one of them.

PermissionLabelCategoryOwnerAdminModerator
events:readRead EventsEvents✓✓✓
events:createCreate EventsEvents✓✓✓
events:userinfoEvent User InfoEvents✓✓✓
overlays:readRead OverlaysOverlays✓✓✓
overlays:createCreate OverlaysOverlays✓✓
overlays:editEdit OverlaysOverlays✓✓
overlays:deleteDelete OverlaysOverlays✓✓
overlays:access-readRead Overlay AccessOverlays✓✓
overlays:access-grantGrant Overlay AccessOverlays✓✓
overlays:access-revokeRevoke Overlay AccessOverlays✓✓
spotify:readRead SpotifySpotify✓✓✓
spotify:playbackSpotify PlaybackSpotify✓✓✓
spotify:volumeSpotify VolumeSpotify✓✓
spotify:queueSpotify QueueSpotify✓✓✓
spotify:playlistSpotify PlaylistsSpotify✓✓✓
spotify:deviceSpotify DevicesSpotify✓✓✓
spotify:workerSpotify WorkerSpotify✓✓
chat:readRead ChatChat✓✓✓
chat:writeWrite ChatChat✓✓✓
chat:userinfoChat User InfoChat✓✓✓
chat:deleteDelete Chat MessagesChat✓✓✓
chat:banBan Chat UsersChat✓✓✓
chat:timeoutTimeout Chat UsersChat✓✓✓
chat:notesChat User NotesChat✓✓✓
chat:raidCancel RaidsChat✓✓✓
chat:refresh_userRefresh User ProfileChat✓✓✓
chat:pollEnd PollsChat✓✓✓
chat:predictionEnd PredictionsChat✓✓✓
chat:automod-readRead AutoMod QueueChat✓✓✓
chat:automod-allowAllow Held MessagesChat✓✓✓
chat:automod-denyDeny Held MessagesChat✓✓✓
chat-bots:readRead Bot ListChat✓✓✓
chat-bots:editEdit Bot ListChat✓✓
connections:readRead ConnectionsConnections✓✓✓
connections:createCreate ConnectionsConnections✓✓
connections:editEdit ConnectionsConnections✓✓
connections:deleteDelete ConnectionsConnections✓✓
settings:readRead SettingsSettings✓✓
settings:editEdit SettingsSettings✓✓
account:readRead Account SettingsAccount✓✓
account:editEdit Account SettingsAccount✓✓
account:deleteDelete AccountAccount✓
plan:readView Plan & BillingPlan✓
plan:editChange PlanPlan✓
members:readRead MembersMembers✓✓✓
members:createCreate InvitesMembers✓✓
members:editEdit MembersMembers✓✓
members:deleteDelete MembersMembers✓✓
roles:readRead RolesRoles✓✓✓
roles:editEdit RolesRoles✓✓
roles:deleteDelete RolesRoles✓✓
login-assignments:readView Login AssignmentsLogin Assignments✓✓
login-assignments:createAssign Login ConnectionsLogin Assignments✓✓
login-assignments:deleteRemove Login AssignmentsLogin Assignments✓✓
uploads:readRead UploadsUploads✓✓✓
uploads:createCreate UploadsUploads✓✓
uploads:deleteDelete UploadsUploads✓✓
rewards:readRead RewardsRewards✓✓✓
rewards:createCreate RewardsRewards✓✓
rewards:editEdit RewardsRewards✓✓
rewards:deleteDelete RewardsRewards✓✓
tokens:readRead TokensTokens✓✓
tokens:createCreate TokensTokens✓✓
tokens:editEdit TokensTokens✓✓
tokens:deleteDelete TokensTokens✓✓
automations:readRead AutomationsAutomations✓✓✓
automations:createCreate AutomationsAutomations✓✓
automations:editEdit AutomationsAutomations✓✓
automations:deleteDelete AutomationsAutomations✓✓
automations:executeExecute AutomationsAutomations✓✓✓
obs:readView OBS ConfigOBS✓✓✓
obs:editEdit OBS ConfigOBS✓✓
obs:deleteDelete OBS ConfigOBS✓✓
copyright:readView CopyrightCopyright✓✓
copyright:editEdit Copyright ListsCopyright✓✓
copyright:deleteDelete Copyright EntriesCopyright✓✓
copyright:voteVote on SongsCopyright✓✓✓
copyright:reportCreate Copyright ReportsCopyright✓✓✓
copyright:recommendRecommend SongsCopyright✓✓✓
copyright:moderateModerate Global Copyright ListCopyright✓✓
bot-modules:readView Bot ModulesBot Modules✓✓✓
bot-modules:editEdit Bot ModulesBot Modules✓✓
bot-commands:readView Bot CommandsBot Commands✓✓✓
bot-commands:createCreate Bot CommandsBot Commands✓✓
bot-commands:editEdit Bot CommandsBot Commands✓✓
bot-commands:deleteDelete Bot CommandsBot Commands✓✓
bot-connections:readView Bot ConnectionsBot Connections✓✓✓
bot-connections:createCreate Bot ConnectionsBot Connections✓✓
bot-connections:deleteDelete Bot ConnectionsBot Connections✓✓
se-tokens:readView SE TokensStreamElements✓✓
se-tokens:createCreate SE TokensStreamElements✓✓
se-tokens:deleteDelete SE TokensStreamElements✓✓
extension-dev:readView Developer DashboardExtension Developer✓✓
extension-dev:createCreate ExtensionsExtension Developer✓✓
extension-dev:editEdit ExtensionsExtension Developer✓✓
extension-dev:deleteDelete ExtensionsExtension Developer✓✓
extension-dev:publishPublish ExtensionsExtension Developer✓✓
extension-dev:analyticsView Extension AnalyticsExtension Developer✓✓
extension-dev:payoutsManage Extension PayoutsExtension Developer✓
extension-store:readBrowse Extension StoreExtension Store✓✓✓
extension-store:installInstall ExtensionsExtension Store✓✓
extension-store:uninstallUninstall ExtensionsExtension Store✓✓
extension-store:configureConfigure ExtensionsExtension Store✓✓
extension-store:reviewReview ExtensionsExtension Store✓✓✓
extension-data:readRead Extension DataExtension Data✓✓✓
extension-data:editEdit Extension DataExtension Data✓✓
widgets:readView WidgetsWidgets✓✓✓
widgets:createCreate WidgetsWidgets✓✓
widgets:editEdit WidgetsWidgets✓✓
widgets:deleteDelete WidgetsWidgets✓✓
widgets:access-readRead Widget AccessWidgets✓✓
widgets:access-grantGrant Widget AccessWidgets✓✓
widgets:access-revokeRevoke Widget AccessWidgets✓✓
sounds:readView SoundsSounds✓✓✓
sounds:createUpload SoundsSounds✓✓
sounds:editEdit SoundsSounds✓✓
sounds:deleteDelete SoundsSounds✓✓
sounds:playPlay SoundsSounds✓✓✓
history:readRead Stream HistoryStream History✓✓✓
history:shareShare History ReportsStream History✓✓
history:exportExport History ReportsStream History✓✓✓
history:deleteDelete Stream HistoryStream History✓✓
history:revenue-readRead Estimated RevenueStream History✓✓
history:revenue-editEdit Revenue AssumptionsStream History✓✓
history:keywords-editEdit Tracked KeywordsStream History✓✓
public-stats:readRead Public Stats SettingsPublic Stats✓✓✓
public-stats:editEdit Public Stats SettingsPublic Stats✓✓
audit-log:readRead Audit LogAudit Log✓✓

User-scope (ideas:*) and admin-scope permissions are separate registries - see User Roles and RBAC & Permissions.


chat:pin is an account permission seeded for Owner, Administrator and Moderator. It guards the GraphQL and REST Twitch pin actions; reading the current card uses chat:read. The acting moderator also needs a Twitch login with moderator:manage:chat_messages.

Checklist for Adding a New Feature with Permissions​

Use this checklist whenever you add a new feature that requires access control:

  • 1. Define constants in crates/lo-auth/src/rbac.rs (pub mod account {})
    • RESOURCE_READ, RESOURCE_CREATE, RESOURCE_EDIT, RESOURCE_DELETE
    • Add domain-specific actions if needed (e.g., RESOURCE_EXECUTE)
  • 2. Add to default roles in rbac.rs
    • Owner: all new permissions
    • Administrator: all except account:delete and plan:edit
    • Moderator: read + relevant domain actions
  • 3. Add GraphQL guards to all resolvers in apps/api/src/graphql/
    • #[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:read\")")]
  • 4. Add REST guards to all route handlers in apps/api/src/routes/
    • auth.require_permission(account::RESOURCE_READ).map_err(from_auth_error)?;
  • 5. Register the permission once in crates/lo-auth/src/rbac.rs
    • Add a p!(KEY, "Label", "Category") entry to account::get_all_account_permission_infos()
    • All pickers derive from it automatically - no per-picker edits:
      • Web role editor: REST GET /v1/roles/permissions + GraphQL availablePermissions (apps/api/src/{routes,graphql}/roles.rs) map the registry
      • Admin override pickers (ZAF-430): the per-user (/admin/users/[id]) and per-account (/admin/accounts/[id]) override pickers fetch GET /api/assignable-permissions (→ adminAssignablePermissions GraphQL, mapping the same registry). No hardcoded arrays - do not reintroduce the old ALL_USER_PERMISSIONS / ALL_ACCOUNT_PERMISSIONS shadow lists that once drifted.
  • 6. Write DB migration in apps/api/migrations/
    • Backfill permissions for all affected default roles
    • Use ON CONFLICT DO NOTHING for idempotency
  • 7. Add translations in apps/web/messages/en.json and apps/web/messages/de.json
    • Permission labels under "permissions": "resource:read": "Read Resource"
    • Category under "permissions.categories": "Resource": "Resource"
  • 8. Add sidebar nav entry in apps/web/src/app/(main)/(app)/shell.tsx
    • { href: "/dashboard/resource", permission: "resource:read", ... }
  • 9. Wrap page with <PermissionErrorBoundary permission="resource:read">
  • 10. Gate write controls with <Gate permission="resource:create"> etc.
  • 11. Create Next.js proxy routes in apps/web/src/app/api/resource/
    • GET → serverGql() → Rust GraphQL query
    • POST / PATCH / DELETE → serverGql() → Rust GraphQL mutation
  • 12. Run lint - just lint-all (clippy + ESLint, zero warnings)

Key Files Reference​

FilePurpose
crates/lo-auth/src/rbac.rsPermission constants, get_all_account_permission_infos() registry, default role definitions
apps/api/src/graphql/roles.rsGraphQL availablePermissions picker + role create/update validation
apps/api/src/routes/roles.rsREST GET /v1/roles/permissions picker + validation
apps/api/src/db/roles.rscreate_default_roles() - inserts the three default roles per account
apps/api/src/graphql/admin.rsGraphQL adminAssignablePermissions (admin override pickers, ZAF-430)
apps/admin/src/app/api/assignable-permissions/route.tsAdmin proxy serving the registry to the override pickers
apps/api/migrations/DB migrations for backfilling permissions
apps/web/messages/en.jsonEnglish translations for permissions + role names
apps/web/messages/de.jsonGerman translations for permissions + role names
apps/web/src/app/(main)/(app)/shell.tsxSidebar nav with permission filtering
apps/web/src/lib/translate-role.tsuseTranslateRole() hook for default role names
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 component