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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user