diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts index a31a060..a8b2cb5 100644 --- a/apps/api/src/favorites/favorites.controller.ts +++ b/apps/api/src/favorites/favorites.controller.ts @@ -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); } } diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 3b60e99..4d27fed 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -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'); diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index f25a3d3..37cc5bb 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -12,13 +12,19 @@ import { MailService } from './mail.service'; * with an env-var fallback (priority 2) when no DB row exists. * * Transport priority: - * 1. DB SmtpConfig (first row — single-tenant default; set via /settings/smtp) + * 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst + * UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh; + * siehe deren Kopfkommentar in settings.service.ts fuer beide + * Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer + * alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie + * `null` und diese Rueckfallkette greift — WINDOWS #TBD-GWH) * 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS * 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD * 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default) * - * The factory is async because getStartupSmtpConfig() reads from the DB. - * No circular import risk: MailModule → SettingsModule → CalendarModule (no reverse edges). + * The factory is async because loadAnySmtpConfigForStartupTransport() reads + * from the DB. No circular import risk: MailModule → SettingsModule → + * CalendarModule (no reverse edges). */ @Module({ imports: [ @@ -26,8 +32,9 @@ import { MailService } from './mail.service'; MailerModule.forRootAsync({ imports: [SettingsModule], useFactory: async (settingsService: SettingsService, configService: ConfigService) => { - // Priority 1: DB SmtpConfig (getStartupSmtpConfig uses findFirst — single-tenant default) - const db = await settingsService.getStartupSmtpConfig(); + // Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport() + // stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot). + const db = await settingsService.loadAnySmtpConfigForStartupTransport(); if (db) { // T-07-11: DB password used only to build transport; never logged diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 1b3a9d4..f61dbe1 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { SmtpConfigDto } from './dto/smtp-config.dto'; import * as nodemailer from 'nodemailer'; @@ -34,9 +35,13 @@ export class SettingsService { /** * Get the SMTP config for a tenant — safe (no password field). * Returns null when no config row exists for the tenant. + * + * Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma` fuer diese + * Methode, wie die restlichen Anfragewege dieser Datei. */ async getSmtpConfig(tenantId: string) { - return this.prisma.smtpConfig.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.smtpConfig.findUnique({ where: { tenantId }, select: { ...SMTP_SAFE_SELECT, @@ -52,8 +57,11 @@ export class SettingsService { * When `dto.password` is empty or absent, the existing encrypted password is preserved. * * T-07-08: Encryption via CryptoService. Never logs the plaintext password. + * Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`. */ async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + // Determine the encrypted password to store let encryptedPassword: string | undefined; @@ -71,7 +79,7 @@ export class SettingsService { ...(encryptedPassword !== undefined ? { encryptedPassword } : {}), }; - const result = await this.prisma.smtpConfig.upsert({ + const result = await tenantPrisma.smtpConfig.upsert({ where: { tenantId }, create: { tenantId, ...data }, update: data, @@ -83,8 +91,15 @@ export class SettingsService { /** * Internal: Get the decrypted SMTP config for a tenant. - * Used by DkvMailService to build a nodemailer transport at send time. + * Used by DkvMailService/TenderMailService to build a nodemailer transport + * at send time — the ONLY send path (Befund K, 260909-laa/260909-mir). * NEVER log the decrypted password (T-07-10 / T-05-13). + * + * Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient + * `tenantPrisma`. Vorher lief diese Methode ungebunden — nach dem + * Scharfschalten waere fuer NIEMANDEN mehr eine Mail rausgegangen + * (tender: warn+skip, dkv: throw). Die Reihenfolgebedingung aus (t4) + * Befund K und (d4) ist mit dieser Bindung erfuellt. */ async getDecryptedSmtpConfig(tenantId: string): Promise<{ host: string; @@ -94,7 +109,8 @@ export class SettingsService { fromAddress: string; decryptedPassword: string | null; } | null> { - const config = await this.prisma.smtpConfig.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const config = await tenantPrisma.smtpConfig.findUnique({ where: { tenantId }, }); @@ -122,6 +138,8 @@ export class SettingsService { * Returns true on success, false on failure. * * T-07-16: Returns only a boolean — no credentials or transport details in the response. + * Kein eigener Datenbankzugriff — greift ueber `getDecryptedSmtpConfig` + * (bereits gebunden) auf gespeicherte Zugangsdaten zurueck. */ async testSmtpConfig( tenantId: string, @@ -175,13 +193,46 @@ export class SettingsService { /** * Tenant-agnostic startup accessor for the MailModule factory. - * Returns the first SmtpConfig row in the DB (single-tenant deployments) with - * the password decrypted. Returns null when no row exists (env-var fallback path). + * + * BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der + * Hintergrunddienst-Falle — gleicher Bauart wie + * `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe + * dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren + * genannt: + * + * - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede + * Bedingung zieht bei mehreren Mandanten den SMTP-Server und die + * Absenderadresse EINES beliebigen Mandanten fuer ALLE + * Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten + * (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit). + * - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage + * `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt + * `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport + * statt einer Meldung; `MailService` faengt jeden Transportfehler + * (T-02-12) und der Controller antwortet `200`. Das Verstummen ist + * damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch + * das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap` + * (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu + * `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT + * Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE + * Protokollzeile. + * + * Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start + * gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport + * je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die + * `DkvMailService`/`TenderMailService` bereits haben, `MailService` + * muesste den Mandanten nur von `requestPasswordReset` entgegennehmen — + * ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau, + * NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt + * Anschluss an #21 (andere Datei, andere Reparatur, andere + * Verdeckungsform) — siehe WINDOWS #TBD-GWH und + * `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt + * "## Bereich settings", (s4)(a). * * D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2). * T-07-11: Decrypted password is used only to build the transport — never logged. */ - async getStartupSmtpConfig(): Promise<{ + async loadAnySmtpConfigForStartupTransport(): Promise<{ host: string; port: number; secure: boolean;