feat: bitwise RBAC engine with channel-level permission overrides

Replace string-based role checks (role === 'admin') with a bitwise BigInt
permission system. Adds computePermissions() resolution engine following
Discord's model: @everyone base → role union → admin shortcut → channel
overrides (role deny/allow → member deny/allow). Ready payload now filters
channels by VIEW_CHANNEL and attaches per-user myPermissions to each
server and channel. Includes channel_overrides table, @everyone role
auto-creation, migration for existing servers, and override CRUD API.
This commit is contained in:
Jannis Braun
2026-02-24 05:08:59 +01:00
parent 024833c470
commit 8030c89c6c
19 changed files with 568 additions and 93 deletions
+9 -15
View File
@@ -3,7 +3,7 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
@@ -190,8 +190,8 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing SEND_MESSAGES permission' });
return;
}
@@ -280,17 +280,11 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
// Allow author or admin to delete
// Allow author or MANAGE_MESSAGES permission holder to delete
const isAuthor = message.userId === userId;
const memberRow = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.all()
.find(m => m.userId === userId);
const canManageMessages = hasPermission(userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId);
const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner';
if (!isAuthor && !isAdminRole) {
if (!isAuthor && !canManageMessages) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
return;
}
@@ -314,7 +308,7 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
const serverId = getChannelServerId(channelId);
if (!serverId) return;
if (!isMember(serverId, userId)) return;
if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) return;
// Clear previous typing timeout for this user+channel
const key = `${userId}:${channelId}`;
@@ -412,8 +406,8 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
if (!hasPermission(userId, serverId, PermissionBits.CONNECT, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing CONNECT permission' });
return;
}
+28 -10
View File
@@ -4,6 +4,7 @@ import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, inArray, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type {
User,
ServerWithChannelsAndMembers,
@@ -622,6 +623,29 @@ function buildReadyPayload(userId: string): {
})
.filter((m): m is MemberWithUser => m !== null);
// Compute server-level permissions for this user
const serverPerms = computePermissions(userId, serverRow.id);
// Filter channels by VIEW_CHANNEL and attach per-channel permissions
const visibleChannels: Channel[] = [];
for (const ch of channels) {
const chPerms = computePermissions(userId, serverRow.id, ch.id);
const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n;
if (hasView) {
visibleChannels.push({
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
lastMessageId: lastMsgMap.get(ch.id) ?? null,
myPermissions: permissionsToString(chPerms),
});
}
}
servers.push({
id: serverRow.id,
name: serverRow.name,
@@ -629,16 +653,7 @@ function buildReadyPayload(userId: string): {
ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode,
createdAt: serverRow.createdAt,
channels: channels.map(ch => ({
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
lastMessageId: lastMsgMap.get(ch.id) ?? null,
})),
channels: visibleChannels,
members,
roles: roles.map(r => ({
id: r.id,
@@ -646,8 +661,11 @@ function buildReadyPayload(userId: string): {
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
permissions: r.permissions ?? undefined,
isEveryone: r.id === serverRow.id,
createdAt: r.createdAt,
})),
myPermissions: permissionsToString(serverPerms),
});
}
}