Esposter

RBAC

Discord-complexity role-based access control per room. Every privileged operation — moderation, settings, invites, webhooks — is gated through it.

How it works

Each room has roles (roomRoles) carrying a permissions bigint bitfield of RoomPermission flags. bigint (not number) lets the field grow past 32 bits; since TypeScript enums cannot hold bigints, RoomPermission is a const object of 1n << n values. A user's effective permissions are the SQL BIT_OR of the room's @everyone role plus every role assigned to them in usersToRoomRoles.

flowchart TD
    P["Procedure built with getPermissionsProcedure(permission, schema, roomIdKey)"] --> O{"Caller is room owner?<br/>(rooms.userId)"}
    O -->|yes| Allow[Allowed — owner bypasses everything]
    O -->|no| Q["BIT_OR(roomRoles.permissions)<br/>over @everyone + assigned roles"]
    Q --> A{"Administrator bit set?"}
    A -->|yes| Allow
    A -->|no| B{"Required permission bit set?"}
    B -->|yes| Allow
    B -->|no| Deny[FORBIDDEN]

Authority is layered: Owner (rooms.userId, immune to all role manipulation) → Administrator permission (all bits, bypasses hierarchy but not ownership) → explicit permission bits (subject to hierarchy) → @everyone baseline. The @everyone role is a real roomRoles row (isEveryone = true, one per room via partial unique index) applied implicitly to every member — it is never stored in usersToRoomRoles. createRoom seeds it in the same transaction.

Hierarchy prevents lower roles acting upward: a user's top position is the max position across explicitly assigned roles (owner = infinity), and role management or member-targeting actions require the actor's top position to exceed the target's (isManageable).

RoomPermission bits, in order: ReadMessages, SendMessages, ManageMessages, MentionEveryone, ManageRoom, ManageRoles, ManageInvites, KickMembers, BanMembers, MuteMembers, MoveMembers, ManageNicknames, ManageWebhooks, Administrator. Bit-ordering rule: Administrator stays the highest bit; new permissions are inserted before ManageWebhooks/Administrator (which shifts those bits and requires a data migration of stored values).

Data model

TableKey facts
roomRolesroomId FK, name, color, position (higher = more authority), permissions bigint, isEveryone
usersToRoomRoles(userId, roomId, roleId) composite PK — explicit role assignments

Service layer & guards

server/services/room/rbac/:

FunctionPurpose
getPermissions(db, userId, roomId)BIT_OR aggregate → bigint
hasPermission(db, userId, roomId, perm)Owner bypass → Administrator bit → specific bit
getTopRolePosition(db, userId, roomId)Max assigned-role position
isManageable(...)Hierarchy check for acting on a member/role

tRPC procedure builders in server/trpc/procedure/room/:

GuardUse
getMemberProcedureCaller must be a room member — standard message/room operations
getPermissionsProcedureCaller must hold a specific RoomPermission — moderation/admin
getOwnerProcedureOwner only — destructive room operations

Key files

FileRole
packages/db-schema/src/schema/roomRolesInMessage.tsRoomPermission const + roles table
packages/db-schema/src/schema/usersToRoomRolesInMessage.tsAssignment table
packages/app/server/services/room/rbac/Service functions (+ tests)
packages/app/server/trpc/procedure/room/getPermissionsProcedure.tsPermission middleware builder
packages/app/server/trpc/routers/role.tsRole CRUD + readMemberRoles

Notes

  • readMemberRoles is deliberately a separate procedure from readMembers (called in parallel from useReadMembers) rather than a join.
  • Default @everyone permissions: ReadMessages | SendMessages | MentionEveryone | ManageInvites.