Skip to content

Permissions

Discord’s built-in permission system is great for channel-level access control — who can send messages, manage channels, kick members. But the moment your bot has its own concepts — who can reset karma, who can view audit logs, who can configure auto-mod settings — you need something more.

@warden/permissions gives you a full-featured authorization system inspired by AWS IAM’s policy model. You define custom permissions, group them into roles, assign those roles per guild, and check them declaratively with a single decorator. It works alongside Discord’s built-in permissions, not instead of them.

Terminal window
pnpm add @warden/permissions

This plugin requires @warden/drizzle because role assignments are stored per user per guild.

Role definitions (what each role grants) live in config/permissions.ts — application policy, owned by code. Member→role assignments (who holds which role, optionally scoped to a guild) persist in the warden_permission_assignments table via drizzle. PermissionsServiceProvider.boot() wires the Gate to the drizzle instance automatically.

warden make:migration auto-includes this plugin’s schema, so you don’t need to publish a separate migration stub:

Terminal window
pnpm warden make:migration add_permissions
pnpm warden db:migrate

The starter ships with an initial migration that already covers this.

import {Bot} from '@warden/core';
import {DrizzleServiceProvider} from '@warden/drizzle';
import {PermissionsServiceProvider} from '@warden/permissions';
new Bot()
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts'))
.plugins([DrizzleServiceProvider, PermissionsServiceProvider])
.intents(i => i.defaults())
.partials(p => p.defaults())
.start();

Before diving in, it’s worth understanding how the two permission systems coexist. Warden gives you two decorators, each serving a different purpose:

DecoratorSourceChecks
@permissions()@warden/coreDiscord’s built-in permissions (channel/role-based)
@gate()@warden/permissionsYour bot’s custom permissions (database-backed)

You’ll often use them together:

@command({parent: 'mod', name: 'ban', description: 'Ban a user'})
@permissions({user: ['BanMembers'], bot: ['BanMembers']}) // Discord perms
@gate(['moderation.ban']) // Custom perms
export default class ModBanCommand extends Subcommand { ... }

The @permissions() check runs first — if the user doesn’t have Discord’s BanMembers permission, they’re rejected before the custom gate is even evaluated. Think of Discord permissions as the foundation and custom gates as the fine-grained layer on top.

Define your permission structure using the fluent Permissions.define() builder. This is typically done in a setup file or your bootstrap:

import {Permissions} from '@warden/permissions';
Permissions.define((builder) => {
// Define individual permissions
builder.permission('moderation.warn', 'Issue warnings to members');
builder.permission('moderation.mute', 'Mute members');
builder.permission('moderation.ban', 'Ban members');
builder.permission('moderation.history', 'View moderation history');
builder.permission('moderation.config', 'Change moderation settings');
builder.permission('karma.view', 'View karma scores');
builder.permission('karma.give', 'Give karma to others');
builder.permission('karma.reset', 'Reset a user\'s karma');
builder.permission('audit.view', 'View the audit log');
builder.permission('audit.export', 'Export audit log data');
// Define roles with granted and denied permissions
builder.role('helper', 'Helper')
.grant(['moderation.warn', 'moderation.history', 'karma.view', 'karma.give']);
builder.role('moderator', 'Moderator')
.grant([
'moderation.warn',
'moderation.mute',
'moderation.history',
'karma.view',
'karma.give',
'karma.reset',
'audit.view',
]);
builder.role('admin', 'Administrator')
.grant(['moderation.*', 'karma.*', 'audit.*']);
});

Roles are cumulative by default — a moderator has everything a helper has, plus more. But you can also explicitly deny specific permissions on a role:

builder.role('trial-moderator', 'Trial Moderator')
.grant(['moderation.warn', 'moderation.mute', 'moderation.history'])
.deny(['moderation.ban', 'moderation.config']);

The simplest way to check custom permissions is the @gate() decorator. Place it on any command, button, modal, or context menu:

@command({parent: 'mod', name: 'warn', description: 'Warn a user'})
@gate(['moderation.warn'])
export default class ModWarnCommand extends Subcommand {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
// Only runs if the user has the moderation.warn permission
}
}

You may require multiple permissions — all must be granted:

@command({parent: 'mod', name: 'config', description: 'Configure moderation'})
@gate(['moderation.config', 'moderation.history'])
export default class ModConfigCommand extends Subcommand { ... }

When a gate check fails, the user receives an ephemeral “You don’t have permission to do that” message. The framework handles this automatically.

