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
+16 -3
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 3
open_count: 4
waived_count: 1
fixed_count: 16
total_count: 20
last_updated: 2026-09-09T09:10:51.419Z
total_count: 21
last_updated: 2026-09-09T14:41:07.256Z
---
# Broken Windows Ledger
@@ -35,6 +35,7 @@ last_updated: 2026-09-09T09:10:51.419Z
| 18 | 2 | unmet-truth | docker-compose.yml | | Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM "Group"' zwei Zeilen, waehrend die Policy USING ("tenantId" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend. NACHTRAG (260909-eor, Aufgabe 3): dieser Plan hat den fuer die Umstellung noetigen Anwendungscode klassifiziert (docs/mandantentrennung-zugriffsklassifikation.md, 227 Fundstellen / 59 Datei-Modell-Paare) und zusaetzlich einen weiteren, beim Anlegen dieses Eintrags noch nicht bekannten Defekt gefunden und behoben: forTenant() setzte den Mandantenkontext auf einer anderen Datenbankverbindung als die eigentliche Abfrage lief (siehe #20). #20 bleibt trotz nachgewiesener Reparatur bewusst OPEN, an dieselbe Bedingung gebunden wie dieser Eintrag — die Wirkung unter der echten Rolle ist erst nach dem Scharfschalten beobachtbar. | open | | 2026-09-09T07:42:13.878Z | |
| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). | open | | 2026-09-09T08:08:19.293Z | |
| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | |
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | |
````json
[
@@ -277,6 +278,18 @@ last_updated: 2026-09-09T09:10:51.419Z
"reason": "",
"recorded_at": "2026-09-09T08:44:18.496Z",
"resolved_at": null
},
{
"id": 21,
"kind": "deviation",
"phase": "2",
"file": "apps/api/src/dkv/dkv-scheduler.service.ts",
"line": null,
"description": "DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T14:41:07.256Z",
"resolved_at": null
}
]
````
+31 -6
View File
@@ -21,10 +21,33 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
* module config. (Research Pattern 7: Dynamic Cron Job; Pitfall 4: ScheduleModule
* must be registered in AppModule — done in Plan 01.)
*
* Multi-tenant note (v1): On init, the scheduler loads the first active
* DkvModuleConfig row via findFirst(). For single-tenant deployments this
* is always the correct config. Multi-tenant scheduling (one cron job per
* active tenant) is deferred to a future plan.
* Multi-tenant note (v1): On init, the scheduler loads config via
* `DkvService.loadAnyActiveConfigForScheduler()`, which pulls the first
* active DkvModuleConfig row via findFirst() — same underlying query as
* before, now split into its own named method (260909-mir).
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D —
* volle Begruendung im Kopfkommentar von
* `DkvService.loadAnyActiveConfigForScheduler()` und im Abschnitt
* "Bereich dkv" von docs/mandantentrennung-etappe2-fehlerrichtung.md).
* Zwei Zustaende, beide gehoeren genannt:
*
* - HEUTE bereits falsch, nicht nur ungenau: bei mehreren Mandanten wird
* EIN beliebiger bedient, die uebrigen NIE — und 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`, und die Protokollzeile
* unten ("no active config found") ist auf einer frischen Installation
* der Normalfall — sie alarmiert deshalb niemanden, obwohl ein
* tatsaechlich eingerichteter Mandant nicht bedient wird.
*
* Fuer single-tenant deployments (heute der einzige produktive Fall) ist
* dieselbe Abfrage stets die korrekte Config. Multi-tenant scheduling
* (poll-once-fan-out-many, ein Cron-Auftrag je aktivem Mandanten) ist die
* in 07-04 zurueckgestellte Mehrmandanten-Planung und bleibt eine
* Funktionsaenderung fuer eine kuenftige Phase, kein Bindungsumbau dieses
* Plans.
*
* The DkvController calls `setInterval()` after saving config so the cron job
* reflects any admin change immediately — without a service restart.
@@ -56,8 +79,10 @@ export class DkvSchedulerService implements OnModuleInit {
*/
async onModuleInit(): Promise<void> {
try {
// loadConfig without tenantId → findFirst (v1 single-tenant)
const config = await this.dkvService.loadConfig();
// Bewusst uebergreifender Planer-Startpfad (WINDOWS #21) — siehe
// Kopfkommentar dieser Klasse und von
// DkvService.loadAnyActiveConfigForScheduler().
const config = await this.dkvService.loadAnyActiveConfigForScheduler();
if (config?.isActive && config.tenantId) {
this.activeTenantId = config.tenantId;
this.setInterval(config.pollIntervalMin, config.tenantId);
+258
View File
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest';
import { DkvService } from './dkv.service';
/**
* DkvService.spec — RED-first (TDD) Nachweis fuer die Bindung an
* forTenant() (260909-mir, Aufgabe 2/3). Dieser Bereich hatte VOR diesem
* Durchlauf KEINE einzige Testdatei (Befund J) — dieser Fake ist deshalb die
* Voraussetzung dafuer, dass irgendeine Aussage dieses Plans nachpruefbar
* ist, nicht eine Zugabe.
*
* Zwei-Klienten-Nachweis (Muster aus groups.service.spec.ts /
* tender-triage.service.spec.ts): `__makeBoundClient(tenantId)` wrappt
* DIESELBEN In-Memory-Maps mit einer protokollierenden Schicht je Modell.
* Der ungebundene Fake protokolliert NICHT, der gebundene schon — eine
* vergessene Bindung wird dadurch sichtbar, ein reiner Identitaets-Mock
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
*
* Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory`
* ERWEITERT, nicht ersetzt.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (select[key]) out[key] = (row as any)[key];
}
return out;
}
function makeFakePrisma() {
const configs = new Map<string, any>(); // key: tenantId
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const dkvModuleConfig = {
findFirst: vi.fn(async ({ select }: { select?: Record<string, boolean> } = {}) => {
// Arbitrary-but-first row — die Form, die der Planer-Startpfad heute
// benutzt (findFirst() ganz ohne Bedingung). Map bewahrt
// Einfuegereihenfolge, das genuegt fuer "irgendeine" Zeile.
const first = configs.values().next().value;
return first ? _applySelect(first, select) : null;
}),
findUnique: vi.fn(
async ({
where,
select,
}: {
where: { tenantId: string };
select?: Record<string, boolean>;
}) => {
const row = configs.get(where.tenantId);
return row ? _applySelect(row, select) : null;
},
),
upsert: vi.fn(
async ({
where,
create,
update,
select,
}: {
where: { tenantId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
select?: Record<string, boolean>;
}) => {
const existing = configs.get(where.tenantId);
const record = existing
? { ...existing, ...update }
: { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.tenantId, record);
return _applySelect(record, select);
},
),
};
const fake: any = {
dkvModuleConfig,
__boundCallLog: boundCallLog,
__seedConfig(tenantId: string, row: Record<string, unknown>) {
configs.set(tenantId, { tenantId, ...row });
},
__makeBoundClient(tenantId: string) {
const wrapped: any = {};
for (const method of ['findFirst', 'findUnique', 'upsert']) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: 'dkvModuleConfig', method });
return (dkvModuleConfig as any)[method](...args);
};
}
return { dkvModuleConfig: wrapped };
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
/** Schlichte Attrappen fuer die uebrigen Konstruktor-Abhaengigkeiten von DkvService. */
function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
return {
encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`),
decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')),
};
}
function makeDkvService(prisma: any, cryptoOverrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
const crypto = makeFakeCrypto(cryptoOverrides);
const parser = {} as any;
const exporter = {} as any;
const mailer = { sendExportEmail: vi.fn() } as any;
const imapProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
const exchangeProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
const service = new DkvService(
prisma,
crypto as any,
parser,
exporter,
mailer,
imapProvider,
exchangeProvider,
);
return { service, crypto };
}
describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
it('Test 1: getConfigForApi(tenantId) — beide Lesezugriffe auf dkvModuleConfig stehen im Bindungsprotokoll unter genau diesem Mandanten', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: null });
const { service } = makeDkvService(prisma);
await service.getConfigForApi('t1');
const configCalls = prisma.__boundCallLog.filter(
(c: any) => c.tenantId === 't1' && c.model === 'dkvModuleConfig' && c.method === 'findUnique',
);
expect(
configCalls.length,
`erwarte mindestens zwei gebundene findUnique-Aufrufe (sicherer + roher Lesezugriff) fuer t1, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBeGreaterThanOrEqual(2);
});
it('Test 2: getConfigForApi eines Mandanten liefert NICHT die Konfiguration eines zweiten Mandanten, wenn beide im Speicher liegen', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, host: 'mail-a.example.invalid', encryptedInboxCreds: null });
prisma.__seedConfig('t2', { id: 'cfg-2', protocol: 'imap', isActive: true, host: 'mail-b.example.invalid', encryptedInboxCreds: null });
const { service } = makeDkvService(prisma);
const configForT1 = await service.getConfigForApi('t1');
expect(configForT1?.host).toBe('mail-a.example.invalid');
expect(configForT1?.tenantId).toBe('t1');
});
it('Test 3: saveConfig(tenantId, dto) mit gesetztem Benutzernamen und LEEREM Passwort — Lese- und Schreibzugriff sind gebunden, das gespeicherte Passwort bleibt unveraendert', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'alt-user', password: 'geheim-123' })),
});
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
await service.saveConfig('t1', {
protocol: 'imap',
encryption: 'ssl-tls',
username: 'neu-user',
password: '',
} as any);
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'upsert');
const raw = await prisma.dkvModuleConfig.findUnique({ where: { tenantId: 't1' } });
const rawCreds = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
expect(rawCreds.password).toBe('geheim-123');
expect(rawCreds.username).toBe('neu-user');
});
it('Test 4: testConnection(tenantId, dto) mit leerem Passwort — der Rueckgriff auf die gespeicherten Zugangsdaten steht gebunden im Protokoll', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'user-a', password: 'geheim-123' })),
});
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
await service.testConnection('t1', {
protocol: 'imap',
encryption: 'ssl-tls',
password: '',
} as any);
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
});
it('Test 5: die Verarbeitungsstrecke liest ihre Konfiguration gebunden; bei fehlender Konfiguration bricht sie wie bisher still ab', async () => {
const prisma = makeFakePrisma();
// Kein __seedConfig fuer t1 — die Konfiguration fehlt bewusst.
const { service } = makeDkvService(prisma);
const result = await service.checkNow('t1');
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
// Verhalten bleibt unveraendert: kein Fehler, checkNow meldet 'ok' —
// die Rechnungsverarbeitung stellt fuer diesen Mandanten still die
// Arbeit ein (Befund K, Stelle 2), das wird hier nur festgeschrieben.
expect(result.status).toBe('ok');
});
it('Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — Fehlen der Bindung ist hier die bestandene Erwartung, NICHT spaeter "reparieren"', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: 'enc(egal)' });
const { service } = makeDkvService(prisma);
await service.loadAnyActiveConfigForScheduler();
expect(
prisma.__boundCallLog.length,
`der Planer-Startpfad darf KEINEN gebundenen Aufruf erzeugen, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(0);
});
it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit (Befund D — Entlastung wird festgeschrieben, nicht geglaubt)', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)',
});
const { service } = makeDkvService(prisma);
const result = await service.loadAnyActiveConfigForScheduler();
expect(result).not.toBeNull();
expect((result as any).encryptedInboxCreds).toBeUndefined();
});
});
+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;
@@ -211,7 +211,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | ungebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | ungebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |