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