Skip to main content

Permissions & RBAC

The Discord bot integrates with Heimdall's RBAC (Role-Based Access Control) system to enforce permissions on commands using Poise's check system.

Permission Model

Discord-Specific Permissions

Discord commands use granular permissions for fine-grained access control:

Dashboard Access Permissions

PermissionDescription
discord:readMain Discord access (sidebar, guilds list, bot settings view)
discord:editEdit Discord bot settings
discord:deleteDelete Discord guilds from the database

Bot Command Permissions

PermissionDescription
discord:bot.adminAccess to Discord bot admin commands (config, settings)

Guild-Level Permissions

PermissionDescription
discord:guild.readView Discord guild data, members, channels, and roles
discord:guild.editEdit Discord guild data and member information
discord:guild.syncResync data for a specific guild (channels, roles, members)

Moderation Permissions

PermissionDescription
discord:guild.warnWarn Discord members (via webapp or bot commands)
discord:guild.timeoutTimeout Discord members (via webapp or bot commands)
discord:guild.kickKick Discord members from server (via webapp or bot commands)
discord:guild.banBan Discord members from server (via webapp or bot commands)

Global Sync Permissions

PermissionDescription
discord:syncSync all guilds (discovery of new guilds)

Permission Hierarchy

  • Public Commands - No permission required, available to all users
  • Moderation Commands - Require specific granular permission (discord:guild.warn, discord:guild.kick, etc.)
  • Sync Commands - Require discord:sync for global sync or discord:guild.sync for guild-specific sync
  • Admin Commands - Require discord:bot.admin

How It Works

Account Linking

Users must link their Discord account to their Heimdall platform account to use protected commands. The linking flow:

  1. User initiates linking in Heimdall ID (id.elcto.com)
  2. User authorizes via Discord OAuth
  3. Discord user ID is stored in Heimdall
  4. Bot can now check permissions via API

Permission Check Flow

User invokes command


┌───────────────────┐
│ Is command public?│
└────────┬──────────┘

Yes │ No
▼ ▼
Allow ┌───────────────────────┐
│ Get Discord user ID │
└───────────┬───────────┘


┌───────────────────────┐
│ Query Heimdall API │
│ (check permission) │
└───────────┬───────────┘

┌──────┴──────┐
│ │
Has perm No perm / Not linked
│ │
▼ ▼
Allow Deny + Error message

Implementation with Poise

RBAC Client

use moka::future::Cache;
use std::time::Duration;

#[derive(Debug)]
pub struct RbacClient {
graphql_client: Arc<GraphqlClient>,
cache: PermissionCache,
}

#[derive(Debug)]
pub struct PermissionCache {
cache: Cache<(u64, String), bool>,
}

impl RbacClient {
pub fn new(graphql_client: Arc<GraphqlClient>) -> Self {
Self {
graphql_client,
cache: PermissionCache::new(),
}
}

/// Check if Discord user has a permission.
/// Returns Ok(true) if has permission, Ok(false) if not.
pub async fn check_permission(
&self,
discord_user_id: u64,
permission: &str,
) -> Result<bool, BotError> {
let cache_key = (discord_user_id, permission.to_string());

// Check cache first
if let Some(cached) = self.cache.get(&cache_key).await {
return Ok(cached);
}

// Query Heimdall API
let has_permission = self.query_permission(discord_user_id, permission).await?;

// Cache result
self.cache.insert(cache_key, has_permission).await;

Ok(has_permission)
}

async fn query_permission(
&self,
discord_user_id: u64,
permission: &str,
) -> Result<bool, BotError> {
let query = r#"
query CheckDiscordPermission($discordId: String!, $permission: String!) {
checkDiscordUserPermission(discordId: $discordId, permission: $permission) {
hasPermission
isLinked
}
}
"#;

let result: PermissionCheckResult = self.graphql_client.query(
query,
Some(serde_json::json!({
"discordId": discord_user_id.to_string(),
"permission": permission
})),
).await?;

if !result.is_linked {
return Ok(false); // Not linked = no permission
}

Ok(result.has_permission)
}
}

Poise Permission Check Functions

With Poise, permission checks use the check attribute on commands:

use crate::bot::data::{Context, Error};

/// Check if user has admin permission
async fn check_admin_permission(ctx: Context<'_>) -> Result<bool, Error> {
let user_id = ctx.author().id.get();
let rbac = &ctx.data().rbac_client;

match rbac.check_permission(user_id, "discord:bot.admin").await {
Ok(has_permission) => Ok(has_permission),
Err(e) => {
tracing::warn!(
user_id = user_id,
error = %e,
"Failed to check admin permission"
);
Ok(false) // Fail closed on error
}
}
}

/// Check if user has kick permission (granular moderation)
async fn check_kick_permission(ctx: Context<'_>) -> Result<bool, Error> {
let user_id = ctx.author().id.get();
let rbac = &ctx.data().rbac_client;

match rbac.check_permission(user_id, "discord:guild.kick").await {
Ok(has_permission) => Ok(has_permission),
Err(e) => {
tracing::warn!(
user_id = user_id,
error = %e,
"Failed to check kick permission"
);
Ok(false)
}
}
}

Applying Permission Checks to Commands

Commands use the check attribute to require permission:

/// Admin command - requires discord:bot.admin
#[poise::command(
slash_command,
prefix_command,
check = "check_admin_permission",
category = "Admin"
)]
pub async fn config(ctx: Context<'_>) -> Result<(), Error> {
// Only users with discord:bot.admin can run this
ctx.say("Configuration panel...").await?;
Ok(())
}

