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:
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -210,8 +210,8 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
|
||||
|
||||
const messageWithUser = getMessageWithUser(messageId);
|
||||
if (messageWithUser) {
|
||||
// Broadcast to all server members (including sender)
|
||||
connectionManager.sendToServer(serverId, {
|
||||
// Broadcast to members with VIEW_CHANNEL on this channel
|
||||
connectionManager.sendToChannel(serverId, channelId, {
|
||||
type: 'message_created',
|
||||
message: messageWithUser,
|
||||
});
|
||||
@@ -255,7 +255,7 @@ function handleMessageEdit(event: Record<string, unknown>, userId: string): void
|
||||
|
||||
const updatedMessage = getMessageWithUser(messageId);
|
||||
if (updatedMessage) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
connectionManager.sendToChannel(serverId, message.channelId, {
|
||||
type: 'message_updated',
|
||||
message: updatedMessage,
|
||||
});
|
||||
@@ -293,7 +293,7 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
|
||||
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
|
||||
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
|
||||
|
||||
connectionManager.sendToServer(serverId, {
|
||||
connectionManager.sendToChannel(serverId, message.channelId, {
|
||||
type: 'message_deleted',
|
||||
messageId,
|
||||
channelId: message.channelId,
|
||||
@@ -317,8 +317,8 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
// Broadcast typing event (exclude sender)
|
||||
connectionManager.sendToServer(serverId, {
|
||||
// Broadcast typing event to channel viewers (exclude sender)
|
||||
connectionManager.sendToChannel(serverId, channelId, {
|
||||
type: 'typing',
|
||||
channelId,
|
||||
userId,
|
||||
@@ -728,7 +728,7 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
connectionManager.sendToServer(serverId, {
|
||||
connectionManager.sendToChannel(serverId, message.channelId, {
|
||||
type: 'reaction_added',
|
||||
messageId,
|
||||
reaction: {
|
||||
@@ -766,7 +766,7 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
|
||||
.run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
connectionManager.sendToChannel(serverId, message.channelId, {
|
||||
type: 'reaction_removed',
|
||||
messageId,
|
||||
userId,
|
||||
|
||||
@@ -425,6 +425,29 @@ class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Send to server members who have VIEW_CHANNEL on the given channel. */
|
||||
sendToChannel(serverId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void {
|
||||
const message = JSON.stringify(event);
|
||||
for (const [userId, serverIds] of this.userServers) {
|
||||
if (serverIds.has(serverId) && userId !== excludeUserId) {
|
||||
const perms = computePermissions(userId, serverId, channelId);
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
const connections = this.getUserConnections(userId);
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expose userServers iterator for pre-delete viewer collection. */
|
||||
getUserServerEntries(): IterableIterator<[string, Set<string>]> {
|
||||
return this.userServers.entries();
|
||||
}
|
||||
|
||||
/** Send to all DM channel members (queries dm_members table). */
|
||||
sendToDmMembers(dmChannelId: string, event: ServerEvent, excludeUserId?: string): void {
|
||||
const db = getDb();
|
||||
@@ -470,6 +493,20 @@ class ConnectionManager {
|
||||
getAllOnlineUserIds(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
/** Push a fresh ready payload to a specific user, forcing full store re-sync. */
|
||||
pushReadyPayload(userId: string): void {
|
||||
const connections = this.getUserConnections(userId);
|
||||
if (connections.size === 0) return;
|
||||
|
||||
const readyData = buildReadyPayload(userId);
|
||||
const message = JSON.stringify({ type: 'ready', ...readyData });
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionManager = new ConnectionManager();
|
||||
|
||||
Reference in New Issue
Block a user