diff --git a/backend/prisma/sync-roles.ts b/backend/prisma/sync-roles.ts index 1c882601..71ce15de 100644 --- a/backend/prisma/sync-roles.ts +++ b/backend/prisma/sync-roles.ts @@ -93,9 +93,34 @@ async function main() { // Developer: alles const developerPermIds = allPermissions.map((p) => p.id); - // DSGVO: audit + gdpr komplett + // 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 === 'audit' || p.resource === 'gdpr') + .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. @@ -152,7 +177,8 @@ async function main() { const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [ { name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds }, { name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds }, - { name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds }, + { 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 }, diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index c07758c7..8c48acc6 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; 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'; @@ -162,9 +163,26 @@ export async function updateUser(req: AuthRequest, res: Response): Promise ...beforeUser, hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'), hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'), + hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE), } : 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 + // 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). + const setztAuditBetrieb = + data.hasAuditOpsAccess !== undefined && + before !== null && + data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess; + const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true; + // 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 @@ -236,6 +254,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise const fieldLabels: Record = { email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv', hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff', + hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)', isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)', }; for (const [key, newVal] of Object.entries(data)) { @@ -265,7 +284,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`, // Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft: // Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden. - sensitivity: setztDienstkonto ? 'CRITICAL' : undefined, + sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined, details: Object.keys(changes).length > 0 ? changes : undefined, }); @@ -291,6 +310,28 @@ export async function updateUser(req: AuthRequest, res: Response): Promise details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto }, }); } + + if (setztAuditBetrieb) { + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'PERMISSION_CHANGED', + severity: 'CRITICAL', + message: aktiviertAuditBetrieb + ? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` + + 'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' + + 'ersetzen, gegen die Manipulation nachgewiesen wird.' + : `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`, + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details: { + betroffenesKonto: user.email, + aktiviert: aktiviertAuditBetrieb, + selbstvergabe: user.id === req.user?.userId, + }, + }); + } } else { await logChange({ req, action: 'UPDATE', resourceType: 'User', diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 75cedc35..140665d1 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -65,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) { ]); // Get hidden role IDs - const [developerRole, gdprRole] = await Promise.all([ + const [developerRole, gdprRole, auditOpsRole] = await Promise.all([ prisma.role.findFirst({ where: { name: 'Developer' } }), prisma.role.findFirst({ where: { name: 'DSGVO' } }), + prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }), ]); return { @@ -78,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) { const hasGdprAccess = gdprRole ? u.roles.some((ur) => ur.roleId === gdprRole.id) : false; + const hasAuditOpsAccess = auditOpsRole + ? u.roles.some((ur) => ur.roleId === auditOpsRole.id) + : false; return { ...u, roles: u.roles.map((r) => r.role), hasDeveloperAccess, hasGdprAccess, + hasAuditOpsAccess, }; }), pagination: buildPaginationResponse(page, limit, total), @@ -146,6 +151,7 @@ export async function createUser(data: { customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; + hasAuditOpsAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string; @@ -190,6 +196,10 @@ export async function createUser(data: { await setUserGdprAccess(user.id, true); } + if (data.hasAuditOpsAccess) { + await setUserAuditOpsAccess(user.id, true); + } + return user; } @@ -206,12 +216,13 @@ export async function updateUser( customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; + hasAuditOpsAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string; } ) { - const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data; + const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data; // Check if this would remove the last admin const isBeingDeactivated = userData.isActive === false; @@ -348,6 +359,10 @@ export async function updateUser( await setUserGdprAccess(id, hasGdprAccess); } + if (hasAuditOpsAccess !== undefined) { + await setUserAuditOpsAccess(id, hasAuditOpsAccess); + } + return getUserById(id); } @@ -407,6 +422,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) { } } +/** + * Name der versteckten Rolle fuer eingreifende Audit-Rechte. + * + * Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein + * duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht + * ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim + * Containerstart angelegt. + */ +export const AUDIT_OPS_ROLLE = 'Audit-Betrieb'; + // Helper to set GDPR access for a user async function setUserGdprAccess(userId: number, enabled: boolean) { // Get or create DSGVO role @@ -415,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) { }); if (!gdprRole) { - // Create DSGVO role with all audit:* and gdpr:* permissions + // Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit- + // Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst + // brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade + // aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen + // auseinander). const gdprPermissions = await prisma.permission.findMany({ where: { - OR: [{ resource: 'audit' }, { resource: 'gdpr' }], + OR: [ + { resource: 'gdpr' }, + { resource: 'audit', action: { in: ['read', 'export'] } }, + ], }, }); gdprRole = await prisma.role.create({ data: { 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 })), }, @@ -457,6 +489,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) { } } +/** + * Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen, + * Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe + * `AUDIT_OPS_ROLLE`). + */ +async function setUserAuditOpsAccess(userId: number, enabled: boolean) { + let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }); + + if (!rolle) { + // Rechte-Satz identisch zu sync-roles.ts. + const rechte = await prisma.permission.findMany({ + where: { resource: 'audit', action: { in: ['read', 'admin'] } }, + }); + rolle = await prisma.role.create({ + data: { + name: AUDIT_OPS_ROLLE, + description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', + permissions: { create: rechte.map((p) => ({ permissionId: p.id })) }, + }, + }); + } + + const hatRolle = await prisma.userRole.findFirst({ + where: { userId, roleId: rolle.id }, + }); + + if (enabled && !hatRolle) { + await prisma.userRole.create({ data: { userId, roleId: rolle.id } }); + // Token invalidieren bei Rechteaenderung + await prisma.user.update({ + where: { id: userId }, + data: { tokenInvalidatedAt: new Date() }, + }); + } else if (!enabled && hatRolle) { + await prisma.userRole.delete({ + where: { userId_roleId: { userId, roleId: rolle.id } }, + }); + await prisma.user.update({ + where: { id: userId }, + data: { tokenInvalidatedAt: new Date() }, + }); + } +} + export async function deleteUser(id: number) { // Check if user is an admin const user = await prisma.user.findUnique({ diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 4034916b..692c6d52 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -654,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [ // stehen, damit pick() sie nicht aus dem Request entfernt. 'hasGdprAccess', 'hasDeveloperAccess', + // Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst + // getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen + // (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'. + 'hasAuditOpsAccess', // Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt // Nicht: password – wird über dedizierten Endpoint POST /users/:id/password // gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update diff --git a/docs/todo.md b/docs/todo.md index 24c2dcba..0cd12dd0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26) + - Die DSGVO-Rolle trug `audit:*` **komplett**, also auch `audit:admin`. Ein + DSGVO-Beauftragter konnte damit `seal-backlog`, `rehash` und `cleanup` – + seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt, + darf es nicht umschreiben können. Dieselbe Klasse wie R184-02: falsche + Domäne, zu breit gebündelt. + - **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus + der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle + ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende + Quelle wäre der Entwicklerzugriff – der *alles* gibt. Prod versiegeln + hätte dann Vollzugriff vorausgesetzt. + - Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`** + (`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie + DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`. + **Für kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es + wird enger, und wer eingreifen können soll, bekommt es ausdrücklich. + - **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem + scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte + ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** – + CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal, + inklusive Kennzeichen, ob sich jemand den Haken selbst gesetzt hat. + - Nebenbei geschlossen: `setUserGdprAccess()` legte die DSGVO-Rolle im + Notfallpfad mit `audit:*` komplett an – eine zweite Liste, die dasselbe + bedeuten sollte und die Bündelung stillschweigend zurückgebracht hätte. + - **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer + Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen. + - **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann + sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt + *alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des + Pentesters. + - Dateien: `backend/prisma/sync-roles.ts`, + `backend/src/services/user.service.ts`, + `backend/src/controllers/user.controller.ts`, + `backend/src/utils/sanitize.ts`, + `frontend/src/pages/users/UserList.tsx`, + `frontend/src/services/api.ts`, `tools/audit-notary/README.md` + - [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26) - Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde diff --git a/frontend/src/pages/users/UserList.tsx b/frontend/src/pages/users/UserList.tsx index 82625a66..0ead9ebf 100644 --- a/frontend/src/pages/users/UserList.tsx +++ b/frontend/src/pages/users/UserList.tsx @@ -121,7 +121,7 @@ export default function UserList() { {user.email}
- {user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => ( + {user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => ( {role.name} @@ -246,6 +246,7 @@ function UserModal({ roleIds: [] as number[], isActive: true, hasDeveloperAccess: false, + hasAuditOpsAccess: false, hasGdprAccess: false, isServiceAccount: false, whatsappNumber: '', @@ -264,9 +265,10 @@ function UserModal({ currentPassword: '', firstName: user.firstName, lastName: user.lastName, - roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [], + roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [], isActive: (user as any).isActive ?? true, hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false, + hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false, hasGdprAccess: (user as any).hasGdprAccess ?? false, isServiceAccount: (user as any).isServiceAccount ?? false, whatsappNumber: (user as any).whatsappNumber || '', @@ -283,6 +285,7 @@ function UserModal({ roleIds: [], isActive: true, hasDeveloperAccess: false, + hasAuditOpsAccess: false, hasGdprAccess: false, isServiceAccount: false, whatsappNumber: '', @@ -326,6 +329,7 @@ function UserModal({ roleIds: formData.roleIds, isActive: formData.isActive, hasDeveloperAccess: formData.hasDeveloperAccess, + hasAuditOpsAccess: formData.hasAuditOpsAccess, hasGdprAccess: formData.hasGdprAccess, isServiceAccount: formData.isServiceAccount, // Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos. @@ -369,6 +373,7 @@ function UserModal({ lastName: formData.lastName, roleIds: formData.roleIds, hasDeveloperAccess: formData.hasDeveloperAccess, + hasAuditOpsAccess: formData.hasAuditOpsAccess, hasGdprAccess: formData.hasGdprAccess, isServiceAccount: formData.isServiceAccount, // Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos. @@ -489,7 +494,7 @@ function UserModal({
- {roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => ( + {roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => ( +