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
@@ -0,0 +1,28 @@
-- Systemrollen kennzeichnen (Rechtemodell, Etappe 1).
--
-- Bis hierher war jede Rolle ueber die Rollen-CRUD frei aenderbar - auch
-- "Admin", "DSGVO" und "Developer". Man konnte sie umbenennen oder ihre
-- Rechte leeren. Zugleich haengen die versteckten Rollen an ihrem NAMEN:
-- `user.service.ts` sucht sie per `findFirst({ where: { name: 'DSGVO' } })`.
-- Ein umbenannter Datensatz haette den Notfallpfad ins Leere laufen lassen,
-- ohne dass irgendwo etwas gemeldet worden waere.
--
-- `isSystem` macht diese Rollen zu dem, was sie immer sein sollten: von der
-- Anwendung gepflegt, ueber die API sichtbar, aber nicht veraenderbar.
-- `isHidden` ersetzt die im Frontend hartkodierte Namensliste, in der
-- "Gegenbuch" bisher fehlte - die Rolle tauchte deshalb als anhakbare
-- Rolle im Benutzerformular auf.
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isSystem` BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isHidden` BOOLEAN NOT NULL DEFAULT false;
-- Backfill nach Namen. Wer eine dieser Rollen lokal umbenannt hat, wird hier
-- nicht getroffen; `synchronisiereRechteUndRollen` legt beim naechsten Start
-- eine neue Rolle unter dem erwarteten Namen an und markiert sie. Das ist
-- sichtbar (zwei Rollen in der Liste) und damit behandelbar - im Gegensatz
-- zu einer stillen Fehlzuordnung.
UPDATE `Role` SET `isSystem` = true
WHERE `name` IN ('Admin','Developer','DSGVO','Audit-Betrieb','Gegenbuch',
'Mitarbeiter','Mitarbeiter (Nur-Lesen)','Kunde');
UPDATE `Role` SET `isHidden` = true
WHERE `name` IN ('Developer','DSGVO','Audit-Betrieb','Gegenbuch','Kunde');
@@ -0,0 +1,104 @@
-- Rechtekatalog begradigen (Rechtemodell, Etappe 1).
--
-- 18 der 50 Rechte bewachten nichts: `tariffs:*`, `cancellation-periods:*`,
-- `contract-durations:*` und `email-providers:*` standen im Katalog und waren
-- in der Rollenverwaltung anhakbar - die Routen prueften in Wahrheit
-- `providers:*`, `platforms:*` und `settings:*`. Ein Haken, der nichts tut,
-- ist schlimmer als ein fehlender: Er behauptet eine Trennung, die es nicht
-- gibt, und wer sich darauf verlaesst, vergibt zu viel oder zu wenig, ohne
-- es zu merken.
--
-- Diese Migration ist REIN ADDITIV. Sie entzieht keiner Rolle irgendetwas.
-- Sie muss vor dem Code laufen, der die Routen umstellt - sonst verlieren
-- bestehende Rollen den Zugriff auf Tarife, Kuendigungsfristen, Laufzeiten
-- und E-Mail-Provider.
--
-- Das Aufraeumen der jetzt ueberfluessigen Sammelrechte ist ausdruecklich
-- NICHT Aufgabe dieser Migration, sondern eine bewusste Entscheidung des
-- Betreibers in der Rollenoberflaeche.
-- 1) Fehlende Rechte in den Katalog.
-- UNIQUE(resource, action) traegt die Idempotenz.
INSERT IGNORE INTO `Permission` (`resource`,`action`) VALUES
('roles','manage'),
('tariffs','create'),('tariffs','read'),('tariffs','update'),('tariffs','delete'),
('cancellation-periods','create'),('cancellation-periods','read'),
('cancellation-periods','update'),('cancellation-periods','delete'),
('contract-durations','create'),('contract-durations','read'),
('contract-durations','update'),('contract-durations','delete'),
('contract-categories','create'),('contract-categories','read'),
('contract-categories','update'),('contract-categories','delete'),
('email-providers','create'),('email-providers','read'),
('email-providers','update'),('email-providers','delete'),
('platforms','read');
-- 2) Vererbung alt -> neu: Jede Rolle, die bisher das Sammelrecht hielt,
-- bekommt das neue Recht dazu. INSERT IGNORE gegen den Primaerschluessel
-- (roleId, permissionId) macht den Block beliebig wiederholbar.
--
-- `users:delete` -> `roles:manage` bewusst nur ueber `users:delete`, nicht
-- ueber `users:create`/`users:update`: `users:delete` ist im Code bereits
-- die Admin-Heuristik (user.service.ts). Wuerde man `roles:manage` an
-- jeden mit `users:update` vererben, waere die Trennung, die dieses Recht
-- herstellen soll, im selben Zug wieder eingerissen.
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
SELECT rp.`roleId`, neu.`id`
FROM `RolePermission` rp
JOIN `Permission` alt ON alt.`id` = rp.`permissionId`
JOIN (
SELECT 'providers' AS aRes,'read' AS aAct,'tariffs' AS nRes,'read' AS nAct
UNION ALL SELECT 'providers','create','tariffs','create'
UNION ALL SELECT 'providers','update','tariffs','update'
UNION ALL SELECT 'providers','delete','tariffs','delete'
UNION ALL SELECT 'platforms','create','cancellation-periods','create'
UNION ALL SELECT 'platforms','update','cancellation-periods','update'
UNION ALL SELECT 'platforms','delete','cancellation-periods','delete'
UNION ALL SELECT 'platforms','create','contract-durations','create'
UNION ALL SELECT 'platforms','update','contract-durations','update'
UNION ALL SELECT 'platforms','delete','contract-durations','delete'
UNION ALL SELECT 'settings','read', 'email-providers','read'
UNION ALL SELECT 'settings','update','email-providers','create'
UNION ALL SELECT 'settings','update','email-providers','update'
UNION ALL SELECT 'settings','update','email-providers','delete'
UNION ALL SELECT 'users','delete','roles','manage'
) m ON m.aRes = alt.`resource` AND m.aAct = alt.`action`
JOIN `Permission` neu ON neu.`resource` = m.nRes AND neu.`action` = m.nAct;
-- 3) Die bisher ungegateten Lese-Endpunkte (Plattformen, Vertragstypen,
-- Kuendigungsfristen, Laufzeiten) bekommen ab jetzt ein Recht. Damit
-- niemand seine Listen verliert, erhalten es alle Rollen, die die
-- Anwendung ueberhaupt benutzen.
--
-- Bewusst NICHT pauschal an jede Rolle: "Gegenbuch" haelt nur `audit:read`
-- und soll so wenig wert bleiben wie moeglich - sein Kennwort liegt auf
-- der Notar-Maschine im Klartext (R185-01). Dasselbe gilt fuer
-- "Audit-Betrieb".
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
SELECT r.`id`, p.`id`
FROM `Role` r
JOIN `Permission` p
ON (p.`resource`,p.`action`) IN
(('cancellation-periods','read'),('contract-durations','read'),
('contract-categories','read'),('platforms','read'),('tariffs','read'))
WHERE EXISTS (
SELECT 1 FROM `RolePermission` rp2
JOIN `Permission` p2 ON p2.`id` = rp2.`permissionId`
WHERE rp2.`roleId` = r.`id`
AND (p2.`resource`,p2.`action`) IN
(('customers','read'),('contracts','read'),('providers','read'),
('platforms','create'),('settings','read'))
);
-- 4) Alle Sitzungen einmalig beenden.
--
-- Die Rechte stehen im Zugangstoken. Nach der Routenumstellung verlangt
-- z.B. PUT /tariffs/:id das Recht `tariffs:update`; jedes bereits
-- ausgestellte Token traegt aber noch den alten Anspruch und liefe bis zu
-- 15 Minuten lang in ein 403. Die Datenbank waere dann laengst richtig -
-- nur das Token alt.
--
-- Einmal neu anmelden ist ehrlicher als ein Uebergangs-Doppelgate, das in
-- sechs Monaten jemand fuer Absicht haelt. Die Meldung dafuer gibt es
-- bereits im Klartext ("Ihre Berechtigungen wurden geändert."), und das
-- Gegenbuch-Dienstkonto meldet sich stuendlich ohnehin neu an.
UPDATE `User` SET `tokenInvalidatedAt` = NOW();
+157
View File
@@ -0,0 +1,157 @@
/**
* Bestandsaufnahme des Rechtesystems. Liest nur, aendert nichts.
*
* npx tsx prisma/rechte-report.ts → Bericht auf stdout
* npx tsx prisma/rechte-report.ts > vorher.txt
*
* Zweck: Vor und nach dem Rechte-Umbau denselben Bericht erzeugen und
* vergleichen. Die Zusage lautet "niemand verliert Zugriff" - und eine
* Zusage, die niemand nachrechnen kann, ist keine.
*
* Der Bericht ist bewusst zeilenweise und sortiert, damit `diff` darauf
* etwas Lesbares ausgibt.
*/
import { PrismaClient } from '@prisma/client';
import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js';
const prisma = new PrismaClient();
/**
* Liest isSystem/isHidden, sofern die Spalten existieren.
*
* Der Bericht muss VOR und NACH der Migration laufen - das ist sein Zweck.
* Vorher gibt es die Spalten noch nicht, und ein Absturz an dieser Stelle
* haette genau die Baseline verhindert, gegen die spaeter verglichen wird.
*/
async function leseKennzeichen(): Promise<Map<number, { isSystem: boolean; isHidden: boolean }>> {
const karte = new Map<number, { isSystem: boolean; isHidden: boolean }>();
try {
const zeilen = await prisma.$queryRawUnsafe<
Array<{ id: number; isSystem: number | boolean; isHidden: number | boolean }>
>('SELECT id, isSystem, isHidden FROM `Role`');
for (const z of zeilen) {
karte.set(Number(z.id), { isSystem: Boolean(z.isSystem), isHidden: Boolean(z.isHidden) });
}
} catch {
// Spalten noch nicht vorhanden - das ist der Zustand vor der Migration.
}
return karte;
}
async function main(): Promise<void> {
const kennzeichen = await leseKennzeichen();
const rollen = await prisma.role.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
permissions: { include: { permission: true } },
_count: { select: { users: true } },
},
});
console.log('=== ROLLEN ===');
for (const r of rollen) {
const rechte = r.permissions
.map((rp) => `${rp.permission.resource}:${rp.permission.action}`)
.sort();
const k = kennzeichen.get(r.id);
const etikett = [k?.isSystem ? 'System' : null, k?.isHidden ? 'versteckt' : null]
.filter(Boolean)
.join(', ');
console.log(
`\n[${r.name}] #${r.id}` +
(etikett ? ` (${etikett})` : '') +
` ${r._count.users} Konten, ${rechte.length} Rechte`,
);
for (const recht of rechte) console.log(` ${recht}`);
}
console.log('\n=== KONTEN (aktiv) ===');
const konten = await prisma.user.findMany({
where: { isActive: true },
orderBy: { email: 'asc' },
select: {
email: true,
roles: {
select: {
role: {
// Ausdrueckliche Spaltenauswahl, damit der Bericht auch auf einer
// noch nicht migrierten Datenbank laeuft.
select: {
name: true,
permissions: { include: { permission: true } },
},
},
},
},
},
});
for (const k of konten) {
const rechte = new Set<string>();
for (const ur of k.roles) {
for (const rp of ur.role.permissions) {
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
const rollenNamen = k.roles.map((ur) => ur.role.name).sort().join(', ') || '(keine)';
console.log(`\n[${k.email}] Rollen: ${rollenNamen} ${rechte.size} Rechte`);
for (const recht of [...rechte].sort()) console.log(` ${recht}`);
}
// --- Auffaelligkeiten -----------------------------------------------------
// Nicht als Alarm gemeint, sondern als das, was man vor einem Deploy
// wissen will. Eine umbenannte Systemrolle etwa wird vom Backfill der
// Migration nicht getroffen.
console.log('\n=== HINWEISE ===');
const hinweise: string[] = [];
const vorhandeneNamen = new Set(rollen.map((r) => r.name));
for (const erwartet of SYSTEMROLLEN_NAMEN) {
if (!vorhandeneNamen.has(erwartet)) {
hinweise.push(`Systemrolle "${erwartet}" fehlt in der Datenbank.`);
}
}
for (const r of rollen) {
const k = kennzeichen.get(r.id);
// Vor der Migration gibt es die Spalte nicht - dann ist "nicht
// gekennzeichnet" kein Befund, sondern der erwartete Zustand.
if (kennzeichen.size === 0) continue;
if (SYSTEMROLLEN_NAMEN.includes(r.name) && !k?.isSystem) {
hinweise.push(`Rolle "${r.name}" ist eine Systemrolle, aber isSystem ist nicht gesetzt.`);
}
if (!SYSTEMROLLEN_NAMEN.includes(r.name) && k?.isSystem) {
hinweise.push(`Rolle "${r.name}" traegt isSystem, gehoert aber nicht zum Katalog.`);
}
}
const alleRechteDb = await prisma.permission.findMany();
const imKatalog = new Set(ALLE_RECHTE);
for (const p of alleRechteDb) {
const s = `${p.resource}:${p.action}`;
if (!imKatalog.has(s)) hinweise.push(`Recht "${s}" steht in der Datenbank, aber nicht im Katalog.`);
}
const inDb = new Set(alleRechteDb.map((p) => `${p.resource}:${p.action}`));
for (const s of ALLE_RECHTE) {
if (!inDb.has(s)) hinweise.push(`Recht "${s}" steht im Katalog, aber nicht in der Datenbank.`);
}
// Konten ohne jede Rolle koennen sich anmelden und sehen nichts - eine
// haeufige Ursache fuer "bei mir ist alles leer".
for (const k of konten) {
if (k.roles.length === 0) hinweise.push(`Konto "${k.email}" hat keine Rolle.`);
}
if (hinweise.length === 0) console.log(' keine');
else for (const h of hinweise) console.log(` ! ${h}`);
}
main()
.catch((e) => {
console.error('[rechte-report] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Weist einem Konto eine Rolle zu oder nimmt sie ihm weg - von der
* Kommandozeile aus.
*
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname>
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> --entfernen
* npx tsx prisma/rolle-zuweisen.ts --liste
*
* Im Container:
* docker compose exec backend npx tsx prisma/rolle-zuweisen.ts \
* name@firma.de Developer
*
* WOZU: Die Teilmengenregel verhindert, dass jemand ueber die Weboberflaeche
* Rechte vergibt, die er selbst nicht haelt. Das ist gewollt - es schliesst
* die Selbst-Erhoehung. Es hat aber eine Kehrseite: Nach einer
* Neuinstallation haelt niemand `developer:access` oder `audit:admin`, und
* dann kann diese Rechte auch niemand erstmalig vergeben.
*
* Dieses Skript ist der dokumentierte Ausweg. Es umgeht die Regel bewusst,
* denn die Vertrauensgrenze stimmt: Wer eine Shell auf dieser Maschine hat,
* hat ohnehin Zugriff auf die Datenbank. Ein gestohlener Web-Zugang hat das
* nicht - und genau das ist der Unterschied, den die Regel schuetzen soll.
*
* Der Vorgang wird protokolliert und meldet die Traeger ab, damit die
* Aenderung sofort wirkt und nicht spurlos bleibt.
*/
import { PrismaClient } from '@prisma/client';
import { createAuditLog } from '../src/services/audit.service.js';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--liste') || args.length === 0) {
const rollen = await prisma.role.findMany({
orderBy: { name: 'asc' },
include: { _count: { select: { users: true } } },
});
console.log('Vorhandene Rollen:');
for (const r of rollen) {
const kennz = [r.isSystem ? 'System' : null, r.isHidden ? 'versteckt' : null]
.filter(Boolean)
.join(', ');
console.log(` ${r.name}${kennz ? ` (${kennz})` : ''} ${r._count.users} Konten`);
}
if (args.length === 0) {
console.log('\nAufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
}
return;
}
const entfernen = args.includes('--entfernen');
const [email, rollenName] = args.filter((a) => !a.startsWith('--'));
if (!email || !rollenName) {
console.error('Aufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
process.exit(1);
}
const konto = await prisma.user.findUnique({ where: { email } });
if (!konto) {
console.error(`Kein Konto mit der Adresse "${email}".`);
process.exit(1);
}
const rolle = await prisma.role.findUnique({ where: { name: rollenName } });
if (!rolle) {
console.error(`Keine Rolle mit dem Namen "${rollenName}". Vorhandene mit --liste ansehen.`);
process.exit(1);
}
const vorhanden = await prisma.userRole.findUnique({
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
});
if (entfernen) {
if (!vorhanden) {
console.log(`"${email}" hat die Rolle "${rollenName}" gar nicht. Nichts zu tun.`);
return;
}
await prisma.userRole.delete({
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
});
} else {
if (vorhanden) {
console.log(`"${email}" hat die Rolle "${rollenName}" bereits. Nichts zu tun.`);
return;
}
await prisma.userRole.create({ data: { userId: konto.id, roleId: rolle.id } });
}
// Sofort wirksam machen: Die Rechte stehen im Zugangstoken, sonst behielte
// das Konto bis zu 15 Minuten den alten Stand.
await prisma.user.update({
where: { id: konto.id },
data: { tokenInvalidatedAt: new Date() },
});
// Nachvollziehbar machen. Ein Eingriff von der Kommandozeile ist berechtigt,
// aber er darf nicht unsichtbar sein - sonst waere das Skript selbst die
// Luecke, die es schliessen soll.
//
// Ueber createAuditLog, NICHT ueber prisma.auditLog.create: Das Protokoll
// ist eine Hash-Kette. Ein roh eingefuegter Datensatz haette kein `hash`
// und keinen `previousHash` und wuerde bei der naechsten Pruefung als
// Luecke erscheinen - das Skript wuerde also ausgerechnet dort Zweifel
// saeen, wo es Klarheit schaffen soll.
await createAuditLog({
userEmail: 'system (CLI)',
userRole: 'Kommandozeile',
action: entfernen ? 'DELETE' : 'CREATE',
sensitivity: 'CRITICAL',
resourceType: 'UserRole',
resourceId: `${konto.id}:${rolle.id}`,
resourceLabel: entfernen
? `Rolle "${rolle.name}" von ${email} entfernt (Kommandozeile)`
: `Rolle "${rolle.name}" an ${email} vergeben (Kommandozeile)`,
endpoint: 'prisma/rolle-zuweisen.ts',
httpMethod: 'CLI',
ipAddress: 'lokal',
changesAfter: { konto: email, rolle: rolle.name, entfernt: entfernen },
});
console.log(
entfernen
? `Rolle "${rolle.name}" von "${email}" entfernt.`
: `Rolle "${rolle.name}" an "${email}" vergeben.`,
);
console.log('Das Konto muss sich neu anmelden, damit die Änderung greift.');
}
main()
.catch((e) => {
console.error('[rolle-zuweisen] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+10
View File
@@ -106,6 +106,16 @@ model Role {
id Int @id @default(autoincrement())
name String @unique
description String?
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
isSystem Boolean @default(false)
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
isHidden Boolean @default(false)
permissions RolePermission[]
users UserRole[]
createdAt DateTime @default(now())
+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-
+13 -191
View File
@@ -1,205 +1,27 @@
/**
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
*
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
* Kopie des Katalogs, und es war nicht die einzige.
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
* Produktionsdatenbanken sicher.
*/
import { PrismaClient } from '@prisma/client';
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
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'],
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
developer: ['access'],
emails: ['delete'],
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
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);
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 an Rolle #${roleId}`);
}
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 von Rolle #${roleId}`);
}
}
async function main() {
console.log('[sync-roles] Permissions-Katalog upserten…');
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
for (const action of actions) {
await prisma.permission.upsert({
where: { resource_action: { resource, action } },
update: {},
create: { resource, action },
});
}
}
const allPermissions = await prisma.permission.findMany();
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
// sind separate hidden roles, über Checkboxen zugewiesen)
const adminPermIds = allPermissions
.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
)
.map((p) => p.id);
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// 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 === '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.
//
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
// und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es
// dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll
// geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*`
// KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup.
//
// Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den
// Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben -
// genau die Waesche aus R185-01, und genau die Trennung, wegen der das
// Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des
// Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig
// wert sein wie moeglich.
const gegenbuchPermIds = allPermissions
.filter((p) => p.resource === 'audit' && p.action === 'read')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' &&
[
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
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 rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ 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 },
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
for (const r of rolesSpec) {
const role = await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: { name: r.name, description: r.description },
});
await syncRolePermissions(role.id, r.permIds);
}
console.log('[sync-roles] fertig.');
}
main()
synchronisiereRechteUndRollen(prisma)
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
console.error('[rollen-sync] Fehler:', e);
process.exit(1);
})
.finally(async () => {