refactor(quick-260921-m34): Aufgabe 2b - getypte Anfrage in elf Controllern, zwei Befunde gemeldet

(req as any) und @Req() req: any durch AuthenticatedRequest ersetzt in
dashboard, favorites, calendar, groups, module-grants, module-registry,
tenders, dkv, ldap, settings; @CurrentUser() in user.controller auf AuthUser.
Die abwehrenden Pruefungen ("No tenant context", "No user context") bleiben
lebendig, weil user auf dem Anfragetyp wahlfrei ist - genau das beschreibt
den Zustand auf oeffentlichen Wegen.

Nebengewinn ohne neue Zusicherungen: req.tenantId as string | undefined
(dkv, settings), file.buffer as Buffer und file.mimetype as string
(dkv, user) sind weggefallen, weil der Typ sie jetzt traegt.

BEFUND 1 (D-03, gemeldet) dashboard.controller.ts:74 alt: der Handler las
req.user?.role NACH extractContext und gab sie an getWidgets(role: Role)
weiter, das eine Rolle zwingend verlangt. Die Annahme "hier gibt es immer
einen Aufrufer" stimmt - die Pruefung "No user context" erzwingt sie -, aber
sie stand in einer anderen Methode, wo der Compiler sie nicht sehen konnte.
extractContext gibt die Rolle jetzt mit zurueck: keine neue Pruefung, kein
erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.

BEFUND 2 (D-03, gemeldet) tenders.controller.ts:142: resolveRequestingTenantId
erklaerte string | undefined, liest aber req.tenantId, das TenantGuard fuer
einen SUPER_ADMIN ohne Mandanten auf null setzt. Die Erklaerung war also nie
vollstaendig. Erweitert auf string | null | undefined, und buildTenderWhere
nimmt string | null - beides nur Erklaerung, kein Verhalten: die Funktion
entscheidet seit jeher ueber Wahrheitswert und faellt bei beiden zu
(nur global sichtbare Ausschreibungen).

Fixtures in user.controller.spec.ts ergaenzt (username, mustChangePassword,
originalname, size). Testzahlen unveraendert.

