feat(quick-260910-das): Steuerungsschicht binden, Selbstloesch-Riegel schliessen

- user.controller.ts: alle sieben Zugriffe binden. ADMIN-Zweig der
  Benutzerliste laeuft ueber forTenant() mit weiterhin bestehender
  Mandantenbedingung im where; SUPER_ADMIN-Zweig ueber die neue
  UserService.findAllForPlatformAdmin(). Die drei Wege ueber die Kennung
  loesen den Zielbenutzer rollenabhaengig ueber resolveTargetUser() auf
  (ADMIN gebunden an eigenen Mandanten, SUPER_ADMIN uebergreifend); der
  Schreibzugriff bei update/delete bindet an den Mandanten des
  Zielbenutzers, nicht des Aufrufers, damit die uebergreifende
  Verwaltung durch die oberste Rolle erhalten bleibt
- Selbstloesch-Riegel (Befund H) repariert: verglich bisher gegen
  currentUser.sub, ein Feld, das der Sitzungsnachweis nicht traegt --
  der Riegel griff nie. Jetzt gegen currentUser.id. Verhaltensaenderung:
  ein Administrator kann sein eigenes Konto nun nicht mehr loeschen
- Alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/
  ausliefern, Akzentfarbe) binden an die Mandantenkennung aus dem
  Sitzungsnachweis
- user.controller.spec.ts (neu): Zwei-Klienten-Nachweis fuer die
  vorher testlose Steuerungsschicht, 8 Testfaelle, Falsifizierungsnachweis
  fuer eine gebundene Stelle sowie Rot-vor-Reparatur-Nachweis fuer den
  Selbstloesch-Riegel (siehe SUMMARY)
- docs/mandantentrennung-zugriffsklassifikation.md: alle vier
  handgepflegten Stellen nachgezogen (Uebersichtszeile 8/14, Summenzeile
  118/124, Klassen-Verteilung 63 Paare, Hintergrunddienst-Abschnitt auf
  fuenf Faelle inkl. admin-seed.service.ts als erster beidseitig
  korrekter Fall) sowie zwei Klassenkorrekturen (user.service.ts/user
  und admin-seed.service.ts/user je auf "beides")
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtrag zum
  user-Abschnitt mit den tatsaechlich umgesetzten Pfaden, der
  geschlossenen Luecke und den Falsifizierungsnachweisen
- 810 Tests gruen (8 neue in user.controller.spec.ts), Typpruefung
  sauber, Wegwerf-Werkzeug meldet weiterhin alle 53 Pruefungen bestanden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 10:35:43 +02:00
