Back to blog
From Four Roles to a Permission System
permissionsrolesmoderation

From Four Roles to a Permission System

Four hardcoded roles became 41 permissions a channel can change. The hard part was hiding a channel properly.

Sivert·

Title: From four roles to a permission system
Date: 8 September 2026
Author: Sivert Gullberg Hansen
Keywords: permissions, roles, access control, channel overwrites, upgrades

Abstract

For most of Gryt's life there were four hardcoded roles, and what each could do was written into whichever handler needed to know. Reading those handlers turned up checks that disagreed with each other and checks that were missing. One let an admin pull the owner out of a call, and one handed the member list to any socket that asked. They were fixed in 1.3.1. The replacement is 41 permissions and a rank per role, five default roles, and 13 permissions a channel can allow or deny. Upgrades only ever grant, so a new permission can't lock a server out of itself. Hiding a channel properly took a leak test of thirteen cases, four of which the first pass missed. Bots, roles that follow you between servers, and taking back what someone has already read are out of scope.

1 Introduction

For most of Gryt's life there were four roles: owner, admin, mod, member. They were hardcoded, they were a ladder, and what each one could do was written into whichever handler happened to need to know. If you wanted a role for people who can post but not join voice, you couldn't have one. If you wanted #rules to be read-only, you couldn't have that either.

This post answers three questions:

  1. What was wrong with the old checks?
  2. How should roles and permissions work so that a server can shape them, and upgrades can't break them?
  3. What does it take to hide a channel, rather than grey it out?

2 The old checks

A Gryt channel called General with a conversation about running a server on a Raspberry Pi, owl avatars beside each name, and a member list on the rightA Gryt channel called General with a conversation about running a server on a Raspberry Pi, owl avatars beside each name, and a member list on the right

Figure 1: A channel on a Gryt server, with the member list on the right.

The rule about who may act on whom was copied by hand into kick, ban, mute and deafen. Four verbatim copies of the same few lines, and they read the roles table directly. The gate that had already let the request in read something else, which also honoured the owner named in the server config. So an owner whose roles row said admin got past the door and was then refused by the handler, on their own server.

Elsewhere the check wasn't there at all:

  • Voice disconnect had no target check, so an admin could pull the owner out of a call.
  • Resolving a report with delete-all-and-ban had none either, so the reports panel could ban the owner while the ban handler two files away refused to.
  • The member list had no auth check whatsoever. Any socket that had connected could ask for it and get everybody.

Two builders were also emitting the member list (Figure 1) with the same seventeen fields and disagreeing about five of them. So if you had two clients open, the moderation menu showed "Server mute" or "Remove server mute" depending on which one had answered most recently.

All of that was fixed in 1.3.1 [2], in seven passes, and there are tests standing on it now.

3 Design

3.1 A role is a set of permissions and a rank

There are 41 permissions. Sending a message is one. Attaching a file is another. Editing your own message is separate from editing somebody else's, deafening is separate from muting, reading the reports queue is separate from acting on what's in it, and starting a call is separate from joining one. That last one is deliberate: gating both would leave somebody unable to answer a call placed to them.

Every role carries two things: that set, and a rank. The permissions say what you can do. The rank says who you can do it to. Keeping them apart is why you can have a role that sits high enough to kick people and holds almost nothing else.

Table 1 lists the five roles every server ships with. They're spaced so a role of your own slots between two of them without renumbering anything. You can rename them, recolour them and change what they hold. You can't delete them, because the defaults and the owner fall back to them.

Table 1: The default roles.

RoleRank
Owner100
Admin80
Moderator60
Member40
Guest10

Four permissions stay out of Admin's set: managing roles, managing the server, replacing an identity, and answering a bot at the door. An admin who could grant manage_roles could hand themselves every other permission on the list.

3.2 Upgrading a server that already has people on it

Adding a permission to the list doesn't change a single stored role. That's fine until you add read_messages. Before it existed, anybody admitted to the server could read. The moment reading is a permission, every role that already exists holds a set that doesn't contain it. The release that made reading a permission would have left an entire server unable to read anything.

So each permission records what it was carved out of, or that it had no gate before. On upgrade, a role holding the old one gets the new one, and a permission that had no gate goes to everybody. It only grants. Nothing in that table takes a permission away, so whatever the person running the server has chosen survives an upgrade untouched. Six batches of it so far, and there'll be more.

3.3 Per-channel permissions

