feat(quick-260909-mir): dkv-Testlage herstellen, Konfigurationspfade binden, Planer-Pfad benennen

- apps/api/src/dkv/dkv.service.spec.ts (neu): Zwei-Klienten-Nachweis nach
  dem Muster aus groups.service.spec.ts/tender-triage.service.spec.ts —
  dieser Bereich hatte vorher KEINE Testdatei (Befund J). 7 Testfaelle
  decken getConfigForApi, saveConfig (Zugangsdaten-Erhaltung), testConnection,
  die Verarbeitungsstrecke und den bewusst ungebundenen Planer-Startpfad ab
- dkv.service.ts: loadConfig(tenantId?) in zwei Methoden geteilt —
  loadConfig(tenantId) [Pflicht-Mandant, gebunden] und die neue, eigene
  Methode loadAnyActiveConfigForScheduler() [bewusst UNGEBUNDEN, eigener
  Kopfkommentar mit beiden Zustaenden]. getConfigForApi/saveConfig/
  testConnection/_runPipeline binden je EINEN Klienten pro Methode
  vollstaendig ueber forTenant()
- dkv-scheduler.service.ts: Kopfkommentar fortgeschrieben (beide Zustaende,
  Praezedenzfall, Unsymmetrie), Aufruf auf loadAnyActiveConfigForScheduler()
  umgestellt — an der Ablauflogik des Planers nichts geaendert
- .planning/WINDOWS.md: Eintrag #21 (deviation) fuer die benannte Altlast
  des Planer-Startpfads angelegt
- docs/mandantentrennung-zugriffsklassifikation.md: dkvModuleConfig-Zeile
  auf den jetzt gemessenen Stand "gemischt" nachgezogen (Rule 3 — noetig,
  damit rls-access-inventory.spec.ts nach der Aufteilung von loadConfig()
  gruen bleibt; die uebrigen zwei dkv-Zeilen und die Uebersichtstabelle
  bleiben Aufgabe 3 vorbehalten)
