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
| Permission | Description |
|---|---|
discord:read | Main Discord access (sidebar, guilds list, bot settings view) |
discord:edit | Edit Discord bot settings |
discord:delete | Delete Discord guilds from the database |
Bot Command Permissions
| Permission | Description |
|---|---|
discord:bot.admin | Access to Discord bot admin commands (config, settings) |
Guild-Level Permissions
| Permission | Description |
|---|---|
discord:guild.read | View Discord guild data, members, channels, and roles |
discord:guild.edit | Edit Discord guild data and member information |
discord:guild.sync | Resync data for a specific guild (channels, roles, members) |
Moderation Permissions
| Permission | Description |
|---|---|
discord:guild.warn | Warn Discord members (via webapp or bot commands) |
discord:guild.timeout | Timeout Discord members (via webapp or bot commands) |
discord:guild.kick | Kick Discord members from server (via webapp or bot commands) |
discord:guild.ban | Ban Discord members from server (via webapp or bot commands) |
Global Sync Permissions
| Permission | Description |
|---|---|
discord:sync | Sync 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:syncfor global sync ordiscord:guild.syncfor 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:
- User initiates linking in Heimdall ID (id.elcto.com)
- User authorizes via Discord OAuth
- Discord user ID is stored in Heimdall
- 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 ID | Permission | Description |
|---|---|---|
perm_discord_read | discord:read | Main Discord access (sidebar, guilds list, bot settings) |
perm_discord_edit | discord:edit | Edit Discord bot settings |
perm_discord_delete | discord:delete | Delete Discord guilds from the database |
perm_discord_sync | discord:sync | Sync all guilds (discovery) |
perm_discord_bot_admin | discord:bot.admin | Admin bot commands (config, settings) |
perm_discord_guild_read | discord:guild.read | View guild data, members, channels, roles |
perm_discord_guild_edit | discord:guild.edit | Edit guild data and member information |
perm_discord_guild_sync | discord:guild.sync | Sync guild-specific data |
perm_discord_guild_warn | discord:guild.warn | Warn users |
perm_discord_guild_kick | discord:guild.kick | Kick users |
perm_discord_guild_ban | discord:guild.ban | Ban users |
perm_discord_guild_timeout | discord:guild.timeout | Timeout (mute) users |
Role Assignments
| Role | Permissions |
|---|---|
| Super Admin | All (via *:* wildcard) |
| Admin | All Discord permissions (via *:* wildcard) |
| Moderator | discord:read, discord:guild.* (view + moderation + sync), NOT discord:edit or discord:delete |
| Developer | None (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(())
}