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 });
},
);
}