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:deletechat:ban,chat:timeoutautomations:execute,automations:history
Rules:
- Use granular scopes. Every resource needs at minimum
readplus granularcreate/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:manageorresource:*for account-level permissions.
Role Properties
| Property | Type | Description |
|---|---|---|
slug | string | Machine-readable identifier (lowercase, stable — used in code checks) |
name | string | Human-readable display name (user-editable on custom roles) |
is_system | bool | Cannot be edited or deleted. Only the Owner role is a system role. |
is_default | bool | Cannot be deleted. All four default roles have this set. |
color | string | Hex color string, e.g. "#f59e0b" |
Default Roles
Lumio creates four default roles for every new account:
| Role | Slug | Color | Notes |
|---|---|---|---|
| Owner | owner | #f59e0b | System role — all permissions, cannot be deleted or edited |
| Administrator | administrator | #ef4444 | All permissions except account:delete and plan:edit |
| Moderator | moderator | #22c55e | Chat moderation, event monitoring, read access |
| Viewer | viewer | #6b7280 | Read-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:
| Role | Gets the permission? |
|---|---|
| Owner | Always — Owner gets ALL permissions |
| Administrator | Yes, unless it's account:delete or plan:edit (those are owner-only) |
| Moderator | Read permissions + feature-specific actions relevant to moderation |
| Viewer | Read-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
Sidebar navigation
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.
| Permission | Label | Owner | Admin | Moderator | Viewer |
|---|---|---|---|---|---|
events:read | Read Events | ✓ | ✓ | ✓ | ✓ |
events:create | Create Events | ✓ | ✓ | ✓ | |
events:delete | Delete Events | ✓ | ✓ | ✓ | |
events:userinfo | Event User Info | ✓ | ✓ | ✓ | ✓ |
overlays:read | Read Overlays | ✓ | ✓ | ✓ | ✓ |
overlays:create | Create Overlays | ✓ | ✓ | ||
overlays:edit | Edit Overlays | ✓ | ✓ | ||
overlays:delete | Delete Overlays | ✓ | ✓ | ||
spotify:read | Read Spotify | ✓ | ✓ | ✓ | |
spotify:playback | Spotify Playback | ✓ | ✓ | ✓ | |
spotify:queue | Spotify Queue | ✓ | ✓ | ✓ | |
spotify:playlist | Spotify Playlists | ✓ | ✓ | ✓ | |
spotify:device | Spotify Devices | ✓ | ✓ | ✓ | |
spotify:worker | Spotify Worker | ✓ | ✓ | ||
chat:read | Read Chat | ✓ | ✓ | ✓ | |
chat:write | Write Chat | ✓ | ✓ | ✓ | |
chat:userinfo | Chat User Info | ✓ | ✓ | ✓ | |
chat:delete | Delete Chat Messages | ✓ | ✓ | ✓ | |
chat:ban | Ban Chat Users | ✓ | ✓ | ✓ | |
chat:timeout | Timeout Chat Users | ✓ | ✓ | ✓ | |
chat:notes | Chat User Notes | ✓ | ✓ | ✓ | |
chat:raid | Cancel Raids | ✓ | ✓ | ✓ | |
chat:poll | End Polls | ✓ | ✓ | ✓ | |
chat:prediction | End Predictions | ✓ | ✓ | ✓ | |
connections:read | Read Connections | ✓ | ✓ | ✓ | |
connections:create | Create Connections | ✓ | ✓ | ||
connections:edit | Edit Connections | ✓ | ✓ | ||
connections:delete | Delete Connections | ✓ | ✓ | ||
settings:read | Read Settings | ✓ | ✓ | ||
settings:edit | Edit Settings | ✓ | ✓ | ||
members:read | Read Members | ✓ | ✓ | ✓ | |
members:create | Create Invites | ✓ | ✓ | ||
members:edit | Edit Members | ✓ | ✓ | ||
members:delete | Delete Members | ✓ | ✓ | ||
roles:read | Read Roles | ✓ | ✓ | ✓ | |
roles:edit | Edit Roles | ✓ | ✓ | ||
roles:delete | Delete Roles | ✓ | ✓ | ||
uploads:read | Read Uploads | ✓ | ✓ | ✓ | |
uploads:create | Create Uploads | ✓ | ✓ | ||
uploads:delete | Delete Uploads | ✓ | ✓ | ||
rewards:read | Read Rewards | ✓ | ✓ | ✓ | |
rewards:create | Create Rewards | ✓ | ✓ | ||
rewards:edit | Edit Rewards | ✓ | ✓ | ||
rewards:delete | Delete Rewards | ✓ | ✓ | ||
tokens:read | Read Tokens | ✓ | ✓ | ||
tokens:create | Create Tokens | ✓ | ✓ | ||
tokens:edit | Edit Tokens | ✓ | ✓ | ||
tokens:delete | Delete Tokens | ✓ | ✓ | ||
automations:read | Read Automations | ✓ | ✓ | ✓ | |
automations:create | Create Automations | ✓ | ✓ | ||
automations:edit | Edit Automations | ✓ | ✓ | ||
automations:delete | Delete Automations | ✓ | ✓ | ||
automations:execute | Execute Automations | ✓ | ✓ | ✓ | |
automations:history | Automation History | ✓ | ✓ | ✓ | |
account:read | Read Account | ✓ | ✓ | ||
account:edit | Edit Account | ✓ | ✓ | ||
account:delete | Delete Account | ✓ | |||
plan:read | Read Plan | ✓ | ✓ | ||
plan:edit | Edit 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:deleteandplan: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_permissionsin 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 NOTHINGfor idempotency
- 7. Add translations in
apps/web/messages/en.jsonandapps/web/messages/de.json- Permission labels under
"permissions":"resource:read": "Read Resource" - Category under
"permissions.categories":"Resource": "Resource"
- Permission labels under
- 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/GET→serverGql()→ Rust GraphQL queryPOST/PATCH/DELETE→serverGql()→ Rust GraphQL mutation
- 12. Run lint —
just lint-all(clippy + ESLint, zero warnings)
Key Files Reference
| File | Purpose |
|---|---|
crates/lo-auth/src/rbac.rs | Permission constants + default role definitions |
apps/api/src/graphql/roles.rs | GraphQL available_permissions list |
apps/api/src/routes/roles.rs | REST available_permissions list + validation |
apps/api/migrations/ | DB migrations for backfilling permissions |
apps/web/messages/en.json | English translations for permissions + role names |
apps/web/messages/de.json | German translations for permissions + role names |
apps/web/src/app/(app)/shell.tsx | Sidebar nav with permission filtering |
apps/web/src/lib/translate-role.ts | useTranslateRole() hook for default role names |
apps/web/src/contexts/permission-context.tsx | PermissionProvider, usePermissions(), useHasPerm() |
apps/web/src/components/gate.tsx | <Gate> declarative permission component |
apps/web/src/components/permission-error-boundary.tsx | Page-level access denial component |