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, events:delete
  • chat:ban, chat:timeout
  • automations:execute, automations:history

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 catch-all permissions like resource:manage or resource:* for account-level permissions.

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 permissions, cannot be deleted or edited
Administratoradministrator#ef4444All permissions except account:delete and plan:edit
Moderatormoderator#22c55eChat moderation, event monitoring, read access
Viewerviewer#6b7280Read-only: events:read, overlays:read, events:userinfo

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's account:delete or plan:edit (those are owner-only)
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 — Add to the available permissions list

The permission must appear in get_all_account_permissions() in both places. These lists are used for validation (the API rejects any role containing an unknown permission) and for the role editor UI.

GraphQL — apps/api/src/graphql/roles.rs:

fn get_all_account_permissions() -> Vec<AccountPermission> {
vec![
// ... existing entries ...
AccountPermission {
key: account::RESOURCE_READ.to_string(),
label: "Read Resource".to_string(),
category: "Resource".to_string(),
},
AccountPermission {
key: account::RESOURCE_CREATE.to_string(),
label: "Create Resource".to_string(),
category: "Resource".to_string(),
},
AccountPermission {
key: account::RESOURCE_EDIT.to_string(),
label: "Edit Resource".to_string(),
category: "Resource".to_string(),
},
AccountPermission {
key: account::RESOURCE_DELETE.to_string(),
label: "Delete Resource".to_string(),
category: "Resource".to_string(),
},
]
}

REST — apps/api/src/routes/roles.rs:

fn get_all_account_permissions() -> Vec<PermissionInfo> {
vec![
// ... existing entries ...
PermissionInfo {
key: account::RESOURCE_READ.to_string(),
label: "Read Resource".to_string(),
category: "Resource".to_string(),
},
PermissionInfo {
key: account::RESOURCE_CREATE.to_string(),
label: "Create Resource".to_string(),
category: "Resource".to_string(),
},
PermissionInfo {
key: account::RESOURCE_EDIT.to_string(),
label: "Edit Resource".to_string(),
category: "Resource".to_string(),
},
PermissionInfo {
key: account::RESOURCE_DELETE.to_string(),
label: "Delete Resource".to_string(),
category: "Resource".to_string(),
},
]
}

Both lists must always be in sync. If they diverge, the GraphQL and REST permission validation will produce different results.

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/(app)/shell.tsx:

// apps/web/src/app/(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/(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 permissions = usePermissions();
const hasPerm = (p: string) => permissions.length === 0 || permissions.includes(p);

// Empty permissions array means the user is the owner — always returns true.
if (hasPerm("resource:edit")) {
// show edit button
}

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

The following table lists every account-level permission, its label, and which default roles include it.

PermissionLabelOwnerAdminModeratorViewer
events:readRead Events
events:createCreate Events
events:deleteDelete Events
events:userinfoEvent User Info
overlays:readRead Overlays
overlays:createCreate Overlays
overlays:editEdit Overlays
overlays:deleteDelete Overlays
spotify:readRead Spotify
spotify:playbackSpotify Playback
spotify:queueSpotify Queue
spotify:playlistSpotify Playlists
spotify:deviceSpotify Devices
spotify:workerSpotify Worker
chat:readRead Chat
chat:writeWrite Chat
chat:userinfoChat User Info
chat:deleteDelete Chat Messages
chat:banBan Chat Users
chat:timeoutTimeout Chat Users
chat:notesChat User Notes
chat:raidCancel Raids
chat:pollEnd Polls
chat:predictionEnd Predictions
connections:readRead Connections
connections:createCreate Connections
connections:editEdit Connections
connections:deleteDelete Connections
settings:readRead Settings
settings:editEdit Settings
members:readRead Members
members:createCreate Invites
members:editEdit Members
members:deleteDelete Members
roles:readRead Roles
roles:editEdit Roles
roles:deleteDelete Roles
uploads:readRead Uploads
uploads:createCreate Uploads
uploads:deleteDelete Uploads
rewards:readRead Rewards
rewards:createCreate Rewards
rewards:editEdit Rewards
rewards:deleteDelete Rewards
tokens:readRead Tokens
tokens:createCreate Tokens
tokens:editEdit Tokens
tokens:deleteDelete Tokens
automations:readRead Automations
automations:createCreate Automations
automations:editEdit Automations
automations:deleteDelete Automations
automations:executeExecute Automations
automations:historyAutomation History
account:readRead Account
account:editEdit Account
account:deleteDelete Account
plan:readRead Plan
plan:editEdit Plan

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. Add to available_permissions in both:
    • apps/api/src/graphql/roles.rs (get_all_account_permissions())
    • apps/api/src/routes/roles.rs (get_all_account_permissions())
    • AccountPermission { key, label, category } for each constant
  • 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/(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 + default role definitions
apps/api/src/graphql/roles.rsGraphQL available_permissions list
apps/api/src/routes/roles.rsREST available_permissions list + validation
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/(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