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
+20 -11
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* Partial triage update accepted by setTriage(). Both fields are optional
@@ -15,10 +16,14 @@ export interface SetTriageInput {
* Service for managing per-user Tender triage state (gelesen/ungelesen,
* Favorit — UI-03/04, D-09/D-10/D-11).
*
* Access control (T-11-10 / V4 — IDOR): every query is scoped by userId,
* exactly the `FavoritesService` convention (T-08-06) — NOT `forTenant()`/
* RLS (Pitfall 4). userId must always be derived from the caller's auth
* context (controller), never accepted as a body/query parameter here.
* Access control (T-11-10 / V4 — IDOR): every query is ADDITIONALLY scoped
* by userId, exactly the `FavoritesService` convention (T-08-06). This
* stays deliberate belt-and-suspenders after binding to `forTenant()`
* (260909-laa): the delivered `tenant_isolation_policy` on TenderTriage
* has no user dimension — a foreign user of the SAME tenant is not
* excluded by the database alone (Befund E, measured for the sibling
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area
* share the identical `"tenantId" = current_tenant_id()` text).
*
* Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete:
* Cascade)` removes a tender's triage rows automatically when Phase 10's
@@ -53,7 +58,8 @@ export class TenderTriageService {
update.favoritedAt = dto.isFavorite ? now : null;
}
return this.prisma.tenderTriage.upsert({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.tenderTriage.upsert({
where: { userId_tenderId: { userId, tenderId } },
update,
create: {
@@ -74,11 +80,13 @@ export class TenderTriageService {
* Scoped by userId (V4/IDOR) — a foreign userId never sees another
* user's rows, even for the same tenderId. Returns [] without querying
* prisma when tenderIds is empty (avoids an unbounded `in: []` no-op
* round-trip).
* round-trip) — deliberately BEFORE forTenant() is created, so an empty
* batch never even opens a bound client.
*/
async listForUser(userId: string, tenderIds: string[]) {
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
if (!tenderIds.length) return [];
return this.prisma.tenderTriage.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.tenderTriage.findMany({
where: { userId, tenderId: { in: tenderIds } },
});
}
@@ -88,11 +96,12 @@ export class TenderTriageService {
* Merklisten-Filter). Scoped by userId — feeds the favOnly branch of
* tender-query.builder.ts's buildTenderWhere.
*/
async favoriteIds(userId: string): Promise<string[]> {
const rows = await this.prisma.tenderTriage.findMany({
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true },
select: { tenderId: true },
});
return rows.map((r) => r.tenderId);
return rows.map((r: { tenderId: string }) => r.tenderId);
}
}