fix: enforce channel-level RBAC across WS broadcasts, REST endpoints, and frontend reactivity

Wire the bitwise permission engine end-to-end:

- Add sendToChannel() to ConnectionManager, filtering WS recipients by VIEW_CHANNEL
- Convert 6 channel-scoped events (messages, typing, reactions) from sendToServer to sendToChannel
- Add broadcastOverrideChange() to push channel_updated/channel_deleted per-user on override mutations
- Bridge legacy server_members.role TEXT to member_roles junction table on PATCH
- Add pushReadyPayload() to force re-sync frontend store after role changes
- Filter channels by VIEW_CHANNEL in GET /api/servers/:id to prevent initial load data leak
- Pre-compute viewers before CASCADE delete on channel_deleted
- Fix frontend channel event handlers to upsert/cleanup channelToServerMap and channelPermissions
- Add ChannelSettingsModal with Private Channel toggle and gear icon in ChannelSidebar
This commit is contained in:
Jannis Braun
2026-02-24 06:10:11 +01:00
parent 8030c89c6c
commit 76b8a43be2
10 changed files with 366 additions and 24 deletions
+57 -8
View File
@@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@opencord/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateChannelRequest,
@@ -23,6 +24,38 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
};
}
/**
* After a channel override changes, notify each server member:
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
* - Non-viewers receive channel_deleted to remove the channel from their UI
*/
function broadcastOverrideChange(serverId: string, channelId: string): void {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) return;
const channelData = rowToChannel(channel);
for (const [userId, serverIds] of connectionManager.getUserServerEntries()) {
if (!serverIds.has(serverId)) continue;
const perms = computePermissions(userId, serverId, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_updated',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
serverId,
});
} else {
connectionManager.sendToUser(userId, {
type: 'channel_deleted',
channelId,
serverId,
});
}
}
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/:id/channels - List channels in a server
app.get<{ Params: { id: string } }>('/api/servers/:id/channels', {
@@ -177,8 +210,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
const channelData = rowToChannel(updated);
// Broadcast channel_updated to all server members
connectionManager.sendToServer(serverId, {
// Broadcast channel_updated to members with VIEW_CHANNEL
connectionManager.sendToChannel(serverId, id, {
type: 'channel_updated',
channel: channelData,
serverId,
@@ -204,16 +237,26 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
// Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
const viewerIds: string[] = [];
for (const [uid, serverIds] of connectionManager.getUserServerEntries()) {
if (serverIds.has(serverId)) {
const perms = computePermissions(uid, serverId, id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
viewerIds.push(uid);
}
}
}
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
// Broadcast channel_deleted to all server members
connectionManager.sendToServer(serverId, {
type: 'channel_deleted',
channelId: id,
serverId,
});
// Broadcast channel_deleted only to users who could see the channel
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, serverId };
for (const uid of viewerIds) {
connectionManager.sendToUser(uid, deleteEvent);
}
return reply.code(200).send({ success: true });
});
@@ -303,6 +346,9 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}).run();
});
// Notify all server members of the permission change
broadcastOverrideChange(channel.serverId, id);
return reply.code(200).send({ success: true });
});
@@ -331,6 +377,9 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
)
).run();
// Notify all server members of the permission change
broadcastOverrideChange(channel.serverId, id);
return reply.code(200).send({ success: true });
},
);
+57 -3
View File
@@ -3,8 +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, isServerOwner, hasPermission, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import type {
@@ -228,9 +228,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
})
.filter((m): m is MemberWithUser => m !== null);
// Filter channels by VIEW_CHANNEL permission before returning
const visibleChannels = channels.filter(ch => {
const perms = computePermissions(request.userId, id, ch.id);
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n;
});
const result: ServerWithChannelsAndMembers = {
...rowToServer(server),
channels: channels.map(rowToChannel),
channels: visibleChannels.map(rowToChannel),
members,
roles: roles.map(r => ({
id: r.id,
@@ -518,6 +524,54 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
))
.run();
// Bridge legacy role string to bitwise member_roles
if (role === 'admin') {
// Find or create Admin role for this server (matches migrate.ts convention)
const adminPerms = permissionsToString(ALL_PERMISSIONS);
let adminRole = db.select().from(schema.roles)
.where(and(
eq(schema.roles.serverId, id),
eq(schema.roles.name, 'Admin'),
eq(schema.roles.permissions, adminPerms),
))
.get();
if (!adminRole) {
const adminRoleId = `${id}-admin`;
db.insert(schema.roles).values({
id: adminRoleId,
serverId: id,
name: 'Admin',
color: '#e74c3c',
position: 1,
permissions: adminPerms,
createdAt: Date.now(),
}).run();
adminRole = db.select().from(schema.roles).where(eq(schema.roles.id, adminRoleId)).get();
}
if (adminRole) {
// Assign the Admin role (no-op if already assigned)
db.insert(schema.memberRoles).values({
serverId: id,
userId: uid,
roleId: adminRole.id,
}).onConflictDoNothing().run();
}
} else if (role === 'member') {
// Remove all explicit role assignments (demote to @everyone only)
// @everyone is implicit via computePermissions, never stored in member_roles
db.delete(schema.memberRoles)
.where(and(
eq(schema.memberRoles.serverId, id),
eq(schema.memberRoles.userId, uid),
))
.run();
}
// Force target user's client to re-sync with their new permissions
connectionManager.pushReadyPayload(uid);
const updatedMember = db.select()
.from(schema.serverMembers)
.where(and(