fix(communication): enforce room and project membership boundaries
This commit is contained in:
28
backend/src/modules/communication-access.ts
Normal file
28
backend/src/modules/communication-access.ts
Normal 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)))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user