User Roles
Overview
User Roles form the third RBAC layer in Lumio, sitting alongside the existing account-scope RBAC (controlling what a team member can do inside a streaming account) and the admin-scope RBAC (controlling what Lumio staff can do in the admin panel).
The User Role system governs what authenticated users — specifically users with direct access to the platform — can do. It controls actions like submitting ideas, commenting, and other user-facing community features that are not account-scoped. The system supports:
- Default roles — three globally seeded roles (
member,restricted,moderator), created once by migration, not per user. - Custom roles created by admins for specific access patterns.
- Per-user role assignments —
user_role_assignmentsis keyed(user_id, role_id); a user with no row at all falls back to the role markedis_default. - Per-user permission overrides — grant or deny specific permissions for an individual user, regardless of their role.
- Per-account permission overrides — admins can expand or restrict what all members of a given streaming account can do. These apply to the account-scope permission list, not to the user-scope one.
All resolution is cached in Redis for performance.
Tables
| Table | Description |
|---|---|
user_roles | Role definitions — id, slug, name, description, is_default, is_system, created_at |
user_role_permissions | PRIMARY KEY (role_id, permission) — permissions granted by a role |
user_role_assignments | PRIMARY KEY (user_id, role_id) — role assignments; no row means "use default role" |
user_permission_overrides | PRIMARY KEY (user_id, permission) — explicit per-user allow/deny overrides |
account_permission_overrides | PRIMARY KEY (account_id, permission) — per-account allow/deny overrides applied to all members of that account |
Schema: apps/api/migrations/20260506000001_create_user_roles.up.sql and 20260506000010_create_account_permission_overrides.up.sql.
Table: user_roles
| Column | Type | Notes |
|---|---|---|
id | UUID PK | gen_random_uuid() |
slug | TEXT UNIQUE | Machine identifier |
name | TEXT | Display name, editable on custom roles |
description | TEXT | Optional description |
is_default | BOOL | The fallback role for users with no assignment |
is_system | BOOL | System roles cannot be deleted |
created_at | TIMESTAMPTZ |
Table: user_role_assignments
| Column | Type | Notes |
|---|---|---|
user_id | UUID FK | References users.id, part of the composite PK |
role_id | UUID FK | References user_roles.id, part of the composite PK |
assigned_by | UUID FK? | Admin who made the assignment; SET NULL on user delete |
created_at | TIMESTAMPTZ |
Table: user_permission_overrides
| Column | Type | Notes |
|---|---|---|
user_id | UUID FK | References users.id, part of the composite PK |
permission | TEXT | resource:action string, part of the composite PK |
granted | BOOL | true = explicitly grant, false = explicitly deny |
reason | TEXT | Optional free-text note recorded with the override |
set_by | UUID FK? | Admin who set the override |
created_at | TIMESTAMPTZ |
Table: account_permission_overrides
Same shape, keyed on (account_id, permission):
| Column | Type | Notes |
|---|---|---|
account_id | UUID FK | References accounts.id, part of the composite PK |
permission | TEXT | resource:action string, part of the composite PK |
granted | BOOL | true = explicitly grant for all account members, false = explicitly deny |
reason | TEXT | Optional free-text note |
set_by | UUID FK? | Admin who set the override |
created_at | TIMESTAMPTZ |
Default Roles
Three roles are seeded by migration 20260506000002_seed_default_user_roles.up.sql. All three have is_system = true and cannot be deleted.
| Role | Slug | is_default | Permissions |
|---|---|---|---|
| Member | member | true | All ten user-scope permissions: 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 |
| Restricted | restricted | false | ideas:read, ideas:comment_read — can browse but not submit, vote, or comment |
| Moderator | moderator | false | The ten member permissions plus the admin-scope moderation strings ideas:moderate_status, ideas:moderate_edit, ideas:moderate_delete, ideas:moderate_comment |
ideas:edit / ideas:delete / ideas:comment_edit / ideas:comment_delete cover the user's own content; editing or deleting someone else's requires the corresponding ideas:moderate_* grant.
The member role is the fallback for every user who does not have an explicit user_role_assignments row.
Permission Resolution
The auth middleware (crates/lo-api/src/middleware/auth.rs) resolves the user-scope layer independently of the account layer, because it is account-independent:
- Check the Redis cache —
lumio:user_perms:{user_id}. If present, use the cached set and skip steps 2–4. - Load role permissions —
SELECT DISTINCT permission FROM user_role_permissions JOIN user_role_assignments. If the user has no assignment row at all, fall back to the permissions of the role withis_default = true. The fallback keys off the absence of an assignment, not off an empty result — a deliberately restricted user assigned an empty role must not silently inherit the defaults (fail-closed). - Apply per-user overrides — for each row in
user_permission_overridesfor the user:granted = true: add the permission to the set (grant even if not in role).granted = false: remove the permission from the set (deny even if in role).
- Write to Redis — cache the final permission set with a 5-minute TTL.
The resolved set is stored in AuthContext::User::user_permissions. Use auth.require_user_permission("resource:action") (from crates/lo-auth/src/context.rs) to enforce it in REST handlers, and resolvedUserPermissions / GET /v1/users/{user_id}/permissions/resolved to inspect it from the admin panel.
Account Permission Overrides
Account overrides apply to the account-scope list, not the user-scope one. In load_permissions_from_db, account_permission_overrides rows for the request's active account_id are folded onto the role-derived account_permissions set — granted = true inserts, granted = false removes — so they let a platform admin lift or clamp what every member of one streaming account can do. When the auth context has no active account the override pass is skipped entirely.
Because they land on a different list, an account override cannot grant or revoke a ideas:* user-scope permission, and the user-scope cache key (lumio:user_perms:{user_id}) is unaffected by an account-override change; the account cache (lumio:perms:{user_id}:{account_id}) is.
AuthContext Changes
The AuthContext::User variant has a user_permissions: Vec<String> field populated by the auth middleware:
AuthContext::User {
user_id: Uuid,
account_id: Uuid,
/// References the `sessions` table row for this request.
session_id: Option<Uuid>,
/// Permissions from `admin_roles` (admin panel access, support, etc.).
admin_permissions: Permissions,
/// Permissions from `account_roles` (per-account).
account_permissions: Permissions,
/// Permissions scoped to the user themselves (cross-account, non-admin).
user_permissions: Permissions,
}
Permissions is Vec<String> (crates/lo-auth/src/rbac.rs).
Enforcement helpers:
// crates/lo-auth/src/context.rs
impl AuthContext {
/// Checked against `user_permissions` ONLY — admin and account grants do not satisfy it.
pub fn require_user_permission(&self, permission: &str) -> Result<(), AuthError> { ... }
/// The resolved user-scope list; empty slice for non-`User` variants.
pub fn user_permissions(&self) -> &[String] { ... }
}
require_user_permission returns AuthError::Forbidden for any non-User auth context (API key, popout/overlay/widget token, anonymous) and AuthError::MissingPermission → HTTP 403 when the grant is absent.
Use these in REST handlers exactly like require_permission() but targeting the user layer:
use lo_auth::rbac::user;
pub async fn create_idea(auth: Auth, ...) -> Result<HttpResponse, ApiError> {
auth.require_user_permission(user::IDEAS_CREATE)
.map_err(from_auth_error)?;
// ...
}
Moderation paths in apps/api/src/{graphql,routes}/ideas.rs use the local helper has_moderate_permission(auth, permission), which passes when either the user-scope or the admin-scope check succeeds — so a staff member with ideas:moderate_* on an admin role does not also need a user role carrying it.
Cache
| Property | Value |
|---|---|
| Redis key | lumio:user_perms:{user_id} |
| TTL | 300 seconds (5 minutes) |
| Type | JSON-encoded Vec<String> |
Invalidation — call RedisClient::invalidate_user_permissions(user_id) (or the invalidate_user_permission_cache wrapper in crates/lo-api/src/middleware/auth.rs) whenever:
- A user's role assignment changes.
- A user's permission overrides change.
- A user role's permission set changes.
An account permission override change invalidates the account cache (lumio:perms:{user_id}:{account_id}) via RedisClient::invalidate_permissions() instead — it never touches the user-scope entry.
Both helpers are defined in crates/lo-cache/src/client.rs.
User Role Permission Constants
User-scope permission constants live in crates/lo-auth/src/rbac.rs inside pub mod user {}:
pub mod user {
pub const IDEAS_READ: &str = "ideas:read";
pub const IDEAS_CREATE: &str = "ideas:create";
pub const IDEAS_EDIT: &str = "ideas:edit";
pub const IDEAS_DELETE: &str = "ideas:delete";
pub const IDEAS_VOTE: &str = "ideas:vote";
pub const IDEAS_COMMENT_READ: &str = "ideas:comment_read";
pub const IDEAS_COMMENT_CREATE: &str = "ideas:comment_create";
pub const IDEAS_COMMENT_EDIT: &str = "ideas:comment_edit";
pub const IDEAS_COMMENT_DELETE: &str = "ideas:comment_delete";
pub const IDEAS_COMMENT_VOTE: &str = "ideas:comment_vote";
/// Returns all user-scoped permission strings.
pub fn get_all_user_permissions() -> Vec<&'static str> { /* ... */ }
}
get_all_user_permissions() is the registry for this scope — the nine strings above and nothing else. It is also exactly the set a custom user role may be assigned (get_all_assignable_user_role_permissions()): the ideas:moderate_* strings are admin-scope, live in pub mod global {}, and are not assignable to a user role. The seeded moderator role still grants them (it is is_system and immutable), but a user-roles:edit holder cannot mint or edit a custom role carrying ideas:moderate_* — that would let them self-assign platform-wide idea moderation without holding the admin permission. If a per-account idea-moderation need arises it must be a distinct account-scoped ideas:* permission, never the global operator string.
Always use the constants in code rather than string literals to prevent typos.
API
User Role CRUD (Admin)
The user role management API is admin-scoped. Reads are gated on user-roles:read; creating a role requires user-roles:create, deleting one requires user-roles:delete, and updating/assigning/overriding require user-roles:edit. The three write actions are distinct permissions — holding user-roles:edit alone does not let you create or delete roles.
GraphQL
The user-role resolvers are not prefixed with admin — they are guarded by AdminPermissionGuard, which is what makes them admin-only.
| Query / Mutation | Permission | Description |
|---|---|---|
userRoles | user-roles:read | List all user roles with their permissions |
userRoleAssignments(userId: UUID!) | user-roles:read | List a user's role assignments |
userRoleMembers(roleId: UUID!) | user-roles:read | List the users holding a given role (role → members) |
userPermissionOverrides(userId: UUID!) | user-roles:read | List a user's permission overrides |
resolvedUserPermissions(userId: UUID!) | user-roles:read | Effective user-scope permission set after role + overrides |
createUserRole(input: CreateUserRoleInput!) | user-roles:create | Create a custom user role |
updateUserRole(id: UUID!, input: UpdateUserRoleInput!) | user-roles:edit | Update role name, description, permissions |
deleteUserRole(id: UUID!) | user-roles:delete | Delete a custom role (system roles are rejected) |
assignUserRole(userId: UUID!, roleId: UUID!) | user-roles:edit | Assign a user role |
removeUserRole(userId: UUID!, roleId: UUID!) | user-roles:edit | Remove a role assignment |
setUserPermissionOverride(userId: UUID!, permission: String!, granted: Boolean!, reason: String) | user-roles:edit | Set a per-user override |
removeUserPermissionOverride(userId: UUID!, permission: String!) | user-roles:edit | Remove a per-user override |
REST
The REST twins are not under an /admin path prefix — they are gated by require_admin_permission() in the handler:
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/user-roles | user-roles:read | List user roles |
POST | /v1/user-roles | user-roles:create | Create a role |
PATCH | /v1/user-roles/{id} | user-roles:edit | Update a role |
DELETE | /v1/user-roles/{id} | user-roles:delete | Delete a role |
GET | /v1/user-roles/{id}/members | user-roles:read | List the users holding a given role (role → members) |
GET | /v1/users/{user_id}/roles | user-roles:read | List a user's role assignments |
POST | /v1/users/{user_id}/roles | user-roles:edit | Assign a role |
DELETE | /v1/users/{user_id}/roles/{role_id} | user-roles:edit | Remove a role assignment |
GET | /v1/users/{user_id}/permission-overrides | user-roles:read | List a user's overrides |
PUT | /v1/users/{user_id}/permission-overrides | user-roles:edit | Set an override (permission in the body) |
DELETE | /v1/users/{user_id}/permission-overrides/{permission} | user-roles:edit | Remove an override |
GET | /v1/users/{user_id}/permissions/resolved | user-roles:read | Effective permission set after role + user overrides + account overrides, computed without writing to cache |
Per-user role assignments and per-user overrides are gated on user-roles:read / user-roles:edit — not on the per-account override permissions (accounts:overrides-read / accounts:overrides-edit).
Per-Account Permission Overrides (Admin)
These live on the admin surface (apps/api/src/{routes,graphql}/admin.rs) and are gated on the per-account override permissions — accounts:overrides-read (list) and accounts:overrides-edit (set / remove).
| GraphQL | REST | Description |
|---|---|---|
adminAccountPermissionOverrides(accountId: UUID!) | GET /v1/admin/accounts/{id}/permission-overrides | List overrides for an account |
adminSetAccountPermissionOverride(accountId: UUID!, permission: String!, granted: Boolean!, reason: String) | PUT /v1/admin/accounts/{id}/permission-overrides | Set an override (permission in the body) |
adminRemoveAccountPermissionOverride(accountId: UUID!, permission: String!) | DELETE /v1/admin/accounts/{id}/permission-overrides/{permission} | Remove an override |
The permission string on the set path is validated against lo_auth::rbac::account::get_all_account_permissions(), so an unknown key is rejected rather than silently stored.
Admin Permissions
| Permission | Description |
|---|---|
user-roles:read | View user roles, their permissions, and user assignments in the admin panel |
user-roles:create | Create custom user roles |
user-roles:edit | Update custom user roles; assign or remove role assignments; set and remove per-user permission overrides |
user-roles:delete | Delete custom user roles |
accounts:overrides-read | Read per-account permission overrides (GET /v1/admin/accounts/{id}/permission-overrides) |
accounts:overrides-edit | Set and remove per-account permission overrides (PUT / DELETE /v1/admin/accounts/{id}/permission-overrides) |
These are admin-scope permissions, defined in crates/lo-auth/src/rbac.rs::global and enforced via AdminPermissionGuard / require_admin_permission().
global also declares user-roles:create and user-roles:delete, and a migration seeds them onto admin roles, but no handler currently checks them — role creation and deletion both gate on user-roles:edit on REST and GraphQL alike. Do not rely on the finer pair as an access boundary.
Key Files
| File | Purpose |
|---|---|
crates/lo-auth/src/rbac.rs | User-scope permission constants (pub mod user {}) + get_all_user_permissions() |
crates/lo-auth/src/context.rs | require_user_permission() and user_permissions() on AuthContext |
crates/lo-api/src/middleware/auth.rs | Resolves + caches all three permission scopes per request |
crates/lo-cache/src/client.rs | cache_user_permissions() / invalidate_user_permissions() Redis helpers |
apps/api/src/db/account_overrides.rs | CRUD for account_permission_overrides |
apps/api/src/graphql/user_roles.rs | GraphQL queries and mutations for user roles, assignments, overrides |
apps/api/src/routes/user_roles.rs | REST handlers for user roles, assignments, overrides |
apps/api/src/db/user_roles.rs | DB operations: CRUD, assignment, override management, permission resolution |
apps/api/migrations/20260506000001_create_user_roles.up.sql | Tables for roles, permissions, assignments, and per-user overrides |
apps/api/migrations/20260506000002_seed_default_user_roles.up.sql | Seeds member / restricted / moderator and their permission sets |
apps/api/migrations/20260506000010_create_account_permission_overrides.up.sql | account_permission_overrides table |
apps/admin/src/app/(admin)/user-roles/ | Admin panel pages for user role management |