diff --git a/backend/src/config/rechte-katalog.ts b/backend/src/config/rechte-katalog.ts index 05dd5964..2f025741 100644 --- a/backend/src/config/rechte-katalog.ts +++ b/backend/src/config/rechte-katalog.ts @@ -291,3 +291,53 @@ export function istSystemrollenName(name: string): boolean { const normalisiert = name.trim().toLocaleLowerCase('de-DE'); return SYSTEMROLLEN_NAMEN.some((n) => n.toLocaleLowerCase('de-DE') === normalisiert); } + +// --------------------------------------------------------------------------- +// Kundenportal - bewusst NICHT Teil des Rollensystems +// --------------------------------------------------------------------------- + +/** + * Die Rechte eines angemeldeten Portal-Kunden. Fest, kurz, abschliessend. + * + * DIESE TRENNUNG IST ABSICHT UND SOLL BLEIBEN. + * + * Kunden im Portal ziehen ihre Rechte NICHT aus Rollen, sondern aus dieser + * Liste. Das ist kein Altbestand, den man noch migrieren muesste - es ist + * die Sicherung dagegen, dass eine Aenderung am Rollenmodell versehentlich + * auf die Kundenseite durchschlaegt. Ein Kunde, der ueber eine Rolle + * ploetzlich `contracts:create` haelt, koennte in fremdem Namen Vertraege + * anlegen; die Portalansicht ist fuer so etwas nicht gebaut und pruefte es + * nicht. + * + * Wer hier etwas hinzufuegen will, aendert nicht eine Liste, sondern eine + * Vertrauensgrenze. `pruefePortalRechte()` weist beim Start alles ab, was + * nicht Lesen ist - absichtlich als Alarm und nicht als stiller Default. + * + * Die Durchsetzung liegt zusaetzlich im Gate (`requirePermission`): Ein + * Portal-Zugang bekommt dort nur, was auch hier steht, unabhaengig davon, + * was in seinem Token behauptet wird. + */ +export const PORTAL_RECHTE: readonly string[] = [ + 'contracts:read', // eigene Vertraege lesen + 'customers:read', // eigene Kundendaten lesen +] as const; + +const PORTAL_ERLAUBTE_AKTIONEN = ['read']; + +/** + * Wacht darueber, dass die Portal-Liste eine Leseliste bleibt. + * + * Wird beim Start aufgerufen. Ein schreibendes Recht hier waere kein + * Schoenheitsfehler, sondern eine stille Ausweitung der Kundenrechte - und + * genau die soll nicht durch eine unbedachte Zeile passieren koennen. + */ +export function pruefePortalRechte(): string[] { + const beanstandet: string[] = []; + for (const recht of PORTAL_RECHTE) { + const [, aktion] = recht.split(':'); + if (!PORTAL_ERLAUBTE_AKTIONEN.includes(aktion)) { + beanstandet.push(recht); + } + } + return beanstandet; +} diff --git a/backend/src/controllers/appSetting.controller.ts b/backend/src/controllers/appSetting.controller.ts index 12bc8b4a..04795054 100644 --- a/backend/src/controllers/appSetting.controller.ts +++ b/backend/src/controllers/appSetting.controller.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import prisma from '../lib/prisma.js'; import * as appSettingService from '../services/appSetting.service.js'; import { logChange } from '../services/audit.service.js'; @@ -81,10 +82,7 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise { endpoint: ctx.endpoint, }); } - res.status(401).json({ - success: false, - error: msg, - } as ApiResponse); + // `msg` geht weiterhin in den Alarmkanal (oben), aber nicht mehr an den + // Aufrufer: Bei einem abgelehnten Refresh stammt der Wortlaut aus der + // JWT-Bibliothek ("jwt malformed", "invalid signature") und sagt einem + // Angreifer, WORAN sein Token gescheitert ist. Fuer den berechtigten + // Nutzer aendert das nichts - er muss sich so oder so neu anmelden. + antworteAufFehler(res, error, 'Refresh fehlgeschlagen', 401); } } @@ -525,9 +525,6 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons clearRefreshCookie(res); res.json({ success: true, message: 'Passwort geändert' } as ApiResponse); } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden', - } as ApiResponse); + antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500); } } diff --git a/backend/src/controllers/bankLookup.controller.ts b/backend/src/controllers/bankLookup.controller.ts index 9050e916..9a497f13 100644 --- a/backend/src/controllers/bankLookup.controller.ts +++ b/backend/src/controllers/bankLookup.controller.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import { isValidIBAN } from 'ibantools'; import * as blzData from '../services/blzData.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; @@ -47,9 +48,6 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise }, } as ApiResponse); } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim IBAN-Nachschlagen', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500); } } diff --git a/backend/src/controllers/birthday.controller.ts b/backend/src/controllers/birthday.controller.ts index c26db5ca..9ecefb60 100644 --- a/backend/src/controllers/birthday.controller.ts +++ b/backend/src/controllers/birthday.controller.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import { AuthRequest } from '../types/index.js'; import * as birthdayService from '../services/birthday.service.js'; import { sendEmail, SmtpCredentials } from '../services/smtpService.js'; @@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) { res.json({ success: true }); } catch (error) { console.error('Fehler beim Zurücksetzen:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen', - }); + antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500); } } @@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) { }); } catch (error) { console.error('Fehler beim Senden des Geburtstagsgrußes:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Senden', - }); + antworteAufFehler(res, error, 'Fehler beim Senden', 500); } } diff --git a/backend/src/controllers/blzData.controller.ts b/backend/src/controllers/blzData.controller.ts index 3923929c..5a8168ef 100644 --- a/backend/src/controllers/blzData.controller.ts +++ b/backend/src/controllers/blzData.controller.ts @@ -1,4 +1,5 @@ import { Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import * as blzData from '../services/blzData.service.js'; @@ -9,10 +10,7 @@ export async function getStatus(req: AuthRequest, res: Response): Promise const status = await blzData.getStatus(); res.json({ success: true, data: status } as ApiResponse); } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500); } } @@ -32,9 +30,6 @@ export async function updateNow(req: AuthRequest, res: Response): Promise const status = await blzData.getStatus(); res.json({ success: true, data: { result, status } } as ApiResponse); } catch (error) { - res.status(502).json({ - success: false, - error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen', - } as ApiResponse); + antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502); } } diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index ca32a703..c933d14d 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -1,6 +1,7 @@ // ==================== CACHED EMAIL CONTROLLER ==================== import { Request, Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import * as cachedEmailService from '../services/cachedEmail.service.js'; import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'; import * as invoiceService from '../services/invoice.service.js'; @@ -272,10 +273,7 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise res.json({ success: true, data: email } as ApiResponse); } catch (error) { console.error('assignToContract error:', error); - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail'); } } @@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro res.json({ success: true, data: email } as ApiResponse); } catch (error) { console.error('unassignFromContract error:', error); - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung'); } } @@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise } catch (error) { console.error('saveAttachmentTo error:', error); // Detailliertere Fehlermeldung für Debugging - const status = error instanceof ApiError ? error.statusCode : 500; - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - res.status(status).json({ - success: false, - error: `Fehler beim Speichern des Anhangs: ${errorMessage}`, - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 500); } } @@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise }); res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags'); } } @@ -447,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise res.status(201).json({ success: true, data: task } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe'); } } @@ -239,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom res.status(201).json({ success: true, data: task } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage'); } } @@ -265,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise res.json({ success: true, data: task } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe'); } } @@ -283,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise }); res.json({ success: true, data: task } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe'); } } @@ -319,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise }); res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe'); } } @@ -357,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise : sanitizeCustomerStrict(customer as any); res.status(201).json({ success: true, data: sanitized } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden'); } } @@ -276,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise res.json({ success: true, data: sanitized } as ApiResponse); } catch (error) { console.error('Update customer error:', error); - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden'); } } @@ -296,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise }); res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden'); } } @@ -328,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise const result = await emailProviderService.testProviderConnection({ id, testData }); res.json({ success: result.success, data: result } as ApiResponse); } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen', - } as ApiResponse); + antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500); } } @@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise } as ApiResponse); } catch (error) { console.error('testMailAccess error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Test', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Test', 500); } } @@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise const result = await emailProviderService.provisionEmail(localPart, customerEmail); res.json({ success: result.success, data: result } as ApiResponse); } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500); } } @@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise res.status(201).json({ success: true, data: invoice } as ApiResponse); } catch (error) { console.error('addInvoice error:', error); - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung'); } } @@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse); } } catch (error) { - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen', - } as ApiResponse); + antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500); } } @@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise }); res.status(201).json({ success: true, data: platform } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform'); } } @@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise }); res.json({ success: true, data: platform } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform'); } } @@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise }); res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform'); } } diff --git a/backend/src/controllers/provider.controller.ts b/backend/src/controllers/provider.controller.ts index 58939432..99757c86 100644 --- a/backend/src/controllers/provider.controller.ts +++ b/backend/src/controllers/provider.controller.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as providerService from '../services/provider.service.js'; @@ -99,10 +100,7 @@ export async function createProvider(req: Request, res: Response): Promise }); res.status(201).json({ success: true, data: provider } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters'); } } @@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise }); res.json({ success: true, data: provider } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters'); } } @@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise }); res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters'); } } diff --git a/backend/src/controllers/stressfreiEmail.controller.ts b/backend/src/controllers/stressfreiEmail.controller.ts index f41f260f..3d099bf8 100644 --- a/backend/src/controllers/stressfreiEmail.controller.ts +++ b/backend/src/controllers/stressfreiEmail.controller.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; @@ -93,11 +94,7 @@ export async function createEmail(req: Request, res: Response): Promise { }); res.status(201).json({ success: true, data: email } as ApiResponse); } catch (error) { - const status = error instanceof ApiError ? error.statusCode : 400; - res.status(status).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400); } } @@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise { }); res.status(201).json({ success: true, data: tariff } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs'); } } @@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise { }); res.json({ success: true, data: tariff } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs'); } } @@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise { }); res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs'); } } diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 84aab0c8..bdbdeae8 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { antworteAufFehler } from '../utils/fehlerAntwort.js'; import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; @@ -520,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise { }); res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers', - } as ApiResponse); + antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers'); } } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 2478b465..5312e22b 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,4 +1,5 @@ import { Response, NextFunction } from 'express'; +import { PORTAL_RECHTE } from '../config/rechte-katalog.js'; import jwt from 'jsonwebtoken'; import prisma from '../lib/prisma.js'; import { AuthRequest, JwtPayload } from '../types/index.js'; @@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) { return; } - const userPermissions = req.user.permissions || []; + // Portal-Zugaenge bekommen hier nur, was in PORTAL_RECHTE steht - + // unabhaengig davon, was ihr Token behauptet. + // + // Das ist ein Riegel, kein Filter: Die Trennung zwischen Kundenportal und + // Rollensystem ist Absicht (siehe config/rechte-katalog.ts). Ein Kunde + // darf niemals operative Rechte bekommen - die Portalansicht ist dafuer + // nicht gebaut und prueft es nicht. Bisher haette eine einzige unbedachte + // Zeile in der Token-Erzeugung gereicht, um das zu kippen; ab hier + // braeuchte es zwei, an zwei Stellen, in Kenntnis dieses Kommentars. + const userPermissions = req.user.isCustomerPortal + ? (req.user.permissions || []).filter((p) => PORTAL_RECHTE.includes(p)) + : req.user.permissions || []; // Check if user has any of the required permissions const hasPermission = requiredPermissions.some((perm) => diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 72476f71..81d0702e 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,4 +1,5 @@ import prisma from '../lib/prisma.js'; +import { PORTAL_RECHTE } from '../config/rechte-katalog.js'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; @@ -420,10 +421,9 @@ export async function customerLogin(email: string, password: string) { const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id); // Kundenportal-Berechtigungen (eingeschränkt) - const customerPermissions = [ - 'contracts:read', // Eigene Verträge lesen - 'customers:read', // Eigene Kundendaten lesen - ]; + // Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom + // Rollensystem - siehe den Kommentar dort. + const customerPermissions = [...PORTAL_RECHTE]; const payload: JwtPayload = { email: customer.portalEmail!, @@ -791,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) { if (!customer || !customer.portalEnabled) return null; - const customerPermissions = [ - 'contracts:read', - 'customers:read', - ]; + const customerPermissions = [...PORTAL_RECHTE]; // Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10): // ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen. diff --git a/backend/src/services/customerReferral.service.ts b/backend/src/services/customerReferral.service.ts index 97f8245d..6a1da516 100644 --- a/backend/src/services/customerReferral.service.ts +++ b/backend/src/services/customerReferral.service.ts @@ -2,6 +2,7 @@ // "Geworben / angeworben": wer hat wen an Board geholt. import prisma from '../lib/prisma.js'; +import { FachlicherFehler } from '../utils/apiError.js'; // Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown // nicht – ein handgebauter Request mit beliebigem String wird abgewiesen. @@ -64,10 +65,11 @@ export async function getReferralsForCustomer(customerId: number) { }; } -export class ReferralError extends Error { - status: number; +export class ReferralError extends FachlicherFehler { + /** Beibehalten, weil bestehende Aufrufer `.status` lesen. */ + readonly status: number; constructor(message: string, status = 400) { - super(message); + super(message, status); this.status = status; } } diff --git a/backend/src/services/pflichtrechte.service.ts b/backend/src/services/pflichtrechte.service.ts index 34ebae05..bea43f00 100644 --- a/backend/src/services/pflichtrechte.service.ts +++ b/backend/src/services/pflichtrechte.service.ts @@ -1,5 +1,5 @@ import prisma from '../lib/prisma.js'; -import { SYSTEMROLLEN } from '../config/rechte-katalog.js'; +import { SYSTEMROLLEN, pruefePortalRechte } from '../config/rechte-katalog.js'; /** * Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand @@ -59,6 +59,27 @@ export async function pruefePflichtrechte(): Promise { } } + // Wache ueber die Portal-Trennung. + // + // Ein schreibendes Recht in PORTAL_RECHTE waere kein Schoenheitsfehler, + // sondern eine stille Ausweitung der Kundenrechte - jeder Portal-Kunde + // haette es sofort. Diese Meldung soll den Tag ueberleben, an dem jemand + // "nur kurz" etwas hinzufuegt. + const portalBeanstandet = pruefePortalRechte(); + if (portalBeanstandet.length > 0) { + console.warn( + '\n' + + '========================================================================\n' + + ' ACHTUNG: Das Kundenportal traegt Rechte, die nicht Lesen sind:\n' + + portalBeanstandet.map((r) => ` – ${r}\n`).join('') + + '\n' + + ' Kunden sollen im Portal NIEMALS operative Rechte haben. Die Ansicht\n' + + ' ist dafuer nicht gebaut und prueft es nicht. Bitte PORTAL_RECHTE in\n' + + ' config/rechte-katalog.ts pruefen.\n' + + '========================================================================\n', + ); + } + // Zweite Wache: Stimmen die Systemrollen noch? // // Die versteckten Rollen werden ueber ihren NAMEN gefunden diff --git a/backend/src/services/rechte.service.ts b/backend/src/services/rechte.service.ts index da086c32..04e609a4 100644 --- a/backend/src/services/rechte.service.ts +++ b/backend/src/services/rechte.service.ts @@ -24,6 +24,7 @@ */ import prisma from '../lib/prisma.js'; +import { FachlicherFehler } from '../utils/apiError.js'; /** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */ const INT_MAX = 2147483647; @@ -35,13 +36,13 @@ import { } from '../config/rechte-katalog.js'; /** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */ -export class RechteEskalationError extends Error { +export class RechteEskalationError extends FachlicherFehler { constructor(public readonly fehlend: string[]) { super( 'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' + `Nicht vergeben werden können: ${fehlend.join(', ')}`, + 403, ); - this.name = 'RechteEskalationError'; } } @@ -50,18 +51,16 @@ export class RechteEskalationError extends Error { * Rollen-ID. Eigene Klasse, damit daraus ein 400 wird und nicht ein 500 aus * einem Fremdschluesselfehler tief in Prisma. */ -export class UngueltigeEingabeError extends Error { +export class UngueltigeEingabeError extends FachlicherFehler { constructor(nachricht: string) { - super(nachricht); - this.name = 'UngueltigeEingabeError'; + super(nachricht, 400); } } /** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */ -export class RollenSperrError extends Error { +export class RollenSperrError extends FachlicherFehler { constructor(nachricht: string) { - super(nachricht); - this.name = 'RollenSperrError'; + super(nachricht, 403); } } diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 3499fa83..08cb7a4e 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -169,6 +169,79 @@ export async function getUserById(id: number) { }; } +/** + * Faehigkeiten, ohne die sich das System nicht mehr verwalten laesst. + * + * Bis 09/2026 galt hier "wer `users:delete` hat, ist Admin". Das war ein + * Zufallsmerkmal: Wer Konten anlegen und bearbeiten darf, aber nicht loeschen, + * verwaltet genauso - zaehlte aber nicht. Umgekehrt zaehlte jemand mit nur + * `users:delete` als Admin, obwohl er kein Konto anlegen kann. Ein + * Sicherheitsnetz an einem Merkmal, das mit der geschuetzten Eigenschaft nur + * lose zusammenhing (Pentest R193, Nachrangpunkt 2). + * + * Bewusst NICHT auf die Rolle "Admin" umgestellt, wie zunaechst vorgeschlagen: + * Eine selbst gebaute Rolle mit `users:update` verwaltet tatsaechlich, und ein + * Namenskriterium wuerde sie uebersehen - man koennte dann den letzten + * Admin loeschen, obwohl die Verwaltungsfaehigkeit erhalten bliebe. Geschuetzt + * wird die FAEHIGKEIT, nicht ihr ueblicher Traeger. + * + * `users:update` ist das Minimum, um Konten wieder in Ordnung zu bringen + * (Rollen zuweisen). `roles:manage` ist das Minimum, um Rollen zu definieren. + * Faellt eines davon ersatzlos weg, hilft nur noch die Kommandozeile. + */ +const UNVERZICHTBARE_FAEHIGKEITEN = [ + { recht: 'users:update', wofuer: 'Benutzer verwalten' }, + { recht: 'roles:manage', wofuer: 'Rollen und Rechte pflegen' }, +] as const; + +/** Die Rechte eines Kontos aus seinen geladenen Rollen. */ +function rechteAusRollen( + rollen: Array<{ role: { permissions: Array<{ permission: { resource: string; action: string } }> } }>, +): Set { + const rechte = new Set(); + for (const ur of rollen) { + for (const rp of ur.role.permissions) { + rechte.add(`${rp.permission.resource}:${rp.permission.action}`); + } + } + return rechte; +} + +/** + * Verhindert, dass die letzte Person mit einer unverzichtbaren Faehigkeit + * sie verliert - durch Rollenwechsel, Deaktivierung oder Loeschung. + * + * `kuenftigeRechte === null` heisst: Das Konto faellt ganz weg. + */ +async function pruefeLetzterVerwalter( + zielId: number, + bisherigeRechte: Set, + kuenftigeRechte: Set | null, +): Promise { + for (const { recht, wofuer } of UNVERZICHTBARE_FAEHIGKEITEN) { + if (!bisherigeRechte.has(recht)) continue; // hatte es nie + if (kuenftigeRechte?.has(recht)) continue; // behaelt es + + const [resource, action] = recht.split(':'); + const andere = await prisma.user.count({ + where: { + id: { not: zielId }, + isActive: true, + roles: { some: { role: { permissions: { some: { permission: { resource, action } } } } } }, + }, + }); + + if (andere === 0) { + throw new Error( + `Dieses Konto ist das letzte, das „${wofuer}" kann (${recht}). ` + + 'Ohne es liesse sich das System nur noch über die Kommandozeile ' + + 'wieder in Ordnung bringen. Bitte zuerst einem anderen aktiven Konto ' + + 'die entsprechende Rolle geben.', + ); + } + } +} + export async function createUser(data: { email: string; password: string; @@ -324,74 +397,31 @@ export async function updateUser( }, }); - const isCurrentlyAdmin = currentUser?.roles.some((ur) => - ur.role.permissions.some( - (rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete' - ) - ); + // Geschuetzt wird die FAEHIGKEIT, nicht ihr ueblicher Traeger - + // siehe UNVERZICHTBARE_FAEHIGKEITEN. + const bisherigeRechte = rechteAusRollen(currentUser?.roles ?? []); - 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' - ); - } - } - } + let kuenftigeRechte: Set | null; + if (isBeingDeactivated) { + // Ein deaktiviertes Konto kann nichts mehr, egal welche Rollen formal + // drankleben. + kuenftigeRechte = null; + } else if (rolesAreBeingChanged) { + const neueRollen = await prisma.role.findMany({ + where: { id: { in: gepruefteRollenIds! } }, + include: { permissions: { include: { permission: true } } }, + }); + kuenftigeRechte = rechteAusRollen(neueRollen.map((role) => ({ role }))); + } else { + kuenftigeRechte = bisherigeRechte; } + + await pruefeLetzterVerwalter(id, bisherigeRechte, kuenftigeRechte); } // Hash password if provided if (password) { - (userData as Record).password = await bcrypt.hash(password, 10); + (userData as Record).password = await bcrypt.hash(password, 12); } // Prüfen ob Rollen geändert werden (für Zwangslogout) @@ -549,42 +579,9 @@ export async function deleteUser(id: number) { 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' - ); - } - } + // Dasselbe Kriterium wie beim Bearbeiten: Geschuetzt wird die Faehigkeit, + // das System zu verwalten - nicht die Rolle, die sie ueblicherweise traegt. + await pruefeLetzterVerwalter(id, rechteAusRollen(user.roles), null); return prisma.user.delete({ where: { id } }); } diff --git a/backend/src/utils/apiError.ts b/backend/src/utils/apiError.ts index d4cc2421..ccb227e3 100644 --- a/backend/src/utils/apiError.ts +++ b/backend/src/utils/apiError.ts @@ -8,11 +8,33 @@ * obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits * angelegt" eindeutig eine 400-Class-Situation ist. */ -export class ApiError extends Error { +/** + * Basisklasse fuer Fehler, deren Wortlaut fuer den AUFRUFER bestimmt ist. + * + * Der Unterschied ist der Kern von R190-01 und R192-01: Bis 09/2026 reichten + * die Controller jede `error.message` durch. Damit landete erst der + * vollstaendige Prisma-Aufruf samt Serverpfad beim Client und dann der + * Wortlaut eines TypeError. Beide Male dieselbe Ursache - eine Meldung, die + * fuer Entwickler geschrieben ist, an einen Empfaenger, fuer den sie nicht + * gedacht war. + * + * Ab hier gilt: Was von dieser Klasse abstammt (oder ein blankes `Error` ist, + * das wir absichtlich geworfen haben), geht nach draussen. Alles andere - + * TypeError, Prisma, was auch immer - wird protokolliert und durch eine + * allgemeine Auskunft ersetzt. + */ +export class FachlicherFehler extends Error { readonly statusCode: number; - constructor(statusCode: number, message: string) { + constructor(message: string, statusCode = 400) { super(message); - this.name = 'ApiError'; + this.name = new.target.name; this.statusCode = statusCode; } } + +export class ApiError extends FachlicherFehler { + constructor(statusCode: number, message: string) { + super(message, statusCode); + this.name = 'ApiError'; + } +} diff --git a/backend/src/utils/fehlerAntwort.ts b/backend/src/utils/fehlerAntwort.ts new file mode 100644 index 00000000..8bb67783 --- /dev/null +++ b/backend/src/utils/fehlerAntwort.ts @@ -0,0 +1,54 @@ +/** + * Eine Stelle, an der entschieden wird, was ein Fehler dem Aufrufer sagt. + * + * Vorher stand in 26 Controllern 124-mal dieselbe Zeile: + * + * error: error instanceof Error ? error.message : 'Irgendein Fallback' + * + * Das reicht JEDE Fehlermeldung durch - auch die, die fuer Entwickler + * geschrieben sind. Konkret gefunden wurden ein vollstaendiger Prisma-Aufruf + * samt Serverpfad (R190-01) und der Wortlaut eines TypeError (R192-01). Kein + * Loch fuer sich genommen, aber eine Landkarte: Pfade, Spaltennamen, + * eingesetzte Bibliotheken. + * + * 124 Einzelkorrekturen waeren die falsche Antwort gewesen - das ist die + * Falle aus R186-01 und R188, wo dieselbe Regel in mehreren Kopien lebte und + * auseinanderlief. Deshalb eine Funktion. + * + * Die Unterscheidung laeuft ueber die Fehlerklasse, nicht ueber eine + * Heuristik auf dem Text: + * + * - `FachlicherFehler` (und Abkoemmlinge wie `ApiError`) - absichtlich + * geworfen, Wortlaut fuer den Aufrufer, eigener Statuscode. + * - blankes `Error` - ebenfalls absichtlich geworfen, historisch die + * ueblichste Form ("Der letzte Admin kann nicht..."). Geht mit dem + * uebergebenen Status hinaus. + * - alles andere (TypeError, RangeError, Prisma...) - Programmierfehler oder + * Fremdfehler. Geht als 500 mit der allgemeinen Auskunft hinaus, die + * Einzelheiten ins Protokoll. 500 und nicht 400, weil ein + * Eingabefehler-Code hier die naechste Meldung waere, die sich falsch + * ausgibt. + */ + +import { Response } from 'express'; +import { FachlicherFehler } from './apiError.js'; + +export function antworteAufFehler( + res: Response, + error: unknown, + fallback: string, + status = 400, +): void { + if (error instanceof FachlicherFehler) { + res.status(error.statusCode).json({ success: false, error: error.message }); + return; + } + + if (error instanceof Error && error.constructor === Error) { + res.status(status).json({ success: false, error: error.message }); + return; + } + + console.error(`[${fallback}] unerwarteter Fehler:`, error); + res.status(500).json({ success: false, error: fallback }); +} diff --git a/docs/todo.md b/docs/todo.md index fd831474..54628d38 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -102,6 +102,69 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung + +- [x] **🧹 Aufräumrunde vor Etappe 2: drei Nachrangpunkte** (2026-09-09) + + **1. R192-01 projektweit — interne Fehlermeldungen an den Client** + - Das Muster `error instanceof Error ? error.message` stand **124-mal in 26 + Controllern**. Jede dieser Stellen konnte Serverpfade, Spaltennamen und + Bibliotheksinterna ausliefern — kein Loch für sich, aber eine Landkarte. + - Zentral gelöst statt 124-mal einzeln (die Falle aus R186-01/R188): + `src/utils/fehlerAntwort.ts` mit `antworteAufFehler()`. Jetzt **123 + Aufrufe in 26 Dateien**, eine Regel. + - Die Unterscheidung läuft über die **Fehlerklasse**: Neue Basisklasse + `FachlicherFehler` (in `utils/apiError.ts`) für alles, dessen Wortlaut + für den Aufrufer bestimmt ist — `ApiError`, `RechteEskalationError` (403), + `RollenSperrError` (403), `UngueltigeEingabeError` (400), `FilterFehler`, + `ReferralError` stammen jetzt davon ab. Ein blankes `Error` gilt weiter + als absichtlich. Alles andere (TypeError, Prisma, JWT-Bibliothek) → **500** + mit allgemeiner Auskunft, Einzelheiten ins Protokoll. + - Mit gefunden: Sechs Stellen in `cachedEmail.controller.ts` **interpolierten** + die interne Meldung in den Antworttext (`Fehler beim Speichern: ${msg}`) — + die hätte kein Klassenfilter erwischt, der nur das Feld ersetzt. + Und `POST /auth/refresh` gab den Wortlaut der JWT-Bibliothek zurück + („jwt malformed", „invalid signature") — der sagt einem Angreifer, **woran** + sein Token gescheitert ist. Geht jetzt nur noch in den Alarmkanal. + + **2. Admin-Heuristik — geschützt wird jetzt die Fähigkeit, nicht ihr Träger** + - Bisher galt „wer `users:delete` hat, ist Admin". Ein Zufallsmerkmal: Wer + Konten anlegen und bearbeiten darf, aber nicht löschen, verwaltet genauso — + zählte aber nicht. + - **Bewusst nicht auf die Rolle „Admin" umgestellt**, wie zunächst + vorgeschlagen: Eine selbst gebaute Rolle mit `users:update` verwaltet + tatsächlich, ein Namenskriterium würde sie übersehen — man könnte dann den + letzten Admin löschen, obwohl die Verwaltungsfähigkeit erhalten bliebe. + - Neu: `UNVERZICHTBARE_FAEHIGKEITEN` = `users:update` („Benutzer verwalten") + und `roles:manage` („Rollen und Rechte pflegen"). Wer die letzte Person mit + einer davon ist, kann sie nicht verlieren — durch Rollenwechsel, + Deaktivierung oder Löschung. Die Meldung nennt die Fähigkeit beim Namen + statt „letzter Administrator". + - Nachgeprüft: Mit drei Trägern ist alles erlaubt; ist einer der letzte, + → 400 mit Klartext. Deaktivierung zählt als Verlust (ein inaktives Konto + kann nichts, egal welche Rollen drankleben). + - Nebenbei: `updateUser` hashte beim Passwort-Zurücksetzen noch mit Cost 10 — + der letzte Rest der Inkonsistenz aus R191-01. + + **3. Portal-Kunden — Trennung festgenagelt, nicht aufgelöst** + - Korrektur meiner eigenen Einordnung: Ich hatte das als „noch zu migrieren" + geführt. Falsch. Kunden bekommen **niemals** operative Rechte; die + Portalansicht ist dafür nicht gebaut und prüft es nicht. + - Die zwei Kopien des festen Arrays in `auth.service.ts` sind jetzt eine + Konstante `PORTAL_RECHTE` mit einem Kommentar, der die Trennung als + Absicht benennt. + - **Harter Riegel im Gate:** `requirePermission` schneidet die Rechte eines + Portal-Zugangs auf `PORTAL_RECHTE` zu — unabhängig davon, was sein Token + behauptet. Heute wirkungslos (sie halten ohnehin nur diese zwei), morgen + die Sicherung: Bisher hätte eine unbedachte Zeile in der Token-Erzeugung + gereicht, um die Trennung zu kippen. + - **Startwache:** `pruefePortalRechte()` meldet beim Start jedes Recht in + `PORTAL_RECHTE`, das nicht Lesen ist. Damit überlebt die Regel den Tag, an + dem jemand „nur kurz" etwas hinzufügt. + - Dateien: `utils/fehlerAntwort.ts` (neu), `utils/apiError.ts`, + `config/rechte-katalog.ts`, `middleware/auth.ts`, `services/auth.service.ts`, + `services/user.service.ts`, `services/rechte.service.ts`, + `services/pflichtrechte.service.ts`, 26 Controller + - [x] **🚨 R193-02: Truthiness-Bypass bei den drei Haken (HIGH)** (2026-09-09) - Befund der Pentesterin, **Prod-Blocker**: Der Eskalations-Guard fragte `=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden