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
+155 -13
View File
@@ -1,6 +1,16 @@
import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs';
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
import {
pruefeTeilmenge,
rechteVonPermissionIds,
rechteVonRollen,
rechteDerHaken,
effektiveRechte,
meldeTraegerAb,
RollenSperrError,
} from './rechte.service.js';
import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js';
export interface UserFilters {
search?: string;
@@ -155,7 +165,17 @@ export async function createUser(data: {
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}) {
},
handelnderId: number,
) {
// Was das neue Konto koennen wird - aus den Rollen UND aus den Haken.
// `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung
// vergessen kann; der Compiler erzwingt sie.
await pruefeTeilmenge(handelnderId, [
...(await rechteVonRollen(data.roleIds)),
...rechteDerHaken(data),
]);
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
@@ -220,10 +240,31 @@ export async function updateUser(
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
},
handelnderId: number,
) {
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
// Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand.
//
// Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert,
// schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde
// hier der Endzustand geprueft, waere jede solche Korrektur ein 403.
// Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt.
{
const bisher = await effektiveRechte(id);
const zuwachs = new Set<string>();
if (roleIds !== undefined) {
for (const r of await rechteVonRollen(roleIds)) {
if (!bisher.has(r)) zuwachs.add(r);
}
}
for (const r of rechteDerHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess })) {
if (!bisher.has(r)) zuwachs.add(r);
}
await pruefeTeilmenge(handelnderId, zuwachs);
}
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
const rolesAreBeingChanged = roleIds !== undefined;
@@ -622,17 +663,42 @@ export async function getRoleById(id: number) {
});
}
export async function createRole(data: {
name: string;
description?: string;
permissionIds: number[];
}) {
/**
* Legt eine Rolle an.
*
* `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller
* die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei
* jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert
* durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access`
* bauen und sie sich anschliessend selbst zuweisen.
*/
export async function createRole(
data: {
name: string;
description?: string;
permissionIds: number[];
},
handelnderId: number,
) {
const name = data.name.trim();
// Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/
// Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in
// einer Liste wie das Original aussieht.
if (istSystemrollenName(name)) {
throw new RollenSperrError(
`${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`,
);
}
await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds));
return prisma.role.create({
data: {
name: data.name,
name,
description: data.description,
permissions: {
create: data.permissionIds.map((permissionId) => ({ permissionId })),
create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
},
},
include: {
@@ -643,15 +709,50 @@ export async function createRole(data: {
});
}
/**
* Aendert eine Rolle. Systemrollen sind gesperrt.
*
* Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der
* Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen.
*/
export async function updateRole(
id: number,
data: {
name?: string;
description?: string;
permissionIds?: number[];
}
},
handelnderId: number,
) {
const bestehend = await prisma.role.findUnique({
where: { id },
include: { permissions: { include: { permission: true } } },
});
if (!bestehend) return null;
if (bestehend.isSystem) {
throw new RollenSperrError(
`${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` +
'gepflegt und kann hier nicht geändert werden.',
);
}
if (data.name !== undefined && istSystemrollenName(data.name)) {
throw new RollenSperrError(
`${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`,
);
}
const { permissionIds, ...roleData } = data;
if (roleData.name !== undefined) roleData.name = roleData.name.trim();
if (permissionIds) {
const bisher = new Set(
bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
);
const kuenftig = await rechteVonPermissionIds(permissionIds);
const zuwachs = [...kuenftig].filter((r) => !bisher.has(r));
await pruefeTeilmenge(handelnderId, zuwachs);
}
await prisma.role.update({
where: { id },
@@ -661,28 +762,69 @@ export async function updateRole(
if (permissionIds) {
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
await prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
// Dedupliziert: Doppelte IDs im Body liefen vorher in einen
// Primaerschluesselkonflikt und kamen als HTTP 500 zurueck.
data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })),
skipDuplicates: true,
});
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
await meldeTraegerAb(id);
}
return getRoleById(id);
}
export async function deleteRole(id: number) {
// Check if role is assigned to any users
const bestehend = await prisma.role.findUnique({ where: { id } });
if (!bestehend) {
throw new Error('Rolle nicht gefunden');
}
if (bestehend.isSystem) {
throw new RollenSperrError(
`${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`,
);
}
// Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade
// nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene
// Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die
// richtige, falls diese Sperre je gelockert wird.
const count = await prisma.userRole.count({ where: { roleId: id } });
if (count > 0) {
throw new Error(
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
);
}
await meldeTraegerAb(id);
return prisma.role.delete({ where: { id } });
}
// Permission operations
/**
* Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe.
*
* Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren
* Schemata liegen (`customers:access`, `settings:create` und aehnliche), die
* nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche
* als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der
* Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein
* Lesefilter ist die kleinere Behauptung als ein DELETE, und der
* Rechte-Report meldet sie ohnehin.
*/
export async function getAllPermissions() {
return prisma.permission.findMany({
const alle = await prisma.permission.findMany({
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
const beschreibung = new Map(
RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]),
);
return alle
.filter((p) => beschreibung.has(`${p.resource}:${p.action}`))
.map((p) => {
const k = beschreibung.get(`${p.resource}:${p.action}`)!;
return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe };
});
}