Permissions
Introduction
Section titled “Introduction”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.
Installation
Section titled “Installation”pnpm add @warden/permissionsThis 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:
pnpm warden make:migration add_permissionspnpm warden db:migrateThe 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();Two lanes of permissions
Section titled “Two lanes of permissions”Before diving in, it’s worth understanding how the two permission systems coexist. Warden gives you two decorators, each serving a different purpose:
| Decorator | Source | Checks |
|---|---|---|
@permissions() | @warden/core | Discord’s built-in permissions (channel/role-based) |
@gate() | @warden/permissions | Your 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 permsexport 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.
Defining permissions and roles
Section titled “Defining permissions and roles”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 @gate() decorator
Section titled “The @gate() decorator”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.
The Gate injectable
Section titled “The Gate injectable”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 permissionsawait gate.allows(userId, guildId, 'moderation.ban') // booleanawait gate.denies(userId, guildId, 'karma.reset') // boolean
// Managing role assignmentsawait gate.assign(userId, guildId, 'moderator') // assign a roleawait gate.revoke(userId, guildId, 'moderator') // revoke a role
// Boundaries (see below)await gate.boundary(guildId, 'free') // set a guild's tier
// Debuggingawait gate.explain(userId, guildId, 'moderation.ban') // detailed evaluation traceAssigning and revoking roles
Section titled “Assigning and revoking roles”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.`) ]}); }}Per-guild scoping
Section titled “Per-guild scoping”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 independentawait 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.
Wildcard matching
Section titled “Wildcard matching”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 permissionawait gate.allows(userId, guildId, 'karma.*')Deny-wins evaluation
Section titled “Deny-wins evaluation”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:
- Collect all roles assigned to the user in this guild
- Collect all
grantanddenyentries across those roles - If any role explicitly denies the permission, the result is denied
- If any role grants the permission (and none deny it), the result is allowed
- 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 moderatorawait gate.allows(userId, guildId, 'karma.reset') // false — denied by karma-restrictedThis makes it safe to layer restrictive roles on top of permissive ones without worrying about grant order.
Boundaries
Section titled “Boundaries”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.
Debugging with explain()
Section titled “Debugging with explain()”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.banUser: 123456789 in guild 987654321Boundary: 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.banUser: 123456789 in guild 111222333Boundary: 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.
Practical examples
Section titled “Practical examples”Full moderation role setup
Section titled “Full moderation role setup”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.*']);});Per-guild role assignment command
Section titled “Per-guild role assignment command”@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, }); }}