Files
opencrm/backend/prisma/seed.ts
T
duffyduckandClaude Opus 5 77aeb69aeb 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>
2026-09-08 13:49:30 +02:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});