R191-01: Ein PUT, eine Klammer
Befund der Pentesterin nach dem R190-01-Fix. Nur der Rollentausch lag in
der Transaktion, die drei Haken liefen danach in eigenen Schreibvorgaengen.
Ein Multi-Feld-PUT war damit nicht gesamt-atomar: Bricht ein Haken-Write
ab, steht der Rollenstand schon und die Haken halb. Kein Verlust, keine
Eskalation, aber ein Zwischenstand, den niemand angefordert hat. Ein PUT
ist ein Vorgang, also gehoert er in eine Klammer.
Die gesamte Schreibphase von updateUser liegt jetzt in einer Transaktion -
Benutzerdaten, Rollentausch, alle drei Haken. createUser ebenso.
Beim Umbau mitgefunden: Die drei Haken-Helfer legten die Rolle bei Bedarf
selbst an, falls sie fehlte. Das war eine vierte Stelle, die definierte,
was "Developer" bedeutet - mit einem anderen Rechtesatz als der Katalog
(nur developer:access statt allem) und ohne isSystem. Eine so entstandene
Rolle waere ueber die Rollenverwaltung aenderbar gewesen. Der Notfallpfad
ist weg: sync-roles legt die Rollen bei jedem Start an, die Startwache
meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang mit klarer
Ansage ab. Drei fast gleiche Funktionen wurden dabei eine.
int4-Ueberlauf (kosmetischer Nebenbefund): IDs jenseits des INT-Bereichs
werden nicht mehr abgefragt, sondern als unbekannt gemeldet.
createUser hasht jetzt mit Cost 12 statt 10, wie seed.ts. Wieder eine
Haertung, die nur in einer von zwei Kopien angekommen war.
Nachgeprueft: Rollback bewiesen, indem die DSGVO-Rolle voruebergehend
umbenannt und {roleIds:[23], hasGdprAccess:true} geschickt wurde - 400 mit
Klartext, Rollen unveraendert. Typvektoren "22", 4.5, 1e3, true, null,
Riesenzahl alle 400 ohne Wipe. R190-01-Regression erneut gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
|
/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */
|
||||||
|
const INT_MAX = 2147483647;
|
||||||
import {
|
import {
|
||||||
ROLLE_DEVELOPER,
|
ROLLE_DEVELOPER,
|
||||||
ROLLE_DSGVO,
|
ROLLE_DSGVO,
|
||||||
@@ -111,10 +114,15 @@ export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
|
|||||||
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
|
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
|
||||||
const rechte = new Set<string>();
|
const rechte = new Set<string>();
|
||||||
if (ids.length === 0) return rechte;
|
if (ids.length === 0) return rechte;
|
||||||
const gefunden = await prisma.permission.findMany({ where: { id: { in: ids } } });
|
const eindeutig = [...new Set(ids)];
|
||||||
if (gefunden.length !== new Set(ids).size) {
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.permission.findMany({ where: { id: { in: abfragbar } } })
|
||||||
|
: [];
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
const bekannt = new Set(gefunden.map((p) => p.id));
|
const bekannt = new Set(gefunden.map((p) => p.id));
|
||||||
const unbekannt = [...new Set(ids)].filter((id) => !bekannt.has(id));
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
|
throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
|
||||||
}
|
}
|
||||||
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
|
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
|
||||||
@@ -213,13 +221,22 @@ export async function normalisiereRollenIds(roleIds: number[]): Promise<number[]
|
|||||||
if (!eindeutig.every((id) => Number.isInteger(id) && id >= 1)) {
|
if (!eindeutig.every((id) => Number.isInteger(id) && id >= 1)) {
|
||||||
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
|
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
|
||||||
}
|
}
|
||||||
const gefunden = await prisma.role.findMany({
|
|
||||||
where: { id: { in: eindeutig } },
|
// Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht
|
||||||
select: { id: true },
|
// dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim
|
||||||
});
|
// Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse
|
||||||
if (gefunden.length !== eindeutig.length) {
|
// (Pentest R191, kosmetisch). Eine ID, die nicht in die Spalte passt,
|
||||||
|
// benennt keine Rolle; sie ist schlicht unbekannt.
|
||||||
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.role.findMany({ where: { id: { in: abfragbar } }, select: { id: true } })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
const bekannt = new Set(gefunden.map((r) => r.id));
|
const bekannt = new Set(gefunden.map((r) => r.id));
|
||||||
const unbekannt = eindeutig.filter((id) => !bekannt.has(id));
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
|
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
|
||||||
}
|
}
|
||||||
return eindeutig;
|
return eindeutig;
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ import {
|
|||||||
meldeTraegerAb,
|
meldeTraegerAb,
|
||||||
RollenSperrError,
|
RollenSperrError,
|
||||||
} from './rechte.service.js';
|
} from './rechte.service.js';
|
||||||
import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js';
|
import {
|
||||||
|
istSystemrollenName,
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-Export aus dem Katalog. Der Name ist an mehreren Stellen im Umlauf;
|
||||||
|
* die Wahrheit steht jetzt an einer Stelle.
|
||||||
|
*/
|
||||||
|
export const AUDIT_OPS_ROLLE = ROLLE_AUDIT_BETRIEB;
|
||||||
|
|
||||||
export interface UserFilters {
|
export interface UserFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -182,50 +195,49 @@ export async function createUser(data: {
|
|||||||
...rechteDerHaken(data),
|
...rechteDerHaken(data),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
// Cost 12 wie in prisma/seed.ts (OWASP 2026). Hier stand 10 - dieselbe
|
||||||
|
// Haertung, die im Seed laengst galt, war an dieser Stelle nie
|
||||||
|
// angekommen. Bestehende Kennwoerter bleiben pruefbar, der Cost steckt im
|
||||||
|
// Hash.
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const angelegt = await tx.user.create({
|
||||||
email: data.email,
|
data: {
|
||||||
password: hashedPassword,
|
email: data.email,
|
||||||
firstName: data.firstName,
|
password: hashedPassword,
|
||||||
lastName: data.lastName,
|
firstName: data.firstName,
|
||||||
customerId: data.customerId,
|
lastName: data.lastName,
|
||||||
whatsappNumber: data.whatsappNumber || null,
|
customerId: data.customerId,
|
||||||
telegramUsername: data.telegramUsername || null,
|
whatsappNumber: data.whatsappNumber || null,
|
||||||
signalNumber: data.signalNumber || null,
|
telegramUsername: data.telegramUsername || null,
|
||||||
roles: {
|
signalNumber: data.signalNumber || null,
|
||||||
create: rollenIds.map((roleId) => ({ roleId })),
|
roles: {
|
||||||
|
create: rollenIds.map((roleId) => ({ roleId })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
email: true,
|
||||||
email: true,
|
firstName: true,
|
||||||
firstName: true,
|
lastName: true,
|
||||||
lastName: true,
|
isActive: true,
|
||||||
isActive: true,
|
isServiceAccount: true,
|
||||||
isServiceAccount: true,
|
customerId: true,
|
||||||
customerId: true,
|
roles: {
|
||||||
roles: {
|
include: { role: true },
|
||||||
include: { role: true },
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// Die Haken in derselben Transaktion wie das Konto (Pentest R191-01).
|
||||||
|
// Sonst koennte ein halb ausgestattetes Konto zurueckbleiben: angelegt,
|
||||||
|
// aber ohne die zugesagte versteckte Rolle.
|
||||||
|
await setzeHaken(tx, angelegt.id, data);
|
||||||
|
|
||||||
|
return angelegt;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,217 +397,120 @@ export async function updateUser(
|
|||||||
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user - bei Rollenänderung Token invalidieren
|
// Die gesamte Schreibphase in EINER Transaktion.
|
||||||
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
|
// Zwei Stufen, beide aus dem Pentest:
|
||||||
// das Anlegen, war das Loeschen schon passiert und das Konto hatte gar
|
//
|
||||||
// keine Rolle mehr - bei einer Antwort, die wie "abgelehnt, nichts
|
// R190-01: deleteMany und createMany standen nackt nebeneinander.
|
||||||
// geschehen" aussah. `updateRole` hatte diese Haertung laengst; sie war
|
// Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto
|
||||||
// nur nicht zum Geschwister mitgewandert (Pentest R190-01).
|
// hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt,
|
||||||
if (gepruefteRollenIds !== undefined) {
|
// nichts geschehen" aussah.
|
||||||
await prisma.$transaction([
|
//
|
||||||
prisma.userRole.deleteMany({ where: { userId: id } }),
|
// R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die
|
||||||
prisma.userRole.createMany({
|
// drei Haken aber liefen als eigene Schreibvorgaenge hinterher. Ein
|
||||||
|
// Datenbankfehler dort hinterliess den Rollenstand gesetzt und die Haken
|
||||||
|
// halb - kein Verlust und keine Rechteerhoehung, aber ein Zwischenstand,
|
||||||
|
// den niemand angefordert hat. Ein PUT ist ein Vorgang, also gehoert er
|
||||||
|
// in eine Klammer.
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...userData,
|
||||||
|
// Token invalidieren wenn Rollen geändert werden
|
||||||
|
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gepruefteRollenIds !== undefined) {
|
||||||
|
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||||
|
await tx.userRole.createMany({
|
||||||
data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
|
data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
}),
|
});
|
||||||
]);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Handle developer access
|
// Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die
|
||||||
if (hasDeveloperAccess !== undefined) {
|
// versteckten Rollen. Die Haken setzen sie anschliessend wieder.
|
||||||
await setUserDeveloperAccess(id, hasDeveloperAccess);
|
await setzeHaken(tx, id, { hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess });
|
||||||
}
|
});
|
||||||
|
|
||||||
// Handle GDPR access
|
|
||||||
if (hasGdprAccess !== undefined) {
|
|
||||||
await setUserGdprAccess(id, hasGdprAccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasAuditOpsAccess !== undefined) {
|
|
||||||
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getUserById(id);
|
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.
|
* Setzt oder entfernt eine der drei versteckten Rollen (DSGVO, Developer,
|
||||||
|
* Audit-Betrieb) - innerhalb der uebergebenen Transaktion.
|
||||||
*
|
*
|
||||||
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
|
* Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei
|
||||||
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
|
* Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und
|
||||||
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
|
* zugleich eine vierte Stelle, die festlegte, was "Developer" bedeutet -
|
||||||
* Containerstart angelegt.
|
* mit einem anderen Rechtesatz als der Katalog (nur `developer:access`
|
||||||
|
* statt allem) und ohne `isSystem`. Eine so entstandene Rolle waere ueber
|
||||||
|
* die Rollenverwaltung aenderbar gewesen.
|
||||||
|
*
|
||||||
|
* Den Notfallpfad braucht es nicht mehr: `synchronisiereRechteUndRollen`
|
||||||
|
* legt die Rollen bei jedem Containerstart an, und die Startwache meldet,
|
||||||
|
* wenn eine fehlt. Fehlt sie hier trotzdem, ist Abbrechen mit klarer Ansage
|
||||||
|
* ehrlicher, als stillschweigend etwas Aehnliches zu erfinden.
|
||||||
*/
|
*/
|
||||||
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
|
async function setzeVersteckteRolle(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
// Helper to set GDPR access for a user
|
userId: number,
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
rollenName: string,
|
||||||
// Get or create DSGVO role
|
aktiv: boolean,
|
||||||
let gdprRole = await prisma.role.findFirst({
|
): Promise<void> {
|
||||||
where: { name: 'DSGVO' },
|
const rolle = await tx.role.findUnique({ where: { name: rollenName } });
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
if (!rolle) {
|
||||||
// Rechte-Satz identisch zu sync-roles.ts.
|
throw new Error(
|
||||||
const rechte = await prisma.permission.findMany({
|
`Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` +
|
||||||
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
|
'Containerstart angelegt – einmalig nachholen mit: npx tsx prisma/sync-roles.ts',
|
||||||
});
|
);
|
||||||
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({
|
const vorhanden = await tx.userRole.findUnique({
|
||||||
where: { userId, roleId: rolle.id },
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled && !hatRolle) {
|
if (aktiv && !vorhanden) {
|
||||||
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
|
await tx.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||||
// Token invalidieren bei Rechteaenderung
|
} else if (!aktiv && vorhanden) {
|
||||||
await prisma.user.update({
|
await tx.userRole.delete({
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else if (!enabled && hatRolle) {
|
|
||||||
await prisma.userRole.delete({
|
|
||||||
where: { userId_roleId: { userId, roleId: rolle.id } },
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
});
|
});
|
||||||
await prisma.user.update({
|
} else {
|
||||||
where: { id: userId },
|
return; // nichts geaendert - dann auch niemanden abmelden
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
}
|
||||||
});
|
|
||||||
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei Haken auf ihre versteckten Rollen abbilden. */
|
||||||
|
async function setzeHaken(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: number,
|
||||||
|
haken: {
|
||||||
|
hasDeveloperAccess?: boolean;
|
||||||
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
if (haken.hasDeveloperAccess !== undefined) {
|
||||||
|
await setzeVersteckteRolle(tx, userId, ROLLE_DEVELOPER, haken.hasDeveloperAccess);
|
||||||
|
}
|
||||||
|
if (haken.hasGdprAccess !== undefined) {
|
||||||
|
await setzeVersteckteRolle(tx, userId, ROLLE_DSGVO, haken.hasGdprAccess);
|
||||||
|
}
|
||||||
|
if (haken.hasAuditOpsAccess !== undefined) {
|
||||||
|
await setzeVersteckteRolle(tx, userId, AUDIT_OPS_ROLLE, haken.hasAuditOpsAccess);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -99,6 +99,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- [x] **🔗 R191-01: Ein PUT, eine Klammer** (2026-09-09)
|
||||||
|
- Befund der Pentesterin nach dem R190-01-Fix: Nur der Rollentausch lag in
|
||||||
|
der Transaktion, die drei Haken (DSGVO/Developer/Audit-Betrieb) liefen
|
||||||
|
**danach** in eigenen Schreibvorgängen. Ein Multi-Feld-PUT war damit nicht
|
||||||
|
gesamt-atomar — bricht ein Haken-Write ab, steht der Rollenstand schon und
|
||||||
|
die Haken halb. Kein Verlust, keine Eskalation, aber ein Zwischenstand, den
|
||||||
|
niemand angefordert hat.
|
||||||
|
- Fix: Die **gesamte** Schreibphase von `updateUser` in einer
|
||||||
|
`$transaction` — Benutzerdaten, Rollentausch, alle drei Haken.
|
||||||
|
`createUser` ebenso: Konto und Haken zusammen.
|
||||||
|
- **Beim Umbau mitgefunden:** Die drei Haken-Helfer **legten die Rolle bei
|
||||||
|
Bedarf selbst an**, falls sie fehlte. Das war eine *vierte* Stelle, die
|
||||||
|
definierte, was „Developer" bedeutet — mit einem anderen Rechtesatz als der
|
||||||
|
Katalog (nur `developer:access` statt allem) und **ohne `isSystem`**. Eine
|
||||||
|
so entstandene Rolle wäre über die Rollenverwaltung änderbar gewesen. Der
|
||||||
|
Notfallpfad ist weg: `sync-roles` legt die Rollen bei jedem Start an, die
|
||||||
|
Startwache meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang
|
||||||
|
mit klarer Ansage ab statt etwas Ähnliches zu erfinden. Drei fast gleiche
|
||||||
|
Funktionen wurden dabei eine.
|
||||||
|
- **int4-Überlauf** (ihr kosmetischer Nebenbefund): IDs jenseits des
|
||||||
|
INT-Bereichs werden gar nicht mehr abgefragt, sondern als unbekannt
|
||||||
|
gemeldet — `9999999999` sagt jetzt „Unbekannte Rollen-ID" statt
|
||||||
|
„Fehler beim Aktualisieren". Gleiche Behandlung für Rechte-IDs.
|
||||||
|
- **`createUser` hasht jetzt mit Cost 12** statt 10, wie `seed.ts`. Wieder
|
||||||
|
eine Härtung, die nur in einer von zwei Kopien angekommen war.
|
||||||
|
- Nachgeprüft: Rollback bewiesen, indem die DSGVO-Rolle vorübergehend
|
||||||
|
umbenannt und ein `{roleIds:[23], hasGdprAccess:true}` geschickt wurde →
|
||||||
|
400 mit Klartext, Rollen **unverändert** (kein Teil-Write). Ihre
|
||||||
|
Typvektoren `"22"`, `4.5`, `1e3`, `true`, `null`, Riesenzahl → alle 400,
|
||||||
|
kein Wipe. R190-01-Regression erneut grün.
|
||||||
|
- Dateien: `src/services/user.service.ts`, `src/services/rechte.service.ts`
|
||||||
|
|
||||||
- [x] **🧨 R190-01: Rollentausch zerstörte, wo er ablehnte** (2026-09-09)
|
- [x] **🧨 R190-01: Rollentausch zerstörte, wo er ablehnte** (2026-09-09)
|
||||||
- Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany`
|
- Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany`
|
||||||
**ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId
|
**ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId
|
||||||
|
|||||||
Reference in New Issue
Block a user