- Falsifizierungsnachweis erbracht: getConfigForApi's erster gebundener
  Client probeweise durch this.prisma ersetzt, genau Test 1 wurde rot
  (6 andere blieben gruen), Rueckbau zurueckgenommen, Dateien identisch
  zum Ausgangsstand bestaetigt

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 16:46:09 +02:00
parent 761e5e2c36
commit 222f453747
5 changed files with 392 additions and 27 deletions
+86 -17
View File
@@ -9,6 +9,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DkvExportService } from './dkv-export.service';
import { DkvMailService } from './dkv-mail.service';
import { DkvParserService } from './dkv-parser.service';
@@ -55,10 +56,13 @@ const CONFIG_SAFE_SELECT = {
* - T-07-09: Export filename validated against safe pattern before reading (traversal guard)
* - Single-flight guard: prevents concurrent inbox processing (Pitfall 7)
*
* Multi-tenant note (v1): The scheduler loads config via findFirst().
* Each processInbox(tenantId) call is per-tenant. The Controller scopes all
* operations to req.tenantId. Full per-tenant scheduling (one cron per active
* tenant) is deferred to a future plan — v1 covers single-tenant deployments.
* Multi-tenant note (v1): The scheduler loads its startup config via
* loadAnyActiveConfigForScheduler(), which stays bewusst UNGEBUNDEN
* (WINDOWS #21, see that method's own doc comment). Each processInbox(tenantId)
* call is per-tenant and fully forTenant()-bound (260909-mir). The Controller
* scopes all operations to req.tenantId. Full per-tenant scheduling (one cron
* per active tenant) is deferred to a future plan — v1 covers single-tenant
* deployments.
*/
@Injectable()
export class DkvService {
@@ -91,30 +95,81 @@ export class DkvService {
/**
* Load DKV module config for a tenant (safe — no encrypted creds).
* When tenantId is omitted, returns the first row (used by scheduler on init).
*
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-mir): der Mandant kommt
* hier als PFLICHT-Parameter herein, ist also vor dem Zugriff bereits
* bekannt. Diese Methode war frueher `loadConfig(tenantId?)` mit einem
* optionalen Parameter, hinter dem der eine Zweig gebunden werden MUSSTE
* und der andere gebunden werden DURFTE NICHT — genau die Form, die
* dieser Umbau aufloest. Der uebergreifende Zweig ist jetzt eine eigene,
* benannte Methode: `loadAnyActiveConfigForScheduler()` unten.
*/
async loadConfig(tenantId?: string) {
if (!tenantId) {
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
}
return this.prisma.dkvModuleConfig.findUnique({
async loadConfig(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.dkvModuleConfig.findUnique({
where: { tenantId },
select: CONFIG_SAFE_SELECT,
});
}
/**
* Pull the DKV module config for a single, ARBITRARY tenant that has one
* configured — used EXCLUSIVELY by DkvSchedulerService.onModuleInit() to
* seed the one (v1, single-tenant) cron job at boot time.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D
* — siehe .planning/WINDOWS.md und den Abschnitt "Bereich dkv" in
* docs/mandantentrennung-etappe2-fehlerrichtung.md fuer die vollstaendige
* Begruendung, hier nur die Kurzfassung):
*
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
* Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient
* die uebrigen NIE. Ist ausgerechnet die gezogene Zeile inaktiv,
* registriert der Planer gar nichts, obwohl ein zweiter Mandant aktiv
* waere.
* - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe
* Abfrage zusaetzlich: sie liefert dann `null` statt einer beliebigen
* Zeile, der Planer protokolliert das als Normalfall und richtet fuer
* JEDEN Mandanten nichts ein — ohne Fehler, ohne Alarm.
* - Binden wuerde diesen Pfad garantiert leer laufen lassen (es gibt beim
* Boot strukturell keinen Mandantenkontext). Umbau auf
* einmal-abfragen-viele-bedienen ist die in 07-04 zurueckgestellte
* Mehrmandanten-Planung — eine Funktionsaenderung, kein Bindungsumbau,
* und deshalb hier NICHT vorgenommen.
* - Praezedenzfall: `LdapConfigService.getAllActiveConfigs()`
* (260909-ipc, Befund B) — mit der einen Unsymmetrie, die dieser
* Praezedenzfall NICHT deckt: `getAllActiveConfigs` ist heute korrekt
* und verstummt erst spaeter, dieser Pfad ist HEUTE bereits falsch UND
* verstummt zusaetzlich spaeter.
*
* Das Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4
* (`apps/api/scripts/rls-preflight.mjs`), NICHT in diesen Durchlauf.
*/
async loadAnyActiveConfigForScheduler() {
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
}
/**
* Load config for API response: safe fields + decrypted username + hasPassword flag.
* T-07-12: password is NEVER returned — only hasPassword boolean.
*
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer BEIDE
* Lesezugriffe dieser Methode (nicht `this.loadConfig(tenantId)` plus ein
* zweiter Aufruf — das waere ein Klient je Modellzugriff statt je
* Methode).
*/
async getConfigForApi(tenantId: string) {
const safe = await this.loadConfig(tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const safe = await tenantPrisma.dkvModuleConfig.findUnique({
where: { tenantId },
select: CONFIG_SAFE_SELECT,
});
if (!safe) return null;
let username: string | null = null;
let hasPassword = false;
try {
const raw = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
const raw = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
if (raw?.encryptedInboxCreds) {
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; password?: string };
username = creds.username ?? null;
@@ -137,8 +192,17 @@ export class DkvService {
*
* T-07-12: Returns safe select (no encryptedInboxCreds).
* T-05-13: Never logs decrypted credentials.
*
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer den
* erhaltenden Lesezugriff UND den Schreibzugriff dieser Methode — beide
* sehen dadurch denselben Mandanten, ein leerer Lesezugriff kann nicht
* mit einem erfolgreichen Schreibzugriff unter einem anderen Kontext
* kombiniert werden (T-MIR-07, die zerstoerende Stelle aus Befund K).
* Kein `where`-Filter entfaellt — die Mandantenbedingung bleibt neben der
* Bindung als zweite Schicht bestehen.
*/
async saveConfig(tenantId: string, dto: DkvConfigDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
let encryptedInboxCreds: string | undefined;
const credChanged = (dto.password && dto.password.length > 0) ||
@@ -151,7 +215,7 @@ export class DkvService {
// Preserve the field that was left empty from the existing stored value
if (!dto.password || !dto.username) {
try {
const existing = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
if (existing?.encryptedInboxCreds) {
// T-05-13: decrypt only to preserve — never log the result
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
@@ -184,7 +248,7 @@ export class DkvService {
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
};
return this.prisma.dkvModuleConfig.upsert({
return tenantPrisma.dkvModuleConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
@@ -197,14 +261,17 @@ export class DkvService {
* When dto.password is empty, falls back to the stored encrypted password.
*
* T-05-13: Decrypted password used only within this method scope — never logged.
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer den
* Rueckgriff auf die gespeicherten Zugangsdaten.
*/
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
let password: string | undefined = dto.password;
// If no password in DTO, fall back to the stored one
if (!password) {
try {
const existing = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
const existing = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
password?: string;
@@ -281,8 +348,10 @@ export class DkvService {
}
private async _runPipeline(tenantId: string): Promise<void> {
// Load raw config (need encryptedInboxCreds for decryption)
const config = await this.prisma.dkvModuleConfig.findUnique({ where: { tenantId } });
// Load raw config (need encryptedInboxCreds for decryption).
// Mandantengebunden (260909-mir).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const config = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
if (!config) {
this.logger.warn(`DKV processInbox: no config for tenant ${tenantId}`);
return;