diff --git a/backend/src/modules/communication-access.ts b/backend/src/modules/communication-access.ts new file mode 100644 index 00000000..2aa72358 --- /dev/null +++ b/backend/src/modules/communication-access.ts @@ -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))) +} diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 58faeda8..a2d7cc63 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -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 = {} for (const membership of memberships) { + if (membership.roomType === "project" && !allowedProjects.has(membership.projectId)) continue const [result] = await server.db.select({ count: sql`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, diff --git a/backend/tests/communicationAccess.test.ts b/backend/tests/communicationAccess.test.ts new file mode 100644 index 00000000..a3f0483e --- /dev/null +++ b/backend/tests/communicationAccess.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import communicationRoutes from "../src/routes/communication" +import { assertCustomRoomKey } from "../src/modules/communication-access" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +test("custom room keys cannot impersonate system rooms", () => { + for (const key of ["allgemein", "direct_abc", "project_7"]) assert.throws(() => assertCustomRoomKey(key)) + assert.doesNotThrow(() => assertCustomRoomKey("baustelle")) +}) + +test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase() + const app = Fastify() + const user = "00000000-0000-4000-8000-000000000001" + const other = "00000000-0000-4000-8000-000000000002" + const profile = "00000000-0000-4000-8000-000000000003" + const room = "00000000-0000-4000-8000-000000000004" + try { + await fixture.pool.query(`INSERT INTO tenants VALUES (1, 'A', null), (2, 'B', null); + INSERT INTO auth_users (id,email) VALUES ('${user}', 'a@example.test'), ('${other}', 'b@example.test'); + INSERT INTO auth_tenant_users (tenant_id,user_id) VALUES (1,'${user}'), (1,'${other}'); + INSERT INTO auth_profiles (id,tenant_id,user_id,first_name,last_name) VALUES ('${profile}',1,'${other}','B','Test'); + INSERT INTO projects (id,tenant,name,profiles,archived) VALUES (1,1,'Hidden',ARRAY['${profile}']::uuid[],false); + INSERT INTO communication_rooms (id,tenant_id,key,name,type,archived) VALUES ('${room}',1,'private','Private','direct',false); + INSERT INTO communication_room_members (room_id,user_id) VALUES ('${room}','${other}');`) + app.decorate("db", fixture.db) + app.addHook("preHandler", async (req: any) => { + req.user = { tenant_id: 1, user_id: req.headers["x-test-user"] || user, is_admin: false } + req.hasPermission = () => false + }) + await app.register(communicationRoutes) + const duplicate = await app.inject({ method: "POST", url: "/communication/chat/rooms", payload: { key: "private", name: "Open" } }) + assert.equal(duplicate.statusCode, 409) + const memberships = await fixture.pool.query("SELECT user_id FROM communication_room_members WHERE room_id=$1", [room]) + assert.deepEqual(memberships.rows.map((row) => row.user_id), [other]) + const forbidden = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" }) + assert.equal(forbidden.statusCode, 404) + const list = await app.inject("/communication/chat/project-rooms") + assert.deepEqual(list.json().rooms, []) + const assigned = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision", headers: { "x-test-user": other } }) + assert.equal(assigned.statusCode, 200, assigned.body) + await fixture.pool.query("UPDATE projects SET profiles='{}'::uuid[] WHERE id=1") + const removed = await app.inject({ url: "/communication/chat/rooms/project_1/messages", headers: { "x-test-user": other } }) + assert.equal(removed.statusCode, 404) + await fixture.pool.query("DELETE FROM auth_tenant_users WHERE user_id=$1", [user]) + assert.equal((await app.inject("/communication/chat/users")).statusCode, 403) + } finally { await app.close(); await fixture.close() } +}) diff --git a/backend/tests/helpers/collaboration-db.ts b/backend/tests/helpers/collaboration-db.ts new file mode 100644 index 00000000..6899a00f --- /dev/null +++ b/backend/tests/helpers/collaboration-db.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto" +import { readFile } from "node:fs/promises" +import { Pool } from "pg" +import { drizzle } from "drizzle-orm/node-postgres" +import { getTableColumns, getTableName } from "drizzle-orm" +import * as schema from "../../db/schema" + +export const testDatabaseUrl = process.env.COLLABORATION_TEST_DATABASE_URL + +/** Always creates and drops a private schema in the explicitly designated disposable test database. */ +export async function collaborationDatabase(migrations: string[] = []) { + if (!testDatabaseUrl || new URL(testDatabaseUrl).pathname !== "/collaboration_test") { + throw new Error("Tests require COLLABORATION_TEST_DATABASE_URL ending in /collaboration_test") + } + const namespace = `collaboration_test_${randomUUID().replaceAll("-", "")}` + const admin = new Pool({ connectionString: testDatabaseUrl, max: 1 }) + await admin.query(`CREATE SCHEMA "${namespace}"`) + const pool = new Pool({ connectionString: testDatabaseUrl, options: `-c search_path=${namespace}`, max: 6 }) + const db = drizzle(pool, { schema }) + try { + await pool.query("CREATE TYPE locked_tenant AS ENUM ('none', 'maintenance_tenant')") + for (const table of [schema.authUsers, schema.authTenantUsers, schema.authProfiles, schema.authRoles, + schema.authUserRoles, schema.authRolePermissions, schema.projects, schema.communicationRooms]) { + const columns = Object.values(getTableColumns(table)).map((column: any) => { + let suffix = column.primary ? " PRIMARY KEY" : "" + if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" + if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" + if (column.getSQLType() === "boolean") suffix += " DEFAULT false" + return `"${column.name}" ${column.getSQLType()}${suffix}` + }) + await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`) + } + await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text)`) + await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`) + await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8")) + for (const name of migrations) { + await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8")) + } + return { db, pool, close: async () => { + await pool.end() + await admin.query(`DROP SCHEMA "${namespace}" CASCADE`) + await admin.end() + } } + } catch (error) { + await pool.end() + await admin.query(`DROP SCHEMA "${namespace}" CASCADE`) + await admin.end() + throw error + } +}