diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 4c673ef7..cabaa69b 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -106,7 +106,7 @@ async function main() { update: {}, create: { name: 'Admin', - description: 'Voller Zugriff auf alle Funktionen', + 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 })), }, @@ -129,16 +129,29 @@ async function main() { }); await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id)); - // DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess) + // DSGVO - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll. + // + // 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 === 'audit' || p.resource === 'gdpr' + (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 und Datenschutz-Verwaltung', + description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permissions: { create: gdprPermissions.map((p) => ({ permissionId: p.id })), }, @@ -265,8 +278,19 @@ async function main() { password: hashedPassword, firstName: 'Admin', lastName: 'User', + // Zusaetzlich die DSGVO-Rolle (Pentest R189-01). + // + // Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach + // Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte + // haengen an DSGVO und Developer, und beide waren keinem Konto + // zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts + // weiter als eine Neuinstallation. + // + // Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin + // nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt + // jemand handlungsfaehig ist. roles: { - create: [{ roleId: adminRole.id }], + create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }], }, }, }); diff --git a/backend/prisma/sync-roles.ts b/backend/prisma/sync-roles.ts index 71ce15de..32fab781 100644 --- a/backend/prisma/sync-roles.ts +++ b/backend/prisma/sync-roles.ts @@ -175,7 +175,7 @@ async function main() { .map((p) => p.id); const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [ - { name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds }, + { 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 }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 2b0f0a5b..f42fdc3b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import express, { Router } from 'express'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import helmet from 'helmet'; @@ -69,9 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js'; import { startSecurityMonitorScheduler } from './services/securityAlert.service.js'; import monitoringRoutes from './routes/monitoring.routes.js'; +import { registriereIdPruefung } from './middleware/routeIds.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js'; +import { pruefePflichtrechte } from './services/pflichtrechte.service.js'; import { authenticate } from './middleware/auth.js'; // ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ==================== @@ -326,69 +328,76 @@ app.use('/api', (_req, res, next) => { next(); }); -// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was -// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert -// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input- -// Validierung. Pentest Runde 7 (2026-05-17), LOW. +// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7, +// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`, +// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als +// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die +// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes". // -// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb -// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` – -// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie -// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings -// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt -// nicht geblockt. -const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/; -app.use('/api', (req, res, next) => { - for (const seg of req.path.split('/')) { - if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) { - res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' }); - return; - } - } - next(); -}); +// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router +// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und +// zwar in beide Richtungen: +// +// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und +// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen +// ID-Parameter und laesst nur kanonische Zahlen zu. +// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter +// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist. +// +// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten +// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts. // Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148). // Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.), // die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung. app.use('/api', apiBackstopRateLimiter); +// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt. +// +// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188) +// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie +// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das +// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann. +const mounte = (pfad: string, router: Router): void => { + app.use(pfad, registriereIdPruefung(router)); +}; + // Öffentliche Routes (OHNE Authentifizierung) -app.use('/api/public/consent', consentPublicRoutes); +mounte('/api/public/consent', consentPublicRoutes); // Routes -app.use('/api/auth', authRoutes); -app.use('/api/customers', customerRoutes); -app.use('/api/addresses', addressRoutes); -app.use('/api/bank-cards', bankcardRoutes); -app.use('/api/documents', documentRoutes); -app.use('/api/meters', meterRoutes); -app.use('/api/stressfrei-emails', stressfreiEmailRoutes); -app.use('/api/contracts', contractRoutes); -app.use('/api/credit-notes', creditNoteRoutes); -app.use('/api/company-profile', companyProfileRoutes); -app.use('/api/platforms', platformRoutes); -app.use('/api/cancellation-periods', cancellationPeriodRoutes); -app.use('/api/contract-durations', contractDurationRoutes); -app.use('/api/providers', providerRoutes); -app.use('/api/tariffs', tariffRoutes); -app.use('/api/users', userRoutes); -app.use('/api/upload', uploadRoutes); -app.use('/api/developer', developerRoutes); -app.use('/api/contract-categories', contractCategoryRoutes); -app.use('/api', contractTaskRoutes); -app.use('/api/settings', appSettingRoutes); -app.use('/api/email-providers', emailProviderRoutes); -app.use('/api', cachedEmailRoutes); -app.use('/api/energy-details', invoiceRoutes); -app.use('/api', contractHistoryRoutes); -app.use('/api/audit-logs', auditLogRoutes); -app.use('/api/gdpr', gdprRoutes); -app.use('/api/email-logs', emailLogRoutes); -app.use('/api/pdf-templates', pdfTemplateRoutes); -app.use('/api/birthdays', birthdayRoutes); -app.use('/api/factory-defaults', factoryDefaultsRoutes); -app.use('/api/monitoring', monitoringRoutes); +mounte('/api/auth', authRoutes); +mounte('/api/customers', customerRoutes); +mounte('/api/addresses', addressRoutes); +mounte('/api/bank-cards', bankcardRoutes); +mounte('/api/documents', documentRoutes); +mounte('/api/meters', meterRoutes); +mounte('/api/stressfrei-emails', stressfreiEmailRoutes); +mounte('/api/contracts', contractRoutes); +mounte('/api/credit-notes', creditNoteRoutes); +mounte('/api/company-profile', companyProfileRoutes); +mounte('/api/platforms', platformRoutes); +mounte('/api/cancellation-periods', cancellationPeriodRoutes); +mounte('/api/contract-durations', contractDurationRoutes); +mounte('/api/providers', providerRoutes); +mounte('/api/tariffs', tariffRoutes); +mounte('/api/users', userRoutes); +mounte('/api/upload', uploadRoutes); +mounte('/api/developer', developerRoutes); +mounte('/api/contract-categories', contractCategoryRoutes); +mounte('/api', contractTaskRoutes); +mounte('/api/settings', appSettingRoutes); +mounte('/api/email-providers', emailProviderRoutes); +mounte('/api', cachedEmailRoutes); +mounte('/api/energy-details', invoiceRoutes); +mounte('/api', contractHistoryRoutes); +mounte('/api/audit-logs', auditLogRoutes); +mounte('/api/gdpr', gdprRoutes); +mounte('/api/email-logs', emailLogRoutes); +mounte('/api/pdf-templates', pdfTemplateRoutes); +mounte('/api/birthdays', birthdayRoutes); +mounte('/api/factory-defaults', factoryDefaultsRoutes); +mounte('/api/monitoring', monitoringRoutes); // Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy // pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch @@ -491,6 +500,8 @@ const LISTEN_ADDR = process.env.LISTEN_ADDR // Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183): // Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen. starteHeartbeatMonitor(); +// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01). +void pruefePflichtrechte(); app.listen(PORT as number, LISTEN_ADDR, () => { console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`); diff --git a/backend/src/middleware/routeIds.ts b/backend/src/middleware/routeIds.ts new file mode 100644 index 00000000..4054c481 --- /dev/null +++ b/backend/src/middleware/routeIds.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express'; +import { ApiResponse } from '../types/index.js'; + +/** + * Numerische Pfad-Parameter zentral pruefen (Pentest R188). + * + * Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.)` + * ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere: + * + * 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der + * Handler antwortete mit **500**. `GET /api/users/permissions` etwa + * trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht". + * + * 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**. + * `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig + * geratene ID wurde stillschweigend zu einer gueltigen gemacht. + * + * Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard + * einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger + * verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander + * (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb + * eine Stelle statt 181 – und eine neue Route ist automatisch mit abgedeckt. + * + * Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der + * Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen. + * + * **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann, + * benennt keine Ressource – das ist „nicht gefunden", nicht „falsch gefragt". + * Der Codebestand hat es an der einzigen bereits abgesicherten Stelle + * (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei + * verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt + * existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt. + */ + +/** + * Parameternamen, die eine Datenbank-ID bezeichnen – abgeglichen mit allen + * Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`, + * `filename`, `hash`, `key`, `localPart`, `name`, `tableName`. + * + * Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand; + * eine neue Route mit `:fooId` gehoert hier ergaenzt. + */ +const ID_PARAMETER = [ + 'id', + 'contractId', + 'contractMeterId', + 'customerId', + 'documentId', + 'ecdId', + 'emailId', + 'entryId', + 'invoiceId', + 'meterId', + 'phoneNumberId', + 'providerId', + 'readingId', + 'referralId', + 'representativeId', + 'simCardId', + 'subtaskId', + 'taskId', +] as const; + +/** Obergrenze von MySQL INT – darueber gibt es keine Zeile, nur einen Fehler. */ +const MAX_ID = 2147483647; + +/** + * Gueltig ist ausschliesslich eine kanonische positive Ganzzahl. + * + * Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile + * bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine + * unnoetige Einladung – etwa fuer Zaehler, Zwischenspeicher oder Sperren, die + * auf dem Pfad als Schluessel arbeiten. + */ +export function istGueltigeId(wert: string): boolean { + if (!/^[1-9][0-9]*$/.test(wert)) return false; + const n = Number(wert); + return Number.isSafeInteger(n) && n <= MAX_ID; +} + +const pruefeIdParameter: RequestParamHandler = ( + _req: Request, + res: Response, + next: NextFunction, + wert: unknown, +) => { + if (typeof wert === 'string' && istGueltigeId(wert)) { + next(); + return; + } + // Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung + // soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten. + res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse); +}; + +/** + * Haengt die Pruefung an einen Router. Namen, die der Router gar nicht + * verwendet, kosten nichts – Express ruft den Callback nur fuer Parameter, + * die in einer getroffenen Route vorkommen. + */ +export function registriereIdPruefung(router: T): T { + for (const name of ID_PARAMETER) { + router.param(name, pruefeIdParameter); + } + return router; +} diff --git a/backend/src/services/pflichtrechte.service.ts b/backend/src/services/pflichtrechte.service.ts new file mode 100644 index 00000000..2c32b8bd --- /dev/null +++ b/backend/src/services/pflichtrechte.service.ts @@ -0,0 +1,82 @@ +import prisma from '../lib/prisma.js'; + +/** + * Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand + * ausueben kann (Pentest R189-01). + * + * Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den + * Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen + * Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft + * nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren, + * obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung, + * ausgeloest durch nichts weiter als eine Neuinstallation. + * + * Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer + * laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf + * ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit, + * so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt. + * + * Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen, + * ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler. + */ + +/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */ +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' }, +]; + +export async function pruefePflichtrechte(): Promise { + try { + const fehlend: string[] = []; + + for (const recht of PFLICHTRECHTE) { + const traeger = await prisma.user.count({ + where: { + isActive: true, + roles: { + some: { + role: { + permissions: { + some: { + permission: { resource: recht.resource, action: recht.action }, + }, + }, + }, + }, + }, + }, + }); + if (traeger === 0) { + fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`); + } + } + + if (fehlend.length === 0) return; + + console.warn( + '\n' + + '========================================================================\n' + + ' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' + + fehlend.map((f) => ` – ${f}\n`).join('') + + '\n' + + ' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' + + ' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' + + '\n' + + ' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' + + ' 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', + ); + } catch (err) { + // Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie + // auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden". + console.warn( + '[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' + + 'damit UNBEKANNT, nicht in Ordnung:', + err instanceof Error ? err.message : err, + ); + } +} diff --git a/docs/todo.md b/docs/todo.md index 8aac2938..9f91a160 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,62 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [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 + setzt einen Guard in `getUser`. + - **Berechtigt – und größer als gemeldet.** Ihr Fix schließt nebenbei etwas, + das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte + `GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181** + ungeprüfte Stellen in 19 Controllern, nicht eine. + - 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei + Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an + **einer** Stelle für alle 33 Router registriert – über einen `mounte()`- + Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use` + schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar. + - **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann, + benennt keine Ressource. Der bestehende Präzedenzfall + (`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden. + - **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte + `^\d+[a-zA-Z]+$` im Pfad – ihr eigener Kommentar nannte den Grund, + „`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und + genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch → + 500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde + geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404 + steht – zwei Antworten für dieselbe Eingabeklasse. + - Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`, + `007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über + sechs Controller gleich. Nicht-ID-Parameter (`roles/list`, + `permissions/list`, `settings/:key`) unverändert. + - Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts` + +- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03) + - Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und + Developer – die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed + war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung + nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung. + - Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die + Admin-**Rolle** bekommt diese Rechte weiterhin nicht – die Trennung aus + R186 bleibt, nur das eine Bootstrap-Konto trägt beides. + - Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne + Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)". + - **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die + DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin` – + also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei + Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte + übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein + einzelnes `npm run db:seed` brachte sie zurück. Korrigiert. + - **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb + zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete` + oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung + im Log. Erkennt die *Abwesenheit* einer Fähigkeit – dasselbe Muster wie der + Heartbeat. Bewusst nur melden, nicht automatisch vergeben. + - Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und + **kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei + Rechten. Rolle zurück → still. + - Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`, + `backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts` + - [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26) - Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird zeilenweise gegen die Vorgängerin geprüft – die **erste** Zeile hat keine,