Files
opencrm/backend/src/services/rollen-sync.service.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

116 lines
3.8 KiB
TypeScript

/**
* Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des
* Codes. Idempotent - laeuft bei jedem Container-Start.
*
* Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts`
* (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei
* benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte
* jeder seine eigene, und die dritte wich ab.
*
* Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist
* auf einer laufenden Produktionsdatenbank sicher.
*/
import type { PrismaClient } from '@prisma/client';
import {
RECHTE_KATALOG,
SYSTEMROLLEN,
alsRechtString,
} from '../config/rechte-katalog.js';
type Protokoll = (zeile: string) => void;
/**
* Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen
* dazu, ueberzaehlige fliegen raus.
*
* Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an
* einer Systemrolle vorgenommene Aenderung den naechsten Container-Start
* nicht ueberlebt.
*/
async function synchronisiereRollenrechte(
prisma: PrismaClient,
roleId: number,
permissionIds: number[],
log: Protokoll,
): Promise<void> {
const vorhanden = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId));
const zielIds = new Set(permissionIds);
const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id));
if (fehlend.length > 0) {
await prisma.rolePermission.createMany({
data: fehlend.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
log(` → +${fehlend.length} Rechte an Rolle #${roleId}`);
}
const ueberzaehlig = vorhanden
.filter((e) => !zielIds.has(e.permissionId))
.map((e) => e.permissionId);
if (ueberzaehlig.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: ueberzaehlig } },
});
log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`);
}
}
/**
* Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand.
* Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche
* Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt
* die Flags nur einmalig.
*/
export async function synchronisiereRechteUndRollen(
prisma: PrismaClient,
log: Protokoll = (z) => console.log(z),
): Promise<void> {
log('[rollen-sync] Rechtekatalog upserten…');
for (const recht of RECHTE_KATALOG) {
await prisma.permission.upsert({
where: { resource_action: { resource: recht.resource, action: recht.action } },
update: {},
create: { resource: recht.resource, action: recht.action },
});
}
const alleRechte = await prisma.permission.findMany();
log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`);
// Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen,
// aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle
// zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand.
const idFuerRecht = new Map<string, number>();
for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id);
for (const spec of SYSTEMROLLEN) {
const rechteIds = RECHTE_KATALOG.filter(spec.rechte)
.map((r) => idFuerRecht.get(alsRechtString(r)))
.filter((id): id is number => id !== undefined);
const rolle = await prisma.role.upsert({
where: { name: spec.name },
update: {
description: spec.description,
isSystem: true,
isHidden: spec.isHidden,
},
create: {
name: spec.name,
description: spec.description,
isSystem: true,
isHidden: spec.isHidden,
},
});
await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log);
}
log('[rollen-sync] fertig.');
}