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
+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();
});