/// Moderation command with Discord permission requirement
#[poise::command(
slash_command,
prefix_command,
check = "check_kick_permission", // Heimdall RBAC: discord:guild.kick
guild_only,
required_permissions = "KICK_MEMBERS", // Also requires Discord permission
category = "Moderation"
)]
pub async fn kick(
ctx: Context<'_>,
#[description = "User to kick"] user: serenity::User,
#[description = "Reason for kicking"] reason: Option<String>,
) -> Result<(), Error> {
// Requires both Heimdall permission AND Discord KICK_MEMBERS
let reason_text = reason.unwrap_or_else(|| "No reason provided".to_string());
// ... kick logic
Ok(())
}

/// Public command - no check attribute needed
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
// Anyone can run this
ctx.say("Pong!").await?;
Ok(())
}

Handling Check Failures

Poise provides the CommandCheckFailed error variant for handling permission denials:

async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
match error {
poise::FrameworkError::CommandCheckFailed { error, ctx, .. } => {
if let Some(error) = error {
tracing::warn!(
command = ctx.command().name,
user = %ctx.author().id,
error = %error,
"Command check failed"
);
}
// Send localized permission denied message
let _ = ctx.say(t!("errors.permission_denied")).await;
}
// ... other error handlers
_ => {}
}
}

Data Struct with RBAC Client

The RBAC client is stored in the Poise Data struct:

use std::sync::Arc;

#[derive(Clone, Debug)]
pub struct Data {
pub settings: Arc<Settings>,
pub graphql_client: Arc<GraphqlClient>,
pub rest_client: Arc<RestClient>,
pub ws_client: Arc<WebSocketClient>,
pub rbac_client: Arc<RbacClient>, // Permission checking
pub start_time: std::time::Instant,
}

impl Data {
pub fn new(settings: Arc<Settings>) -> Self {
let graphql_client = Arc::new(GraphqlClient::new(
&settings.api.graphql_endpoint,
&settings.api.api_key,
));

Self {
rbac_client: Arc::new(RbacClient::new(Arc::clone(&graphql_client))),
graphql_client,
// ... other fields
}
}
}

Database Permissions

The Discord RBAC permissions are set up via database migrations:

All Permissions

Permission IDPermissionDescription
perm_discord_readdiscord:readMain Discord access (sidebar, guilds list, bot settings)
perm_discord_editdiscord:editEdit Discord bot settings
perm_discord_deletediscord:deleteDelete Discord guilds from the database
perm_discord_syncdiscord:syncSync all guilds (discovery)
perm_discord_bot_admindiscord:bot.adminAdmin bot commands (config, settings)
perm_discord_guild_readdiscord:guild.readView guild data, members, channels, roles
perm_discord_guild_editdiscord:guild.editEdit guild data and member information
perm_discord_guild_syncdiscord:guild.syncSync guild-specific data
perm_discord_guild_warndiscord:guild.warnWarn users
perm_discord_guild_kickdiscord:guild.kickKick users
perm_discord_guild_bandiscord:guild.banBan users
perm_discord_guild_timeoutdiscord:guild.timeoutTimeout (mute) users

Role Assignments

RolePermissions
Super AdminAll (via *:* wildcard)
AdminAll Discord permissions (via *:* wildcard)
Moderatordiscord:read, discord:guild.* (view + moderation + sync), NOT discord:edit or discord:delete
DeveloperNone (developers don't have Discord access by default)

Running the Migration

# Run all pending migrations
just migrate

# Or manually
sqlx migrate run --source platform/migrations

Manual Assignment (Optional)

You can also assign permissions via GraphQL:

mutation AssignDiscordPermission {
assignPermissionToRole(
roleId: "role_custom"
permissionId: "perm_discord_command_moderate"
) {
success
}
}

Caching

Permissions are cached for 5 minutes to reduce API calls. The cache:

  • Uses Moka for async caching
  • Stores up to 10,000 entries
  • TTL of 5 minutes per entry
  • Key: (discord_user_id, permission)
  • Value: bool (has permission)
impl PermissionCache {
pub fn new() -> Self {
Self {
cache: Cache::builder()
.time_to_live(Duration::from_secs(300)) // 5 minute TTL
.max_capacity(10_000)
.build(),
}
}

pub async fn get(&self, key: &(u64, String)) -> Option<bool> {
self.cache.get(key).await
}

pub async fn insert(&self, key: (u64, String), value: bool) {
self.cache.insert(key, value).await;
}

pub async fn invalidate(&self, key: &(u64, String)) {
self.cache.invalidate(key).await;
}
}

Error Messages

Localized error messages for permission failures:

# en.yml
errors:
permission_denied: "You don't have permission to use this command."
not_linked: "Your Discord account is not linked to a Heimdall account."

# de.yml
errors:
permission_denied: "Du hast keine Berechtigung für diesen Befehl."
not_linked: "Dein Discord-Konto ist nicht mit einem Heimdall-Konto verknüpft."

Combining Permissions

You can combine Heimdall RBAC with Discord's native permissions:

#[poise::command(
slash_command,
prefix_command,
// Heimdall RBAC check (granular permission)
check = "check_kick_permission",
// Discord permission requirements
required_permissions = "KICK_MEMBERS",
// Must be in a guild
guild_only,
category = "Moderation"
)]
pub async fn kick(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> {
// User must have:
// 1. discord:guild.kick in Heimdall
// 2. KICK_MEMBERS in Discord
// 3. Be in a server (not DM)
Ok(())
}