Befund der Pentesterin. updateUser machte deleteMany + createMany ohne Transaktion und ohne skipDuplicates. Eine doppelte roleId ([4,4]) oder eine erfundene ([99999]) kam durch die Teilmengenregel - sie bringt keine Rechte mit, faellt dort also nicht auf - und schlug erst beim Schreiben fehl, nach dem Loeschen. Antwort HTTP 400, Konto danach ohne jede Rolle. Die Antwort log ueber sich selbst: "400" liest sich als abgelehnt, nichts passiert. Zerstoert wurde trotzdem. Damit war die Letzter-Admin-Sperre umgangen, ohne sie anzugreifen: Sie prueft die Absicht (die Admin-Rolle steht im Body, also "bleibt Admin"), der Schreibvorgang scheiterte danach. Aussperrung, Rueckweg nur per CLI. Auch versehentlich ausloesbar durch doppelte IDs aus dem Frontend. Es war meine eigene Haertung, die ich nicht mitgenommen habe: updateRole hatte Dedup und skipDuplicates seit Etappe 1, das Geschwister updateUser/createUser nicht. Genau das Muster, das in derselben Runde dreimal aufgeraeumt wurde - eine Absicherung, die nur in einer von zwei Kopien ankommt. normalisiereRollenIds() prueft jetzt vor jeder Entscheidung und jedem Schreibvorgang gegen die Datenbank, dedupliziert, und der Tausch laeuft in einer Transaktion. Die Letzter-Admin-Sperre arbeitet damit auf einer Liste, die auch einloesbar ist. Nebenbefund beim Nachstellen: Die rohe Prisma-Fehlermeldung ging wortwoertlich an den Client, inklusive Dateipfad des Servers. Wird jetzt protokolliert statt ausgeliefert. Nachgestellt: [22,22] -> 200 mit erhaltener Rolle; [99999] und [22,99999] -> 400 mit Rollen unveraendert; [-1] -> 400; [22,23] -> 200 korrekt gesetzt. createUser ebenso, abgelehnte Anlagen hinterlassen kein Konto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
853 lines
24 KiB
TypeScript
853 lines
24 KiB
TypeScript
import prisma from '../lib/prisma.js';
|
|
import bcrypt from 'bcryptjs';
|
|
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
|
import {
|
|
pruefeTeilmenge,
|
|
rechteVonPermissionIds,
|
|
rechteVonRollen,
|
|
rechteDerHaken,
|
|
effektiveRechte,
|
|
normalisiereRollenIds,
|
|
meldeTraegerAb,
|
|
RollenSperrError,
|
|
} from './rechte.service.js';
|
|
import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js';
|
|
|
|
export interface UserFilters {
|
|
search?: string;
|
|
isActive?: boolean;
|
|
roleId?: number;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
export async function getAllUsers(filters: UserFilters) {
|
|
const { search, isActive, roleId, page = 1, limit = 20 } = filters;
|
|
const { skip, take } = paginate(page, limit);
|
|
|
|
const where: Record<string, unknown> = {};
|
|
|
|
if (isActive !== undefined) {
|
|
where.isActive = isActive;
|
|
}
|
|
|
|
if (roleId) {
|
|
where.roles = { some: { roleId } };
|
|
}
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ email: { contains: search } },
|
|
{ firstName: { contains: search } },
|
|
{ lastName: { contains: search } },
|
|
];
|
|
}
|
|
|
|
const [users, total] = await Promise.all([
|
|
prisma.user.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
isActive: true,
|
|
isServiceAccount: true,
|
|
customerId: true,
|
|
whatsappNumber: true,
|
|
telegramUsername: true,
|
|
signalNumber: true,
|
|
createdAt: true,
|
|
roles: {
|
|
include: {
|
|
role: {
|
|
include: {
|
|
permissions: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
prisma.user.count({ where }),
|
|
]);
|
|
|
|
// Get hidden role IDs
|
|
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
|
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
|
]);
|
|
|
|
return {
|
|
users: users.map((u) => {
|
|
const hasDeveloperAccess = developerRole
|
|
? u.roles.some((ur) => ur.roleId === developerRole.id)
|
|
: false;
|
|
const hasGdprAccess = gdprRole
|
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
|
: false;
|
|
const hasAuditOpsAccess = auditOpsRole
|
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
|
: false;
|
|
return {
|
|
...u,
|
|
roles: u.roles.map((r) => r.role),
|
|
hasDeveloperAccess,
|
|
hasGdprAccess,
|
|
hasAuditOpsAccess,
|
|
};
|
|
}),
|
|
pagination: buildPaginationResponse(page, limit, total),
|
|
};
|
|
}
|
|
|
|
export async function getUserById(id: number) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
isActive: true,
|
|
isServiceAccount: true,
|
|
customerId: true,
|
|
whatsappNumber: true,
|
|
telegramUsername: true,
|
|
signalNumber: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
roles: {
|
|
include: {
|
|
role: {
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!user) return null;
|
|
|
|
const permissions = new Set<string>();
|
|
for (const userRole of user.roles) {
|
|
for (const rolePerm of userRole.role.permissions) {
|
|
permissions.add(
|
|
`${rolePerm.permission.resource}:${rolePerm.permission.action}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...user,
|
|
roles: user.roles.map((r) => r.role),
|
|
permissions: Array.from(permissions),
|
|
};
|
|
}
|
|
|
|
export async function createUser(data: {
|
|
email: string;
|
|
password: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
roleIds: number[];
|
|
customerId?: number;
|
|
hasDeveloperAccess?: boolean;
|
|
hasGdprAccess?: boolean;
|
|
hasAuditOpsAccess?: boolean;
|
|
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.
|
|
// Erst pruefen, dann rechnen: Eine unbekannte oder doppelte Rollen-ID
|
|
// faellt der Teilmengenregel nicht auf (sie bringt keine Rechte mit) und
|
|
// schluege erst beim Schreiben fehl.
|
|
const rollenIds = await normalisiereRollenIds(data.roleIds);
|
|
|
|
await pruefeTeilmenge(handelnderId, [
|
|
...(await rechteVonRollen(rollenIds)),
|
|
...rechteDerHaken(data),
|
|
]);
|
|
|
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: data.email,
|
|
password: hashedPassword,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
customerId: data.customerId,
|
|
whatsappNumber: data.whatsappNumber || null,
|
|
telegramUsername: data.telegramUsername || null,
|
|
signalNumber: data.signalNumber || null,
|
|
roles: {
|
|
create: rollenIds.map((roleId) => ({ roleId })),
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
isActive: true,
|
|
isServiceAccount: true,
|
|
customerId: true,
|
|
roles: {
|
|
include: { role: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Entwicklerzugriff setzen falls aktiviert
|
|
if (data.hasDeveloperAccess) {
|
|
await setUserDeveloperAccess(user.id, true);
|
|
}
|
|
|
|
// DSGVO-Zugriff setzen falls aktiviert
|
|
if (data.hasGdprAccess) {
|
|
await setUserGdprAccess(user.id, true);
|
|
}
|
|
|
|
if (data.hasAuditOpsAccess) {
|
|
await setUserAuditOpsAccess(user.id, true);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
export async function updateUser(
|
|
id: number,
|
|
data: {
|
|
email?: string;
|
|
password?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
isActive?: boolean;
|
|
isServiceAccount?: boolean;
|
|
roleIds?: number[];
|
|
customerId?: number;
|
|
hasDeveloperAccess?: boolean;
|
|
hasGdprAccess?: boolean;
|
|
hasAuditOpsAccess?: boolean;
|
|
whatsappNumber?: string;
|
|
telegramUsername?: string;
|
|
signalNumber?: string;
|
|
},
|
|
handelnderId: number,
|
|
) {
|
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
|
|
|
// Rollen-IDs pruefen, bevor irgendetwas geschrieben oder entschieden wird.
|
|
// Die Letzter-Admin-Sperre weiter unten arbeitet mit dieser Liste - mit
|
|
// einer unbekannten ID darin haette sie eine Absicht bestaetigt, die der
|
|
// Schreibvorgang danach nicht einloesen konnte (Pentest R190-01).
|
|
const gepruefteRollenIds =
|
|
roleIds !== undefined ? await normalisiereRollenIds(roleIds) : undefined;
|
|
|
|
// 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(gepruefteRollenIds!)) {
|
|
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;
|
|
|
|
if (isBeingDeactivated || rolesAreBeingChanged) {
|
|
// Check if user currently has admin permissions
|
|
const currentUser = await prisma.user.findUnique({
|
|
where: { id },
|
|
include: {
|
|
roles: {
|
|
include: {
|
|
role: {
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const isCurrentlyAdmin = currentUser?.roles.some((ur) =>
|
|
ur.role.permissions.some(
|
|
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
)
|
|
);
|
|
|
|
if (isCurrentlyAdmin) {
|
|
// Check if user will still be admin after role change
|
|
let willStillBeAdmin = false;
|
|
if (rolesAreBeingChanged) {
|
|
const newRoles = await prisma.role.findMany({
|
|
where: { id: { in: gepruefteRollenIds! } },
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
});
|
|
willStillBeAdmin = newRoles.some((role) =>
|
|
role.permissions.some(
|
|
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
)
|
|
);
|
|
} else {
|
|
willStillBeAdmin = true; // Roles not being changed
|
|
}
|
|
|
|
// If user is losing admin status or being deactivated, check for other admins
|
|
if (!willStillBeAdmin || isBeingDeactivated) {
|
|
const otherAdminCount = await prisma.user.count({
|
|
where: {
|
|
id: { not: id },
|
|
isActive: true,
|
|
roles: {
|
|
some: {
|
|
role: {
|
|
permissions: {
|
|
some: {
|
|
permission: {
|
|
resource: 'users',
|
|
action: 'delete',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (otherAdminCount === 0) {
|
|
if (isBeingDeactivated) {
|
|
throw new Error(
|
|
'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden'
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hash password if provided
|
|
if (password) {
|
|
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10);
|
|
}
|
|
|
|
// Prüfen ob Rollen geändert werden (für Zwangslogout)
|
|
let rolesChanged = false;
|
|
if (roleIds !== undefined) {
|
|
const currentRoles = await prisma.userRole.findMany({
|
|
where: { userId: id },
|
|
select: { roleId: true },
|
|
});
|
|
const currentRoleIds = currentRoles.map((r) => r.roleId).sort();
|
|
const newRoleIds = [...roleIds].sort();
|
|
rolesChanged =
|
|
currentRoleIds.length !== newRoleIds.length ||
|
|
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
|
}
|
|
|
|
// Update user - bei Rollenänderung Token invalidieren
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: {
|
|
...userData,
|
|
// Token invalidieren wenn Rollen geändert werden
|
|
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
|
|
},
|
|
});
|
|
|
|
// Rollentausch in EINER Transaktion.
|
|
//
|
|
// Vorher standen deleteMany und createMany nackt nebeneinander: Scheiterte
|
|
// das Anlegen, war das Loeschen schon passiert und das Konto hatte gar
|
|
// keine Rolle mehr - bei einer Antwort, die wie "abgelehnt, nichts
|
|
// geschehen" aussah. `updateRole` hatte diese Haertung laengst; sie war
|
|
// nur nicht zum Geschwister mitgewandert (Pentest R190-01).
|
|
if (gepruefteRollenIds !== undefined) {
|
|
await prisma.$transaction([
|
|
prisma.userRole.deleteMany({ where: { userId: id } }),
|
|
prisma.userRole.createMany({
|
|
data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
|
|
skipDuplicates: true,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
// Handle developer access
|
|
if (hasDeveloperAccess !== undefined) {
|
|
await setUserDeveloperAccess(id, hasDeveloperAccess);
|
|
}
|
|
|
|
// Handle GDPR access
|
|
if (hasGdprAccess !== undefined) {
|
|
await setUserGdprAccess(id, hasGdprAccess);
|
|
}
|
|
|
|
if (hasAuditOpsAccess !== undefined) {
|
|
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
|
}
|
|
|
|
return getUserById(id);
|
|
}
|
|
|
|
// Helper to set developer access for a user
|
|
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
|
// Get or create developer:access permission
|
|
let developerPerm = await prisma.permission.findFirst({
|
|
where: { resource: 'developer', action: 'access' },
|
|
});
|
|
|
|
if (!developerPerm) {
|
|
developerPerm = await prisma.permission.create({
|
|
data: { resource: 'developer', action: 'access' },
|
|
});
|
|
}
|
|
|
|
// Get or create Developer role
|
|
let developerRole = await prisma.role.findFirst({
|
|
where: { name: 'Developer' },
|
|
});
|
|
|
|
if (!developerRole) {
|
|
developerRole = await prisma.role.create({
|
|
data: {
|
|
name: 'Developer',
|
|
description: 'Entwicklerzugriff auf Datenbanktools',
|
|
permissions: {
|
|
create: [{ permissionId: developerPerm.id }],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Check if user already has Developer role
|
|
const hasRole = await prisma.userRole.findFirst({
|
|
where: { userId, roleId: developerRole.id },
|
|
});
|
|
|
|
if (enabled && !hasRole) {
|
|
await prisma.userRole.create({
|
|
data: { userId, roleId: developerRole.id },
|
|
});
|
|
// Token invalidieren bei Rechteänderung
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
} else if (!enabled && hasRole) {
|
|
await prisma.userRole.delete({
|
|
where: { userId_roleId: { userId, roleId: developerRole.id } },
|
|
});
|
|
// Token invalidieren bei Rechteänderung
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
|
|
*
|
|
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
|
|
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
|
|
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
|
|
* Containerstart angelegt.
|
|
*/
|
|
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
|
|
|
|
// Helper to set GDPR access for a user
|
|
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|
// Get or create DSGVO role
|
|
let gdprRole = await prisma.role.findFirst({
|
|
where: { name: 'DSGVO' },
|
|
});
|
|
|
|
if (!gdprRole) {
|
|
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
|
|
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
|
|
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
|
|
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
|
|
// auseinander).
|
|
const gdprPermissions = await prisma.permission.findMany({
|
|
where: {
|
|
OR: [
|
|
{ resource: 'gdpr' },
|
|
{ resource: 'audit', action: { in: ['read', 'export'] } },
|
|
],
|
|
},
|
|
});
|
|
|
|
gdprRole = await prisma.role.create({
|
|
data: {
|
|
name: 'DSGVO',
|
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
|
permissions: {
|
|
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Check if user already has DSGVO role
|
|
const hasRole = await prisma.userRole.findFirst({
|
|
where: { userId, roleId: gdprRole.id },
|
|
});
|
|
|
|
if (enabled && !hasRole) {
|
|
await prisma.userRole.create({
|
|
data: { userId, roleId: gdprRole.id },
|
|
});
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
} else if (!enabled && hasRole) {
|
|
await prisma.userRole.delete({
|
|
where: { userId_roleId: { userId, roleId: gdprRole.id } },
|
|
});
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
|
|
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
|
|
* `AUDIT_OPS_ROLLE`).
|
|
*/
|
|
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
|
|
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
|
|
|
|
if (!rolle) {
|
|
// Rechte-Satz identisch zu sync-roles.ts.
|
|
const rechte = await prisma.permission.findMany({
|
|
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
|
|
});
|
|
rolle = await prisma.role.create({
|
|
data: {
|
|
name: AUDIT_OPS_ROLLE,
|
|
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
|
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
|
|
},
|
|
});
|
|
}
|
|
|
|
const hatRolle = await prisma.userRole.findFirst({
|
|
where: { userId, roleId: rolle.id },
|
|
});
|
|
|
|
if (enabled && !hatRolle) {
|
|
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
|
|
// Token invalidieren bei Rechteaenderung
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
} else if (!enabled && hatRolle) {
|
|
await prisma.userRole.delete({
|
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
|
});
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenInvalidatedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function deleteUser(id: number) {
|
|
// Check if user is an admin
|
|
const user = await prisma.user.findUnique({
|
|
where: { id },
|
|
include: {
|
|
roles: {
|
|
include: {
|
|
role: {
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new Error('Benutzer nicht gefunden');
|
|
}
|
|
|
|
// Check if user has admin permissions (users:delete means admin)
|
|
const isAdmin = user.roles.some((ur) =>
|
|
ur.role.permissions.some(
|
|
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
)
|
|
);
|
|
|
|
if (isAdmin) {
|
|
// Count other admins (users with users:delete permission)
|
|
const adminCount = await prisma.user.count({
|
|
where: {
|
|
id: { not: id },
|
|
isActive: true,
|
|
roles: {
|
|
some: {
|
|
role: {
|
|
permissions: {
|
|
some: {
|
|
permission: {
|
|
resource: 'users',
|
|
action: 'delete',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (adminCount === 0) {
|
|
throw new Error(
|
|
'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden'
|
|
);
|
|
}
|
|
}
|
|
|
|
return prisma.user.delete({ where: { id } });
|
|
}
|
|
|
|
// Role operations
|
|
export async function getAllRoles() {
|
|
return prisma.role.findMany({
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
_count: {
|
|
select: { users: true },
|
|
},
|
|
},
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
export async function getRoleById(id: number) {
|
|
return prisma.role.findUnique({
|
|
where: { id },
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
description: data.description,
|
|
permissions: {
|
|
create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
|
|
},
|
|
},
|
|
include: {
|
|
permissions: {
|
|
include: { permission: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
data: roleData,
|
|
});
|
|
|
|
if (permissionIds) {
|
|
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
|
await prisma.rolePermission.createMany({
|
|
// 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) {
|
|
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() {
|
|
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 };
|
|
});
|
|
}
|