noExplicitAny in apps/api/src: 137 -> 66. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531, tenant.guard.ts unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 17:08:44 +02:00
parent f2fc39f51c
commit 7c9d7c1223
13 changed files with 182 additions and 155 deletions
+10 -10
View File
@@ -10,7 +10,7 @@ import {
Query,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CalendarService } from './calendar.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
@@ -45,10 +45,10 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
private extractContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -66,7 +66,7 @@ export class CalendarController {
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
*/
@Get('sources')
async getSources(@Req() req: Request) {
async getSources(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.getSources(userId, tenantId);
}
@@ -78,7 +78,7 @@ export class CalendarController {
*/
@Post('sources')
async addSource(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: CreateCalendarSourceDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -93,7 +93,7 @@ export class CalendarController {
@Patch('sources/:id')
async updateSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateCalendarSourceDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -107,7 +107,7 @@ export class CalendarController {
@Delete('sources/:id')
async deleteSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.deleteSource(id, userId, tenantId);
@@ -132,7 +132,7 @@ export class CalendarController {
@Post('sources/:id/test')
async testSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.testConnection(id, userId, tenantId);
@@ -144,7 +144,7 @@ export class CalendarController {
*/
@Get('events')
async getEvents(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Query() query: CalendarEventsQueryDto,
) {
const { userId, tenantId } = this.extractContext(req);
+28 -17
View File
@@ -10,7 +10,7 @@ import {
Put,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
@@ -39,42 +39,53 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
/**
* BEFUND quick-260921-m34 (D-03, gemeldet nicht repariert): `getWidgets`
* las die Rolle vorher als `req.user?.role` NACH dieser Pruefung und gab
* sie an `DashboardService.getWidgets(role: Role)` weiter, das eine Rolle
* zwingend verlangt. Der Bestandscode nahm also an, dass an dieser Stelle
* immer ein Aufrufer vorliegt. Die Annahme stimmt — die Pruefung "No user
* context" direkt darunter erzwingt sie seit jeher —, aber der Compiler
* konnte die beiden Stellen nicht verbinden, weil sie in zwei Methoden
* standen. Deshalb gibt diese Methode die Rolle jetzt MIT zurueck: keine
* neue Pruefung, kein erfundener Wert, gleiche Reihenfolge, gleiche
* Meldungen, gleiches Verhalten — nur sichtbar statt angenommen.
*/
private extractContext(req: AuthenticatedRequest) {
const user = req.user;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
if (!user?.id) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
return { userId: user.id, tenantId, role: user.role };
}
@Get('layout')
async getLayout(@Req() req: Request) {
async getLayout(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId);
}
@Put('layout')
async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) {
async saveLayout(@Req() req: AuthenticatedRequest, @Body() dto: SaveLayoutDto) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.saveLayout(userId, tenantId, dto);
}
@Get('widgets')
async getWidgets(@Req() req: Request) {
const { userId, tenantId } = this.extractContext(req);
const role = (req as any).user?.role;
async getWidgets(@Req() req: AuthenticatedRequest) {
const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role);
}
@Post('widgets')
async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) {
async addWidget(@Req() req: AuthenticatedRequest, @Body() dto: CreateWidgetDto) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.addWidget(userId, tenantId, dto);
}
@@ -82,7 +93,7 @@ export class DashboardController {
@Patch('widgets/:id/config')
async updateWidgetConfig(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateWidgetConfigDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -92,7 +103,7 @@ export class DashboardController {
@Delete('widgets/:id')
async removeWidget(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeWidget(id, userId, tenantId);
@@ -101,14 +112,14 @@ export class DashboardController {
// --- Search Providers (05-02, D-15) ---
@Get('search-providers')
async getSearchProviders(@Req() req: Request) {
async getSearchProviders(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId, tenantId);
}
@Post('search-providers')
async addSearchProvider(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: CreateSearchProviderDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -118,7 +129,7 @@ export class DashboardController {
@Delete('search-providers/:id')
async removeSearchProvider(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId, tenantId);
+21 -16
View File
@@ -17,6 +17,11 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type {
AuthenticatedRequest,
UploadedFileLike,
} from '../auth/types/auth-user';
import type { Response } from 'express';
import { DkvSchedulerService } from './dkv-scheduler.service';
import { DkvService } from './dkv.service';
import { DkvConfigDto } from './dto/dkv-config.dto';
@@ -58,7 +63,7 @@ export class DkvController {
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) {
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId);
if (!config) {
@@ -75,7 +80,7 @@ export class DkvController {
*/
@Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: any, @Body() dto: DkvConfigDto) {
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto);
@@ -94,7 +99,7 @@ export class DkvController {
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: any) {
async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId);
}
@@ -105,7 +110,7 @@ export class DkvController {
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) {
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto);
}
@@ -118,7 +123,7 @@ export class DkvController {
*/
@Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: any, @Query() query: DkvHistoryQueryDto) {
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
@@ -137,9 +142,9 @@ export class DkvController {
@Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport(
@Req() req: any,
@Req() req: AuthenticatedRequest,
@Param('filename') filename: string,
@Res() res: any,
@Res() res: Response,
) {
const tenantId = this._requireTenant(req);
@@ -164,7 +169,7 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: any) {
async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId);
}
@@ -172,7 +177,7 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: any, @Body() dto: CreateVehicleDto) {
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto);
}
@@ -181,7 +186,7 @@ export class DkvController {
@Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle(
@Req() req: any,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateVehicleDto,
) {
@@ -192,7 +197,7 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: any, @Param('id') id: string) {
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id);
}
@@ -213,8 +218,8 @@ export class DkvController {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
}))
async importVehicles(
@Req() req: any,
@UploadedFile() file: any,
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('mode') mode: string,
) {
const tenantId = this._requireTenant(req);
@@ -223,7 +228,7 @@ export class DkvController {
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
}
const csvText = (file.buffer as Buffer).toString('utf-8');
const csvText = file.buffer.toString('utf-8');
const importMode = mode === 'replace' ? 'replace' : 'merge';
return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode);
@@ -232,8 +237,8 @@ export class DkvController {
// ─── Private helpers ───────────────────────────────────────────────────────
/** Extract and validate tenantId from request; throw BadRequestException when absent. */
private _requireTenant(req: any): string {
const tenantId = req.tenantId as string | undefined;
private _requireTenant(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
+11 -10
View File
@@ -13,7 +13,8 @@ import {
Req,
Res,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Response } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
@@ -46,10 +47,10 @@ import { FavoritesService } from './favorites.service';
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
private extractContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -64,7 +65,7 @@ export class FavoritesController {
@Get()
async list(
@Query('widgetId', ParseUUIDPipe) widgetId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -74,7 +75,7 @@ export class FavoritesController {
@Post()
async create(
@Body() dto: CreateFavoriteDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -91,7 +92,7 @@ export class FavoritesController {
@Put('order')
async reorder(
@Body() dto: ReorderFavoritesDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -110,7 +111,7 @@ export class FavoritesController {
@Get(':id/icon')
async getIcon(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -135,7 +136,7 @@ export class FavoritesController {
async update(
@Param('id') id: string,
@Body() dto: UpdateFavoriteDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -145,7 +146,7 @@ export class FavoritesController {
@Delete(':id')
async remove(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
+11 -11
View File
@@ -11,7 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AddGroupMembersDto } from './dto/add-group-members.dto';
@@ -30,8 +30,8 @@ import { GroupsService } from './groups.service';
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -45,7 +45,7 @@ export class GroupsController {
@Get()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async list(@Req() req: Request) {
async list(@Req() req: AuthenticatedRequest) {
return this.groupsService.listForTenant(this.getTenantId(req));
}
@@ -56,7 +56,7 @@ export class GroupsController {
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateGroupDto, @Req() req: Request) {
async create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) {
return this.groupsService.create(this.getTenantId(req), dto);
}
@@ -70,7 +70,7 @@ export class GroupsController {
async update(
@Param('id') id: string,
@Body() dto: UpdateGroupDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
return this.groupsService.update(this.getTenantId(req), id, dto);
}
@@ -82,7 +82,7 @@ export class GroupsController {
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @Req() req: Request) {
async remove(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.remove(this.getTenantId(req), id);
}
@@ -93,7 +93,7 @@ export class GroupsController {
@Get(':id/impact')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async impact(@Param('id') id: string, @Req() req: Request) {
async impact(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.getImpact(this.getTenantId(req), id);
}
@@ -104,7 +104,7 @@ export class GroupsController {
@Get(':id/members')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async members(@Param('id') id: string, @Req() req: Request) {
async members(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.listMembers(this.getTenantId(req), id);
}
@@ -118,7 +118,7 @@ export class GroupsController {
async addMembers(
@Param('id') id: string,
@Body() dto: AddGroupMembersDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
}
@@ -134,7 +134,7 @@ export class GroupsController {
async removeMember(
@Param('id') id: string,
@Param('userId') userId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
await this.groupsService.removeMember(this.getTenantId(req), id, userId);
return { success: true };
@@ -10,7 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
@@ -31,8 +31,8 @@ import { ModuleGrantsService } from './module-grants.service';
export class ModuleGrantsController {
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -46,7 +46,7 @@ export class ModuleGrantsController {
@Get('matrix')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async matrix(@Req() req: Request) {
async matrix(@Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
}
@@ -61,7 +61,7 @@ export class ModuleGrantsController {
@Get('users/:userId')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async userAccess(@Param('userId') userId: string, @Req() req: Request) {
async userAccess(@Param('userId') userId: string, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId);
}
@@ -72,7 +72,7 @@ export class ModuleGrantsController {
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
async create(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.grant(this.getTenantId(req), dto);
}
@@ -84,7 +84,7 @@ export class ModuleGrantsController {
@Delete()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
await this.moduleGrantsService.revoke(this.getTenantId(req), dto);
return { success: true };
}
+12 -11
View File
@@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import {
CreateFieldMappingDto,
CreateLdapConfigDto,
@@ -41,7 +42,7 @@ export class LdapController {
*/
@Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) {
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -65,7 +66,7 @@ export class LdapController {
*/
@Post('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) {
async createConfig(@Req() req: AuthenticatedRequest, @Body() dto: CreateLdapConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -92,7 +93,7 @@ export class LdapController {
*/
@Patch('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) {
async updateConfig(@Req() req: AuthenticatedRequest, @Body() dto: UpdateLdapConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -123,7 +124,7 @@ export class LdapController {
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) {
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestConnectionDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -158,7 +159,7 @@ export class LdapController {
*/
@Get('groups')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listGroups(@Req() req: any) {
async listGroups(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -189,7 +190,7 @@ export class LdapController {
*/
@Post('groups/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) {
async importGroups(@Req() req: AuthenticatedRequest, @Body() dto: ImportGroupsDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -225,7 +226,7 @@ export class LdapController {
*/
@Get('users/search')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async searchUsers(@Req() req: any, @Query('q') q: string) {
async searchUsers(@Req() req: AuthenticatedRequest, @Query('q') q: string) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -256,7 +257,7 @@ export class LdapController {
*/
@Post('users/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) {
async importUsers(@Req() req: AuthenticatedRequest, @Body() dto: ImportUsersDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -292,7 +293,7 @@ export class LdapController {
*/
@Post('sync')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async triggerSync(@Req() req: any) {
async triggerSync(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -326,7 +327,7 @@ export class LdapController {
*/
@Post('config/mappings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) {
async addFieldMapping(@Req() req: AuthenticatedRequest, @Body() dto: CreateFieldMappingDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -351,7 +352,7 @@ export class LdapController {
*/
@Delete('config/mappings/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeFieldMapping(@Req() req: any, @Param('id') id: string) {
async removeFieldMapping(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -8,7 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleAccessService } from './module-access.service';
@@ -53,10 +53,10 @@ export class ModuleRegistryController {
* catalog (mandantenweite Sicht, kein Benutzerfilter).
*/
@Get('active')
async findActive(@Req() req: Request) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const userId = (req as any).user?.id;
const role = (req as any).user?.role;
async findActive(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = req.user?.id;
const role = req.user?.role;
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
@@ -74,10 +74,10 @@ export class ModuleRegistryController {
* Erreichbar für jeden authentifizierten Benutzer, wie GET /modules.
*/
@Get('catalog')
async findCatalog(@Req() req: Request) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const userId = (req as any).user?.id;
const role = (req as any).user?.role;
async findCatalog(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = req.user?.id;
const role = req.user?.role;
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
@@ -105,9 +105,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async activate(
@Param('moduleId') moduleId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -124,9 +124,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deactivate(
@Param('moduleId') moduleId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
+7 -6
View File
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { SmtpConfigDto } from './dto/smtp-config.dto';
import { SettingsService } from './settings.service';
@@ -29,8 +30,8 @@ export class SettingsController {
*/
@Get('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSmtpConfig(@Req() req: any) {
const tenantId = req.tenantId as string | undefined;
async getSmtpConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
@@ -50,8 +51,8 @@ export class SettingsController {
*/
@Put('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined;
async saveSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
@@ -67,8 +68,8 @@ export class SettingsController {
*/
@Post('smtp/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined;
async testSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
+1 -1
View File
@@ -44,7 +44,7 @@ const MAX_FAV_IDS = 500;
export function buildTenderWhere(
dto: TenderQueryDto,
favIds?: string[],
ownerTenantId?: string,
ownerTenantId?: string | null,
): Prisma.TenderWhereInput {
const where: Prisma.TenderWhereInput = {};
const AND: Prisma.TenderWhereInput[] = [];
+26 -24
View File
@@ -13,7 +13,7 @@ import {
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { UseModule } from '../module-registry/module.guard';
import { PrismaService } from '../prisma/prisma.service';
@@ -115,10 +115,10 @@ export class TendersController {
* (`roles.guard.ts`) — one single spot in this controller resolves
* account data from the request.
*/
private extractTriageContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const role = (req as any).user?.role;
private extractTriageContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId = req.tenantId ?? req.user?.tenantId;
const role = req.user?.role;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -138,8 +138,10 @@ export class TendersController {
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
* tenant context is present.
*/
private resolveRequestingTenantId(req?: Request): string | undefined {
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId;
private resolveRequestingTenantId(
req?: AuthenticatedRequest,
): string | null | undefined {
return req?.user?.tenantId ?? req?.tenantId;
}
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
@@ -164,7 +166,7 @@ export class TendersController {
*/
@Get()
@UseModule('tender-radar')
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
async listTenders(@Query() query: TenderQueryDto, @Req() req?: AuthenticatedRequest) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
@@ -175,7 +177,7 @@ export class TendersController {
// optional type only accommodates unit tests that call this method
// directly without favOnly set (T-11-10: extractTriageContext
// throws ForbiddenException if req/user context is genuinely absent).
const { userId, tenantId } = this.extractTriageContext(req as Request);
const { userId, tenantId } = this.extractTriageContext(req as AuthenticatedRequest);
favIds = await this.tenderTriage.favoriteIds(userId, tenantId);
}
@@ -263,7 +265,7 @@ export class TendersController {
*/
@Get('rss-feeds')
@UseModule('tender-radar')
async listRssFeeds(@Req() req: Request) {
async listRssFeeds(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId);
@@ -286,7 +288,7 @@ export class TendersController {
*/
@Post('rss-feeds')
@UseModule('tender-radar')
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId, role } = this.extractTriageContext(req);
if (dto.scope === 'platform') {
@@ -319,7 +321,7 @@ export class TendersController {
*/
@Delete('rss-feeds/:feedId')
@UseModule('tender-radar')
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) {
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: AuthenticatedRequest) {
const { userId, role } = this.extractTriageContext(req);
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
@@ -342,7 +344,7 @@ export class TendersController {
*/
@Get('email-config')
@UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) {
async getEmailConfig(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId, tenantId);
}
@@ -356,7 +358,7 @@ export class TendersController {
*/
@Put('email-config')
@UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
}
@@ -381,7 +383,7 @@ export class TendersController {
*/
@Post('email-config/test')
@UseModule('tender-radar')
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.testConnection(userId, tenantId, dto);
}
@@ -456,7 +458,7 @@ export class TendersController {
*/
@Get('triage')
@UseModule('tender-radar')
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
async listTriage(@Query('ids') ids: string | undefined, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
const tenderIds = (ids ?? '')
.split(',')
@@ -480,7 +482,7 @@ export class TendersController {
*/
@Put('triage')
@UseModule('tender-radar')
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) {
async setTriage(@Body() dto: TenderTriageDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
@@ -502,7 +504,7 @@ export class TendersController {
*/
@Get('saved-searches')
@UseModule('tender-radar')
async listSavedSearches(@Req() req: Request) {
async listSavedSearches(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.list(userId, tenantId);
}
@@ -517,7 +519,7 @@ export class TendersController {
@UseModule('tender-radar')
async createSavedSearch(
@Body() dto: CreateSavedSearchDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.create(userId, tenantId, dto);
@@ -535,7 +537,7 @@ export class TendersController {
async updateSavedSearch(
@Param('searchId') searchId: string,
@Body() dto: UpdateSavedSearchDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.update(searchId, userId, tenantId, dto);
@@ -550,7 +552,7 @@ export class TendersController {
@UseModule('tender-radar')
async removeSavedSearch(
@Param('searchId') searchId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
await this.tenderSavedSearch.remove(searchId, userId, tenantId);
@@ -571,7 +573,7 @@ export class TendersController {
*/
@Get('notification-pref')
@UseModule('tender-radar')
async getNotificationPref(@Req() req: Request) {
async getNotificationPref(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.getForUser(userId, tenantId);
}
@@ -586,7 +588,7 @@ export class TendersController {
@UseModule('tender-radar')
async setNotificationPref(
@Body() dto: UpdateNotificationPrefDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval);
@@ -606,7 +608,7 @@ export class TendersController {
*/
@Get(':id')
@UseModule('tender-radar')
async getTender(@Param('id') id: string, @Req() req?: Request) {
async getTender(@Param('id') id: string, @Req() req?: AuthenticatedRequest) {
const tender = await this.prisma.tender.findUnique({
where: { id },
include: {
+21 -16
View File
@@ -153,7 +153,7 @@ describe('UserController', () => {
lastLoginAt: null,
});
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(result.map((u: any) => u.username)).toEqual(['alice']);
expectBoundCall(prisma, 't1', 'user', 'findMany');
@@ -170,6 +170,8 @@ describe('UserController', () => {
role: Role.SUPER_ADMIN,
tenantId: 't1',
id: 'super1',
username: 'super1',
mustChangePassword: false,
});
expect(result).toBe(expected);
@@ -183,11 +185,11 @@ describe('UserController', () => {
const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' };
userService.findById.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1' });
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
});
@@ -195,14 +197,14 @@ describe('UserController', () => {
userService.findById.mockResolvedValue(null);
await expect(
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }),
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false }),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('create', () => {
it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
await expect(
controller.create(
@@ -224,7 +226,7 @@ describe('UserController', () => {
describe('remove — Selbstlöschriegel (Befund H)', () => {
it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' });
await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf(
@@ -235,7 +237,7 @@ describe('UserController', () => {
});
describe('Selbstbedienungswege (Befund G)', () => {
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me' };
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
beforeEach(() => {
prisma.__seedUser({
@@ -247,7 +249,10 @@ describe('UserController', () => {
});
it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => {
await controller.uploadAvatar({ buffer: Buffer.from('x'), mimetype: 'image/png' }, currentUser);
await controller.uploadAvatar(
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
currentUser,
);
await controller.deleteAvatar(currentUser);
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
@@ -280,7 +285,7 @@ describe('UserController', () => {
describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => {
it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect(
@@ -300,7 +305,7 @@ describe('UserController', () => {
});
it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss',
tenantId: 't1',
@@ -324,7 +329,7 @@ describe('UserController', () => {
});
it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
@@ -338,7 +343,7 @@ describe('UserController', () => {
});
it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect(
@@ -348,7 +353,7 @@ describe('UserController', () => {
});
it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss', admin)).rejects.toThrow(
@@ -358,7 +363,7 @@ describe('UserController', () => {
});
it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss',
tenantId: 't1',
@@ -373,7 +378,7 @@ describe('UserController', () => {
});
it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.delete.mockResolvedValue({ message: 'User deleted' });
@@ -383,7 +388,7 @@ describe('UserController', () => {
});
it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss2', admin)).rejects.toThrow(
+14 -13
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
@@ -65,7 +66,7 @@ export class UserController {
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
* darf nicht eingeebnet werden.
*/
private async resolveTargetUser(currentUser: any, id: string) {
private async resolveTargetUser(currentUser: AuthUser, id: string) {
if (currentUser.role === Role.SUPER_ADMIN) {
return this.userService.findByIdForPlatformAdmin(id);
}
@@ -79,7 +80,7 @@ export class UserController {
*/
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findAll(@CurrentUser() currentUser: any) {
async findAll(@CurrentUser() currentUser: AuthUser) {
if (currentUser.role === Role.SUPER_ADMIN) {
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
@@ -115,7 +116,7 @@ export class UserController {
*/
@Get(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
@@ -139,7 +140,7 @@ export class UserController {
*/
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: AuthUser) {
// ADMIN can only create users in own tenant
const tenantId =
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
@@ -173,7 +174,7 @@ export class UserController {
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
@@ -228,7 +229,7 @@ export class UserController {
*/
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
async remove(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
@@ -290,14 +291,14 @@ export class UserController {
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
)
async uploadAvatar(
@UploadedFile() file: any,
@CurrentUser() currentUser: any,
@UploadedFile() file: UploadedFileLike | undefined,
@CurrentUser() currentUser: AuthUser,
) {
if (!file?.buffer) {
throw new BadRequestException('No file provided');
}
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string];
const ext = AVATAR_MIME_TO_EXT[file.mimetype];
if (!ext) {
throw new BadRequestException(
'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
@@ -318,7 +319,7 @@ export class UserController {
}
}
fs.writeFileSync(filePath, file.buffer as Buffer);
fs.writeFileSync(filePath, file.buffer);
// Persist relative path (relative to monorepo root)
const relativePath = path.join('user-files', 'avatars', filename);
@@ -336,7 +337,7 @@ export class UserController {
* Remove the current user's profile picture.
*/
@Delete('me/avatar')
async deleteAvatar(@CurrentUser() currentUser: any) {
async deleteAvatar(@CurrentUser() currentUser: AuthUser) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
@@ -366,7 +367,7 @@ export class UserController {
@Patch('me/accent-color')
async updateAccentColor(
@Body() body: { color: string | null },
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
) {
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
@@ -388,7 +389,7 @@ export class UserController {
*/
@Get('me/avatar')
async getAvatar(
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
@Res() res: Response,
) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);