For cases where you need more control than a decorator provides — conditional checks, runtime decisions, or manual role management — inject the Gate service:

import {Gate} from '@warden/permissions';
@command({parent: 'mod', name: 'history', description: 'View moderation history'})
@gate(['moderation.history'])
export default class ModHistoryCommand extends Subcommand {
constructor(private gate: Gate, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const cases = await this.caseService.getCases(interaction.guildId!);
// Check if the user can see full details (including moderator notes)
const canSeeNotes = await this.gate.allows(
interaction.user.id,
interaction.guildId!,
'moderation.config',
);
const embed = canSeeNotes
? this.caseService.formatDetailed(cases)
: this.caseService.formatSummary(cases);
await interaction.followUp({embeds: [embed]});
}
}

The full Gate API:

// Checking permissions
await gate.allows(userId, guildId, 'moderation.ban') // boolean
await gate.denies(userId, guildId, 'karma.reset') // boolean
// Managing role assignments
await gate.assign(userId, guildId, 'moderator') // assign a role
await gate.revoke(userId, guildId, 'moderator') // revoke a role
// Boundaries (see below)
await gate.boundary(guildId, 'free') // set a guild's tier
// Debugging
await gate.explain(userId, guildId, 'moderation.ban') // detailed evaluation trace

You’ll typically manage role assignments through a command:

@command({parent: 'mod', name: 'promote', description: 'Promote a user to a role'})
@gate(['moderation.config'])
export default class ModPromoteCommand extends Subcommand {
constructor(private gate: Gate) {}
public options = [
Options.user('user', 'User to promote').required(),
Options.string('role', 'Role to assign').required().choices([
{name: 'Helper', value: 'helper'},
{name: 'Moderator', value: 'moderator'},
{name: 'Administrator', value: 'admin'},
]),
];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const role = interaction.options.getString('role')!;
await this.gate.assign(target.id, interaction.guildId!, role);
await interaction.followUp({embeds: [
Embed.success(`${target.username} has been assigned the ${role} role.`)
]});
}
}

All permission checks and role assignments are scoped to a guild. A user can be an admin in one server and a helper in another — or have no custom role at all in a third. There’s nothing extra to configure; this is how it works by default.

// These are completely independent
await gate.assign(userId, 'guild-a', 'admin');
await gate.assign(userId, 'guild-b', 'helper');
await gate.allows(userId, 'guild-a', 'moderation.ban') // true (admin)
await gate.allows(userId, 'guild-b', 'moderation.ban') // false (helper)

This is essential for bots that operate in multiple servers — a user’s authority in one community shouldn’t bleed into another.

Permissions support wildcard matching with *. This is especially useful for admin-level grants:

builder.role('admin', 'Administrator')
.grant(['moderation.*', 'karma.*', 'audit.*']);

The pattern moderation.* matches moderation.warn, moderation.ban, moderation.config, and any other permission under the moderation namespace. You can use wildcards in both role definitions and gate checks:

// Grant all moderation permissions
.grant(['moderation.*'])
// Check if the user has any karma permission
await gate.allows(userId, guildId, 'karma.*')

When evaluating permissions, deny always wins. If a user has two roles and one grants karma.reset while the other denies it, the deny takes precedence. This follows the principle of least privilege and matches how AWS IAM evaluates policies.

The evaluation order is:

  1. Collect all roles assigned to the user in this guild
  2. Collect all grant and deny entries across those roles
  3. If any role explicitly denies the permission, the result is denied
  4. If any role grants the permission (and none deny it), the result is allowed
  5. If no role mentions the permission at all, the result is denied (implicit deny)
builder.role('moderator', 'Moderator')
.grant(['karma.*']);
builder.role('karma-restricted', 'Karma Restricted')
.deny(['karma.reset']);
// A user with both roles:
await gate.allows(userId, guildId, 'karma.view') // true — granted by moderator
await gate.allows(userId, guildId, 'karma.reset') // false — denied by karma-restricted

This makes it safe to layer restrictive roles on top of permissive ones without worrying about grant order.

Boundaries let you set permission ceilings per guild. This is perfect for bots that offer tiered plans — free servers might get basic moderation while premium servers unlock advanced features.

Permissions.define((builder) => {
builder.boundary('free')
.allow(['moderation.warn', 'moderation.mute', 'moderation.history'])
.disallow(['moderation.ban', 'audit.*', 'karma.reset']);
builder.boundary('premium')
.allow(['moderation.*', 'karma.*', 'audit.*']);
});

