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>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
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...');
|
||
|
||
// ==================== RECHTE UND ROLLEN ====================
|
||
// Katalog und Systemrollen kommen aus einer einzigen Definition
|
||
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
|
||
//
|
||
// 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);
|
||
|
||
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-
|
||
// Komplexitätspolicy). Stattdessen:
|
||
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
|
||
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
|
||
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
|
||
// Passwort-vergessen-Flow nutzen.
|
||
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
|
||
function generateInitialPassword(): string {
|
||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||
const digits = '23456789';
|
||
const special = '!@#$%&*+=?';
|
||
const all = upper + lower + digits + special;
|
||
// Kryptografisch sichere Auswahl – Math.random() ist vorhersagbar
|
||
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
|
||
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
|
||
// mind. einen aus jeder Klasse + Rest zufällig
|
||
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
|
||
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
|
||
// Mitarbeiter-Schwellwert (Pentest Runde 13).
|
||
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
|
||
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
|
||
for (let i = chars.length - 1; i > 0; i--) {
|
||
const j = crypto.randomInt(0, i + 1);
|
||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||
}
|
||
return chars.join('');
|
||
}
|
||
|
||
const envPassword = process.env.SEED_ADMIN_PASSWORD;
|
||
const adminPlainPassword = envPassword && envPassword.length >= 25
|
||
? envPassword
|
||
: generateInitialPassword();
|
||
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||
|
||
const adminUser = await prisma.user.upsert({
|
||
where: { email: 'admin@admin.com' },
|
||
update: {},
|
||
create: {
|
||
email: 'admin@admin.com',
|
||
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 }, { roleId: gdprRole.id }],
|
||
},
|
||
},
|
||
});
|
||
|
||
console.log('========================================================');
|
||
console.log(' Admin-User: admin@admin.com');
|
||
if (envPassword && envPassword.length >= 25) {
|
||
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
|
||
} else {
|
||
if (envPassword && envPassword.length < 25) {
|
||
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
|
||
}
|
||
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||
}
|
||
console.log('========================================================');
|
||
|
||
// Create some sales platforms
|
||
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
||
for (const name of platforms) {
|
||
await prisma.salesPlatform.upsert({
|
||
where: { name },
|
||
update: {},
|
||
create: { name, isActive: true },
|
||
});
|
||
}
|
||
|
||
console.log('Sales platforms created');
|
||
|
||
// ==================== STANDARD PROVIDERS ====================
|
||
const providers = [
|
||
{
|
||
name: 'Vodafone',
|
||
portalUrl: 'https://www.vodafone.de/meinvodafone/account/login',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: 'Klarmobil',
|
||
portalUrl: 'https://www.klarmobil.de/login',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: 'Otelo',
|
||
portalUrl: 'https://www.otelo.de/mein-otelo/login',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: 'Congstar',
|
||
portalUrl: 'https://www.congstar.de/login/',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: 'Telekom',
|
||
portalUrl: 'https://www.telekom.de/kundencenter/startseite',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: 'O2',
|
||
portalUrl: 'https://www.o2online.de/ecare/selfcare',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
{
|
||
name: '1&1',
|
||
portalUrl: 'https://control-center.1und1.de/',
|
||
usernameFieldName: 'username',
|
||
passwordFieldName: 'password',
|
||
},
|
||
];
|
||
|
||
for (const provider of providers) {
|
||
await prisma.provider.upsert({
|
||
where: { name: provider.name },
|
||
update: {
|
||
portalUrl: provider.portalUrl,
|
||
usernameFieldName: provider.usernameFieldName,
|
||
passwordFieldName: provider.passwordFieldName,
|
||
},
|
||
create: { ...provider, isActive: true },
|
||
});
|
||
}
|
||
|
||
console.log('Providers created');
|
||
|
||
// Create contract categories (matching existing enum values)
|
||
const contractCategories = [
|
||
{ code: 'ELECTRICITY', name: 'Strom', icon: 'Zap', color: '#FFC107', sortOrder: 1 },
|
||
{ code: 'GAS', name: 'Gas', icon: 'Flame', color: '#FF5722', sortOrder: 2 },
|
||
{ code: 'DSL', name: 'DSL', icon: 'Wifi', color: '#2196F3', sortOrder: 3 },
|
||
{ code: 'FIBER', name: 'Glasfaser', icon: 'Cable', color: '#9C27B0', sortOrder: 4 },
|
||
{ code: 'CABLE', name: 'Kabel Internet (Coax)', icon: 'Cable', color: '#00BCD4', sortOrder: 5 },
|
||
{ code: 'MOBILE', name: 'Mobilfunk', icon: 'Smartphone', color: '#4CAF50', sortOrder: 6 },
|
||
{ code: 'TV', name: 'TV', icon: 'Tv', color: '#E91E63', sortOrder: 7 },
|
||
{ code: 'CAR_INSURANCE', name: 'KFZ-Versicherung', icon: 'Car', color: '#607D8B', sortOrder: 8 },
|
||
];
|
||
|
||
for (const category of contractCategories) {
|
||
await prisma.contractCategory.upsert({
|
||
where: { code: category.code },
|
||
update: { name: category.name, icon: category.icon, color: category.color, sortOrder: category.sortOrder },
|
||
create: category,
|
||
});
|
||
}
|
||
|
||
console.log('Contract categories created');
|
||
|
||
// ==================== CANCELLATION PERIODS ====================
|
||
const cancellationPeriods = [
|
||
{ code: '14D', description: '14 Tage' },
|
||
{ code: '1M', description: '1 Monat' },
|
||
{ code: '2M', description: '2 Monate' },
|
||
{ code: '3M', description: '3 Monate' },
|
||
{ code: '6M', description: '6 Monate' },
|
||
{ code: '12M', description: '12 Monate' },
|
||
{ code: '1W', description: '1 Woche' },
|
||
{ code: '2W', description: '2 Wochen' },
|
||
{ code: '4W', description: '4 Wochen' },
|
||
{ code: '6W', description: '6 Wochen' },
|
||
];
|
||
|
||
for (const period of cancellationPeriods) {
|
||
await prisma.cancellationPeriod.upsert({
|
||
where: { code: period.code },
|
||
update: { description: period.description },
|
||
create: period,
|
||
});
|
||
}
|
||
|
||
console.log('Cancellation periods created');
|
||
|
||
// ==================== CONTRACT DURATIONS ====================
|
||
const contractDurations = [
|
||
{ code: '1M', description: '1 Monat' },
|
||
{ code: '3M', description: '3 Monate' },
|
||
{ code: '6M', description: '6 Monate' },
|
||
{ code: '12M', description: '12 Monate' },
|
||
{ code: '24M', description: '24 Monate' },
|
||
{ code: '36M', description: '36 Monate' },
|
||
{ code: '1J', description: '1 Jahr' },
|
||
{ code: '2J', description: '2 Jahre' },
|
||
{ code: '3J', description: '3 Jahre' },
|
||
{ code: '4J', description: '4 Jahre' },
|
||
{ code: '5J', description: '5 Jahre' },
|
||
{ code: 'UNBEFRISTET', description: 'Unbefristet' },
|
||
];
|
||
|
||
for (const duration of contractDurations) {
|
||
await prisma.contractDuration.upsert({
|
||
where: { code: duration.code },
|
||
update: { description: duration.description },
|
||
create: duration,
|
||
});
|
||
}
|
||
|
||
console.log('Contract durations created');
|
||
|
||
// ==================== APP SETTINGS ====================
|
||
const appSettings = [
|
||
// Cockpit-Einstellungen (Fristen-Ampel)
|
||
{ key: 'deadlineCriticalDays', value: '14' }, // Rot: <= 14 Tage
|
||
{ key: 'deadlineWarningDays', value: '42' }, // Gelb: <= 42 Tage
|
||
{ key: 'deadlineOkDays', value: '90' }, // Grün: <= 90 Tage
|
||
// Allgemeine Einstellungen
|
||
{ key: 'companyName', value: 'OpenCRM' },
|
||
{ key: 'defaultEmailDomain', value: 'stressfrei-wechseln.de' },
|
||
];
|
||
|
||
for (const setting of appSettings) {
|
||
await prisma.appSetting.upsert({
|
||
where: { key: setting.key },
|
||
update: {}, // Bestehende Werte nicht überschreiben
|
||
create: setting,
|
||
});
|
||
}
|
||
|
||
console.log('App settings created');
|
||
|
||
// ==================== AUDIT RETENTION POLICIES (DSGVO) ====================
|
||
// Standard-Policy (ohne Sensitivity)
|
||
const existingDefault = await prisma.auditRetentionPolicy.findFirst({
|
||
where: { resourceType: '*', sensitivity: null },
|
||
});
|
||
if (!existingDefault) {
|
||
await prisma.auditRetentionPolicy.create({
|
||
data: {
|
||
resourceType: '*',
|
||
sensitivity: null,
|
||
retentionDays: 3650, // 10 Jahre
|
||
description: 'Standard-Aufbewahrungsfrist',
|
||
legalBasis: 'AO §147, HGB §257',
|
||
},
|
||
});
|
||
}
|
||
|
||
// Spezifische Policies mit Sensitivity
|
||
const specificPolicies = [
|
||
{
|
||
resourceType: 'Authentication',
|
||
sensitivity: 'CRITICAL' as const,
|
||
retentionDays: 730, // 2 Jahre
|
||
description: 'Login-Versuche und Authentifizierung',
|
||
legalBasis: 'Sicherheitsanforderungen',
|
||
},
|
||
{
|
||
resourceType: 'Customer',
|
||
sensitivity: 'HIGH' as const,
|
||
retentionDays: 3650, // 10 Jahre
|
||
description: 'Kundendaten-Zugriffe',
|
||
legalBasis: 'Steuerrecht (AO §147)',
|
||
},
|
||
{
|
||
resourceType: 'Contract',
|
||
sensitivity: 'MEDIUM' as const,
|
||
retentionDays: 3650, // 10 Jahre
|
||
description: 'Vertragsdaten-Zugriffe',
|
||
legalBasis: 'Steuerrecht (AO §147)',
|
||
},
|
||
{
|
||
resourceType: 'AppSetting',
|
||
sensitivity: 'LOW' as const,
|
||
retentionDays: 1095, // 3 Jahre
|
||
description: 'Allgemeine Einstellungen',
|
||
legalBasis: 'Verjährungsfrist (BGB §195)',
|
||
},
|
||
{
|
||
// Stiller Token-Refresh (Routine). Ohne eigene Regel fiele diese
|
||
// Kombination in die Auffangregel `*` mit 3650 Tagen – das Rauschen
|
||
// wäre dann länger aufbewahrt als echte Logins (730 Tage).
|
||
resourceType: 'Authentication',
|
||
sensitivity: 'LOW' as const,
|
||
retentionDays: 90,
|
||
description: 'Routine-Auth (stiller Token-Refresh)',
|
||
legalBasis: 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)',
|
||
},
|
||
];
|
||
|
||
for (const policy of specificPolicies) {
|
||
await prisma.auditRetentionPolicy.upsert({
|
||
where: {
|
||
resourceType_sensitivity: {
|
||
resourceType: policy.resourceType,
|
||
sensitivity: policy.sensitivity,
|
||
},
|
||
},
|
||
update: {
|
||
retentionDays: policy.retentionDays,
|
||
description: policy.description,
|
||
legalBasis: policy.legalBasis,
|
||
},
|
||
create: policy,
|
||
});
|
||
}
|
||
|
||
console.log('Audit retention policies created');
|
||
|
||
// ==================== CONSENT HASH FÜR BESTEHENDE KUNDEN ====================
|
||
const customersWithoutHash = await prisma.customer.findMany({
|
||
where: { consentHash: null },
|
||
select: { id: true },
|
||
});
|
||
|
||
for (const c of customersWithoutHash) {
|
||
await prisma.customer.update({
|
||
where: { id: c.id },
|
||
data: { consentHash: crypto.randomUUID() },
|
||
});
|
||
}
|
||
|
||
if (customersWithoutHash.length > 0) {
|
||
console.log(`ConsentHash für ${customersWithoutHash.length} Kunden generiert`);
|
||
}
|
||
|
||
console.log('Seeding completed!');
|
||
}
|
||
|
||
main()
|
||
.catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|