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. A :manage suffix is allowed only when it is an explicit, registry-listed action.

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 four default roles have this set.
colorstringHex color string, e.g. "#f59e0b"

Default Roles

Lumio creates four default roles for every new account:

RoleSlugColorNotes
Ownerowner#f59e0bSystem role — all 124 registry permissions, cannot be deleted or edited
Administratoradministrator#ef4444116 permissions — everything except account:delete, plan:read, plan:edit, extension-dev:payouts
Moderatormoderator#22c55e46 permissions — chat moderation, event monitoring, read access
Viewerviewer#6b728011 permissions — read-only, plus extension-store:review and own-session management

The definitions live in ROLE_OWNER / ROLE_ADMIN / ROLE_MODERATOR / ROLE_VIEWER 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 four.


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, ROLE_MODERATOR, and ROLE_VIEWER 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
ViewerRead-only permissions only
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",
"viewer": "Viewer"
}
}
}

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. 124 entries — Owner holds every one of them.

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


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
    • Viewer: read only (if appropriate)
  • 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/
    • GETserverGql() → Rust GraphQL query
    • POST / PATCH / DELETEserverGql() → Rust GraphQL mutation
  • 12. Run lintjust 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 four 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