diff --git a/backend/src/services/rechte.service.ts b/backend/src/services/rechte.service.ts index affaf337..b552e405 100644 --- a/backend/src/services/rechte.service.ts +++ b/backend/src/services/rechte.service.ts @@ -24,6 +24,9 @@ */ import prisma from '../lib/prisma.js'; + +/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */ +const INT_MAX = 2147483647; import { ROLLE_DEVELOPER, ROLLE_DSGVO, @@ -111,10 +114,15 @@ export async function rechteVonRollen(roleIds: number[]): Promise> { export async function rechteVonPermissionIds(ids: number[]): Promise> { const rechte = new Set(); if (ids.length === 0) return rechte; - const gefunden = await prisma.permission.findMany({ where: { id: { in: ids } } }); - if (gefunden.length !== new Set(ids).size) { + const eindeutig = [...new Set(ids)]; + const zuGross = eindeutig.filter((id) => id > INT_MAX); + const abfragbar = eindeutig.filter((id) => id <= INT_MAX); + const gefunden = abfragbar.length + ? await prisma.permission.findMany({ where: { id: { in: abfragbar } } }) + : []; + if (gefunden.length !== abfragbar.length || zuGross.length > 0) { const bekannt = new Set(gefunden.map((p) => p.id)); - const unbekannt = [...new Set(ids)].filter((id) => !bekannt.has(id)); + const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross]; throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`); } for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`); @@ -213,13 +221,22 @@ export async function normalisiereRollenIds(roleIds: number[]): Promise Number.isInteger(id) && id >= 1)) { throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten'); } - const gefunden = await prisma.role.findMany({ - where: { id: { in: eindeutig } }, - select: { id: true }, - }); - if (gefunden.length !== eindeutig.length) { + + // Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht + // dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim + // Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse + // (Pentest R191, kosmetisch). Eine ID, die nicht in die Spalte passt, + // benennt keine Rolle; sie ist schlicht unbekannt. + const zuGross = eindeutig.filter((id) => id > INT_MAX); + const abfragbar = eindeutig.filter((id) => id <= INT_MAX); + + const gefunden = abfragbar.length + ? await prisma.role.findMany({ where: { id: { in: abfragbar } }, select: { id: true } }) + : []; + + if (gefunden.length !== abfragbar.length || zuGross.length > 0) { const bekannt = new Set(gefunden.map((r) => r.id)); - const unbekannt = eindeutig.filter((id) => !bekannt.has(id)); + const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross]; throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`); } return eindeutig; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 78d72e95..a595d65f 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -11,7 +11,20 @@ import { meldeTraegerAb, RollenSperrError, } from './rechte.service.js'; -import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js'; +import { + istSystemrollenName, + RECHTE_KATALOG, + ROLLE_DEVELOPER, + ROLLE_DSGVO, + ROLLE_AUDIT_BETRIEB, +} from '../config/rechte-katalog.js'; +import { Prisma } from '@prisma/client'; + +/** + * Re-Export aus dem Katalog. Der Name ist an mehreren Stellen im Umlauf; + * die Wahrheit steht jetzt an einer Stelle. + */ +export const AUDIT_OPS_ROLLE = ROLLE_AUDIT_BETRIEB; export interface UserFilters { search?: string; @@ -182,50 +195,49 @@ export async function createUser(data: { ...rechteDerHaken(data), ]); - const hashedPassword = await bcrypt.hash(data.password, 10); + // Cost 12 wie in prisma/seed.ts (OWASP 2026). Hier stand 10 - dieselbe + // Haertung, die im Seed laengst galt, war an dieser Stelle nie + // angekommen. Bestehende Kennwoerter bleiben pruefbar, der Cost steckt im + // Hash. + const hashedPassword = await bcrypt.hash(data.password, 12); - 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 })), + const user = await prisma.$transaction(async (tx) => { + const angelegt = await tx.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 }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + isServiceAccount: true, + customerId: true, + roles: { + include: { role: true }, + }, }, - }, + }); + + // Die Haken in derselben Transaktion wie das Konto (Pentest R191-01). + // Sonst koennte ein halb ausgestattetes Konto zurueckbleiben: angelegt, + // aber ohne die zugesagte versteckte Rolle. + await setzeHaken(tx, angelegt.id, data); + + return angelegt; }); - // 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; } @@ -385,217 +397,120 @@ export async function updateUser( !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. + // Die gesamte Schreibphase 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({ + // Zwei Stufen, beide aus dem Pentest: + // + // R190-01: deleteMany und createMany standen nackt nebeneinander. + // Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto + // hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt, + // nichts geschehen" aussah. + // + // R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die + // drei Haken aber liefen als eigene Schreibvorgaenge hinterher. Ein + // Datenbankfehler dort hinterliess den Rollenstand gesetzt und die Haken + // halb - kein Verlust und keine Rechteerhoehung, aber ein Zwischenstand, + // den niemand angefordert hat. Ein PUT ist ein Vorgang, also gehoert er + // in eine Klammer. + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id }, + data: { + ...userData, + // Token invalidieren wenn Rollen geändert werden + ...(rolesChanged && { tokenInvalidatedAt: new Date() }), + }, + }); + + if (gepruefteRollenIds !== undefined) { + await tx.userRole.deleteMany({ where: { userId: id } }); + await tx.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); - } + // Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die + // versteckten Rollen. Die Haken setzen sie anschliessend wieder. + await setzeHaken(tx, id, { hasDeveloperAccess, hasGdprAccess, 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. + * Setzt oder entfernt eine der drei versteckten Rollen (DSGVO, Developer, + * Audit-Betrieb) - innerhalb der uebergebenen Transaktion. * - * 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. + * Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei + * Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und + * zugleich eine vierte Stelle, die festlegte, was "Developer" bedeutet - + * mit einem anderen Rechtesatz als der Katalog (nur `developer:access` + * statt allem) und ohne `isSystem`. Eine so entstandene Rolle waere ueber + * die Rollenverwaltung aenderbar gewesen. + * + * Den Notfallpfad braucht es nicht mehr: `synchronisiereRechteUndRollen` + * legt die Rollen bei jedem Containerstart an, und die Startwache meldet, + * wenn eine fehlt. Fehlt sie hier trotzdem, ist Abbrechen mit klarer Ansage + * ehrlicher, als stillschweigend etwas Aehnliches zu erfinden. */ -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 } }); - +async function setzeVersteckteRolle( + tx: Prisma.TransactionClient, + userId: number, + rollenName: string, + aktiv: boolean, +): Promise { + const rolle = await tx.role.findUnique({ where: { name: rollenName } }); 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 })) }, - }, - }); + throw new Error( + `Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` + + 'Containerstart angelegt – einmalig nachholen mit: npx tsx prisma/sync-roles.ts', + ); } - const hatRolle = await prisma.userRole.findFirst({ - where: { userId, roleId: rolle.id }, + const vorhanden = await tx.userRole.findUnique({ + where: { userId_roleId: { 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({ + if (aktiv && !vorhanden) { + await tx.userRole.create({ data: { userId, roleId: rolle.id } }); + } else if (!aktiv && vorhanden) { + await tx.userRole.delete({ where: { userId_roleId: { userId, roleId: rolle.id } }, }); - await prisma.user.update({ - where: { id: userId }, - data: { tokenInvalidatedAt: new Date() }, - }); + } else { + return; // nichts geaendert - dann auch niemanden abmelden + } + + // Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens. + await tx.user.update({ + where: { id: userId }, + data: { tokenInvalidatedAt: new Date() }, + }); +} + +/** Die drei Haken auf ihre versteckten Rollen abbilden. */ +async function setzeHaken( + tx: Prisma.TransactionClient, + userId: number, + haken: { + hasDeveloperAccess?: boolean; + hasGdprAccess?: boolean; + hasAuditOpsAccess?: boolean; + }, +): Promise { + if (haken.hasDeveloperAccess !== undefined) { + await setzeVersteckteRolle(tx, userId, ROLLE_DEVELOPER, haken.hasDeveloperAccess); + } + if (haken.hasGdprAccess !== undefined) { + await setzeVersteckteRolle(tx, userId, ROLLE_DSGVO, haken.hasGdprAccess); + } + if (haken.hasAuditOpsAccess !== undefined) { + await setzeVersteckteRolle(tx, userId, AUDIT_OPS_ROLLE, haken.hasAuditOpsAccess); } } + export async function deleteUser(id: number) { // Check if user is an admin const user = await prisma.user.findUnique({ diff --git a/docs/todo.md b/docs/todo.md index 77b56b11..45e6651b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -99,6 +99,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung + +- [x] **🔗 R191-01: Ein PUT, eine Klammer** (2026-09-09) + - Befund der Pentesterin nach dem R190-01-Fix: Nur der Rollentausch lag in + der Transaktion, die drei Haken (DSGVO/Developer/Audit-Betrieb) liefen + **danach** in eigenen Schreibvorgängen. Ein Multi-Feld-PUT war damit nicht + gesamt-atomar — bricht ein Haken-Write ab, steht der Rollenstand schon und + die Haken halb. Kein Verlust, keine Eskalation, aber ein Zwischenstand, den + niemand angefordert hat. + - Fix: Die **gesamte** Schreibphase von `updateUser` in einer + `$transaction` — Benutzerdaten, Rollentausch, alle drei Haken. + `createUser` ebenso: Konto und Haken zusammen. + - **Beim Umbau mitgefunden:** Die drei Haken-Helfer **legten die Rolle bei + Bedarf selbst an**, falls sie fehlte. Das war eine *vierte* Stelle, die + definierte, was „Developer" bedeutet — mit einem anderen Rechtesatz als der + Katalog (nur `developer:access` statt allem) und **ohne `isSystem`**. Eine + so entstandene Rolle wäre über die Rollenverwaltung änderbar gewesen. Der + Notfallpfad ist weg: `sync-roles` legt die Rollen bei jedem Start an, die + Startwache meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang + mit klarer Ansage ab statt etwas Ähnliches zu erfinden. Drei fast gleiche + Funktionen wurden dabei eine. + - **int4-Überlauf** (ihr kosmetischer Nebenbefund): IDs jenseits des + INT-Bereichs werden gar nicht mehr abgefragt, sondern als unbekannt + gemeldet — `9999999999` sagt jetzt „Unbekannte Rollen-ID" statt + „Fehler beim Aktualisieren". Gleiche Behandlung für Rechte-IDs. + - **`createUser` hasht jetzt mit Cost 12** statt 10, wie `seed.ts`. Wieder + eine Härtung, die nur in einer von zwei Kopien angekommen war. + - Nachgeprüft: Rollback bewiesen, indem die DSGVO-Rolle vorübergehend + umbenannt und ein `{roleIds:[23], hasGdprAccess:true}` geschickt wurde → + 400 mit Klartext, Rollen **unverändert** (kein Teil-Write). Ihre + Typvektoren `"22"`, `4.5`, `1e3`, `true`, `null`, Riesenzahl → alle 400, + kein Wipe. R190-01-Regression erneut grün. + - Dateien: `src/services/user.service.ts`, `src/services/rechte.service.ts` + - [x] **🧨 R190-01: Rollentausch zerstörte, wo er ablehnte** (2026-09-09) - Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany` **ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId