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
+135 -12
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js';
import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateChannelRequest,
@@ -40,15 +40,21 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
const allChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
// Sort by position
channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
// Filter by VIEW_CHANNEL permission per channel
const visibleChannels = allChannels.filter(ch => {
const perms = computePermissions(request.userId, id, ch.id);
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
});
return reply.code(200).send(channels.map(rowToChannel));
// Sort by position
visibleChannels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return reply.code(200).send(visibleChannels.map(rowToChannel));
});
// POST /api/servers/:id/channels - Create a channel (admin+)
@@ -64,8 +70,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!name || typeof name !== 'string') {
@@ -133,8 +139,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 });
if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const updates: Partial<typeof schema.channels.$inferInsert> = {};
@@ -194,8 +200,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 });
if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
// Delete messages in channel (attachments cascade), then channel
@@ -211,4 +217,121 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// ─── Channel Override Endpoints ───────────────────────────────────────────
// GET /api/channels/:id/overrides - List channel permission overrides
app.get<{ Params: { id: string } }>('/api/channels/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
const overrides = db.select().from(schema.channelOverrides)
.where(eq(schema.channelOverrides.channelId, id))
.all();
return reply.code(200).send(overrides.map(o => ({
channelId: o.channelId,
targetType: o.targetType,
targetId: o.targetId,
allow: o.allow,
deny: o.deny,
})));
});
// PUT /api/channels/:id/overrides - Create or update a channel override
app.put<{
Params: { id: string };
Body: { targetType: string; targetId: string; allow: string; deny: string };
}>('/api/channels/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { targetType, targetId, allow, deny } = request.body;
const db = getDb();
if (!targetType || !['role', 'member'].includes(targetType)) {
return reply.code(400).send({ error: 'targetType must be "role" or "member"', statusCode: 400 });
}
if (!targetId || typeof targetId !== 'string') {
return reply.code(400).send({ error: 'targetId is required', statusCode: 400 });
}
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
// Validate that allow/deny are valid bigint strings
try {
BigInt(allow || '0');
BigInt(deny || '0');
} catch {
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
}
// Upsert: delete existing then insert
db.transaction((tx) => {
tx.delete(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, id),
eq(schema.channelOverrides.targetType, targetType),
eq(schema.channelOverrides.targetId, targetId),
)
).run();
tx.insert(schema.channelOverrides).values({
channelId: id,
targetType,
targetId,
allow: allow || '0',
deny: deny || '0',
}).run();
});
return reply.code(200).send({ success: true });
});
// DELETE /api/channels/:id/overrides/:targetType/:targetId - Remove a channel override
app.delete<{ Params: { id: string; targetType: string; targetId: string } }>(
'/api/channels/:id/overrides/:targetType/:targetId',
{ preHandler: authenticate },
async (request, reply) => {
const { id, targetType, targetId } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.delete(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, id),
eq(schema.channelOverrides.targetType, targetType),
eq(schema.channelOverrides.targetId, targetId),
)
).run();
return reply.code(200).send({ success: true });
},
);
}
+3 -3
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelServerId, isMember, isDmMember } from '../utils/permissions.js';
import { getChannelServerId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
@@ -30,8 +30,8 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
if (!hasPermission(request.userId, serverId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
}
roomName = channelId;
} else {
+7 -7
View File
@@ -3,7 +3,7 @@ import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js';
import { hasPermission, getChannelServerId, PermissionBits } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
@@ -175,8 +175,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
if (!hasPermission(request.userId, serverId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) {
return reply.code(403).send({ error: 'Missing VIEW_CHANNEL or READ_MESSAGE_HISTORY permission', statusCode: 403 });
}
const db = getDb();
@@ -268,8 +268,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
if (!hasPermission(request.userId, serverId, PermissionBits.SEND_MESSAGES, id)) {
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
@@ -418,9 +418,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
}
const isAuthor = message.userId === request.userId;
const isAdminUser = isAdmin(serverId, request.userId);
const canManageMessages = hasPermission(request.userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId);
if (!isAuthor && !isAdminUser) {
if (!isAuthor && !canManageMessages) {
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
}
+46 -23
View File
@@ -3,7 +3,8 @@ import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
import { isMember, isServerOwner, hasPermission, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import type {
@@ -80,7 +81,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const now = Date.now();
const inviteCode = generateInviteCode();
// Create server, owner membership, and default channel atomically
// Create server, owner membership, default channel, and @everyone role atomically
db.transaction((tx) => {
tx.insert(schema.servers).values({
id: serverId,
@@ -106,6 +107,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
position: 0,
createdAt: now,
}).run();
// Auto-create @everyone role (id = serverId)
tx.insert(schema.roles).values({
id: serverId,
serverId,
name: '@everyone',
color: '#b9bbbe',
position: 0,
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
createdAt: now,
}).run();
});
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
@@ -246,8 +258,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) {
return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 });
}
const updates: Partial<typeof schema.servers.$inferInsert> = {};
@@ -298,7 +310,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
if (!isServerOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
}
@@ -324,8 +336,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
}
// Return existing invite code if one exists, otherwise generate a new one
@@ -474,8 +486,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
if (uid === request.userId) {
@@ -549,14 +561,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
const isSelf = uid === request.userId;
const isServerOwnerUser = isOwner(id, request.userId);
const isOwnerUser = isServerOwner(id, request.userId);
const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS);
if (!isSelf && !isServerOwnerUser) {
return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 });
if (!isSelf && !canKick) {
return reply.code(403).send({ error: 'Missing KICK_MEMBERS permission', statusCode: 403 });
}
// Owner cannot leave their own server - they must delete it
if (isSelf && isServerOwnerUser) {
if (isSelf && isOwnerUser) {
return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 });
}
@@ -604,8 +617,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { name, color } = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
const roleId = generateSnowflake();
@@ -630,8 +643,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const updates = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
@@ -646,10 +659,20 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { id, roleId } = request.params;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
// Cannot delete @everyone role
if (roleId === id) {
return reply.code(400).send({ error: 'Cannot delete the @everyone role', statusCode: 400 });
}
// Delete channel overrides referencing this role
db.delete(schema.channelOverrides).where(
and(eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, roleId))
).run();
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
return reply.code(200).send({ success: true });
});
@@ -662,8 +685,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { roleId } = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.insert(schema.memberRoles).values({
@@ -682,8 +705,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { id, uid, roleId } = request.params;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.delete(schema.memberRoles).where(and(