Then set a guild’s boundary:

await gate.boundary(guildId, 'free');

Even if a user has the admin role with moderation.* granted, the boundary acts as a ceiling — in a free-tier guild, moderation.ban is still denied because the boundary disallows it.

When a permission check doesn’t behave the way you expect, explain() gives you a full trace of the evaluation:

const explanation = await gate.explain(userId, guildId, 'moderation.ban');
console.log(explanation);
Permission: moderation.ban
User: 123456789 in guild 987654321
Boundary: premium → ALLOWS moderation.ban
Roles:
moderator → GRANTS moderation.* (matches moderation.ban)
karma-restricted → no opinion
Result: ALLOWED
Matched by: moderator (grant: moderation.*)

Or when something is denied:

Permission: moderation.ban
User: 123456789 in guild 111222333
Boundary: free → DISALLOWS moderation.ban
Result: DENIED
Blocked by: boundary "free" (disallow: moderation.ban)
Note: Role evaluation was skipped because the boundary denied the permission.

This is invaluable during development and when server admins report that “the permissions aren’t working.” You can even expose explain() through a debug command for trusted administrators.

A complete role hierarchy:

Permissions.define((builder) => {
// Permissions
builder.permission('moderation.warn', 'Issue warnings');
builder.permission('moderation.mute', 'Mute members');
builder.permission('moderation.kick', 'Kick members');
builder.permission('moderation.ban', 'Ban members');
builder.permission('moderation.history', 'View moderation history');
builder.permission('moderation.config', 'Configure moderation settings');
builder.permission('karma.view', 'View karma');
builder.permission('karma.give', 'Give karma');
builder.permission('karma.reset', 'Reset karma');
builder.permission('audit.view', 'View audit logs');
builder.permission('audit.export', 'Export audit data');
// Roles
builder.role('helper', 'Helper')
.grant(['moderation.warn', 'moderation.history', 'karma.view', 'karma.give']);
builder.role('moderator', 'Moderator')
.grant(['moderation.warn', 'moderation.mute', 'moderation.kick', 'moderation.history', 'karma.*', 'audit.view']);
builder.role('admin', 'Administrator')
.grant(['moderation.*', 'karma.*', 'audit.*']);
// Boundaries for tiered servers
builder.boundary('free')
.allow(['moderation.warn', 'moderation.mute', 'moderation.history', 'karma.view', 'karma.give']);
builder.boundary('premium')
.allow(['moderation.*', 'karma.*', 'audit.*']);
});
@command({parent: 'mod', name: 'roles', description: 'Manage bot roles for a user'})
@permissions({user: ['Administrator']})
@gate(['moderation.config'])
export default class ModRolesCommand extends Subcommand {
constructor(private gate: Gate) {}
public options = [
Options.string('action', 'Assign or revoke').required().choices([
{name: 'Assign', value: 'assign'},
{name: 'Revoke', value: 'revoke'},
]),
Options.user('user', 'Target user').required(),
Options.string('role', 'Bot role').required().choices([
{name: 'Helper', value: 'helper'},
{name: 'Moderator', value: 'moderator'},
{name: 'Administrator', value: 'admin'},
]),
];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const action = interaction.options.getString('action')!;
const target = interaction.options.getUser('user')!;
const role = interaction.options.getString('role')!;
if (action === 'assign') {
await this.gate.assign(target.id, interaction.guildId!, role);
} else {
await this.gate.revoke(target.id, interaction.guildId!, role);
}
await interaction.followUp({embeds: [
Embed.success(`${action === 'assign' ? 'Assigned' : 'Revoked'} the ${role} role ${action === 'assign' ? 'to' : 'from'} ${target.username}.`)
]});
}
}

Conditional feature access based on permissions

Section titled “Conditional feature access based on permissions”
@command({parent: 'karma', name: 'leaderboard', description: 'View the karma leaderboard'})
@gate(['karma.view'])
export default class KarmaLeaderboardCommand extends Subcommand {
constructor(private gate: Gate, private karmaService: KarmaService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const leaderboard = await this.karmaService.getLeaderboard(interaction.guildId!);
// Only show the reset button if the user has permission
const canReset = await this.gate.allows(
interaction.user.id,
interaction.guildId!,
'karma.reset',
);
const components = canReset
? [Row(Btn.danger('Reset All', 'karma-reset'))]
: [];
await interaction.followUp({
embeds: [this.karmaService.formatLeaderboard(leaderboard)],
components,
});
}
}