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>
831 lines
23 KiB
TypeScript
831 lines
23 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,
|
|
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.
|
|
await pruefeTeilmenge(handelnderId, [
|
|
...(await rechteVonRollen(data.roleIds)),
|
|
...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: data.roleIds.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;
|
|
|
|
// 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;
|
|
|
|
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: roleIds } },
|
|
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() }),
|
|
},
|
|
});
|
|
|
|
// Update roles if provided
|
|
if (roleIds) {
|
|
await prisma.userRole.deleteMany({ where: { userId: id } });
|
|
await prisma.userRole.createMany({
|
|
data: roleIds.map((roleId) => ({ userId: id, roleId })),
|
|
});
|
|
}
|
|
|
|
// 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 };
|
|
});
|
|
}
|