#rules is what forced the next step. A public server where anyone can post in the rules isn't a public server you can launch.

Full per-channel overwrites were the honest fix and they weren't small, so I did the cheap one first: a minimum rank to post, as one integer on the channel, checked next to the existing send-messages gate. That shipped in 1.6.17 and it unblocked read-only and staff-only channels without building the model underneath. The client half arrived a release later, which meant that for a while you could stand in #rules, see a normal composer, type a message and get told the channel was read-only for your role. It worked and it wasn't good. The refusal should be visible before you've typed anything.

1.8.0 replaced that. A channel can now have an opinion about 13 of the 41 permissions: the ones that mean something in one room. Banning somebody means the same thing wherever you're standing, so it isn't on the list.

A rule is allow or deny. There's no third value for inherit, because inherit is the absence of a rule. A channel stores only what it changes, so hiding a channel from three roles is three rows rather than thirteen permissions times every role. It also means a permission I add next year needs no backfill here: every channel that exists already inherits it, which is both the right default and the safe one.

Allow isn't the same as inheriting. It grants a permission the role doesn't hold across the server, which is how one channel lets a role post when it can't post anywhere else.

3.4 Holding more than one role

Until 1.8.5 the roles table had the member id as its primary key, so holding a second role wasn't representable. Mapping our own Discord's eight roles onto Gryt meant collapsing three of them into one, because they couldn't stack.

You can hold several now. Permissions add up across them and your rank is the highest one you hold. Where two of your roles disagree about a channel, allow wins. Picking up a role shouldn't take something away, which is the rule Discord settled on [1] and the one people arrive expecting.

3.5 Roles that people earn automatically

A role can ask for a number of days and a number of messages before it grants itself. Set both and both have to be true. Time on its own lands a trusted tier on an account that signed up a month ago and never said a word, which is exactly who you didn't mean. It only ever promotes. Nothing here takes a role away from anybody.

4 Results: hiding a channel

Denying read_messages on a channel doesn't grey it out. The server stops naming it. It's absent from the server details, absent from the sidebar, and asking for it by id gets the same answer as asking for a channel that was never created.

Getting that right meant finding every path a channel id can travel out on, and the obvious ones weren't the problem. There are thirteen cases in the leak test now. Table 2 has the four I missed on the first pass. I wrote them down as their own task rather than pretend the job was done.

Table 2: Leaks the first pass missed.

WhereHow it leaked
Webhook listA webhook row names the channel it posts into, so the list hands over the id of anything hidden
Audit logAn entry's target, for a channel action, is a channel id. Reading the audit log is its own permission and can sit well below any channel gate, so somebody at rank 40 could read the id of a channel gated at 80
ReportsA report snapshots the conversation it came from, so a moderator queue names a hidden channel to whoever is working it
Server configsystem_channel_id is one field pointing at a channel that might be gated

None of those is a channel list. They're all places a channel id ended up as a side effect of something else.

5 Discussion

5.1 What the permission system doesn't cover

  • Bots don't hold roles at all. A bot knocks, whoever runs the server decides what it may do once, and that's its whole permission set, so no role edit can widen one by accident. What it asked for is written down and never rewritten, because a bot coming back asking for more is asking a question that's already been answered.
  • Roles are per server, and they always will be. There's no notion of a role that follows you somewhere else.
  • None of this is retroactive against somebody who has already read a channel. If a channel was open and you close it, you've stopped them reading it from now on. You haven't unsent anything.

5.2 Future work

Pins, search and mentions join the leak test the day they exist, since each is a new way for a channel id to travel.

5.3 Where to find it in the app

Server settings → Role editor for the roles and what they hold, and Server settings → Channel permissions for what a single channel changes. Both need manage_roles, which is owner-only until you decide otherwise.

6 Conclusion

What was wrong with the old checks? The same rule lived in four copies that read a different source than the gate in front of them, and some handlers had no check at all. Fixed in 1.3.1.

How should roles and permissions work? As a set of permissions plus a rank, kept apart. Channels store only the allow and deny rules they change. Upgrades only grant, so a new permission can't lock a server out of itself.

What does it take to hide a channel? Treating every channel id leaving the server as a leak, not only channel lists. Thirteen cases are tested now, four of them from places nobody would think of as a channel list.

References

  1. Discord. Permissions. https://discord.com/developers/docs/topics/permissions
  2. Gryt changelog. gryt.chat/changelog