diff --git a/backend/prisma/migrations/20260904090000_role_is_system/migration.sql b/backend/prisma/migrations/20260904090000_role_is_system/migration.sql new file mode 100644 index 00000000..5407a1d3 --- /dev/null +++ b/backend/prisma/migrations/20260904090000_role_is_system/migration.sql @@ -0,0 +1,28 @@ +-- Systemrollen kennzeichnen (Rechtemodell, Etappe 1). +-- +-- Bis hierher war jede Rolle ueber die Rollen-CRUD frei aenderbar - auch +-- "Admin", "DSGVO" und "Developer". Man konnte sie umbenennen oder ihre +-- Rechte leeren. Zugleich haengen die versteckten Rollen an ihrem NAMEN: +-- `user.service.ts` sucht sie per `findFirst({ where: { name: 'DSGVO' } })`. +-- Ein umbenannter Datensatz haette den Notfallpfad ins Leere laufen lassen, +-- ohne dass irgendwo etwas gemeldet worden waere. +-- +-- `isSystem` macht diese Rollen zu dem, was sie immer sein sollten: von der +-- Anwendung gepflegt, ueber die API sichtbar, aber nicht veraenderbar. +-- `isHidden` ersetzt die im Frontend hartkodierte Namensliste, in der +-- "Gegenbuch" bisher fehlte - die Rolle tauchte deshalb als anhakbare +-- Rolle im Benutzerformular auf. +ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isSystem` BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isHidden` BOOLEAN NOT NULL DEFAULT false; + +-- Backfill nach Namen. Wer eine dieser Rollen lokal umbenannt hat, wird hier +-- nicht getroffen; `synchronisiereRechteUndRollen` legt beim naechsten Start +-- eine neue Rolle unter dem erwarteten Namen an und markiert sie. Das ist +-- sichtbar (zwei Rollen in der Liste) und damit behandelbar - im Gegensatz +-- zu einer stillen Fehlzuordnung. +UPDATE `Role` SET `isSystem` = true + WHERE `name` IN ('Admin','Developer','DSGVO','Audit-Betrieb','Gegenbuch', + 'Mitarbeiter','Mitarbeiter (Nur-Lesen)','Kunde'); + +UPDATE `Role` SET `isHidden` = true + WHERE `name` IN ('Developer','DSGVO','Audit-Betrieb','Gegenbuch','Kunde'); diff --git a/backend/prisma/migrations/20260904091000_rechte_granular/migration.sql b/backend/prisma/migrations/20260904091000_rechte_granular/migration.sql new file mode 100644 index 00000000..5c07d650 --- /dev/null +++ b/backend/prisma/migrations/20260904091000_rechte_granular/migration.sql @@ -0,0 +1,104 @@ +-- Rechtekatalog begradigen (Rechtemodell, Etappe 1). +-- +-- 18 der 50 Rechte bewachten nichts: `tariffs:*`, `cancellation-periods:*`, +-- `contract-durations:*` und `email-providers:*` standen im Katalog und waren +-- in der Rollenverwaltung anhakbar - die Routen prueften in Wahrheit +-- `providers:*`, `platforms:*` und `settings:*`. Ein Haken, der nichts tut, +-- ist schlimmer als ein fehlender: Er behauptet eine Trennung, die es nicht +-- gibt, und wer sich darauf verlaesst, vergibt zu viel oder zu wenig, ohne +-- es zu merken. +-- +-- Diese Migration ist REIN ADDITIV. Sie entzieht keiner Rolle irgendetwas. +-- Sie muss vor dem Code laufen, der die Routen umstellt - sonst verlieren +-- bestehende Rollen den Zugriff auf Tarife, Kuendigungsfristen, Laufzeiten +-- und E-Mail-Provider. +-- +-- Das Aufraeumen der jetzt ueberfluessigen Sammelrechte ist ausdruecklich +-- NICHT Aufgabe dieser Migration, sondern eine bewusste Entscheidung des +-- Betreibers in der Rollenoberflaeche. + +-- 1) Fehlende Rechte in den Katalog. +-- UNIQUE(resource, action) traegt die Idempotenz. +INSERT IGNORE INTO `Permission` (`resource`,`action`) VALUES + ('roles','manage'), + ('tariffs','create'),('tariffs','read'),('tariffs','update'),('tariffs','delete'), + ('cancellation-periods','create'),('cancellation-periods','read'), + ('cancellation-periods','update'),('cancellation-periods','delete'), + ('contract-durations','create'),('contract-durations','read'), + ('contract-durations','update'),('contract-durations','delete'), + ('contract-categories','create'),('contract-categories','read'), + ('contract-categories','update'),('contract-categories','delete'), + ('email-providers','create'),('email-providers','read'), + ('email-providers','update'),('email-providers','delete'), + ('platforms','read'); + +-- 2) Vererbung alt -> neu: Jede Rolle, die bisher das Sammelrecht hielt, +-- bekommt das neue Recht dazu. INSERT IGNORE gegen den Primaerschluessel +-- (roleId, permissionId) macht den Block beliebig wiederholbar. +-- +-- `users:delete` -> `roles:manage` bewusst nur ueber `users:delete`, nicht +-- ueber `users:create`/`users:update`: `users:delete` ist im Code bereits +-- die Admin-Heuristik (user.service.ts). Wuerde man `roles:manage` an +-- jeden mit `users:update` vererben, waere die Trennung, die dieses Recht +-- herstellen soll, im selben Zug wieder eingerissen. +INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`) +SELECT rp.`roleId`, neu.`id` +FROM `RolePermission` rp +JOIN `Permission` alt ON alt.`id` = rp.`permissionId` +JOIN ( + SELECT 'providers' AS aRes,'read' AS aAct,'tariffs' AS nRes,'read' AS nAct + UNION ALL SELECT 'providers','create','tariffs','create' + UNION ALL SELECT 'providers','update','tariffs','update' + UNION ALL SELECT 'providers','delete','tariffs','delete' + UNION ALL SELECT 'platforms','create','cancellation-periods','create' + UNION ALL SELECT 'platforms','update','cancellation-periods','update' + UNION ALL SELECT 'platforms','delete','cancellation-periods','delete' + UNION ALL SELECT 'platforms','create','contract-durations','create' + UNION ALL SELECT 'platforms','update','contract-durations','update' + UNION ALL SELECT 'platforms','delete','contract-durations','delete' + UNION ALL SELECT 'settings','read', 'email-providers','read' + UNION ALL SELECT 'settings','update','email-providers','create' + UNION ALL SELECT 'settings','update','email-providers','update' + UNION ALL SELECT 'settings','update','email-providers','delete' + UNION ALL SELECT 'users','delete','roles','manage' +) m ON m.aRes = alt.`resource` AND m.aAct = alt.`action` +JOIN `Permission` neu ON neu.`resource` = m.nRes AND neu.`action` = m.nAct; + +-- 3) Die bisher ungegateten Lese-Endpunkte (Plattformen, Vertragstypen, +-- Kuendigungsfristen, Laufzeiten) bekommen ab jetzt ein Recht. Damit +-- niemand seine Listen verliert, erhalten es alle Rollen, die die +-- Anwendung ueberhaupt benutzen. +-- +-- Bewusst NICHT pauschal an jede Rolle: "Gegenbuch" haelt nur `audit:read` +-- und soll so wenig wert bleiben wie moeglich - sein Kennwort liegt auf +-- der Notar-Maschine im Klartext (R185-01). Dasselbe gilt fuer +-- "Audit-Betrieb". +INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`) +SELECT r.`id`, p.`id` +FROM `Role` r +JOIN `Permission` p + ON (p.`resource`,p.`action`) IN + (('cancellation-periods','read'),('contract-durations','read'), + ('contract-categories','read'),('platforms','read'),('tariffs','read')) +WHERE EXISTS ( + SELECT 1 FROM `RolePermission` rp2 + JOIN `Permission` p2 ON p2.`id` = rp2.`permissionId` + WHERE rp2.`roleId` = r.`id` + AND (p2.`resource`,p2.`action`) IN + (('customers','read'),('contracts','read'),('providers','read'), + ('platforms','create'),('settings','read')) +); + +-- 4) Alle Sitzungen einmalig beenden. +-- +-- Die Rechte stehen im Zugangstoken. Nach der Routenumstellung verlangt +-- z.B. PUT /tariffs/:id das Recht `tariffs:update`; jedes bereits +-- ausgestellte Token traegt aber noch den alten Anspruch und liefe bis zu +-- 15 Minuten lang in ein 403. Die Datenbank waere dann laengst richtig - +-- nur das Token alt. +-- +-- Einmal neu anmelden ist ehrlicher als ein Uebergangs-Doppelgate, das in +-- sechs Monaten jemand fuer Absicht haelt. Die Meldung dafuer gibt es +-- bereits im Klartext ("Ihre Berechtigungen wurden geändert."), und das +-- Gegenbuch-Dienstkonto meldet sich stuendlich ohnehin neu an. +UPDATE `User` SET `tokenInvalidatedAt` = NOW(); diff --git a/backend/prisma/rechte-report.ts b/backend/prisma/rechte-report.ts new file mode 100644 index 00000000..ec537de3 --- /dev/null +++ b/backend/prisma/rechte-report.ts @@ -0,0 +1,157 @@ +/** + * Bestandsaufnahme des Rechtesystems. Liest nur, aendert nichts. + * + * npx tsx prisma/rechte-report.ts → Bericht auf stdout + * npx tsx prisma/rechte-report.ts > vorher.txt + * + * Zweck: Vor und nach dem Rechte-Umbau denselben Bericht erzeugen und + * vergleichen. Die Zusage lautet "niemand verliert Zugriff" - und eine + * Zusage, die niemand nachrechnen kann, ist keine. + * + * Der Bericht ist bewusst zeilenweise und sortiert, damit `diff` darauf + * etwas Lesbares ausgibt. + */ + +import { PrismaClient } from '@prisma/client'; +import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js'; + +const prisma = new PrismaClient(); + +/** + * Liest isSystem/isHidden, sofern die Spalten existieren. + * + * Der Bericht muss VOR und NACH der Migration laufen - das ist sein Zweck. + * Vorher gibt es die Spalten noch nicht, und ein Absturz an dieser Stelle + * haette genau die Baseline verhindert, gegen die spaeter verglichen wird. + */ +async function leseKennzeichen(): Promise> { + const karte = new Map(); + try { + const zeilen = await prisma.$queryRawUnsafe< + Array<{ id: number; isSystem: number | boolean; isHidden: number | boolean }> + >('SELECT id, isSystem, isHidden FROM `Role`'); + for (const z of zeilen) { + karte.set(Number(z.id), { isSystem: Boolean(z.isSystem), isHidden: Boolean(z.isHidden) }); + } + } catch { + // Spalten noch nicht vorhanden - das ist der Zustand vor der Migration. + } + return karte; +} + +async function main(): Promise { + const kennzeichen = await leseKennzeichen(); + const rollen = await prisma.role.findMany({ + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + permissions: { include: { permission: true } }, + _count: { select: { users: true } }, + }, + }); + + console.log('=== ROLLEN ==='); + for (const r of rollen) { + const rechte = r.permissions + .map((rp) => `${rp.permission.resource}:${rp.permission.action}`) + .sort(); + const k = kennzeichen.get(r.id); + const etikett = [k?.isSystem ? 'System' : null, k?.isHidden ? 'versteckt' : null] + .filter(Boolean) + .join(', '); + console.log( + `\n[${r.name}] #${r.id}` + + (etikett ? ` (${etikett})` : '') + + ` – ${r._count.users} Konten, ${rechte.length} Rechte`, + ); + for (const recht of rechte) console.log(` ${recht}`); + } + + console.log('\n=== KONTEN (aktiv) ==='); + const konten = await prisma.user.findMany({ + where: { isActive: true }, + orderBy: { email: 'asc' }, + select: { + email: true, + roles: { + select: { + role: { + // Ausdrueckliche Spaltenauswahl, damit der Bericht auch auf einer + // noch nicht migrierten Datenbank laeuft. + select: { + name: true, + permissions: { include: { permission: true } }, + }, + }, + }, + }, + }, + }); + for (const k of konten) { + const rechte = new Set(); + for (const ur of k.roles) { + for (const rp of ur.role.permissions) { + rechte.add(`${rp.permission.resource}:${rp.permission.action}`); + } + } + const rollenNamen = k.roles.map((ur) => ur.role.name).sort().join(', ') || '(keine)'; + console.log(`\n[${k.email}] Rollen: ${rollenNamen} – ${rechte.size} Rechte`); + for (const recht of [...rechte].sort()) console.log(` ${recht}`); + } + + // --- Auffaelligkeiten ----------------------------------------------------- + // Nicht als Alarm gemeint, sondern als das, was man vor einem Deploy + // wissen will. Eine umbenannte Systemrolle etwa wird vom Backfill der + // Migration nicht getroffen. + console.log('\n=== HINWEISE ==='); + const hinweise: string[] = []; + + const vorhandeneNamen = new Set(rollen.map((r) => r.name)); + for (const erwartet of SYSTEMROLLEN_NAMEN) { + if (!vorhandeneNamen.has(erwartet)) { + hinweise.push(`Systemrolle "${erwartet}" fehlt in der Datenbank.`); + } + } + for (const r of rollen) { + const k = kennzeichen.get(r.id); + // Vor der Migration gibt es die Spalte nicht - dann ist "nicht + // gekennzeichnet" kein Befund, sondern der erwartete Zustand. + if (kennzeichen.size === 0) continue; + if (SYSTEMROLLEN_NAMEN.includes(r.name) && !k?.isSystem) { + hinweise.push(`Rolle "${r.name}" ist eine Systemrolle, aber isSystem ist nicht gesetzt.`); + } + if (!SYSTEMROLLEN_NAMEN.includes(r.name) && k?.isSystem) { + hinweise.push(`Rolle "${r.name}" traegt isSystem, gehoert aber nicht zum Katalog.`); + } + } + + const alleRechteDb = await prisma.permission.findMany(); + const imKatalog = new Set(ALLE_RECHTE); + for (const p of alleRechteDb) { + const s = `${p.resource}:${p.action}`; + if (!imKatalog.has(s)) hinweise.push(`Recht "${s}" steht in der Datenbank, aber nicht im Katalog.`); + } + const inDb = new Set(alleRechteDb.map((p) => `${p.resource}:${p.action}`)); + for (const s of ALLE_RECHTE) { + if (!inDb.has(s)) hinweise.push(`Recht "${s}" steht im Katalog, aber nicht in der Datenbank.`); + } + + // Konten ohne jede Rolle koennen sich anmelden und sehen nichts - eine + // haeufige Ursache fuer "bei mir ist alles leer". + for (const k of konten) { + if (k.roles.length === 0) hinweise.push(`Konto "${k.email}" hat keine Rolle.`); + } + + if (hinweise.length === 0) console.log(' keine'); + else for (const h of hinweise) console.log(` ! ${h}`); +} + +main() + .catch((e) => { + console.error('[rechte-report] Fehler:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/rolle-zuweisen.ts b/backend/prisma/rolle-zuweisen.ts new file mode 100644 index 00000000..92f1c724 --- /dev/null +++ b/backend/prisma/rolle-zuweisen.ts @@ -0,0 +1,141 @@ +/** + * Weist einem Konto eine Rolle zu oder nimmt sie ihm weg - von der + * Kommandozeile aus. + * + * npx tsx prisma/rolle-zuweisen.ts + * npx tsx prisma/rolle-zuweisen.ts --entfernen + * npx tsx prisma/rolle-zuweisen.ts --liste + * + * Im Container: + * docker compose exec backend npx tsx prisma/rolle-zuweisen.ts \ + * name@firma.de Developer + * + * WOZU: Die Teilmengenregel verhindert, dass jemand ueber die Weboberflaeche + * Rechte vergibt, die er selbst nicht haelt. Das ist gewollt - es schliesst + * die Selbst-Erhoehung. Es hat aber eine Kehrseite: Nach einer + * Neuinstallation haelt niemand `developer:access` oder `audit:admin`, und + * dann kann diese Rechte auch niemand erstmalig vergeben. + * + * Dieses Skript ist der dokumentierte Ausweg. Es umgeht die Regel bewusst, + * denn die Vertrauensgrenze stimmt: Wer eine Shell auf dieser Maschine hat, + * hat ohnehin Zugriff auf die Datenbank. Ein gestohlener Web-Zugang hat das + * nicht - und genau das ist der Unterschied, den die Regel schuetzen soll. + * + * Der Vorgang wird protokolliert und meldet die Traeger ab, damit die + * Aenderung sofort wirkt und nicht spurlos bleibt. + */ + +import { PrismaClient } from '@prisma/client'; +import { createAuditLog } from '../src/services/audit.service.js'; + +const prisma = new PrismaClient(); + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.includes('--liste') || args.length === 0) { + const rollen = await prisma.role.findMany({ + orderBy: { name: 'asc' }, + include: { _count: { select: { users: true } } }, + }); + console.log('Vorhandene Rollen:'); + for (const r of rollen) { + const kennz = [r.isSystem ? 'System' : null, r.isHidden ? 'versteckt' : null] + .filter(Boolean) + .join(', '); + console.log(` ${r.name}${kennz ? ` (${kennz})` : ''} – ${r._count.users} Konten`); + } + if (args.length === 0) { + console.log('\nAufruf: npx tsx prisma/rolle-zuweisen.ts [--entfernen]'); + } + return; + } + + const entfernen = args.includes('--entfernen'); + const [email, rollenName] = args.filter((a) => !a.startsWith('--')); + + if (!email || !rollenName) { + console.error('Aufruf: npx tsx prisma/rolle-zuweisen.ts [--entfernen]'); + process.exit(1); + } + + const konto = await prisma.user.findUnique({ where: { email } }); + if (!konto) { + console.error(`Kein Konto mit der Adresse "${email}".`); + process.exit(1); + } + + const rolle = await prisma.role.findUnique({ where: { name: rollenName } }); + if (!rolle) { + console.error(`Keine Rolle mit dem Namen "${rollenName}". Vorhandene mit --liste ansehen.`); + process.exit(1); + } + + const vorhanden = await prisma.userRole.findUnique({ + where: { userId_roleId: { userId: konto.id, roleId: rolle.id } }, + }); + + if (entfernen) { + if (!vorhanden) { + console.log(`"${email}" hat die Rolle "${rollenName}" gar nicht. Nichts zu tun.`); + return; + } + await prisma.userRole.delete({ + where: { userId_roleId: { userId: konto.id, roleId: rolle.id } }, + }); + } else { + if (vorhanden) { + console.log(`"${email}" hat die Rolle "${rollenName}" bereits. Nichts zu tun.`); + return; + } + await prisma.userRole.create({ data: { userId: konto.id, roleId: rolle.id } }); + } + + // Sofort wirksam machen: Die Rechte stehen im Zugangstoken, sonst behielte + // das Konto bis zu 15 Minuten den alten Stand. + await prisma.user.update({ + where: { id: konto.id }, + data: { tokenInvalidatedAt: new Date() }, + }); + + // Nachvollziehbar machen. Ein Eingriff von der Kommandozeile ist berechtigt, + // aber er darf nicht unsichtbar sein - sonst waere das Skript selbst die + // Luecke, die es schliessen soll. + // + // Ueber createAuditLog, NICHT ueber prisma.auditLog.create: Das Protokoll + // ist eine Hash-Kette. Ein roh eingefuegter Datensatz haette kein `hash` + // und keinen `previousHash` und wuerde bei der naechsten Pruefung als + // Luecke erscheinen - das Skript wuerde also ausgerechnet dort Zweifel + // saeen, wo es Klarheit schaffen soll. + await createAuditLog({ + userEmail: 'system (CLI)', + userRole: 'Kommandozeile', + action: entfernen ? 'DELETE' : 'CREATE', + sensitivity: 'CRITICAL', + resourceType: 'UserRole', + resourceId: `${konto.id}:${rolle.id}`, + resourceLabel: entfernen + ? `Rolle "${rolle.name}" von ${email} entfernt (Kommandozeile)` + : `Rolle "${rolle.name}" an ${email} vergeben (Kommandozeile)`, + endpoint: 'prisma/rolle-zuweisen.ts', + httpMethod: 'CLI', + ipAddress: 'lokal', + changesAfter: { konto: email, rolle: rolle.name, entfernt: entfernen }, + }); + + console.log( + entfernen + ? `Rolle "${rolle.name}" von "${email}" entfernt.` + : `Rolle "${rolle.name}" an "${email}" vergeben.`, + ); + console.log('Das Konto muss sich neu anmelden, damit die Änderung greift.'); +} + +main() + .catch((e) => { + console.error('[rolle-zuweisen] Fehler:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 00b7b029..43ff72dd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -106,6 +106,16 @@ model Role { id Int @id @default(autoincrement()) name String @unique description String? + /// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die + /// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht + /// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die + /// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen + /// die versteckten Rollen hängen, waren nur scheinbar stabil. + isSystem Boolean @default(false) + /// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über + /// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff, + /// Audit-Betrieb) oder gehören zu einem technischen Konto. + isHidden Boolean @default(false) permissions RolePermission[] users UserRole[] createdAt DateTime @default(now()) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index cabaa69b..d93b8e35 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,238 +1,28 @@ import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; +import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js'; +import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js'; const prisma = new PrismaClient(); async function main() { console.log('Seeding database...'); - // ==================== PERMISSIONS ==================== - // Ressourcen mit ihren erlaubten Aktionen - const resourcePermissions: Record = { - // Haupt-Ressourcen (CRUD) - customers: ['create', 'read', 'update', 'delete'], - contracts: ['create', 'read', 'update', 'delete'], - users: ['create', 'read', 'update', 'delete'], - platforms: ['create', 'read', 'update', 'delete'], - providers: ['create', 'read', 'update', 'delete'], - tariffs: ['create', 'read', 'update', 'delete'], - // Konfiguration (CRUD) - 'cancellation-periods': ['create', 'read', 'update', 'delete'], - 'contract-durations': ['create', 'read', 'update', 'delete'], - 'contract-categories': ['create', 'read', 'update', 'delete'], - 'email-providers': ['create', 'read', 'update', 'delete'], - // Einstellungen (nur lesen/ändern) - settings: ['read', 'update'], - // Spezial-Permissions - developer: ['access'], - emails: ['delete'], - // DSGVO & Audit - audit: ['read', 'export', 'admin'], - gdpr: ['export', 'delete', 'admin'], - }; - - const permissions: { resource: string; action: string }[] = []; - for (const [resource, actions] of Object.entries(resourcePermissions)) { - for (const action of actions) { - permissions.push({ resource, action }); - } - } - - for (const perm of permissions) { - await prisma.permission.upsert({ - where: { resource_action: perm }, - update: {}, - create: perm, - }); - } - - console.log(`Permissions created (${permissions.length} total)`); - - // Get all permissions - const allPermissions = await prisma.permission.findMany(); - const customerReadPerm = allPermissions.find( - (p) => p.resource === 'customers' && p.action === 'read' - ); - const contractReadPerm = allPermissions.find( - (p) => p.resource === 'contracts' && p.action === 'read' - ); - const platformReadPerm = allPermissions.find( - (p) => p.resource === 'platforms' && p.action === 'read' - ); - const providerReadPerm = allPermissions.find( - (p) => p.resource === 'providers' && p.action === 'read' - ); - - // Helper: Sync permissions for a role (adds missing, removes excess) - async function syncRolePermissions(roleId: number, permissionIds: number[]) { - const existing = await prisma.rolePermission.findMany({ - where: { roleId }, - select: { permissionId: true }, - }); - const existingIds = new Set(existing.map((e) => e.permissionId)); - const targetIds = new Set(permissionIds); - - // Add missing permissions - const missing = permissionIds.filter((id) => !existingIds.has(id)); - if (missing.length > 0) { - await prisma.rolePermission.createMany({ - data: missing.map((permissionId) => ({ roleId, permissionId })), - skipDuplicates: true, - }); - console.log(` → ${missing.length} Permissions hinzugefügt für Rolle #${roleId}`); - } - - // Remove excess permissions - const excess = existing.filter((e) => !targetIds.has(e.permissionId)).map((e) => e.permissionId); - if (excess.length > 0) { - await prisma.rolePermission.deleteMany({ - where: { roleId, permissionId: { in: excess } }, - }); - console.log(` → ${excess.length} Permissions entfernt für Rolle #${roleId}`); - } - } - - // Create roles - // Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes) - const adminPermissions = allPermissions.filter( - (p) => - !(p.resource === 'developer' && p.action === 'access') && - p.resource !== 'audit' && - p.resource !== 'gdpr' - ); - const adminRole = await prisma.role.upsert({ - where: { name: 'Admin' }, - update: {}, - create: { - name: 'Admin', - description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', - permissions: { - create: adminPermissions.map((p) => ({ permissionId: p.id })), - }, - }, - }); - await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id)); - - // Developer - ALL permissions (developer:access + alles andere) - const developerPermissions = allPermissions; - const developerRole = await prisma.role.upsert({ - where: { name: 'Developer' }, - update: {}, - create: { - name: 'Developer', - description: 'Voller Zugriff inkl. Entwickler-Tools', - permissions: { - create: developerPermissions.map((p) => ({ permissionId: p.id })), - }, - }, - }); - await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id)); - - // DSGVO - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll. + // ==================== RECHTE UND ROLLEN ==================== + // Katalog und Systemrollen kommen aus einer einzigen Definition + // (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts. // - // Bewusst OHNE `audit:admin`: Wer das Protokoll beaufsichtigt, darf seine - // eigene Beweisgrundlage nicht ersetzen koennen (Pentest R186). Die - // eingreifenden Rechte liegen in der Rolle `Audit-Betrieb`. - // - // Diese Liste stand hier bis 09/2026 noch auf `audit:*` komplett und war - // damit die dritte Stelle, die denselben Rechtesatz beschrieb - neben - // `sync-roles.ts` und dem Notfallpfad in `user.service.ts`. Gerettet hat es - // nur die Reihenfolge im Container-Start (sync-roles laeuft danach und - // raeumt Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte die - // Buendelung zurueck. - const gdprPermissions = allPermissions.filter( - (p) => - p.resource === 'gdpr' || - (p.resource === 'audit' && (p.action === 'read' || p.action === 'export')) - ); - const gdprRole = await prisma.role.upsert({ - where: { name: 'DSGVO' }, - update: {}, - create: { - name: 'DSGVO', - description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', - permissions: { - create: gdprPermissions.map((p) => ({ permissionId: p.id })), - }, - }, - }); - await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id)); + // Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die + // DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet + // hat das nur die Reihenfolge im Container-Start (sync-roles lief danach + // und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte + // die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind + // zwei zu viel. + await synchronisiereRechteUndRollen(prisma); - // Employee - full access to customers, contracts, read access to lookup tables - const employeePermIds = allPermissions - .filter( - (p) => - p.resource === 'customers' || - p.resource === 'contracts' || - // Read-only Zugriff auf Stammdaten und Konfiguration - (p.action === 'read' && [ - 'platforms', - 'providers', - 'tariffs', - 'cancellation-periods', - 'contract-durations', - 'contract-categories', - ].includes(p.resource)) - ) - .map((p) => p.id); - - const employeeRole = await prisma.role.upsert({ - where: { name: 'Mitarbeiter' }, - update: {}, - create: { - name: 'Mitarbeiter', - description: 'Kann Kunden und Verträge verwalten', - permissions: { - create: employeePermIds.map((id) => ({ permissionId: id })), - }, - }, - }); - await syncRolePermissions(employeeRole.id, employeePermIds); - - // Read-only employee - read access to main entities and lookup tables - const readOnlyResources = [ - 'customers', - 'contracts', - 'platforms', - 'providers', - 'tariffs', - 'cancellation-periods', - 'contract-durations', - 'contract-categories', - ]; - const readOnlyPermIds = allPermissions - .filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource)) - .map((p) => p.id); - - const readOnlyRole = await prisma.role.upsert({ - where: { name: 'Mitarbeiter (Nur-Lesen)' }, - update: {}, - create: { - name: 'Mitarbeiter (Nur-Lesen)', - description: 'Kann nur lesen, keine Änderungen', - permissions: { - create: readOnlyPermIds.map((id) => ({ permissionId: id })), - }, - }, - }); - await syncRolePermissions(readOnlyRole.id, readOnlyPermIds); - - // Customer role - read own data only (handled in middleware) - const customerRole = await prisma.role.upsert({ - where: { name: 'Kunde' }, - update: {}, - create: { - name: 'Kunde', - description: 'Kann nur eigene Daten lesen', - permissions: { - create: readOnlyPermIds.map((id) => ({ permissionId: id })), - }, - }, - }); - await syncRolePermissions(customerRole.id, readOnlyPermIds); - - console.log('Roles created'); + const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } }); + const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } }); // Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo // landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen- diff --git a/backend/prisma/sync-roles.ts b/backend/prisma/sync-roles.ts index 32fab781..77e12beb 100644 --- a/backend/prisma/sync-roles.ts +++ b/backend/prisma/sync-roles.ts @@ -1,205 +1,27 @@ /** - * Idempotenter Permissions+Rollen-Sync für den Container-Start. + * Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand. * - * Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das - * System schon installiert hat, bekommt nachträglich hinzugefügte - * Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann - * dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt. + * Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer + * das System schon installiert hat, bekommt nachtraeglich hinzugefuegte + * Rechte oder geaenderte Rollenzuordnungen sonst NICHT. * - * Dieses Skript synchronisiert ausschließlich: - * - Permission-Katalog (resource/action-Paare aus dem Code) - * - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter, - * Mitarbeiter (Nur-Lesen), Kunde) + * Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in + * `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der + * Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene + * Kopie des Katalogs, und es war nicht die einzige. * - * KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf - * laufenden Prod-DBs sicher. + * KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden + * Produktionsdatenbanken sicher. */ import { PrismaClient } from '@prisma/client'; +import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js'; const prisma = new PrismaClient(); -const RESOURCE_PERMISSIONS: Record = { - customers: ['create', 'read', 'update', 'delete'], - contracts: ['create', 'read', 'update', 'delete'], - users: ['create', 'read', 'update', 'delete'], - platforms: ['create', 'read', 'update', 'delete'], - providers: ['create', 'read', 'update', 'delete'], - tariffs: ['create', 'read', 'update', 'delete'], - 'cancellation-periods': ['create', 'read', 'update', 'delete'], - 'contract-durations': ['create', 'read', 'update', 'delete'], - 'contract-categories': ['create', 'read', 'update', 'delete'], - 'email-providers': ['create', 'read', 'update', 'delete'], - settings: ['read', 'update'], - developer: ['access'], - emails: ['delete'], - audit: ['read', 'export', 'admin'], - gdpr: ['export', 'delete', 'admin'], -}; - -async function syncRolePermissions(roleId: number, permissionIds: number[]) { - const existing = await prisma.rolePermission.findMany({ - where: { roleId }, - select: { permissionId: true }, - }); - const existingIds = new Set(existing.map((e) => e.permissionId)); - const targetIds = new Set(permissionIds); - - const missing = permissionIds.filter((id) => !existingIds.has(id)); - if (missing.length > 0) { - await prisma.rolePermission.createMany({ - data: missing.map((permissionId) => ({ roleId, permissionId })), - skipDuplicates: true, - }); - console.log(` → +${missing.length} Permissions an Rolle #${roleId}`); - } - - const excess = existing - .filter((e) => !targetIds.has(e.permissionId)) - .map((e) => e.permissionId); - if (excess.length > 0) { - await prisma.rolePermission.deleteMany({ - where: { roleId, permissionId: { in: excess } }, - }); - console.log(` → -${excess.length} Permissions von Rolle #${roleId}`); - } -} - -async function main() { - console.log('[sync-roles] Permissions-Katalog upserten…'); - for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) { - for (const action of actions) { - await prisma.permission.upsert({ - where: { resource_action: { resource, action } }, - update: {}, - create: { resource, action }, - }); - } - } - const allPermissions = await prisma.permission.findMany(); - console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`); - - // Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer - // sind separate hidden roles, über Checkboxen zugewiesen) - const adminPermIds = allPermissions - .filter( - (p) => - !(p.resource === 'developer' && p.action === 'access') && - p.resource !== 'audit' && - p.resource !== 'gdpr' - ) - .map((p) => p.id); - - // Developer: alles - const developerPermIds = allPermissions.map((p) => p.id); - - // DSGVO: Datenschutz-Verwaltung plus LESENDEN Zugriff aufs Audit-Protokoll. - // - // Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll - sie - // darf es nicht umschreiben koennen. Mit `audit:admin` haette ein - // DSGVO-Beauftragter `seal-backlog`, `rehash` und `cleanup`, also die Mittel, - // seine eigene Beweisgrundlage zu ersetzen. Wer prueft und wer eingreift, - // sind zwei Rollen (Pentest R186, im Anschluss an R184-02: falsche Domaene, - // zu breit gebuendelt). - const gdprPermIds = allPermissions - .filter( - (p) => - p.resource === 'gdpr' || - (p.resource === 'audit' && (p.action === 'read' || p.action === 'export')), - ) - .map((p) => p.id); - - // Audit-Betrieb: die eingreifenden Rechte am Protokoll - versiegeln, - // neu berechnen, aufraeumen, Aufbewahrung aendern. Plus `audit:read`, denn - // siegeln zu duerfen ohne das Ergebnis sehen zu koennen waere unbrauchbar. - // - // Eigene versteckte Rolle statt einem Anhaengsel an Admin: So weitet sich - // durch die Umstellung fuer KEIN bestehendes Konto etwas aus. Wer eingreifen - // koennen soll, bekommt es ausdruecklich - und diese Zuweisung ist selbst - // ein sichtbarer Vorgang. - const auditBetriebPermIds = allPermissions - .filter( - (p) => p.resource === 'audit' && (p.action === 'read' || p.action === 'admin'), - ) - .map((p) => p.id); - - // Gegenbuch: NUR audit:read. - // - // Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint - // und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es - // dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll - // geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*` - // KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup. - // - // Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den - // Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben - - // genau die Waesche aus R185-01, und genau die Trennung, wegen der das - // Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des - // Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig - // wert sein wie moeglich. - const gegenbuchPermIds = allPermissions - .filter((p) => p.resource === 'audit' && p.action === 'read') - .map((p) => p.id); - - // Mitarbeiter: customers + contracts + read auf Stammdaten - const employeePermIds = allPermissions - .filter( - (p) => - p.resource === 'customers' || - p.resource === 'contracts' || - (p.action === 'read' && - [ - 'platforms', - 'providers', - 'tariffs', - 'cancellation-periods', - 'contract-durations', - 'contract-categories', - ].includes(p.resource)) - ) - .map((p) => p.id); - - // Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten - const readOnlyResources = [ - 'customers', - 'contracts', - 'platforms', - 'providers', - 'tariffs', - 'cancellation-periods', - 'contract-durations', - 'contract-categories', - ]; - const readOnlyPermIds = allPermissions - .filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource)) - .map((p) => p.id); - - const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [ - { name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds }, - { name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds }, - { name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds }, - { name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds }, - { name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds }, - { name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds }, - { name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds }, - { name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds }, - ]; - - for (const r of rolesSpec) { - const role = await prisma.role.upsert({ - where: { name: r.name }, - update: { description: r.description }, - create: { name: r.name, description: r.description }, - }); - await syncRolePermissions(role.id, r.permIds); - } - - console.log('[sync-roles] fertig.'); -} - -main() +synchronisiereRechteUndRollen(prisma) .catch((e) => { - console.error('[sync-roles] Fehler:', e); + console.error('[rollen-sync] Fehler:', e); process.exit(1); }) .finally(async () => { diff --git a/backend/src/config/rechte-katalog.ts b/backend/src/config/rechte-katalog.ts new file mode 100644 index 00000000..05dd5964 --- /dev/null +++ b/backend/src/config/rechte-katalog.ts @@ -0,0 +1,293 @@ +/** + * Der Rechte- und Rollenkatalog. Eine Quelle, drei Verbraucher. + * + * Bis hierher stand derselbe Katalog dreimal im Code: in `prisma/seed.ts`, + * in `prisma/sync-roles.ts` und - abweichend - in `factoryReset` + * (`services/backup.service.ts`). Die dritte Kopie kannte weder `audit:*` + * noch `gdpr:*` und legte die Rollen DSGVO, Audit-Betrieb und Gegenbuch gar + * nicht erst an: Nach einem Werksreset konnte niemand mehr eine Auskunft + * nach Art. 15 DSGVO ausfuehren, und gemerkt haette man es erst, wenn eine + * Frist laeuft. + * + * Drei Beschreibungen desselben Sachverhalts sind zwei zu viel. Welche davon + * gilt, entschied bisher die Reihenfolge im Container-Start. + * + * Dieses Modul liegt bewusst unter `src/` und nicht unter `prisma/`: Es wird + * sowohl vom kompilierten Backend (`dist/`) als auch von den CLI-Skripten + * unter `prisma/` gebraucht, die per `tsx` laufen. Das Dockerfile kopiert + * `src/` zusaetzlich ins Runtime-Image, genau dafuer. + * + * KEIN Prisma-Import hier - reine Daten. Die Aufloesung gegen die Datenbank + * macht `services/rollen-sync.service.ts`. + */ + +export interface RechtDefinition { + resource: string; + action: string; + /** Klartext fuer die Oberflaeche. "cancellation-periods:update" sagt einem + * Sachbearbeiter nichts. */ + bezeichnung: string; + /** Ueberschrift, unter der das Recht in der Rechtematrix steht. */ + gruppe: string; +} + +export const GRUPPE = { + KUNDEN: 'Kunden', + VERTRAEGE: 'Verträge', + EMAILS: 'E-Mails', + STAMMDATEN: 'Stammdaten', + EINSTELLUNGEN: 'Einstellungen', + BENUTZER: 'Benutzer und Rechte', + AUFSICHT: 'Aufsicht und Datenschutz', + SYSTEM: 'System', +} as const; + +/** + * Der vollstaendige Katalog. Was hier nicht steht, existiert nicht. + * + * Bis 09/2026 standen hier 18 Rechte, die nichts bewachten: `tariffs:*`, + * `cancellation-periods:*`, `contract-durations:*` und `email-providers:*` + * waren anhakbar, aber die Routen prueften in Wahrheit `providers:*`, + * `platforms:*` und `settings:*`. Ein Haken, der nichts tut, ist schlimmer + * als ein fehlender: Er behauptet eine Trennung, die es nicht gibt. + * Seit dem granularen Gaten stimmt jede Zeile hier mit mindestens einer + * Route ueberein. + */ +export const RECHTE_KATALOG: RechtDefinition[] = [ + // ---- Kunden ------------------------------------------------------------- + { resource: 'customers', action: 'create', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden anlegen' }, + { resource: 'customers', action: 'read', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden sehen' }, + { resource: 'customers', action: 'update', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden bearbeiten' }, + { resource: 'customers', action: 'delete', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden löschen' }, + + // ---- Verträge ----------------------------------------------------------- + { resource: 'contracts', action: 'create', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge anlegen' }, + { resource: 'contracts', action: 'read', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge sehen' }, + { resource: 'contracts', action: 'update', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge bearbeiten' }, + { resource: 'contracts', action: 'delete', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge löschen' }, + + // ---- E-Mails ------------------------------------------------------------ + { resource: 'emails', action: 'delete', gruppe: GRUPPE.EMAILS, bezeichnung: 'E-Mails löschen' }, + + // ---- Stammdaten --------------------------------------------------------- + { resource: 'platforms', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen anlegen' }, + { resource: 'platforms', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen sehen' }, + { resource: 'platforms', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen bearbeiten' }, + { resource: 'platforms', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen löschen' }, + + { resource: 'providers', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter anlegen' }, + { resource: 'providers', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter sehen' }, + { resource: 'providers', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter bearbeiten' }, + { resource: 'providers', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter löschen' }, + + { resource: 'tariffs', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife anlegen' }, + { resource: 'tariffs', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife sehen' }, + { resource: 'tariffs', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife bearbeiten' }, + { resource: 'tariffs', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife löschen' }, + + { resource: 'cancellation-periods', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen anlegen' }, + { resource: 'cancellation-periods', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen sehen' }, + { resource: 'cancellation-periods', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen bearbeiten' }, + { resource: 'cancellation-periods', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen löschen' }, + + { resource: 'contract-durations', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten anlegen' }, + { resource: 'contract-durations', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten sehen' }, + { resource: 'contract-durations', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten bearbeiten' }, + { resource: 'contract-durations', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten löschen' }, + + { resource: 'contract-categories', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen anlegen' }, + { resource: 'contract-categories', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen sehen' }, + { resource: 'contract-categories', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen bearbeiten' }, + { resource: 'contract-categories', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen löschen' }, + + // ---- Einstellungen ------------------------------------------------------ + { resource: 'settings', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen sehen' }, + { resource: 'settings', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen ändern' }, + + { resource: 'email-providers', action: 'create', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider anlegen' }, + { resource: 'email-providers', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider sehen' }, + { resource: 'email-providers', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider bearbeiten' }, + { resource: 'email-providers', action: 'delete', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider löschen' }, + + // ---- Benutzer und Rechte ------------------------------------------------ + { resource: 'users', action: 'create', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer anlegen' }, + { resource: 'users', action: 'read', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer sehen' }, + { resource: 'users', action: 'update', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer bearbeiten' }, + { resource: 'users', action: 'delete', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer löschen' }, + { + resource: 'roles', + action: 'manage', + gruppe: GRUPPE.BENUTZER, + // Getrennt von `users:*`, weil "Konten anlegen" und "festlegen, was ein + // Konto darf" zwei verschiedene Befugnisse sind (Pentest, Rollenmodell). + bezeichnung: 'Rollen und Rechte verwalten', + }, + + // ---- Aufsicht und Datenschutz ------------------------------------------- + { resource: 'audit', action: 'read', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll lesen' }, + { resource: 'audit', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll exportieren' }, + { resource: 'audit', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll versiegeln, aufräumen, Aufbewahrung ändern' }, + + { resource: 'gdpr', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Auskunft nach Art. 15 DSGVO erteilen' }, + { resource: 'gdpr', action: 'delete', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Löschung nach Art. 17 DSGVO ausführen' }, + { resource: 'gdpr', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Datenschutz-Verwaltung' }, + + // ---- System ------------------------------------------------------------- + { resource: 'developer', action: 'access', gruppe: GRUPPE.SYSTEM, bezeichnung: 'Entwicklerwerkzeuge und Datenbankzugriff' }, +]; + +/** `resource:action` in der Schreibweise, die `requirePermission` erwartet. */ +export function alsRechtString(r: { resource: string; action: string }): string { + return `${r.resource}:${r.action}`; +} + +export const ALLE_RECHTE: string[] = RECHTE_KATALOG.map(alsRechtString); + +// --------------------------------------------------------------------------- +// Rollen +// --------------------------------------------------------------------------- + +/** + * Die Namen der von der Anwendung gepflegten Rollen. Sie werden an mehreren + * Stellen als Schluessel benutzt (Checkbox-Zuweisung, Notfallpfade in + * `user.service.ts`, Anzeigefilter im Frontend). Ab der Systemrollen-Sperre + * sind sie stabil: Eine Rolle mit `isSystem` laesst sich nicht mehr + * umbenennen, deshalb traegt der Name jetzt, was er vorher nur zu tragen + * schien. + */ +export const ROLLE_ADMIN = 'Admin'; +export const ROLLE_DEVELOPER = 'Developer'; +export const ROLLE_DSGVO = 'DSGVO'; +export const ROLLE_AUDIT_BETRIEB = 'Audit-Betrieb'; +export const ROLLE_GEGENBUCH = 'Gegenbuch'; +export const ROLLE_MITARBEITER = 'Mitarbeiter'; +export const ROLLE_MITARBEITER_LESEND = 'Mitarbeiter (Nur-Lesen)'; +export const ROLLE_KUNDE = 'Kunde'; + +export interface SystemrollenSpec { + name: string; + description: string; + /** + * Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden ueber + * die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff, + * Audit-Betrieb) oder gehoeren zu einem technischen Konto. + */ + isHidden: boolean; + /** Praedikat ueber den Katalog. */ + rechte: (r: RechtDefinition) => boolean; +} + +/** Stammdaten, die ein Sachbearbeiter zum Arbeiten lesen koennen muss. */ +const LESE_STAMMDATEN = [ + 'platforms', + 'providers', + 'tariffs', + 'cancellation-periods', + 'contract-durations', + 'contract-categories', +]; + +export const SYSTEMROLLEN: SystemrollenSpec[] = [ + { + name: ROLLE_ADMIN, + description: + 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', + isHidden: false, + // Alles ausser den Entwicklerwerkzeugen und der Aufsicht. Die Trennung + // ist Absicht: Wer verwaltet, beaufsichtigt sich nicht selbst. + rechte: (r) => + r.resource !== 'developer' && r.resource !== 'audit' && r.resource !== 'gdpr', + }, + { + name: ROLLE_DEVELOPER, + description: 'Voller Zugriff inkl. Entwickler-Tools', + isHidden: true, + rechte: () => true, + }, + { + name: ROLLE_DSGVO, + description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', + isHidden: true, + // Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll - + // sie darf es nicht umschreiben koennen. Mit `audit:admin` haette ein + // DSGVO-Beauftragter seal-backlog, rehash und cleanup, also die Mittel, + // seine eigene Beweisgrundlage zu ersetzen (Pentest R186). + rechte: (r) => + r.resource === 'gdpr' || + (r.resource === 'audit' && (r.action === 'read' || r.action === 'export')), + }, + { + name: ROLLE_AUDIT_BETRIEB, + description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', + isHidden: true, + // Die eingreifenden Rechte am Protokoll, plus `audit:read` - siegeln zu + // duerfen, ohne das Ergebnis sehen zu koennen, waere unbrauchbar. + rechte: (r) => r.resource === 'audit' && (r.action === 'read' || r.action === 'admin'), + }, + { + name: ROLLE_GEGENBUCH, + description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', + isHidden: true, + // Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint + // und /verify, und beide verlangen `audit:read`. Das Kennwort des + // Dienstkontos liegt auf der Gegenbuch-Maschine im Klartext in der .env; + // es muss deshalb so wenig wert sein wie moeglich (Pentest R185-01). + rechte: (r) => r.resource === 'audit' && r.action === 'read', + }, + { + name: ROLLE_MITARBEITER, + description: 'Kann Kunden und Verträge verwalten', + isHidden: false, + rechte: (r) => + r.resource === 'customers' || + r.resource === 'contracts' || + (r.action === 'read' && LESE_STAMMDATEN.includes(r.resource)), + }, + { + name: ROLLE_MITARBEITER_LESEND, + description: 'Kann nur lesen, keine Änderungen', + isHidden: false, + rechte: (r) => + r.action === 'read' && + (r.resource === 'customers' || + r.resource === 'contracts' || + LESE_STAMMDATEN.includes(r.resource)), + }, + { + name: ROLLE_KUNDE, + description: 'Kann nur eigene Daten lesen', + isHidden: true, + // Hinweis: Portal-Kunden bekommen ihre Rechte NICHT aus dieser Rolle, + // sondern aus einem festen Array in `auth.service.ts`. Die Rolle existiert + // fuer CRM-seitig angelegte Kundenkonten. + rechte: (r) => + r.action === 'read' && + (r.resource === 'customers' || + r.resource === 'contracts' || + LESE_STAMMDATEN.includes(r.resource)), + }, +]; + +export const SYSTEMROLLEN_NAMEN: string[] = SYSTEMROLLEN.map((r) => r.name); + +/** Die ueber Haken im Benutzerformular vergebenen Rollen. */ +export const VERSTECKTE_ROLLEN_NAMEN: string[] = SYSTEMROLLEN.filter((r) => r.isHidden).map( + (r) => r.name, +); + +/** Rechte einer Systemrolle als `resource:action`-Liste. */ +export function rechteDerSystemrolle(name: string): string[] { + const spec = SYSTEMROLLEN.find((r) => r.name === name); + if (!spec) return []; + return RECHTE_KATALOG.filter(spec.rechte).map(alsRechtString); +} + +/** + * Vergleicht Rollennamen so, wie die Sperre sie vergleichen muss: getrimmt + * und ohne Ruecksicht auf Gross-/Kleinschreibung. Sonst liesse sich eine + * zweite Rolle " admin " anlegen, die in Listen wie die echte aussieht. + */ +export function istSystemrollenName(name: string): boolean { + const normalisiert = name.trim().toLocaleLowerCase('de-DE'); + return SYSTEMROLLEN_NAMEN.some((n) => n.toLocaleLowerCase('de-DE') === normalisiert); +} diff --git a/backend/src/controllers/backup.controller.ts b/backend/src/controllers/backup.controller.ts index 0ca404d9..c43d58d6 100644 --- a/backend/src/controllers/backup.controller.ts +++ b/backend/src/controllers/backup.controller.ts @@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) { label: `Werkseinstellungen wiederhergestellt`, }); res.json({ - message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.', + // Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht + // genau einmal im Server-Log. Die alte Meldung hier nannte es noch + // im Klartext - sie haette nach der Haertung ins Leere gefuehrt. + message: + 'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' + + 'steht einmalig im Server-Log (docker compose logs backend).', }); } else { res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error }); diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 8c48acc6..a7203106 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -6,7 +6,8 @@ import { logChange } from '../services/audit.service.js'; import { AUDIT_OPS_ROLLE } from '../services/user.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; -import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js'; +import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js'; +import { RechteEskalationError, RollenSperrError } from '../services/rechte.service.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Users @@ -86,7 +87,12 @@ export async function createUser(req: Request, res: Response): Promise { res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); return; } - const user = await userService.createUser(data); + const handelnder = handelnderOderNull(req); + if (handelnder === null) { + res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse); + return; + } + const user = await userService.createUser(data, handelnder); await logChange({ req, action: 'CREATE', resourceType: 'User', resourceId: user.id.toString(), @@ -94,10 +100,7 @@ export async function createUser(req: Request, res: Response): Promise { }); res.status(201).json({ success: true, data: user } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers', - } as ApiResponse); + antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers'); } } @@ -167,22 +170,56 @@ export async function updateUser(req: AuthRequest, res: Response): Promise } : null; - // Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll - // (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist - // kein gewoehnliches Benutzer-Update: Er entscheidet, wer die + // Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und + // Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer" + // traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die // Beweisgrundlage ersetzen darf. // - // Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem - - // nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann - // koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT: - // CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim - // Dienstkonto-Kennzeichen (R184-01). + // Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am + // Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand + // `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war + // richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`, + // um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto, + // sondern ueber ein frisch angelegtes zweites mit gesetztem Haken. + // + // Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts): + // Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst + // halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern + // die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der + // Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und + // "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein + // Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container + // nicht. const setztAuditBetrieb = data.hasAuditOpsAccess !== undefined && before !== null && data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess; const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true; + const setztDsgvo = + data.hasGdprAccess !== undefined && + before !== null && + data.hasGdprAccess !== (before as any).hasGdprAccess; + const setztDeveloper = + data.hasDeveloperAccess !== undefined && + before !== null && + data.hasDeveloperAccess !== (before as any).hasDeveloperAccess; + + // Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht: + // Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut + // zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender + // dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur. + if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) { + res.status(403).json({ + success: false, + error: + 'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' + + 'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' + + 'vornehmen lassen.', + } as ApiResponse); + return; + } + // Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses // Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein // Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als @@ -246,7 +283,12 @@ export async function updateUser(req: AuthRequest, res: Response): Promise return; } - const user = await userService.updateUser(userId, data as any); + const handelnder = handelnderOderNull(req); + if (handelnder === null) { + res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse); + return; + } + const user = await userService.updateUser(userId, data as any, handelnder); if (user) { // Audit: Geänderte Felder ermitteln und loggen if (before) { @@ -311,6 +353,45 @@ export async function updateUser(req: AuthRequest, res: Response): Promise }); } + // DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im + // Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen. + // Dabei traegt die Developer-Rolle saemtliche Rechte des Systems: + // Der weitreichendste Haken war der leiseste. + if (setztDeveloper) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'PERMISSION_CHANGED', + severity: 'CRITICAL', + message: data.hasDeveloperAccess === true + ? `Konto ${user.email} hat ab jetzt Entwicklerzugriff – das schliesst die ` + + 'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.' + : `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`, + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true }, + }); + } + + if (setztDsgvo) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'PERMISSION_CHANGED', + severity: 'HIGH', + message: data.hasGdprAccess === true + ? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` + + 'sowie Auskunft und Loeschung nach DSGVO ausfuehren.' + : `Konto ${user.email} hat keinen DSGVO-Zugriff mehr – es kann damit keine ` + + 'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.', + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true }, + }); + } + if (setztAuditBetrieb) { const ctx = contextFromRequest(req); emitSecurityEvent({ @@ -342,10 +423,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise } res.json({ success: true, data: user } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers', - } as ApiResponse); + antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers'); } } @@ -402,7 +480,15 @@ export async function setUserPassword(req: Request, res: Response): Promise { } // Roles + +/** + * Meldet jede Aenderung am Rollenmodell in den Alarmkanal. + * + * Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe + * ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das + * Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war + * ausgerechnet der wirksamste Weg der leiseste. + */ +/** + * Die ID des Handelnden - oder null. + * + * `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die + * Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht + * stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt + * durchgewinkt. + */ +function handelnderOderNull(req: AuthRequest): number | null { + return typeof req.user?.userId === 'number' ? req.user.userId : null; +} + +function meldeRollenAenderung( + req: AuthRequest, + nachricht: string, + details: Record, +): void { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'PERMISSION_CHANGED', + severity: 'HIGH', + message: nachricht, + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details, + }); +} + export async function getRoles(req: Request, res: Response): Promise { try { const roles = await userService.getAllRoles(); @@ -482,43 +607,138 @@ export async function getRole(req: Request, res: Response): Promise { } } -export async function createRole(req: Request, res: Response): Promise { +/** + * Prueft Name und Rechte-IDs aus dem Request. + * + * Gibt einen Fehlertext zurueck oder null. Bis 09/2026 gab es das gar nicht: + * Ein leerer Name landete in der Datenbank, doppelte IDs liefen in einen + * Primaerschluesselkonflikt und eine erfundene ID in einen + * Fremdschluesselfehler - beides kam als HTTP 500 zurueck und sah damit nach + * einem Serverfehler aus, obwohl es eine ungueltige Eingabe war. + */ +function pruefeRollenEingabe( + daten: Partial>, + nameNoetig: boolean, +): string | null { + if (nameNoetig || daten.name !== undefined) { + if (typeof daten.name !== 'string' || daten.name.trim().length === 0) { + return 'Name der Rolle fehlt'; + } + if (daten.name.trim().length > 100) { + return 'Name der Rolle ist zu lang (maximal 100 Zeichen)'; + } + } + if (daten.description !== undefined && typeof daten.description !== 'string') { + return 'Beschreibung muss Text sein'; + } + if (nameNoetig || daten.permissionIds !== undefined) { + const ids = daten.permissionIds; + if (!Array.isArray(ids)) return 'permissionIds muss eine Liste sein'; + if (!ids.every((id) => Number.isInteger(id) && (id as number) >= 1)) { + return 'permissionIds darf nur positive ganze Zahlen enthalten'; + } + } + return null; +} + +/** Bildet die Fehler aus der Rechteprüfung auf HTTP-Codes ab. */ +function antworteAufRollenFehler(res: Response, error: unknown, fallback: string): void { + // 403 statt 400: "Sie duerfen das nicht" ist etwas anderes als "Ihre + // Eingabe ist kaputt". Ohne die Unterscheidung kann die Oberflaeche keinen + // brauchbaren Hinweis geben. + if (error instanceof RechteEskalationError || error instanceof RollenSperrError) { + res.status(403).json({ success: false, error: error.message } as ApiResponse); + return; + } + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : fallback, + } as ApiResponse); +} + +export async function createRole(req: AuthRequest, res: Response): Promise { try { - const role = await userService.createRole(req.body); + const daten = pickRoleUpdate(req.body); + const fehler = pruefeRollenEingabe(daten, true); + if (fehler) { + res.status(400).json({ success: false, error: fehler } as ApiResponse); + return; + } + + const handelnder = handelnderOderNull(req); + if (handelnder === null) { + res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse); + return; + } + + const role = await userService.createRole( + { + name: daten.name as string, + description: daten.description as string | undefined, + permissionIds: daten.permissionIds as number[], + }, + handelnder, + ); await logChange({ req, action: 'CREATE', resourceType: 'Role', resourceId: role.id.toString(), label: `Rolle ${role.name} angelegt`, }); + meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, { + rolle: role.name, + rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`), + }); res.status(201).json({ success: true, data: role } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle', - } as ApiResponse); + antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle'); } } -export async function updateRole(req: Request, res: Response): Promise { +export async function updateRole(req: AuthRequest, res: Response): Promise { try { - const role = await userService.updateRole(parseInt(req.params.id), req.body); - if (role) { - await logChange({ - req, action: 'UPDATE', resourceType: 'Role', - resourceId: role.id.toString(), - label: `Rolle ${role.name} aktualisiert`, - }); + const daten = pickRoleUpdate(req.body); + const fehler = pruefeRollenEingabe(daten, false); + if (fehler) { + res.status(400).json({ success: false, error: fehler } as ApiResponse); + return; } + + const handelnder = handelnderOderNull(req); + if (handelnder === null) { + res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse); + return; + } + + const role = await userService.updateRole( + parseInt(req.params.id), + { + name: daten.name as string | undefined, + description: daten.description as string | undefined, + permissionIds: daten.permissionIds as number[] | undefined, + }, + handelnder, + ); + if (!role) { + res.status(404).json({ success: false, error: 'Rolle nicht gefunden' } as ApiResponse); + return; + } + await logChange({ + req, action: 'UPDATE', resourceType: 'Role', + resourceId: role.id.toString(), + label: `Rolle ${role.name} aktualisiert`, + }); + meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, { + rolle: role.name, + rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`), + traegerAbgemeldet: daten.permissionIds !== undefined, + }); res.json({ success: true, data: role } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle', - } as ApiResponse); + antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle'); } } -export async function deleteRole(req: Request, res: Response): Promise { +export async function deleteRole(req: AuthRequest, res: Response): Promise { try { const roleId = parseInt(req.params.id); const role = await userService.getRoleById(roleId); @@ -528,12 +748,12 @@ export async function deleteRole(req: Request, res: Response): Promise { resourceId: roleId.toString(), label: `Rolle ${role?.name || roleId} gelöscht`, }); + meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, { + rolle: role?.name, + }); res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse); } catch (error) { - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle', - } as ApiResponse); + antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle'); } } diff --git a/backend/src/routes/appSetting.routes.ts b/backend/src/routes/appSetting.routes.ts index e033f7ad..ad652c6f 100644 --- a/backend/src/routes/appSetting.routes.ts +++ b/backend/src/routes/appSetting.routes.ts @@ -61,11 +61,17 @@ router.post( backupController.createBackup ); -// Backup wiederherstellen +// Backup wiederherstellen. +// +// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und +// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst +// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim +// Werksreset, nur leiser. router.post( '/backup/:name/restore', authenticate, requirePermission('settings:update'), + requirePermission('roles:manage'), backupController.restoreBackup ); @@ -94,11 +100,23 @@ router.post( backupController.uploadBackup ); -// Werkseinstellungen (alles löschen) +// Werkseinstellungen (alles löschen). +// +// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei +// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren +// Rechten ODER bedeutet. +// +// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt +// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update` +// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend +// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den +// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es +// auch pflegen duerfen. router.post( '/factory-reset', authenticate, requirePermission('settings:update'), + requirePermission('roles:manage'), backupController.factoryReset ); diff --git a/backend/src/routes/cancellation-period.routes.ts b/backend/src/routes/cancellation-period.routes.ts index beffce07..8c8f2c9c 100644 --- a/backend/src/routes/cancellation-period.routes.ts +++ b/backend/src/routes/cancellation-period.routes.ts @@ -1,13 +1,17 @@ +// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`). +// +// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar +// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts. import { Router } from 'express'; import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); -router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods); -router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod); -router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod); -router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod); -router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod); +router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods); +router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod); +router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod); +router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod); +router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod); export default router; diff --git a/backend/src/routes/contract-duration.routes.ts b/backend/src/routes/contract-duration.routes.ts index 3e519c68..ba06c073 100644 --- a/backend/src/routes/contract-duration.routes.ts +++ b/backend/src/routes/contract-duration.routes.ts @@ -1,13 +1,17 @@ +// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`). +// +// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar +// nicht. `contract-durations:*` stand im Katalog und bewachte nichts. import { Router } from 'express'; import * as contractDurationController from '../controllers/contract-duration.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); -router.get('/', authenticate, contractDurationController.getContractDurations); -router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration); -router.get('/:id', authenticate, contractDurationController.getContractDuration); -router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration); -router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration); +router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations); +router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration); +router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration); +router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration); +router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration); export default router; diff --git a/backend/src/routes/contractCategory.routes.ts b/backend/src/routes/contractCategory.routes.ts index 4240561a..f25917fe 100644 --- a/backend/src/routes/contractCategory.routes.ts +++ b/backend/src/routes/contractCategory.routes.ts @@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); -// Lesen für alle authentifizierten Benutzer -router.get('/', authenticate, contractCategoryController.getContractCategories); -router.get('/:id', authenticate, contractCategoryController.getContractCategory); +// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte +// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die +// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit +// niemand seine Listen verliert. +router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories); +router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory); // Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin- // Rollen vergeben. Vorher stand hier `developer:access` mit dem diff --git a/backend/src/routes/emailProvider.routes.ts b/backend/src/routes/emailProvider.routes.ts index 6ccb1ac9..012703d7 100644 --- a/backend/src/routes/emailProvider.routes.ts +++ b/backend/src/routes/emailProvider.routes.ts @@ -1,3 +1,14 @@ +// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`). +// +// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` - +// wer irgendeine Einstellung aendern durfte, konnte damit auch die +// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*` +// stand derweil im Katalog und bewachte nichts. +// +// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden +// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/ +// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine +// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene. // ==================== EMAIL PROVIDER ROUTES ==================== import { Router } from 'express'; @@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); // Provider Config CRUD (Admin-only) -router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs); -router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig); -router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig); -router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig); -router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig); +router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs); +router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig); +router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig); +router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig); +router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig); // Email Operations -router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection); -router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess); +router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection); +router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess); router.get('/domain', authenticate, emailProviderController.getProviderDomain); router.get('/public-settings', authenticate, emailProviderController.getPublicSettings); router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists); diff --git a/backend/src/routes/platform.routes.ts b/backend/src/routes/platform.routes.ts index fbd1ce74..c6771241 100644 --- a/backend/src/routes/platform.routes.ts +++ b/backend/src/routes/platform.routes.ts @@ -1,12 +1,13 @@ +// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein. import { Router } from 'express'; import * as platformController from '../controllers/platform.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); -router.get('/', authenticate, platformController.getPlatforms); +router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms); router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform); -router.get('/:id', authenticate, platformController.getPlatform); +router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform); router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform); router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform); diff --git a/backend/src/routes/provider.routes.ts b/backend/src/routes/provider.routes.ts index 98668617..5754a887 100644 --- a/backend/src/routes/provider.routes.ts +++ b/backend/src/routes/provider.routes.ts @@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider); router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider); -// Nested tariff routes -router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs); -router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff); +// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts +router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs); +router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff); export default router; diff --git a/backend/src/routes/tariff.routes.ts b/backend/src/routes/tariff.routes.ts index c62c3354..90f8040d 100644 --- a/backend/src/routes/tariff.routes.ts +++ b/backend/src/routes/tariff.routes.ts @@ -1,3 +1,9 @@ +// Tarife haben eigene Rechte (`tariffs:*`). +// +// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*` +// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten" +// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte, +// durfte automatisch auch alle Tarife aendern. import { Router } from 'express'; import * as tariffController from '../controllers/tariff.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; @@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); // Standalone tariff routes (for update/delete by tariff id) -router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff); -router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff); -router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff); +router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff); +router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff); +router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff); export default router; diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts index d394366e..eb4f822f 100644 --- a/backend/src/routes/user.routes.ts +++ b/backend/src/routes/user.routes.ts @@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr // davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann. router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword); -// Roles -router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles); -router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole); -router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole); -router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole); -router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole); +// Rollen und Rechte. +// +// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`: +// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei +// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit +// `developer:access` bauen und zuweisen - die Rollenverwaltung war damit +// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des +// Riegels ist die Teilmengenregel in rechte.service.ts. +// +// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das +// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der +// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist +// ODER-verknuepft, das traegt ohne Zusatzcode. +router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles); +router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole); +router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole); +router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole); +router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole); // Permissions -router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions); +router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions); export default router; diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts index a6d2d619..6ffe5909 100644 --- a/backend/src/services/backup.service.ts +++ b/backend/src/services/backup.service.ts @@ -10,6 +10,31 @@ import * as path from 'path'; import archiver from 'archiver'; import AdmZip from 'adm-zip'; import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; +import { synchronisiereRechteUndRollen } from './rollen-sync.service.js'; +import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js'; + +/** + * Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem + * Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens + * eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist + * vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20). + */ +function erzeugeInitialKennwort(): string { + const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + const klein = 'abcdefghijkmnopqrstuvwxyz'; + const ziffern = '23456789'; + const sonder = '!@#$%&*+=?'; + const alle = gross + klein + ziffern + sonder; + const waehle = (s: string) => s[crypto.randomInt(0, s.length)]; + const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)]; + for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle)); + for (let i = zeichen.length - 1; i > 0; i--) { + const j = crypto.randomInt(0, i + 1); + [zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]]; + } + return zeichen.join(''); +} // Verzeichnisse const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups'); @@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string } } - // Grundlegende Stammdaten neu anlegen (aus Seed) - // Berechtigungen - muss mit seed.ts übereinstimmen! - const resourcePermissions: Record = { - // Haupt-Ressourcen (CRUD) - customers: ['create', 'read', 'update', 'delete'], - contracts: ['create', 'read', 'update', 'delete'], - users: ['create', 'read', 'update', 'delete'], - platforms: ['create', 'read', 'update', 'delete'], - providers: ['create', 'read', 'update', 'delete'], - tariffs: ['create', 'read', 'update', 'delete'], - // Lookup-Tabellen (nur lesen) - 'contract-categories': ['read'], - 'cancellation-periods': ['read'], - 'contract-durations': ['read'], - // Einstellungen (nur lesen/ändern) - settings: ['read', 'update'], - // Spezial-Permissions - developer: ['access'], - emails: ['delete'], - }; + // ==================== RECHTE UND ROLLEN ==================== + // Aus derselben Definition wie Seed und Container-Start + // (config/rechte-katalog.ts). + // + // Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem + // Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte + // nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`, + // `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf + // angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem + // Werksreset nicht mehr. + // + // Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann + // niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17 + // ausfuehren. Ein Werksreset setzte damit stillschweigend die + // Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere + // es erst, wenn eine Frist laeuft. + await synchronisiereRechteUndRollen(prisma, (zeile) => + console.log(`[FactoryReset] ${zeile}`), + ); - for (const [resource, actions] of Object.entries(resourcePermissions)) { - for (const action of actions) { - await prisma.permission.create({ - data: { resource, action }, - }); - } - } - console.log('[FactoryReset] Berechtigungen erstellt'); + const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } }); + const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } }); + console.log('[FactoryReset] Rechte und Rollen erstellt'); - // Admin-Rolle mit allen Berechtigungen (außer developer:access) - const allPermissions = await prisma.permission.findMany(); - const adminRole = await prisma.role.create({ - data: { - name: 'Admin', - description: 'Voller Zugriff auf alle Funktionen', - permissions: { - create: allPermissions - .filter(p => !(p.resource === 'developer' && p.action === 'access')) - .map(p => ({ permissionId: p.id })), - }, - }, - }); - - // Developer-Rolle - ALLE Berechtigungen inkl. developer:access - await prisma.role.create({ - data: { - name: 'Developer', - description: 'Voller Zugriff inkl. Entwickler-Tools', - permissions: { - create: allPermissions.map(p => ({ permissionId: p.id })), - }, - }, - }); - - // Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten - const employeePermIds = allPermissions - .filter(p => - p.resource === 'customers' || - p.resource === 'contracts' || - (p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource)) - ) - .map(p => p.id); - await prisma.role.create({ - data: { - name: 'Mitarbeiter', - description: 'Kann Kunden und Verträge verwalten', - permissions: { - create: employeePermIds.map(id => ({ permissionId: id })), - }, - }, - }); - - // Nur-Lesen Rolle - const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations']; - const readOnlyPermIds = allPermissions - .filter(p => p.action === 'read' && readOnlyResources.includes(p.resource)) - .map(p => p.id); - await prisma.role.create({ - data: { - name: 'Mitarbeiter (Nur-Lesen)', - description: 'Kann nur lesen, keine Änderungen', - permissions: { - create: readOnlyPermIds.map(id => ({ permissionId: id })), - }, - }, - }); - - // Kunden-Rolle - await prisma.role.create({ - data: { - name: 'Kunde', - description: 'Kann nur eigene Daten lesen', - permissions: { - create: readOnlyPermIds.map(id => ({ permissionId: id })), - }, - }, - }); - console.log('[FactoryReset] Rollen erstellt'); - - // Standard Admin-Benutzer erstellen + // Standard Admin-Benutzer erstellen. + // + // Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit + // bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12 + // ausdruecklich - und wieder war die Haertung nur in einer der beiden + // Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein + // zufaelliges Kennwort, das genau einmal im Log erscheint. console.log('[FactoryReset] Erstelle Admin-Benutzer...'); - const hashedPassword = await bcrypt.hash('admin', 10); - console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id); + const adminPlainPassword = erzeugeInitialKennwort(); + const hashedPassword = await bcrypt.hash(adminPlainPassword, 12); const adminUser = await prisma.user.create({ data: { @@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string password: hashedPassword, firstName: 'Admin', lastName: 'User', + // Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die + // Datenschutzrechte bewusst nicht, also braucht dieses eine + // Bootstrap-Konto beide - sonst ist nach dem Reset niemand + // handlungsfaehig. roles: { - create: [{ roleId: adminRole.id }], + create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }], }, }, }); + console.log('========================================================'); + console.log(' Admin-User: admin@admin.com'); + console.log(` Initial-Passwort: ${adminPlainPassword}`); + console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!'); + console.log(' Bitte sofort nach dem ersten Login ändern.'); + console.log('========================================================'); console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id); // Standard Kündigungsfristen (wie in seed.ts) diff --git a/backend/src/services/pflichtrechte.service.ts b/backend/src/services/pflichtrechte.service.ts index 2c32b8bd..34ebae05 100644 --- a/backend/src/services/pflichtrechte.service.ts +++ b/backend/src/services/pflichtrechte.service.ts @@ -1,4 +1,5 @@ import prisma from '../lib/prisma.js'; +import { SYSTEMROLLEN } from '../config/rechte-katalog.js'; /** * Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand @@ -25,6 +26,11 @@ const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> { resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' }, { resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' }, { resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' }, + // Ohne dieses Recht laesst sich keine Rolle mehr anlegen oder aendern - die + // Rechtevergabe waere eingefroren. Kein Rechtsproblem wie die beiden + // darueber, aber dieselbe Bauart: eine Faehigkeit, deren Fehlen erst + // auffaellt, wenn man sie braucht. + { resource: 'roles', action: 'manage', wofuer: 'Pflege der Rollen und Rechte' }, ]; export async function pruefePflichtrechte(): Promise { @@ -53,6 +59,37 @@ export async function pruefePflichtrechte(): Promise { } } + // Zweite Wache: Stimmen die Systemrollen noch? + // + // Die versteckten Rollen werden ueber ihren NAMEN gefunden + // (`findFirst({ where: { name: 'DSGVO' } })`). Fehlt eine, oder ist ihr + // isSystem-Flag von Hand entfernt worden, laeuft der Notfallpfad ins + // Leere - lautlos. Das hier ist die Meldung, die es dann geben soll. + const rollenHinweise: string[] = []; + for (const spec of SYSTEMROLLEN) { + const rolle = await prisma.role.findUnique({ where: { name: spec.name } }); + if (!rolle) { + rollenHinweise.push(`Systemrolle „${spec.name}" fehlt.`); + } else if (!rolle.isSystem) { + rollenHinweise.push( + `Systemrolle „${spec.name}" ist nicht als Systemrolle gekennzeichnet – ` + + 'sie ist damit über die Rollenverwaltung änderbar.', + ); + } + } + + if (rollenHinweise.length > 0) { + console.warn( + '\n' + + '========================================================================\n' + + ' ACHTUNG: Die Systemrollen stimmen nicht mit dem Katalog überein:\n' + + rollenHinweise.map((h) => ` – ${h}\n`).join('') + + '\n' + + ' Beheben: npx tsx prisma/sync-roles.ts\n' + + '========================================================================\n', + ); + } + if (fehlend.length === 0) return; console.warn( @@ -68,6 +105,13 @@ export async function pruefePflichtrechte(): Promise { ' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' + ' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' + ' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' + + '\n' + + ' Geht das nicht, weil niemand mehr die nötigen Rechte hat: Rechte\n' + + ' lassen sich über die Oberfläche nur weitergeben, nicht erschaffen.\n' + + ' Für die Erstvergabe gibt es den Weg über die Kommandozeile:\n' + + ' docker compose exec backend \\\n' + + ' npx tsx prisma/rolle-zuweisen.ts DSGVO\n' + + ' („--liste" zeigt die vorhandenen Rollen.)\n' + '========================================================================\n', ); } catch (err) { diff --git a/backend/src/services/rechte.service.ts b/backend/src/services/rechte.service.ts new file mode 100644 index 00000000..5955f331 --- /dev/null +++ b/backend/src/services/rechte.service.ts @@ -0,0 +1,177 @@ +/** + * Rechteaufloesung und die Regel, die Selbst-Erhoehung verhindert. + * + * Die Regel lautet: Niemand kann ein Recht weitergeben, das er selbst nicht + * besitzt. Ohne sie genuegte `users:create`, um sich zum Vollzugriff zu + * befoerdern - eine Rolle mit `developer:access` anlegen und sich zuweisen, + * oder gleich ein zweites Konto mit dem Haken "Entwicklerzugriff" erzeugen. + * Die Rollenverwaltung war damit faktisch eine Rechteerhoehung mit + * Zwischenschritt. + * + * Zwei Dinge sind hier bewusst so gebaut: + * + * 1. Die effektiven Rechte werden IMMER frisch aus der Datenbank gelesen, + * nie aus `req.user.permissions`. Der JWT-Claim ist bis zu 15 Minuten alt + * (JWT_EXPIRES_IN). Fuer ein Gate ist das vertretbar - fuer die Frage + * "darf dieser Mensch dieses Recht weitergeben" nicht: Ein Konto, dem + * gerade `gdpr:admin` entzogen wurde, koennte es im Fenster noch + * weiterreichen und damit den Entzug ueberdauern. + * + * 2. Geprueft wird immer nur der ZUWACHS. Rechte entziehen bleibt jederzeit + * erlaubt, auch solche, die der Handelnde selbst nicht hat - sonst + * koennte ein Admin einen uebernommenen Developer-Zugang nicht mehr + * entschaerfen, und die Regel wuerde den Angreifer schuetzen. + */ + +import prisma from '../lib/prisma.js'; +import { + ROLLE_DEVELOPER, + ROLLE_DSGVO, + ROLLE_AUDIT_BETRIEB, + rechteDerSystemrolle, +} from '../config/rechte-katalog.js'; + +/** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */ +export class RechteEskalationError extends Error { + constructor(public readonly fehlend: string[]) { + super( + 'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' + + `Nicht vergeben werden können: ${fehlend.join(', ')}`, + ); + this.name = 'RechteEskalationError'; + } +} + +/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */ +export class RollenSperrError extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = 'RollenSperrError'; + } +} + +/** + * Die effektiven Rechte eines Kontos, frisch aus der Datenbank. + * + * Das ist dieselbe Aufloesung, die auth.service.ts beim Anmelden und beim + * Erneuern des Tokens vornimmt - hier an einer Stelle, statt in vier Kopien. + */ +export async function effektiveRechte(userId: number): Promise> { + const konto = await prisma.user.findUnique({ + where: { id: userId }, + include: { + roles: { + include: { role: { include: { permissions: { include: { permission: true } } } } }, + }, + }, + }); + const rechte = new Set(); + if (!konto) return rechte; + for (const ur of konto.roles) { + for (const rp of ur.role.permissions) { + rechte.add(`${rp.permission.resource}:${rp.permission.action}`); + } + } + return rechte; +} + +/** Vereinigung der Rechte mehrerer Rollen. */ +export async function rechteVonRollen(roleIds: number[]): Promise> { + const rechte = new Set(); + if (roleIds.length === 0) return rechte; + const rollen = await prisma.role.findMany({ + where: { id: { in: roleIds } }, + include: { permissions: { include: { permission: true } } }, + }); + for (const r of rollen) { + for (const rp of r.permissions) { + rechte.add(`${rp.permission.resource}:${rp.permission.action}`); + } + } + return rechte; +} + +/** + * Loest Rechte-IDs auf. Wirft bei unbekannter ID einen sprechenden Fehler - + * bisher lief eine erfundene ID in einen Fremdschluesselfehler und kam als + * HTTP 500 zurueck, also als "unser Fehler" statt "Ihre Eingabe". + */ +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 bekannt = new Set(gefunden.map((p) => p.id)); + const unbekannt = [...new Set(ids)].filter((id) => !bekannt.has(id)); + throw new Error(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`); + } + for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`); + return rechte; +} + +/** + * Die Kernregel. Wirft RechteEskalationError, wenn der Handelnde etwas + * weitergeben will, das er selbst nicht haelt. + */ +export async function pruefeTeilmenge( + handelnderId: number, + benoetigt: Iterable, +): Promise { + const zuPruefen = [...benoetigt]; + if (zuPruefen.length === 0) return; + + const eigene = await effektiveRechte(handelnderId); + const fehlend = zuPruefen.filter((r) => !eigene.has(r)).sort(); + if (fehlend.length > 0) throw new RechteEskalationError(fehlend); +} + +/** + * Beendet die Sitzungen aller Traeger einer Rolle. + * + * Noetig, weil die Rechte im Zugangstoken stehen: Ohne das behielte jeder + * Traeger bis zu 15 Minuten lang die alten Rechte, und ein Entzug waere + * genau so lange wirkungslos. `updateUser` machte das laengst - die + * Rollenpflege nicht, und dort wiegt es schwerer, weil sie viele Konten auf + * einmal betrifft. + * + * Muss bei Loeschungen VOR dem Loeschen laufen: Danach sind die Traeger + * durch den Cascade nicht mehr ermittelbar. + */ +export async function meldeTraegerAb(roleId: number): Promise { + const traeger = await prisma.userRole.findMany({ + where: { roleId }, + select: { userId: true }, + }); + if (traeger.length === 0) return 0; + await prisma.user.updateMany({ + where: { id: { in: traeger.map((t) => t.userId) } }, + data: { tokenInvalidatedAt: new Date() }, + }); + return traeger.length; +} + +/** + * Die Rechte, die hinter den drei Haken im Benutzerformular stehen. + * + * Die Haken sind keine Datenbankspalten, sondern Kurzschrift fuer die + * versteckten Rollen DSGVO, Developer und Audit-Betrieb. Sie muessen unter + * dieselbe Teilmengenregel wie die Rollenzuweisung fallen, sonst ist der + * Rest Theater: Die Umgehung braucht nur `users:create` - ein zweites Konto + * mit dem Haken "Entwicklerzugriff" anlegen und sich damit anmelden. Die + * Developer-Rolle traegt ALLE Rechte. Eine reine Selbstvergabe-Sperre + * griffe dagegen nicht, denn der Angreifer vergibt sich nichts selbst. + * + * Nur das EINSCHALTEN wird geprueft. Wer einen Haken entfernt, nimmt Rechte + * weg - das darf jeder duerfen, der das Konto verwalten darf. + */ +export function rechteDerHaken(haken: { + hasDeveloperAccess?: boolean; + hasGdprAccess?: boolean; + hasAuditOpsAccess?: boolean; +}): string[] { + const rechte: string[] = []; + if (haken.hasDeveloperAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DEVELOPER)); + if (haken.hasGdprAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DSGVO)); + if (haken.hasAuditOpsAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_AUDIT_BETRIEB)); + return [...new Set(rechte)]; +} diff --git a/backend/src/services/rollen-sync.service.ts b/backend/src/services/rollen-sync.service.ts new file mode 100644 index 00000000..0a6c925a --- /dev/null +++ b/backend/src/services/rollen-sync.service.ts @@ -0,0 +1,115 @@ +/** + * Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des + * Codes. Idempotent - laeuft bei jedem Container-Start. + * + * Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts` + * (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei + * benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte + * jeder seine eigene, und die dritte wich ab. + * + * Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist + * auf einer laufenden Produktionsdatenbank sicher. + */ + +import type { PrismaClient } from '@prisma/client'; +import { + RECHTE_KATALOG, + SYSTEMROLLEN, + alsRechtString, +} from '../config/rechte-katalog.js'; + +type Protokoll = (zeile: string) => void; + +/** + * Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen + * dazu, ueberzaehlige fliegen raus. + * + * Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an + * einer Systemrolle vorgenommene Aenderung den naechsten Container-Start + * nicht ueberlebt. + */ +async function synchronisiereRollenrechte( + prisma: PrismaClient, + roleId: number, + permissionIds: number[], + log: Protokoll, +): Promise { + const vorhanden = await prisma.rolePermission.findMany({ + where: { roleId }, + select: { permissionId: true }, + }); + const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId)); + const zielIds = new Set(permissionIds); + + const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id)); + if (fehlend.length > 0) { + await prisma.rolePermission.createMany({ + data: fehlend.map((permissionId) => ({ roleId, permissionId })), + skipDuplicates: true, + }); + log(` → +${fehlend.length} Rechte an Rolle #${roleId}`); + } + + const ueberzaehlig = vorhanden + .filter((e) => !zielIds.has(e.permissionId)) + .map((e) => e.permissionId); + if (ueberzaehlig.length > 0) { + await prisma.rolePermission.deleteMany({ + where: { roleId, permissionId: { in: ueberzaehlig } }, + }); + log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`); + } +} + +/** + * Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand. + * Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche + * Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt + * die Flags nur einmalig. + */ +export async function synchronisiereRechteUndRollen( + prisma: PrismaClient, + log: Protokoll = (z) => console.log(z), +): Promise { + log('[rollen-sync] Rechtekatalog upserten…'); + for (const recht of RECHTE_KATALOG) { + await prisma.permission.upsert({ + where: { resource_action: { resource: recht.resource, action: recht.action } }, + update: {}, + create: { resource: recht.resource, action: recht.action }, + }); + } + + const alleRechte = await prisma.permission.findMany(); + log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`); + + // Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen, + // aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle + // zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand. + const idFuerRecht = new Map(); + for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id); + + for (const spec of SYSTEMROLLEN) { + const rechteIds = RECHTE_KATALOG.filter(spec.rechte) + .map((r) => idFuerRecht.get(alsRechtString(r))) + .filter((id): id is number => id !== undefined); + + const rolle = await prisma.role.upsert({ + where: { name: spec.name }, + update: { + description: spec.description, + isSystem: true, + isHidden: spec.isHidden, + }, + create: { + name: spec.name, + description: spec.description, + isSystem: true, + isHidden: spec.isHidden, + }, + }); + await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log); + } + + log('[rollen-sync] fertig.'); +} diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 140665d1..5d0bd24c 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -1,6 +1,16 @@ import prisma from '../lib/prisma.js'; import bcrypt from 'bcryptjs'; import { paginate, buildPaginationResponse } from '../utils/helpers.js'; +import { + pruefeTeilmenge, + rechteVonPermissionIds, + rechteVonRollen, + rechteDerHaken, + effektiveRechte, + meldeTraegerAb, + RollenSperrError, +} from './rechte.service.js'; +import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js'; export interface UserFilters { search?: string; @@ -155,7 +165,17 @@ export async function createUser(data: { 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. + await pruefeTeilmenge(handelnderId, [ + ...(await rechteVonRollen(data.roleIds)), + ...rechteDerHaken(data), + ]); + const hashedPassword = await bcrypt.hash(data.password, 10); const user = await prisma.user.create({ @@ -220,10 +240,31 @@ export async function updateUser( whatsappNumber?: string; telegramUsername?: string; signalNumber?: string; - } + }, + handelnderId: number, ) { const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data; + // 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(roleIds)) { + 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; @@ -622,17 +663,42 @@ export async function getRoleById(id: number) { }); } -export async function createRole(data: { - name: string; - description?: string; - permissionIds: number[]; -}) { +/** + * 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: data.name, + name, description: data.description, permissions: { - create: data.permissionIds.map((permissionId) => ({ permissionId })), + create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })), }, }, include: { @@ -643,15 +709,50 @@ export async function createRole(data: { }); } +/** + * 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 }, @@ -661,28 +762,69 @@ export async function updateRole( if (permissionIds) { await prisma.rolePermission.deleteMany({ where: { roleId: id } }); await prisma.rolePermission.createMany({ - data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })), + // 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) { - // Check if role is assigned to any users + 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() { - return prisma.permission.findMany({ + 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 }; + }); } diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 692c6d52..61862b24 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -845,6 +845,18 @@ export function pickUserCreate(body: unknown): Partial> return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true }); } +// Rollen-Whitelist. +// +// `createRole`/`updateRole` reichten `req.body` bis 09/2026 ungefiltert an +// Prisma durch - dasselbe Muster wie R110, nur an der Stelle, an der +// festgelegt wird, wer was darf. `isSystem` und `isHidden` duerfen NIEMALS +// aus dem Request kommen: Sie sind die Sperre selbst, nicht ihr Gegenstand. +const ROLE_UPDATABLE_FIELDS = ['name', 'description', 'permissionIds'] as const; + +export function pickRoleUpdate(body: unknown): Partial> { + return pick((body as object) || {}, ROLE_UPDATABLE_FIELDS, { stripHtmlFromStrings: true }); +} + // ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ==================== // Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten // `req.body` ungefiltert an Prisma durch – gleiches Muster wie das diff --git a/docs/todo.md b/docs/todo.md index 9f91a160..53d7f2c2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,83 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt + +- [x] **🔑 Rechtemodell Etappe 1: Katalog begradigt, Selbst-Erhöhung geschlossen** (2026-09-04) + - Vorarbeit für die Rollen-Oberfläche (Etappe 2). Eine Checkbox-Liste über + einem Katalog, der nicht stimmt, wäre schlimmer als gar keine. + - **18 von 50 Rechten bewachten nichts.** `tariffs:*`, + `cancellation-periods:*`, `contract-durations:*` und `email-providers:*` + standen im Katalog und waren anhakbar – die Routen prüften in Wahrheit + `providers:*`, `platforms:*` und `settings:*`. Jetzt gaten die Routen auf + ihre eigenen Rechte; eine rein additive Migration vererbt jedes neue Recht + an jede Rolle, die bisher das Sammelrecht hatte. **Nachgerechnet: keine + Rolle hat etwas verloren.** + - **Der Katalog stand dreifach und divergent** – `seed.ts`, `sync-roles.ts` + und, abweichend, `factoryReset`. Die dritte Kopie kannte weder `audit:*` + noch `gdpr:*` und legte DSGVO, Audit-Betrieb und Gegenbuch gar nicht an: + **Nach einem Werksreset konnte niemand mehr eine Auskunft nach Art. 15 + ausführen**, und aufgefallen wäre es erst, wenn eine Frist läuft. Jetzt + eine Quelle: `backend/src/config/rechte-katalog.ts`. + - **Nebenbefund im selben Code:** `factoryReset` setzte das Admin-Kennwort + fest auf `"admin"` mit bcrypt-Cost 10 – genau das, was `seed.ts` seit + Pentest Runde 12 verbietet. Dieselbe Härtung war nur in einer der beiden + Kopien angekommen. Jetzt zufällig, Cost 12, einmalig im Log. + - **Selbst-Erhöhung geschlossen** (der offene Punkt aus dem Audit-Betrieb- + Eintrag vom 26.08.): Neues Recht `roles:manage`, getrennt von `users:*`. + Dazu die Teilmengenregel in `rechte.service.ts` – niemand kann ein Recht + weitergeben, das er selbst nicht hält. Sie greift auf **allen vier** Wegen: + Rolle anlegen, Rolle ändern, Rollen zuweisen und die drei Haken + (DSGVO/Developer/Audit-Betrieb). Der Haken-Weg war der wichtigste: Er + brauchte nur `users:create` – ein zweites Konto mit „Entwicklerzugriff" + anlegen und sich damit anmelden, und die Developer-Rolle trägt *alle* + Rechte. + - Geprüft wird der **Zuwachs**, nicht der Endzustand: Rechte entziehen bleibt + jederzeit erlaubt, sonst könnte ein Admin einen übernommenen + Developer-Zugang nicht mehr entschärfen. Und die Prüfung liest die Rechte + des Handelnden **frisch aus der Datenbank**, nicht aus dem JWT – der ist + bis zu 15 Minuten alt, und ein gerade entzogenes Recht darf nicht im + Nachlauf noch weitergereicht werden. + - **Systemrollen sind gesperrt** (`Role.isSystem`): Admin ließ sich bisher + umbenennen oder leeren – und die versteckten Rollen hängen an ihrem + *Namen*. `Role.isHidden` ersetzt die im Frontend hartkodierte Namensliste, + in der „Gegenbuch" fehlte. + - **Rechteänderung wirkt sofort**: `updateRole`/`deleteRole` melden alle + Träger ab. Vorher behielten sie bis zu 15 Minuten die alten Rechte. + - **Werksreset und Backup-Restore** verlangen jetzt zusätzlich + `roles:manage`. Beide löschen alle Rollen und legen ein frisches + `admin@admin.com` an – eine Rolle mit nur `settings:update` hätte damit die + ganze Rechtevergabe zurücksetzen und sich anschließend anmelden können. + - **Aussperr-Ausweg**: `npx tsx prisma/rolle-zuweisen.ts `. + Umgeht die Regel bewusst – wer Shell-Zugang hat, hat ohnehin die Datenbank; + ein gestohlener Web-Zugang hat ihn nicht. Schreibt einen Eintrag über die + Hash-Kette (nicht roh, sonst risse er eine Lücke) und meldet ab. + Die Startwache nennt diesen Weg jetzt im Klartext. + - **Rollenpflege ist nicht mehr der leiseste Eingriff**: Sie war der einzige + Weg in die Rechtevergabe ohne SecurityEvent, obwohl sie viele Konten auf + einmal trifft. Jetzt `PERMISSION_CHANGED` – ebenso für die Haken DSGVO und + Entwicklerzugriff, die bisher stumm waren. + - **Nachgeprüft** (Dev-Datenbank + frische Wegwerf-Datenbank): 7 Eskalations- + wege → alle 403, 2 Gegenproben → erlaubt; 6 Sperrtests auf Systemrollen → + alle 403, eigene Rollen weiter änder- und löschbar; Stammdaten-Lesen für + Mitarbeiter unverändert 200; Rechteänderung → sofort 401; Migration und + `sync-roles` dreimal hintereinander → identischer Bericht; `db:seed` auf + bestehender Datenbank → Rollenmatrix unverändert; frische Installation → + alle 8 Systemrollen, keine Hinweise. + - **Neu**: `prisma/rechte-report.ts` – Bestandsaufnahme zum Vorher/Nachher- + Vergleich. Läuft auf beiden Seiten der Migration. Meldet auch Waisen: acht + Rechte aus alten Schemata (`*:access`, `settings:create/delete`) liegen + noch in der Datenbank und bewachen nichts. Nicht gelöscht, sondern am + Lesezugriff gefiltert – ein Filter ist die kleinere Behauptung als ein + DELETE. + - **Beim Deploy**: Die Migration beendet alle Sitzungen einmalig + (`tokenInvalidatedAt`). Nötig, weil die Rechte im Token stehen – sonst + liefen bis zu 15 Minuten 403er. Alle müssen sich einmal neu anmelden. + - Dateien: `src/config/rechte-katalog.ts`, `src/services/rechte.service.ts`, + `src/services/rollen-sync.service.ts`, `prisma/rechte-report.ts`, + `prisma/rolle-zuweisen.ts` (alle neu); zwei Migrationen; + `user.service.ts`, `user.controller.ts`, `backup.service.ts`, + `pflichtrechte.service.ts`, `sanitize.ts`, 8 Route-Dateien, 5 Frontend-Seiten + - [x] **🔢 R188: Ungültige IDs im Pfad – zentral statt 181-mal** (2026-09-03) - Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 76a3e72b..80c45ecb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -26,28 +26,28 @@ export default function Settings() { icon: Clock, title: 'Kündigungsfristen', description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.', - show: hasPermission('platforms:read'), + show: hasPermission('cancellation-periods:read'), }, { to: '/settings/contract-durations', icon: Calendar, title: 'Vertragslaufzeiten', description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.', - show: hasPermission('platforms:read'), + show: hasPermission('contract-durations:read'), }, { to: '/settings/providers', icon: Building2, title: 'Anbieter & Tarife', description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.', - show: hasPermission('providers:read') || hasPermission('platforms:read'), + show: hasPermission('providers:read') || hasPermission('tariffs:read'), }, { to: '/settings/contract-categories', icon: FileType, title: 'Vertragstypen', description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).', - show: hasPermission('platforms:read'), + show: hasPermission('contract-categories:read'), }, { to: '/settings/credit-note-number-range', @@ -180,6 +180,7 @@ export default function Settings() { + {hasPermission('email-providers:read') && ( + )}

Kündigungsfristen

- {hasPermission('platforms:create') && ( + {hasPermission('cancellation-periods:create') && ( )} - {hasPermission('platforms:delete') && ( + {hasPermission('cancellation-periods:delete') && ( )} - {hasPermission('developer:access') && ( + {hasPermission('contract-categories:delete') && ( )} - {hasPermission('platforms:delete') && ( + {hasPermission('contract-durations:delete') && ( )} - {hasPermission('providers:delete') && ( + {hasPermission('tariffs:delete') && (