feat(laa-02): binde die fuenf Nutzer-CRUD-Dienste des Bereichs tenders an forTenant()

tender-saved-search.service.ts, tender-triage.service.ts,
tender-notification-pref.service.ts und tender-email-config.service.ts
laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list,
update, remove, listForUser, favoriteIds, getForUser, getConfigForApi,
testConnection bekommen tenantId), die anwendungsseitige userId-Filterung
bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension).

tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage,
beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform
und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 —
eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar,
ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen).

tender-notification-pref.service.ts und tender-email-config.service.ts
uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel
(Befund F) in eine verstaendliche deutsche Meldung statt eines rohen
Fehlers.

tenders.controller.ts reicht tenantId an den acht betroffenen
Aufrufstellen durch extractTriageContext() durch (kein neuer
Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre
Dienstmethoden nicht binden.

Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis
(__makeBoundClient ueber demselben Speicher) und Bindungstests je
umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den
Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT
aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in
tender-saved-search.service.ts machte genau den erwarteten Bindungstest
rot, danach zurueckgenommen.

docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare
auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von
muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall
wie ldapConfig in 260909-ipc).

761 Tests gruen (743 + 18 neue Bindungsnachweise), Typpruefung sauber,
Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/
Umgebungsdatei-Diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 15:53:53 +02:00
parent 349814747e
commit 3336a6e419
13 changed files with 790 additions and 229 deletions
@@ -1,10 +1,11 @@
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ConflictException, Injectable, Logger, Optional } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider';
import { ImapProvider } from '../inbox/imap.provider';
import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
/**
@@ -43,6 +44,16 @@ const EMAIL_CONFIG_SAFE_SELECT = {
* `tenantId` on TenderMatch) and is written on both create and update,
* since a user's tenant can in principle change.
*
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-laa): every read/write
* below runs through `forTenant()`. Because `userId` alone (not
* `(userId, tenantId)`) is the row's unique key, a user whose stored
* `tenantId` has gone stale sees their OWN row become invisible under the
* now-bound context — `getConfigForApi`/`testConnection` then correctly
* report "no mailbox configured" (safe direction, no cross-tenant leak),
* and a bound `saveConfig` upsert on that stale row hits the platform-wide
* uniqueness constraint on `userId` and surfaces as a translated
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
*
* Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password.
@@ -84,8 +95,9 @@ export class TenderEmailConfigService {
* hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly
* by userId (T-17-01) — a user only ever reads their own mailbox.
*/
async getConfigForApi(userId: string) {
const safe = await this.prisma.tenderEmailConfig.findUnique({
async getConfigForApi(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT,
});
@@ -94,7 +106,7 @@ export class TenderEmailConfigService {
let username: string | null = null;
let hasPassword = false;
try {
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
const raw = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
username?: string;
@@ -125,9 +137,16 @@ export class TenderEmailConfigService {
*
* T-07-12: Returns safe select (no encryptedInboxCreds).
* T-05-13: Never logs decrypted credentials.
*
* T-LAA-07 (Befund F): the upsert target (`userId`) has no tenant
* dimension. A P2002 here means the caller's stale-tenant row already
* exists and is now invisible under the bound context — translated into
* a German ConflictException instead of a raw error (same pattern as
* `tender-saved-search.service.ts`).
*/
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
let encryptedInboxCreds: string | undefined;
const credChanged =
@@ -140,7 +159,7 @@ export class TenderEmailConfigService {
if (!dto.password || !dto.username) {
try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string;
@@ -172,12 +191,21 @@ export class TenderEmailConfigService {
// tenantId is written on BOTH create and update — it is denormalized
// (Phase 17, D-01), so a user's resolved tenant must always overwrite
// whatever was stored previously, not just be set once on create.
return this.prisma.tenderEmailConfig.upsert({
where: { userId },
create: { userId, tenantId, ...data },
update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT,
});
try {
return await tenantPrisma.tenderEmailConfig.upsert({
where: { userId },
create: { userId, tenantId, ...data },
update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT,
});
} catch (error: any) {
if (error?.code === 'P2002') {
throw new ConflictException(
'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
}
throw error;
}
}
/**
@@ -198,6 +226,7 @@ export class TenderEmailConfigService {
*/
async testConnection(
userId: string,
tenantId: string,
dto: TenderEmailConfigDto,
): Promise<{ success: boolean; message?: string }> {
let username: string | undefined = dto.username;
@@ -205,7 +234,8 @@ export class TenderEmailConfigService {
if (!username || !password) {
try {
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { userId } });
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
username?: string;