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,

View File

@@ -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() }
})

View File

@@ -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
}
}