Rechtemodell Etappe 1: toter Katalog begradigt, Selbst-Erhoehung geschlossen

Vorarbeit fuer die Rollen-Oberflaeche. Eine Checkbox-Liste ueber einem
Katalog, der nicht stimmt, waere 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 prueften in Wahrheit providers:*, platforms:* und
settings:*. Die Routen gaten jetzt granular; 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
ausfuehren, und aufgefallen waere es erst, wenn eine Frist laeuft. Jetzt
eine Quelle: src/config/rechte-katalog.ts.

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 Haertung war nur in einer der beiden Kopien
angekommen. Jetzt zufaellig, Cost 12, einmalig im Log.

Selbst-Erhoehung geschlossen: neues Recht roles:manage, getrennt von
users:*, dazu die Teilmengenregel in rechte.service.ts - niemand kann ein
Recht weitergeben, das er selbst nicht haelt. Sie greift auf allen vier
Wegen: Rolle anlegen, Rolle aendern, Rollen zuweisen und die drei Haken.
Der Haken-Weg war der wichtigste: Er brauchte nur users:create, ein
zweites Konto mit "Entwicklerzugriff" anlegen und sich damit anmelden -
die Developer-Rolle traegt alle Rechte. Geprueft wird der Zuwachs, nicht
der Endzustand, damit Entziehen erlaubt bleibt; und die Rechte des
Handelnden kommen frisch aus der Datenbank, nicht aus dem bis zu 15
Minuten alten Token.

Systemrollen gesperrt (Role.isSystem): Admin liess sich bisher umbenennen
oder leeren - und die versteckten Rollen haengen an ihrem Namen.
Role.isHidden ersetzt die im Frontend hartkodierte Namensliste, in der
"Gegenbuch" fehlte.

Rechteaenderung wirkt sofort: updateRole/deleteRole melden alle Traeger
ab. Werksreset und Backup-Restore verlangen zusaetzlich roles:manage -
beide loeschen alle Rollen und legen ein frisches admin@admin.com an.

Rollenpflege war der einzige Eingriff in die Rechtevergabe ohne
SecurityEvent, obwohl sie viele Konten auf einmal trifft. Jetzt
PERMISSION_CHANGED - ebenso fuer die bisher stummen Haken DSGVO und
Entwicklerzugriff.

Aussperr-Ausweg: prisma/rolle-zuweisen.ts. Umgeht die Regel bewusst - wer
Shell-Zugang hat, hat ohnehin die Datenbank; ein gestohlener Web-Zugang
hat ihn nicht. Schreibt ueber die Hash-Kette, nicht roh. Die Startwache
nennt den Befehl im Klartext.

Geprueft gegen Dev-DB und frische Wegwerf-DB: 7 Eskalationswege alle 403,
2 Gegenproben erlaubt; 6 Sperrtests auf Systemrollen alle 403, eigene
Rollen weiter aenderbar; Stammdaten-Lesen unveraendert 200;
Rechteaenderung sofort 401; Migration und sync-roles dreimal identisch;
db:seed auf bestehender DB ohne Wirkung auf die Rollenmatrix; frische
Installation mit allen 8 Systemrollen und ohne Hinweise.

Beim Deploy: Die Migration beendet alle Sitzungen einmalig. Noetig, weil
die Rechte im Token stehen - sonst liefen bis zu 15 Minuten 403er.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:49:30 +02:00
co-authored by Claude Opus 5
parent cca242119b
commit 77aeb69aeb
31 changed files with 1790 additions and 627 deletions
+14 -224
View File
@@ -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<string, string[]> = {
// 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-