parent 888f66003c
commit 3a9391d9c8
4 changed files with 468 additions and 54 deletions
+72 -36
View File
@@ -22,6 +22,7 @@ import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@@ -40,6 +41,13 @@ function resolveAvatarsDir(): string {
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'avatars');
}
/**
* Bindung an forTenant() (WINDOWS #20 Etappe 2, 260910-das, Aufgabe 3): alle
* sieben Zugriffe dieses Controllers laufen entweder direkt ueber einen
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
* von `UserService`, deren Rumpf je Mandant gebunden ist.
*/
@Controller('users')
@UseGuards(RolesGuard)
export class UserController {
@@ -48,6 +56,22 @@ export class UserController {
private readonly prisma: PrismaService,
) {}
/**
* Loest den Zielbenutzer rollenabhaengig auf (260910-das, Aufgabe 3): ein
* Mandanten-Administrator sieht nur den eigenen Mandanten (gebunden ueber
* `UserService.findById`), die oberste Rolle (SUPER_ADMIN) behaelt die
* uebergreifende Sicht ueber `UserService.findByIdForPlatformAdmin()` --
* diese Verzweigung ist die Stelle, an der dieser Bereich die gewollte
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
* darf nicht eingeebnet werden.
*/
private async resolveTargetUser(currentUser: any, id: string) {
if (currentUser.role === Role.SUPER_ADMIN) {
return this.userService.findByIdForPlatformAdmin(id);
}
return this.userService.findById(currentUser.tenantId, id);
}
/**
* GET /users
* ADMIN sees own-tenant users only. SUPER_ADMIN sees all users.
@@ -57,24 +81,19 @@ export class UserController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findAll(@CurrentUser() currentUser: any) {
if (currentUser.role === Role.SUPER_ADMIN) {
return this.prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
displayName: true,
role: true,
isActive: true,
tenantId: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { username: 'asc' },
});
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
// Schleife-je-Mandant-gebunden aus UserService.findAllForPlatformAdmin()
// statt ueber ein ungebundenes findMany().
return this.userService.findAllForPlatformAdmin();
}
// ADMIN: filter by own tenant
return this.prisma.user.findMany({
// ADMIN: gebunden an den eigenen Mandanten. Die vorhandene
// Mandantenbedingung im where BLEIBT erhalten -- nicht entfernen mit
// dem Argument, das mache jetzt die Datenbank; dieselbe Regel, die die
// Bereiche `tenders` und `dkv` aufgestellt haben.
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
return tenantPrisma.user.findMany({
where: { tenantId: currentUser.tenantId },
select: {
id: true,
@@ -97,13 +116,7 @@ export class UserController {
@Get(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
// 260910-das, Aufgabe 2: UserService.findById() bekommt einen
// Pflicht-Mandanten (siehe user.service.ts). Nur die Signatur wird hier
// nachgezogen, damit die Typpruefung sauber bleibt -- die Rollenlogik
// (insbesondere die uebergreifende SUPER_ADMIN-Sicht ueber
// findByIdForPlatformAdmin()) wird erst in Aufgabe 3 vollstaendig
// verdrahtet.
const user = await this.userService.findById(currentUser.tenantId, id);
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
}
@@ -162,8 +175,7 @@ export class UserController {
@Body() dto: UpdateUserDto,
@CurrentUser() currentUser: any,
) {
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
const user = await this.userService.findById(currentUser.tenantId, id);
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
}
@@ -181,7 +193,12 @@ export class UserController {
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
}
const updated = await this.userService.update(currentUser.tenantId, id, {
// Der Schreibzugriff bindet an den Mandanten des ZIELBENUTZERS, wie er
// aus der vorangegangenen Aufloesung hervorgeht — NICHT an den des
// Aufrufers (260910-das, Aufgabe 3). Nur so bleibt die uebergreifende
// Verwaltung durch die oberste Rolle erhalten und ist der
// Schreibzugriff trotzdem gebunden.
const updated = await this.userService.update(user.tenantId, id, {
username: dto.username,
email: dto.email,
password: dto.password,
@@ -201,14 +218,21 @@ export class UserController {
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
const user = await this.userService.findById(currentUser.tenantId, id);
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
}
// Cannot delete self
if (user.id === currentUser.sub) {
// Cannot delete self (260910-das, Befund H): dieser Vergleich verglich
// bisher gegen `currentUser.sub` — ein Feld, das der Sitzungsnachweis
// GAR NICHT traegt (JwtStrategy.validate() liefert exakt { id,
// username, role, tenantId }). Der Riegel hat deshalb NIE gegriffen:
// ein Administrator konnte sich selbst loeschen und seinen Mandanten
// ohne Verwaltung zuruecklassen. Die Reparatur ist eine
// Verhaltensaenderung: ein Administrator kann sein eigenes Konto nun
// nicht mehr loeschen — das ist die urspruengliche, im Code bereits
// formulierte Absicht.
if (user.id === currentUser.id) {
throw new ForbiddenException('Cannot delete your own account');
}
@@ -220,13 +244,21 @@ export class UserController {
throw new ForbiddenException('Cannot delete users from other tenants');
}
await this.userService.delete(currentUser.tenantId, id);
// Gebunden an den Mandanten des ZIELBENUTZERS, derselbe Grund wie bei
// update() oben.
await this.userService.delete(user.tenantId, id);
return { message: 'User deleted' };
}
// ─── Self-service avatar endpoints (all authenticated roles) ───────────────
// No @Roles() → RolesGuard.canActivate() returns true when requiredRoles is
// empty (see guards/roles.guard.ts). Global JwtAuthGuard still enforces auth.
//
// Alle fuenf Zugriffe binden vollstaendig an die Mandantenkennung aus dem
// Sitzungsnachweis (260910-das, Befund G, Aufgabe 3): der angemeldete
// Benutzer liegt per Definition im Mandanten seiner eigenen Sitzung, die
// Bindung aendert an Pfaden, Dateityp-Pruefung, Groessenbegrenzung und dem
// Aufraeumen alter Bilddateien nichts.
/**
* POST /users/me/avatar
@@ -272,7 +304,8 @@ export class UserController {
// Persist relative path (relative to monorepo root)
const relativePath = path.join('user-files', 'avatars', filename);
await this.prisma.user.update({
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { avatarPath: relativePath },
});
@@ -286,7 +319,8 @@ export class UserController {
*/
@Delete('me/avatar')
async deleteAvatar(@CurrentUser() currentUser: any) {
const user = await this.prisma.user.findUnique({
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
});
@@ -297,7 +331,7 @@ export class UserController {
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
await this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { avatarPath: null },
});
@@ -320,7 +354,8 @@ export class UserController {
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
}
await this.prisma.user.update({
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { accentColor: body.color ?? null },
});
@@ -338,7 +373,8 @@ export class UserController {
@CurrentUser() currentUser: any,
@Res() res: Response,
) {
const user = await this.prisma.user.findUnique({
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
});