feat(260911-gwh): GREEN — favorites/settings binden, Startpfad umbenennen, Widget-Besitzriegel

- favorites.service.ts: alle fuenf Methoden nehmen tenantId als ersten
  Parameter, laufen je ueber EINEN Klienten tenantPrisma (7 gebundene
  Favoritenzugriffe, 5 Aufrufstellen); create() prueft vor der Icon-Suche,
  dass das Ziel-Widget dem Aufrufer gehoert (T-GWH-05, Befund F aus Aufgabe 1
  bestaetigt den Fremdschluessel-Durchgriff) -- Widget not found fuer alle
  drei Faelle (existiert nicht/Kollege/fremder Mandant)
- favorites.controller.ts: reicht tenantId an alle fuenf Aufrufe durch,
  extractContext unveraendert (dashboard-Praezedenzfall)
- settings.service.ts: getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig
  je EIN Klient (3 gebundene Zugriffe, 3 Aufrufstellen) -- Befund K damit
  erfuellt; Startpfad umbenannt in loadAnySmtpConfigForStartupTransport(),
  bleibt bewusst ungebunden (sechster Fall der Hintergrunddienst-Falle,
  WINDOWS #TBD-GWH -- Aufgabe 3 vergibt die Nummer)
- mail.module.ts: ruft den umbenannten Startpfad auf, Kommentar nennt beide
  Zustaende statt "single-tenant default"
- Vier Falsifizierungsnachweise durchgefuehrt und zurueckgenommen (siehe
  SUMMARY): (a) 3 Faelle rot, (b) 4 Faelle rot, (c) 9 Faelle rot, (d) 4 Faelle
  rot

Bekannt und erwartet (siehe SUMMARY, Praezedenzfall 260911-fh9): zwischen
dieser Aufgabe und Aufgabe 3 ist rls-access-inventory.spec.ts rot (2
Faelle) -- die Klassifikationstabelle ist noch nicht nachgezogen, das ist
Aufgabe 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 13:57:02 +02:00
parent 8f2c13a35f
commit b5f22e2c4a
4 changed files with 149 additions and 31 deletions
+19 -8
View File
@@ -22,6 +22,16 @@ import { FavoritesService } from './favorites.service';
*
* All routes are protected by the global JwtAuthGuard + TenantGuard.
*
* Mandantenquelle (260911-gwh): `extractContext` liest `req.tenantId ??
* req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts`, unter
* dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt ueber
* `widgetId` an `WidgetInstance`; eine andere Quelle (z. B. das Claim, wie
* bei `auth` fuer Selbstbedienung) wuerde Widget und Link unter einem
* `x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten
* auseinanderreissen. Das Favoriten-Frontend sendet die `x-tenant-id`-
* Kopfzeile heute nicht — die Entscheidung haengt an der Bauform, nicht am
* heutigen Aufrufer.
*
* Routes:
* - GET /favorites?widgetId= — list favorites for a widget instance
* - POST /favorites — create a favorite (triggers server-side icon discovery)
@@ -52,9 +62,9 @@ export class FavoritesController {
@Query('widgetId', ParseUUIDPipe) widgetId: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
const { userId, tenantId } = this.extractContext(req);
return this.favoritesService.list(userId, widgetId);
return this.favoritesService.list(tenantId, userId, widgetId);
}
@Post()
@@ -64,7 +74,7 @@ export class FavoritesController {
) {
const { userId, tenantId } = this.extractContext(req);
return this.favoritesService.create(userId, tenantId, dto);
return this.favoritesService.create(tenantId, userId, dto);
}
/**
@@ -82,8 +92,9 @@ export class FavoritesController {
@Req() req: Request,
@Res() res: Response,
) {
const { userId } = this.extractContext(req);
const { userId, tenantId } = this.extractContext(req);
const { contentType, body } = await this.favoritesService.getIconBytes(
tenantId,
id,
userId,
);
@@ -99,9 +110,9 @@ export class FavoritesController {
@Body() dto: UpdateFavoriteDto,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
const { userId, tenantId } = this.extractContext(req);
return this.favoritesService.update(id, userId, dto);
return this.favoritesService.update(tenantId, id, userId, dto);
}
@Delete(':id')
@@ -109,8 +120,8 @@ export class FavoritesController {
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
const { userId, tenantId } = this.extractContext(req);
return this.favoritesService.remove(id, userId);
return this.favoritesService.remove(tenantId, id, userId);
}
}
+60 -11
View File
@@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
@@ -13,10 +14,35 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
/**
* Service for managing per-user, per-widget favorite links.
*
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
*
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh,
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach).
*
* Access control (T-08-06 / Pitfall 3):
* - Every query is scoped by userId (prevents cross-user access).
* - list() additionally scopes by widgetId so each widget instance has its own set.
* - update() and remove() verify userId ownership before mutating.
*
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
*/
@Injectable()
export class FavoritesService {
@@ -29,9 +55,11 @@ export class FavoritesService {
* Returns all favorites for a user's widget instance, ordered by position asc.
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
*/
async list(userId: string, widgetId: string) {
async list(tenantId: string, userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required');
return this.prisma.favoriteLink.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.favoriteLink.findMany({
where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
@@ -39,9 +67,26 @@ export class FavoritesService {
/**
* Creates a new favorite link.
* Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
*/
async create(userId: string, tenantId: string, dto: CreateFavoriteDto) {
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id: dto.widgetId },
select: { userId: true },
});
if (!widget || widget.userId !== userId) {
throw new NotFoundException('Widget not found');
}
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
// as "https://ctl.de" — otherwise the link and icon discovery both break.
const url = normalizeUrl(dto.url);
@@ -52,7 +97,7 @@ export class FavoritesService {
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
}
return this.prisma.favoriteLink.create({
return tenantPrisma.favoriteLink.create({
data: {
userId,
tenantId,
@@ -70,8 +115,9 @@ export class FavoritesService {
* Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon).
*/
async update(id: string, userId: string, dto: UpdateFavoriteDto) {
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
@@ -100,7 +146,7 @@ export class FavoritesService {
}
}
return this.prisma.favoriteLink.update({
return tenantPrisma.favoriteLink.update({
where: { id },
data,
});
@@ -110,14 +156,15 @@ export class FavoritesService {
* Deletes a favorite link.
* Verifies userId ownership before deleting (T-08-06).
*/
async remove(id: string, userId: string) {
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
}
await this.prisma.favoriteLink.delete({ where: { id } });
await tenantPrisma.favoriteLink.delete({ where: { id } });
}
/**
@@ -132,10 +179,12 @@ export class FavoritesService {
* SSRF-blocked) -- never returns a placeholder image.
*/
async getIconBytes(
tenantId: string,
id: string,
userId: string,
): Promise<{ contentType: string; body: Buffer }> {
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) {
throw new NotFoundException('FavoriteLink not found');