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:
2026-09-09 01:40:56 +02:00
co-authored by Claude Opus 5
parent b2963be482
commit 702e630ee5
3 changed files with 204 additions and 239 deletions
+26 -9
View File
@@ -24,6 +24,9 @@
*/
import prisma from '../lib/prisma.js';
/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */
const INT_MAX = 2147483647;
import {
ROLLE_DEVELOPER,
ROLLE_DSGVO,
@@ -111,10 +114,15 @@ export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
const rechte = new Set<string>();
if (ids.length === 0) return rechte;
const gefunden = await prisma.permission.findMany({ where: { id: { in: ids } } });
if (gefunden.length !== new Set(ids).size) {
const eindeutig = [...new Set(ids)];
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 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(', ')}`);
}
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)) {
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
}
const gefunden = await prisma.role.findMany({
where: { id: { in: eindeutig } },
select: { id: true },
});
if (gefunden.length !== eindeutig.length) {
// Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht
// dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim
// Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse
// (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 unbekannt = eindeutig.filter((id) => !bekannt.has(id));
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
}
return eindeutig;
+145 -230
View File
@@ -11,7 +11,20 @@ import {
meldeTraegerAb,
RollenSperrError,
} 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 {
search?: string;
@@ -182,50 +195,49 @@ export async function createUser(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({
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 })),
const user = await prisma.$transaction(async (tx) => {
const angelegt = await tx.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 },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
isServiceAccount: true,
customerId: true,
roles: {
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;
}
@@ -385,217 +397,120 @@ export async function updateUser(
!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.
// Die gesamte Schreibphase 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({
// Zwei Stufen, beide aus dem Pentest:
//
// R190-01: deleteMany und createMany standen nackt nebeneinander.
// Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto
// hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt,
// nichts geschehen" aussah.
//
// R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die
// 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 })),
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);
}
// Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die
// versteckten Rollen. Die Haken setzen sie anschliessend wieder.
await setzeHaken(tx, id, { hasDeveloperAccess, hasGdprAccess, 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.
* 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
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
* Containerstart angelegt.
* Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei
* Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und
* zugleich eine vierte Stelle, die festlegte, 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.
*
* 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';
// 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 } });
async function setzeVersteckteRolle(
tx: Prisma.TransactionClient,
userId: number,
rollenName: string,
aktiv: boolean,
): Promise<void> {
const rolle = await tx.role.findUnique({ where: { name: rollenName } });
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 })) },
},
});
throw new Error(
`Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` +
'Containerstart angelegt einmalig nachholen mit: npx tsx prisma/sync-roles.ts',
);
}
const hatRolle = await prisma.userRole.findFirst({
where: { userId, roleId: rolle.id },
const vorhanden = await tx.userRole.findUnique({
where: { userId_roleId: { 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({
if (aktiv && !vorhanden) {
await tx.userRole.create({ data: { userId, roleId: rolle.id } });
} else if (!aktiv && vorhanden) {
await tx.userRole.delete({
where: { userId_roleId: { userId, roleId: rolle.id } },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else {
return; // nichts geaendert - dann auch niemanden abmelden
}
// 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) {
// Check if user is an admin
const user = await prisma.user.findUnique({
+33
View File
@@ -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)
- Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany`
**ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId