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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user