fix(communication): enforce room and project membership boundaries

This commit is contained in:
2026-09-30 13:59:29 +02:00
parent 052a3a8446
commit 21fe4723e3
4 changed files with 180 additions and 31 deletions

View File

@@ -0,0 +1,28 @@
import { and, eq } from "drizzle-orm"
import { authProfiles, projects } from "../../db/schema"
export function chatError(message: string, statusCode = 403): never {
throw Object.assign(new Error(message), { statusCode })
}
export function assertCustomRoomKey(key: string) {
if (key === "allgemein" || /^(direct|project)_/.test(key)) {
chatError("Dieser Raumschlüssel ist für Systemräume reserviert", 409)
}
}
export function managesProjects(req: any): boolean {
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
}
export async function projectChatAccess(db: any, req: any, projectId?: number) {
const tenantId = Number(req.user?.tenant_id)
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
const rows = await db.select().from(projects).where(and(...conditions))
if (managesProjects(req)) return rows
const profiles = await db.select({ id: authProfiles.id }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, req.user.user_id)))
const profileIds = new Set(profiles.map((profile: any) => profile.id))
return rows.filter((project: any) => (project.profiles || []).some((id: string) => profileIds.has(id)))
}

View File

@@ -13,6 +13,7 @@ import {
projects,
} from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
type ChatUser = {
userId: string
@@ -54,9 +55,12 @@ const normalizeRoomKey = (value: string) => value
export default async function communicationRoutes(server: FastifyInstance) {
const notifications = new NotificationService(server, getUserDirectory)
const requireTenant = (req: any) => {
const requireTenant = async (req: any) => {
const tenantId = Number(req.user.tenant_id)
if (!tenantId) throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 })
const [membership] = await server.db.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
.where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, req.user.user_id))).limit(1)
if (!membership) chatError("Keine aktive Mandantenmitgliedschaft")
return tenantId
}
@@ -106,11 +110,12 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
}
if (room.type !== "general" || room.archived) chatError("Allgemeiner Chat ist nicht verfügbar", 409)
await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId))
return room
}
const requireRoom = async (tenantId: number, userId: string, roomKey: string) => {
const requireRoom = async (req: any, tenantId: number, userId: string, roomKey: string) => {
const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms)
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(
@@ -121,6 +126,9 @@ export default async function communicationRoutes(server: FastifyInstance) {
))
.limit(1)
if (!row) throw Object.assign(new Error("Chatraum nicht gefunden oder kein Zugriff"), { statusCode: 404 })
if (row.room.type === "project" && !(await projectChatAccess(server.db, req, row.room.entityId)).length) {
chatError("Chatraum nicht gefunden oder kein Zugriff", 404)
}
return row.room
}
@@ -225,7 +233,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
server.get("/communication/chat/users", async (req: any) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
return { users: (await tenantUsers(tenantId)).map((user) => ({
userId: user.userId,
email: user.email,
@@ -234,12 +242,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.get("/communication/chat/rooms", async (req: any) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
await ensureGeneralRoom(tenantId, req.user.user_id)
const rows = await server.db.select({ room: communicationRooms }).from(communicationRooms)
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(
eq(communicationRooms.tenantId, tenantId),
inArray(communicationRooms.type, ["general", "room"]),
eq(communicationRooms.archived, false),
eq(communicationRoomMembers.userId, req.user.user_id)
)).orderBy(asc(communicationRooms.name))
@@ -247,27 +256,32 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.post("/communication/chat/rooms", async (req: any, reply) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const body = (req.body || {}) as { key?: string; name?: string; topic?: string }
const name = body.name?.trim()
const key = normalizeRoomKey(body.key?.trim() || name || "")
if (!name || !key) return reply.code(400).send({ error: "Name und gültiger Raumschlüssel sind erforderlich" })
const [created] = await server.db.insert(communicationRooms).values({
tenantId, key, name, topic: body.topic?.trim() || null, type: "room", createdBy: req.user.user_id,
}).onConflictDoNothing().returning()
const room = created || (await server.db.select().from(communicationRooms)
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1))[0]
await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId))
return { ...roomToApi(room), alreadyExisted: !created }
assertCustomRoomKey(key)
const userIds = (await tenantUsers(tenantId)).map((user) => user.userId)
const created = await server.db.transaction(async (tx) => {
const [room] = await tx.insert(communicationRooms).values({
tenantId, key, name, topic: body.topic?.trim() || null, type: "room", createdBy: req.user.user_id,
}).onConflictDoNothing().returning()
if (room) await tx.insert(communicationRoomMembers)
.values([...new Set(userIds)].map((userId) => ({ roomId: room.id, userId }))).onConflictDoNothing()
return room
})
if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" })
return { ...roomToApi(created), alreadyExisted: false }
})
server.get("/communication/chat/project-rooms", async (req: any) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const [roomRows, projectRows] = await Promise.all([
server.db.select({ room: communicationRooms }).from(communicationRooms)
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "project"), eq(communicationRoomMembers.userId, req.user.user_id))),
server.db.select().from(projects).where(and(eq(projects.tenant, tenantId), eq(projects.archived, false))),
projectChatAccess(server.db, req),
])
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
return { rooms: projectRows.map((project) => {
@@ -283,10 +297,9 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.post("/communication/chat/project-rooms/:projectId/provision", async (req: any, reply) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const projectId = Number(req.params.projectId)
const [project] = await server.db.select().from(projects)
.where(and(eq(projects.tenant, tenantId), eq(projects.id, projectId))).limit(1)
const [project] = await projectChatAccess(server.db, req, projectId)
if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" })
const key = `project_${project.id}`
let [room] = await server.db.select().from(communicationRooms)
@@ -303,6 +316,9 @@ export default async function communicationRoutes(server: FastifyInstance) {
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1)
}
}
if (room.type !== "project" || room.entityId !== projectId || room.archived) {
chatError("Projektchat ist nicht verfügbar", 409)
}
const profileIds = (project.profiles || []) as string[]
const memberProfiles = profileIds.length
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
@@ -314,7 +330,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.get("/communication/chat/direct-rooms", async (req: any) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const users = (await tenantUsers(tenantId)).filter((user) => user.userId !== req.user.user_id)
const existingRows = await server.db.select({ room: communicationRooms }).from(communicationRooms)
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
@@ -339,7 +355,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.post("/communication/chat/direct-rooms/:userId/provision", async (req: any, reply) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const targetId = String(req.params.userId)
const target = (await tenantUsers(tenantId)).find((user) => user.userId === targetId)
if (!target || targetId === req.user.user_id) return reply.code(404).send({ error: "Benutzer nicht gefunden" })
@@ -356,6 +372,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1)
}
}
if (room.type !== "direct" || room.archived) chatError("Direktchat ist nicht verfügbar", 409)
await addMembers(room.id, [req.user.user_id, targetId])
return {
...roomToApi(room),
@@ -368,17 +385,21 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.get("/communication/chat/unread", async (req: any) => {
const tenantId = requireTenant(req)
const tenantId = await requireTenant(req)
const memberships = await server.db.select({
roomId: communicationRoomMembers.roomId,
roomKey: communicationRooms.key,
roomType: communicationRooms.type,
projectId: communicationRooms.entityId,
lastReadMessageId: communicationRoomReads.lastReadMessageId,
}).from(communicationRoomMembers)
.innerJoin(communicationRooms, eq(communicationRooms.id, communicationRoomMembers.roomId))
.leftJoin(communicationRoomReads, and(eq(communicationRoomReads.roomId, communicationRoomMembers.roomId), eq(communicationRoomReads.userId, req.user.user_id)))
.where(and(eq(communicationRoomMembers.userId, req.user.user_id), eq(communicationRooms.tenantId, tenantId)))
.where(and(eq(communicationRoomMembers.userId, req.user.user_id), eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.archived, false)))
const allowedProjects = new Set((await projectChatAccess(server.db, req)).map((project: any) => project.id))
const rooms: Record<string, { count: number; mentions: number }> = {}
for (const membership of memberships) {
if (membership.roomType === "project" && !allowedProjects.has(membership.projectId)) continue
const [result] = await server.db.select({ count: sql<number>`count(*)::int` }).from(communicationMessages)
.where(and(eq(communicationMessages.roomId, membership.roomId), gt(communicationMessages.id, membership.lastReadMessageId || 0), ne(communicationMessages.authorUserId, req.user.user_id)))
rooms[membership.roomKey] = { count: result?.count || 0, mentions: 0 }
@@ -387,22 +408,22 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.get("/communication/chat/rooms/:roomKey/messages", async (req: any) => {
const tenantId = requireTenant(req)
const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey)
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return { ...roomToApi(room), messages: await messagesForRoom(room.id, req.user.user_id, tenantId) }
})
server.get("/communication/chat/rooms/:roomKey/sync", async (req: any) => {
const tenantId = requireTenant(req)
const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey)
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const afterId = Math.max(Number(req.query?.afterId || 0), 0)
const messages = await messagesForRoom(room.id, req.user.user_id, tenantId, afterId, 100)
return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId }
})
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => {
const tenantId = requireTenant(req)
const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey)
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const body = String(req.body?.text || "").trim()
if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" })
if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" })
@@ -414,8 +435,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {
const tenantId = requireTenant(req)
const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey)
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const requestedId = Math.max(Number(req.body?.messageId || 0), 0)
const [latest] = await server.db.select({ id: communicationMessages.id }).from(communicationMessages)
.where(eq(communicationMessages.roomId, room.id)).orderBy(desc(communicationMessages.id)).limit(1)
@@ -434,8 +455,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
})
server.get("/communication/chat/rooms/:roomKey/members", async (req: any) => {
const tenantId = requireTenant(req)
const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey)
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const members = await server.db.select({
userId: authUsers.id, email: authUsers.email,
firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name,