diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 1d198f3f..c07758c7 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -4,6 +4,7 @@ import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; import { logChange } from '../services/audit.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 { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; @@ -175,6 +176,25 @@ export async function updateUser(req: AuthRequest, res: Response): Promise (data as any).isServiceAccount !== before?.isServiceAccount; const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true; + // R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein + // gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten, + // seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es + // haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil + // nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit + // „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben. + // Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden + // liegt. + if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) { + res.status(403).json({ + success: false, + error: + 'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' + + 'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' + + 'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.', + } as ApiResponse); + return; + } + if (setztDienstkonto && req.user?.userId === userId) { res.status(403).json({ success: false, @@ -187,14 +207,23 @@ export async function updateUser(req: AuthRequest, res: Response): Promise return; } - if (aktiviertDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') { + // Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet – + // und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der + // Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das + // „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde. + if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') { res.status(400).json({ success: false, - error: - `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im Audit-Log ` + - 'als Routine geführt statt als kritisches Ereignis. Das ist für planmäßig arbeitende ' + - 'Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ein Mensch benutzt, wäre es ' + - 'eine Tarnung. Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.', + error: aktiviertDienstkonto + ? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` + + 'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' + + 'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' + + 'ein Mensch benutzt, wäre es eine Tarnung. ' + + 'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.' + : `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` + + 'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' + + 'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' + + 'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.', } as ApiResponse); return; } @@ -239,6 +268,29 @@ export async function updateUser(req: AuthRequest, res: Response): Promise sensitivity: setztDienstkonto ? 'CRITICAL' : undefined, details: Object.keys(changes).length > 0 ? changes : undefined, }); + + // Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile + // im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das + // Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache; + // ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung + // gehoert deshalb dorthin, wo automatisch reagiert wird. + if (setztDienstkonto) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'PERMISSION_CHANGED', + severity: 'CRITICAL', + message: aktiviertDienstkonto + ? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` + + 'jetzt als Routine statt als kritisches Ereignis.' + : `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` + + 'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.', + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto }, + }); + } } else { await logChange({ req, action: 'UPDATE', resourceType: 'User', diff --git a/docs/todo.md b/docs/todo.md index 833f0327..8148ff1b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,32 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24) + - **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist + der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount: + true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit + richtig – Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen + der Tripwire gebaut wurde: stilllegen und auf Stille setzen. + Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim + Entfernen: „faellt aus der Ueberwachung heraus“). + - **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den + **Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log. + Eine CRITICAL-Zeile muss jemand LESEN – das war die R183-02-Klasse. Jetzt + reagiert die Ueberwachung automatisch. + - **R184-02** – Das Kennzeichen hing an `users:update`, einer + Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist – + Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die + alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige + Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“ + geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller. + - Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung + **400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** · + zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet + die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc` + gruen. + - Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder + ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt. + - [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24) - Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den diff --git a/frontend/src/pages/users/UserList.tsx b/frontend/src/pages/users/UserList.tsx index 3a1a3a64..82625a66 100644 --- a/frontend/src/pages/users/UserList.tsx +++ b/frontend/src/pages/users/UserList.tsx @@ -331,7 +331,10 @@ function UserModal({ // Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos. // Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken // im Formular IST diese Bestätigung. - ...(formData.isServiceAccount ? { confirm: 'SERVICE_ACCOUNT' } : {}), + // Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses + // Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche + // Bestätigung; der Haken im Formular ist diese Bestätigung. + confirm: 'SERVICE_ACCOUNT', whatsappNumber: formData.whatsappNumber || undefined, telegramUsername: formData.telegramUsername || undefined, signalNumber: formData.signalNumber || undefined, @@ -371,7 +374,10 @@ function UserModal({ // Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos. // Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken // im Formular IST diese Bestätigung. - ...(formData.isServiceAccount ? { confirm: 'SERVICE_ACCOUNT' } : {}), + // Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses + // Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche + // Bestätigung; der Haken im Formular ist diese Bestätigung. + confirm: 'SERVICE_ACCOUNT', whatsappNumber: formData.whatsappNumber || undefined, telegramUsername: formData.telegramUsername || undefined, signalNumber: formData.signalNumber || undefined,