import prisma from '../lib/prisma.js'; import bcrypt from 'bcryptjs'; import { paginate, buildPaginationResponse } from '../utils/helpers.js'; import { pruefeTeilmenge, rechteVonPermissionIds, rechteVonRollen, rechteDerHaken, effektiveRechte, normalisiereRollenIds, meldeTraegerAb, RollenSperrError, } from './rechte.service.js'; import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js'; export interface UserFilters { search?: string; isActive?: boolean; roleId?: number; page?: number; limit?: number; } export async function getAllUsers(filters: UserFilters) { const { search, isActive, roleId, page = 1, limit = 20 } = filters; const { skip, take } = paginate(page, limit); const where: Record = {}; if (isActive !== undefined) { where.isActive = isActive; } if (roleId) { where.roles = { some: { roleId } }; } if (search) { where.OR = [ { email: { contains: search } }, { firstName: { contains: search } }, { lastName: { contains: search } }, ]; } const [users, total] = await Promise.all([ prisma.user.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, select: { id: true, email: true, firstName: true, lastName: true, isActive: true, isServiceAccount: true, customerId: true, whatsappNumber: true, telegramUsername: true, signalNumber: true, createdAt: true, roles: { include: { role: { include: { permissions: true, }, }, }, }, }, }), prisma.user.count({ where }), ]); // Get hidden role IDs const [developerRole, gdprRole, auditOpsRole] = await Promise.all([ prisma.role.findFirst({ where: { name: 'Developer' } }), prisma.role.findFirst({ where: { name: 'DSGVO' } }), prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }), ]); return { users: users.map((u) => { const hasDeveloperAccess = developerRole ? u.roles.some((ur) => ur.roleId === developerRole.id) : false; const hasGdprAccess = gdprRole ? u.roles.some((ur) => ur.roleId === gdprRole.id) : false; const hasAuditOpsAccess = auditOpsRole ? u.roles.some((ur) => ur.roleId === auditOpsRole.id) : false; return { ...u, roles: u.roles.map((r) => r.role), hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, }; }), pagination: buildPaginationResponse(page, limit, total), }; } export async function getUserById(id: number) { const user = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, firstName: true, lastName: true, isActive: true, isServiceAccount: true, customerId: true, whatsappNumber: true, telegramUsername: true, signalNumber: true, createdAt: true, updatedAt: true, roles: { include: { role: { include: { permissions: { include: { permission: true }, }, }, }, }, }, }, }); if (!user) return null; const permissions = new Set(); for (const userRole of user.roles) { for (const rolePerm of userRole.role.permissions) { permissions.add( `${rolePerm.permission.resource}:${rolePerm.permission.action}` ); } } return { ...user, roles: user.roles.map((r) => r.role), permissions: Array.from(permissions), }; } export async function createUser(data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; hasAuditOpsAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string; }, handelnderId: number, ) { // Was das neue Konto koennen wird - aus den Rollen UND aus den Haken. // `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung // vergessen kann; der Compiler erzwingt sie. // Erst pruefen, dann rechnen: Eine unbekannte oder doppelte Rollen-ID // faellt der Teilmengenregel nicht auf (sie bringt keine Rechte mit) und // schluege erst beim Schreiben fehl. const rollenIds = await normalisiereRollenIds(data.roleIds); await pruefeTeilmenge(handelnderId, [ ...(await rechteVonRollen(rollenIds)), ...rechteDerHaken(data), ]); const hashedPassword = await bcrypt.hash(data.password, 10); const user = await prisma.user.create({ data: { email: data.email, password: hashedPassword, firstName: data.firstName, lastName: data.lastName, customerId: data.customerId, whatsappNumber: data.whatsappNumber || null, telegramUsername: data.telegramUsername || null, signalNumber: data.signalNumber || null, roles: { create: rollenIds.map((roleId) => ({ roleId })), }, }, select: { id: true, email: true, firstName: true, lastName: true, isActive: true, isServiceAccount: true, customerId: true, roles: { include: { role: true }, }, }, }); // Entwicklerzugriff setzen falls aktiviert if (data.hasDeveloperAccess) { await setUserDeveloperAccess(user.id, true); } // DSGVO-Zugriff setzen falls aktiviert if (data.hasGdprAccess) { await setUserGdprAccess(user.id, true); } if (data.hasAuditOpsAccess) { await setUserAuditOpsAccess(user.id, true); } return user; } export async function updateUser( id: number, data: { email?: string; password?: string; firstName?: string; lastName?: string; isActive?: boolean; isServiceAccount?: boolean; roleIds?: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; hasAuditOpsAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string; }, handelnderId: number, ) { const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data; // Rollen-IDs pruefen, bevor irgendetwas geschrieben oder entschieden wird. // Die Letzter-Admin-Sperre weiter unten arbeitet mit dieser Liste - mit // einer unbekannten ID darin haette sie eine Absicht bestaetigt, die der // Schreibvorgang danach nicht einloesen konnte (Pentest R190-01). const gepruefteRollenIds = roleIds !== undefined ? await normalisiereRollenIds(roleIds) : undefined; // Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand. // // Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert, // schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde // hier der Endzustand geprueft, waere jede solche Korrektur ein 403. // Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt. { const bisher = await effektiveRechte(id); const zuwachs = new Set(); if (roleIds !== undefined) { for (const r of await rechteVonRollen(gepruefteRollenIds!)) { if (!bisher.has(r)) zuwachs.add(r); } } for (const r of rechteDerHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess })) { if (!bisher.has(r)) zuwachs.add(r); } await pruefeTeilmenge(handelnderId, zuwachs); } // Check if this would remove the last admin const isBeingDeactivated = userData.isActive === false; const rolesAreBeingChanged = roleIds !== undefined; if (isBeingDeactivated || rolesAreBeingChanged) { // Check if user currently has admin permissions const currentUser = await prisma.user.findUnique({ where: { id }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true }, }, }, }, }, }, }, }); const isCurrentlyAdmin = currentUser?.roles.some((ur) => ur.role.permissions.some( (rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete' ) ); if (isCurrentlyAdmin) { // Check if user will still be admin after role change let willStillBeAdmin = false; if (rolesAreBeingChanged) { const newRoles = await prisma.role.findMany({ where: { id: { in: gepruefteRollenIds! } }, include: { permissions: { include: { permission: true }, }, }, }); willStillBeAdmin = newRoles.some((role) => role.permissions.some( (rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete' ) ); } else { willStillBeAdmin = true; // Roles not being changed } // If user is losing admin status or being deactivated, check for other admins if (!willStillBeAdmin || isBeingDeactivated) { const otherAdminCount = await prisma.user.count({ where: { id: { not: id }, isActive: true, roles: { some: { role: { permissions: { some: { permission: { resource: 'users', action: 'delete', }, }, }, }, }, }, }, }); if (otherAdminCount === 0) { if (isBeingDeactivated) { throw new Error( 'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden' ); } else { throw new Error( 'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist' ); } } } } } // Hash password if provided if (password) { (userData as Record).password = await bcrypt.hash(password, 10); } // Prüfen ob Rollen geändert werden (für Zwangslogout) let rolesChanged = false; if (roleIds !== undefined) { const currentRoles = await prisma.userRole.findMany({ where: { userId: id }, select: { roleId: true }, }); const currentRoleIds = currentRoles.map((r) => r.roleId).sort(); const newRoleIds = [...roleIds].sort(); rolesChanged = currentRoleIds.length !== newRoleIds.length || !currentRoleIds.every((id, i) => id === newRoleIds[i]); } // Update user - bei Rollenänderung Token invalidieren await prisma.user.update({ where: { id }, data: { ...userData, // Token invalidieren wenn Rollen geändert werden ...(rolesChanged && { tokenInvalidatedAt: new Date() }), }, }); // Rollentausch in EINER Transaktion. // // Vorher standen deleteMany und createMany nackt nebeneinander: Scheiterte // das Anlegen, war das Loeschen schon passiert und das Konto hatte gar // keine Rolle mehr - bei einer Antwort, die wie "abgelehnt, nichts // geschehen" aussah. `updateRole` hatte diese Haertung laengst; sie war // nur nicht zum Geschwister mitgewandert (Pentest R190-01). if (gepruefteRollenIds !== undefined) { await prisma.$transaction([ prisma.userRole.deleteMany({ where: { userId: id } }), prisma.userRole.createMany({ data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })), skipDuplicates: true, }), ]); } // Handle developer access if (hasDeveloperAccess !== undefined) { await setUserDeveloperAccess(id, hasDeveloperAccess); } // Handle GDPR access if (hasGdprAccess !== undefined) { await setUserGdprAccess(id, hasGdprAccess); } if (hasAuditOpsAccess !== undefined) { await setUserAuditOpsAccess(id, hasAuditOpsAccess); } return getUserById(id); } // Helper to set developer access for a user async function setUserDeveloperAccess(userId: number, enabled: boolean) { // Get or create developer:access permission let developerPerm = await prisma.permission.findFirst({ where: { resource: 'developer', action: 'access' }, }); if (!developerPerm) { developerPerm = await prisma.permission.create({ data: { resource: 'developer', action: 'access' }, }); } // Get or create Developer role let developerRole = await prisma.role.findFirst({ where: { name: 'Developer' }, }); if (!developerRole) { developerRole = await prisma.role.create({ data: { name: 'Developer', description: 'Entwicklerzugriff auf Datenbanktools', permissions: { create: [{ permissionId: developerPerm.id }], }, }, }); } // Check if user already has Developer role const hasRole = await prisma.userRole.findFirst({ where: { userId, roleId: developerRole.id }, }); if (enabled && !hasRole) { await prisma.userRole.create({ data: { userId, roleId: developerRole.id }, }); // Token invalidieren bei Rechteänderung await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } else if (!enabled && hasRole) { await prisma.userRole.delete({ where: { userId_roleId: { userId, roleId: developerRole.id } }, }); // Token invalidieren bei Rechteänderung await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } } /** * Name der versteckten Rolle fuer eingreifende Audit-Rechte. * * Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein * duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht * ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim * Containerstart angelegt. */ export const AUDIT_OPS_ROLLE = 'Audit-Betrieb'; // Helper to set GDPR access for a user async function setUserGdprAccess(userId: number, enabled: boolean) { // Get or create DSGVO role let gdprRole = await prisma.role.findFirst({ where: { name: 'DSGVO' }, }); if (!gdprRole) { // Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit- // Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst // brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade // aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen // auseinander). const gdprPermissions = await prisma.permission.findMany({ where: { OR: [ { resource: 'gdpr' }, { resource: 'audit', action: { in: ['read', 'export'] } }, ], }, }); gdprRole = await prisma.role.create({ data: { name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permissions: { create: gdprPermissions.map((p) => ({ permissionId: p.id })), }, }, }); } // Check if user already has DSGVO role const hasRole = await prisma.userRole.findFirst({ where: { userId, roleId: gdprRole.id }, }); if (enabled && !hasRole) { await prisma.userRole.create({ data: { userId, roleId: gdprRole.id }, }); await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } else if (!enabled && hasRole) { await prisma.userRole.delete({ where: { userId_roleId: { userId, roleId: gdprRole.id } }, }); await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } } /** * Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen, * Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe * `AUDIT_OPS_ROLLE`). */ async function setUserAuditOpsAccess(userId: number, enabled: boolean) { let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }); if (!rolle) { // Rechte-Satz identisch zu sync-roles.ts. const rechte = await prisma.permission.findMany({ where: { resource: 'audit', action: { in: ['read', 'admin'] } }, }); rolle = await prisma.role.create({ data: { name: AUDIT_OPS_ROLLE, description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permissions: { create: rechte.map((p) => ({ permissionId: p.id })) }, }, }); } const hatRolle = await prisma.userRole.findFirst({ where: { userId, roleId: rolle.id }, }); if (enabled && !hatRolle) { await prisma.userRole.create({ data: { userId, roleId: rolle.id } }); // Token invalidieren bei Rechteaenderung await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } else if (!enabled && hatRolle) { await prisma.userRole.delete({ where: { userId_roleId: { userId, roleId: rolle.id } }, }); await prisma.user.update({ where: { id: userId }, data: { tokenInvalidatedAt: new Date() }, }); } } export async function deleteUser(id: number) { // Check if user is an admin const user = await prisma.user.findUnique({ where: { id }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true }, }, }, }, }, }, }, }); if (!user) { throw new Error('Benutzer nicht gefunden'); } // Check if user has admin permissions (users:delete means admin) const isAdmin = user.roles.some((ur) => ur.role.permissions.some( (rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete' ) ); if (isAdmin) { // Count other admins (users with users:delete permission) const adminCount = await prisma.user.count({ where: { id: { not: id }, isActive: true, roles: { some: { role: { permissions: { some: { permission: { resource: 'users', action: 'delete', }, }, }, }, }, }, }, }); if (adminCount === 0) { throw new Error( 'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden' ); } } return prisma.user.delete({ where: { id } }); } // Role operations export async function getAllRoles() { return prisma.role.findMany({ include: { permissions: { include: { permission: true }, }, _count: { select: { users: true }, }, }, orderBy: { name: 'asc' }, }); } export async function getRoleById(id: number) { return prisma.role.findUnique({ where: { id }, include: { permissions: { include: { permission: true }, }, }, }); } /** * Legt eine Rolle an. * * `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller * die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei * jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert * durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access` * bauen und sie sich anschliessend selbst zuweisen. */ export async function createRole( data: { name: string; description?: string; permissionIds: number[]; }, handelnderId: number, ) { const name = data.name.trim(); // Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/ // Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in // einer Liste wie das Original aussieht. if (istSystemrollenName(name)) { throw new RollenSperrError( `„${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`, ); } await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds)); return prisma.role.create({ data: { name, description: data.description, permissions: { create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })), }, }, include: { permissions: { include: { permission: true }, }, }, }); } /** * Aendert eine Rolle. Systemrollen sind gesperrt. * * Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der * Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen. */ export async function updateRole( id: number, data: { name?: string; description?: string; permissionIds?: number[]; }, handelnderId: number, ) { const bestehend = await prisma.role.findUnique({ where: { id }, include: { permissions: { include: { permission: true } } }, }); if (!bestehend) return null; if (bestehend.isSystem) { throw new RollenSperrError( `„${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` + 'gepflegt und kann hier nicht geändert werden.', ); } if (data.name !== undefined && istSystemrollenName(data.name)) { throw new RollenSperrError( `„${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`, ); } const { permissionIds, ...roleData } = data; if (roleData.name !== undefined) roleData.name = roleData.name.trim(); if (permissionIds) { const bisher = new Set( bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`), ); const kuenftig = await rechteVonPermissionIds(permissionIds); const zuwachs = [...kuenftig].filter((r) => !bisher.has(r)); await pruefeTeilmenge(handelnderId, zuwachs); } await prisma.role.update({ where: { id }, data: roleData, }); if (permissionIds) { await prisma.rolePermission.deleteMany({ where: { roleId: id } }); await prisma.rolePermission.createMany({ // Dedupliziert: Doppelte IDs im Body liefen vorher in einen // Primaerschluesselkonflikt und kamen als HTTP 500 zurueck. data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })), skipDuplicates: true, }); // Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens. await meldeTraegerAb(id); } return getRoleById(id); } export async function deleteRole(id: number) { const bestehend = await prisma.role.findUnique({ where: { id } }); if (!bestehend) { throw new Error('Rolle nicht gefunden'); } if (bestehend.isSystem) { throw new RollenSperrError( `„${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`, ); } // Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade // nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene // Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die // richtige, falls diese Sperre je gelockert wird. const count = await prisma.userRole.count({ where: { roleId: id } }); if (count > 0) { throw new Error( `Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist` ); } await meldeTraegerAb(id); return prisma.role.delete({ where: { id } }); } // Permission operations /** * Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe. * * Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren * Schemata liegen (`customers:access`, `settings:create` und aehnliche), die * nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche * als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der * Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein * Lesefilter ist die kleinere Behauptung als ein DELETE, und der * Rechte-Report meldet sie ohnehin. */ export async function getAllPermissions() { const alle = await prisma.permission.findMany({ orderBy: [{ resource: 'asc' }, { action: 'asc' }], }); const beschreibung = new Map( RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]), ); return alle .filter((p) => beschreibung.has(`${p.resource}:${p.action}`)) .map((p) => { const k = beschreibung.get(`${p.resource}:${p.action}`)!; return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe }; }); }