feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig

- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 11:46:08 +02:00
parent 3d645674f0
commit 6e2a641d76
18 changed files with 828 additions and 336 deletions
+206
View File
@@ -5549,11 +5549,25 @@ async function runSystemContextChecks(adminUrl, scratchRoleUrl, results) {
report(results, 'system-context-migration-gefunden', false, 'Migration "_rls_system_context_read" nicht gefunden');
return;
}
// Mandantenregeln je Tabelle aus IHRER Quellmigration (Aufgabe 2, 260914-eym):
// DkvModuleConfig/TenderMatch aus _rls_remaining_tenant_tables, LdapConfig/
// LdapFieldMapping aus _rls_policies, TenderSavedSearch aus
// _rls_user_dimension_personal_tables.
const remainingTablesMigrationSql = readRemainingTenantTablesMigrationSql();
if (!remainingTablesMigrationSql) {
report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden');
return;
}
const rlsPoliciesMigrationSql = readRlsPoliciesMigrationSql();
if (!rlsPoliciesMigrationSql) {
report(results, 'system-context-rls-policies-migration-gefunden', false, 'Migration "_rls_policies" nicht gefunden');
return;
}
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
if (!userDimensionMigrationSql) {
report(results, 'system-context-user-dimension-migration-gefunden', false, 'Migration "_rls_user_dimension_personal_tables" nicht gefunden');
return;
}
// Vier Funktionsfaelle, je in einer eigenen Transaktion.
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
@@ -5623,6 +5637,198 @@ async function runSystemContextChecks(adminUrl, scratchRoleUrl, results) {
createAttemptData: { id: 'cfg-system-schreibversuch', tenantId: 'TENANT-A', isActive: true },
updateManyData: { folder: 'SYSTEM-SCHREIBVERSUCH' },
});
// Aufgabe 2 (260914-eym): die vier weiteren Tabellen ueber dieselbe Routine.
// LdapConfig — Mandantenregel aus 20260618112133_rls_policies, alle 15
// skalaren Spalten (text[]-Spalten mit DEFAULT '{}'). MUSS vor
// LdapFieldMapping laufen (deren Regel joint auf "LdapConfig").
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapconfig',
tableName: 'LdapConfig',
modelName: 'LdapConfig',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapConfig'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL UNIQUE,
"serverUrl" text NOT NULL,
"baseDn" text NOT NULL,
"bindDn" text,
"encryptedBindPassword" text,
"searchFilter" text NOT NULL DEFAULT '(objectClass=person)',
"syncIntervalMin" integer NOT NULL DEFAULT 0,
"isActive" boolean NOT NULL DEFAULT true,
"tlsRejectUnauthorized" boolean NOT NULL DEFAULT true,
"groupFilterDns" text[] NOT NULL DEFAULT '{}',
"userExcludeList" text[] NOT NULL DEFAULT '{}',
"lastSyncAt" timestamp(3),
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "LdapConfig" (id, "tenantId", "serverUrl", "baseDn", "isActive") VALUES
('cfg-a', 'TENANT-A', 'ldap://a.example.invalid', 'dc=a', true),
('cfg-b', 'TENANT-B', 'ldap://b.example.invalid', 'dc=b', true);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'cfg-system-schreibversuch',
tenantId: 'TENANT-A',
serverUrl: 'ldap://x.example.invalid',
baseDn: 'dc=x',
},
updateManyData: { searchFilter: '(cn=SYSTEM-SCHREIBVERSUCH)' },
});
// LdapFieldMapping — Regel aus derselben Datei (Join auf LdapConfig), 6
// Spalten, ohne DROP der Elternzeilen (cfg-a/cfg-b bleiben stehen); der
// Mandant einer Zeile ergibt sich ueber ldapConfigId.
const tenantOfMapping = (row) => (row.ldapConfigId === 'cfg-a' ? 'TENANT-A' : row.ldapConfigId === 'cfg-b' ? 'TENANT-B' : row.ldapConfigId);
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapfieldmapping',
tableName: 'LdapFieldMapping',
modelName: 'LdapFieldMapping',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapFieldMapping'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapFieldMapping" (
id text PRIMARY KEY,
"ldapConfigId" text NOT NULL REFERENCES "LdapConfig"(id) ON DELETE CASCADE,
"ldapField" text NOT NULL,
"tesseraField" text NOT NULL,
"isDefault" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("ldapConfigId", "ldapField")
);
`,
seedSql: `
INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES
('fm-a', 'cfg-a', 'mail', 'email'),
('fm-b', 'cfg-b', 'mail', 'email');
`,
tenantOfRow: tenantOfMapping,
createAttemptData: {
id: 'fm-system-schreibversuch',
ldapConfigId: 'cfg-a',
ldapField: 'sn',
tesseraField: 'lastName',
},
updateManyData: { tesseraField: 'SYSTEM-SCHREIBVERSUCH' },
});
// Relations-Kennung: die #27-Form unter Systemkontext — ldapConfig.findMany
// mit include: { fieldMappings } liefert beide Mandanten und je genau eine
// Zuordnung (der Pfad von LdapConfigService.getAllActiveConfigs()).
{
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const rows = await buildInlineSystemClient(prisma).ldapConfig.findMany({
where: { isActive: true },
include: { fieldMappings: true },
orderBy: { tenantId: 'asc' },
});
const shape = rows.map((r) => `${r.tenantId}:${r.fieldMappings.length}`);
report(
results,
'ldapconfig-systemkontext-include-fieldmappings-beider-mandanten',
rows.length === 2 && shape.join(',') === 'TENANT-A:1,TENANT-B:1',
`system.ldapConfig.findMany({ where: { isActive: true }, include: { fieldMappings: true } }) liefert ${rows.length} Zeile(n): ${JSON.stringify(shape)} (Mandant:Anzahl Zuordnungen)`,
);
} finally {
await prisma.$disconnect();
}
}
// TenderMatch — Regel aus 20260909140000_rls_remaining_tenant_tables, 8
// Spalten, je Mandant eine Zeile mit notifiedAt NULL (die Kandidatenform
// des Digest). Keine Fremdschluessel in der Wegwerf-Tabelle — gemessen
// wird die Regel, nicht die Referenz.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendermatch',
tableName: 'TenderMatch',
modelName: 'TenderMatch',
tenantPolicySql: extractPolicySql(remainingTablesMigrationSql, 'TenderMatch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderMatch" (
id text PRIMARY KEY,
"tenderId" text NOT NULL,
"savedSearchId" text NOT NULL,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"matchedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifiedAt" timestamp(3),
"notifiedChannel" text,
UNIQUE ("tenderId", "savedSearchId")
);
`,
seedSql: `
INSERT INTO "TenderMatch" (id, "tenderId", "savedSearchId", "userId", "tenantId", "notifiedAt") VALUES
('tm-a', 'tender-1', 'ss-a', 'user-a', 'TENANT-A', NULL),
('tm-b', 'tender-1', 'ss-b', 'user-b', 'TENANT-B', NULL);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'tm-system-schreibversuch',
tenderId: 'tender-2',
savedSearchId: 'ss-a',
userId: 'user-a',
tenantId: 'TENANT-A',
},
updateManyData: { notifiedChannel: 'SYSTEM-SCHREIBVERSUCH' },
});
// TenderSavedSearch — Regel aus 20260911120000_rls_user_dimension_personal_tables
// (IS-NULL-OR-Form), 8 Spalten.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendersavedsearch',
tableName: 'TenderSavedSearch',
modelName: 'TenderSavedSearch',
tenantPolicySql: extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
filters jsonb NOT NULL DEFAULT '{}',
"instantAlert" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", name)
);
`,
seedSql: `
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) VALUES
('ss-a', 'user-a', 'TENANT-A', 'Profil A', '{}'),
('ss-b', 'user-b', 'TENANT-B', 'Profil B', '{}');
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'ss-system-schreibversuch',
userId: 'user-a',
tenantId: 'TENANT-A',
name: 'Schreibversuch',
filters: {},
},
updateManyData: { name: 'SYSTEM-SCHREIBVERSUCH' },
});
}
/**
+3
View File
@@ -387,9 +387,12 @@ describe('AuthService.requestPasswordReset', () => {
expiresAt: expect.any(Date),
},
});
// Drittes Argument (260914-eym): die tenantId des Empfaengers (emailUser
// liegt unter 't1') — MailService baut daraus den Transport je Versand.
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com',
expect.any(String),
't1',
);
});
+4 -2
View File
@@ -242,8 +242,10 @@ export class AuthService {
},
});
// Send the reset email (fire-and-forget, errors logged by MailService)
await this.mailService.sendPasswordResetEmail(email, token);
// Send the reset email (fire-and-forget, errors logged by MailService).
// Der Mandant des Empfaengers entscheidet ueber den SMTP-Transport
// (260914-eym, WINDOWS #30) — er ist hier bereits bekannt.
await this.mailService.sendPasswordResetEmail(email, token, user.tenantId);
}
/**
+2 -2
View File
@@ -24,8 +24,8 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
* AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN):
*
* Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden, `forSystem()`,
* nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden ueber den
* Systemkontext-Helfer, nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem
* Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen
* `dkv-inbox-poll:<tenantId>`. Der Tick eines Auftrags ruft
* `processInbox(tenantId)` fuer GENAU diesen Mandanten — der Tick selbst
+59 -5
View File
@@ -8,9 +8,12 @@ import { LdapConfigService } from './ldap-config.service';
// Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
// Systemkontext (260914-eym): liefert den in `__systemClient` hinterlegten
// Klienten, sonst denselben Client (Bestandstests).
forSystem: vi.fn((p: any) => p.__systemClient ?? p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
/**
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
@@ -298,14 +301,65 @@ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
});
it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
await service.getAllActiveConfigs();
it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => {
const systemClient = {
ldapConfig: { findMany: vi.fn().mockResolvedValue([{ ...CONFIG_ROW }]) },
};
prisma.__systemClient = systemClient;
const result = await service.getAllActiveConfigs();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(forTenant).not.toHaveBeenCalled();
expect(systemClient.ldapConfig.findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
expect(prisma.ldapConfig.findMany).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('onApplicationBootstrap() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([]);
it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => {
const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } };
prisma.__systemClient = systemClient;
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).not.toHaveBeenCalled();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('onApplicationBootstrap() mit einer Altzeile (Klartext, t1): liest system, schreibt GEBUNDEN — forTenant genau einmal mit t1, update traegt das verschluesselte Kennwort (260914-eym)', async () => {
const systemClient = {
ldapConfig: {
findMany: vi.fn().mockResolvedValue([
{ id: 'alt', tenantId: 't1', encryptedBindPassword: 'klartext' },
]),
},
};
prisma.__systemClient = systemClient;
const boundClient = {
ldapConfig: { update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })) },
};
vi.mocked(forTenant).mockImplementation(() => boundClient as any);
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(boundClient.ldapConfig.update).toHaveBeenCalledTimes(1);
const call = boundClient.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'alt' });
expect(call.data.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
);
// Der rohe Client schreibt NICHT.
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
// Implementierung zuruecksetzen (vi.clearAllMocks loescht nur Aufrufe).
vi.mocked(forTenant).mockImplementation((p: unknown) => p as any);
});
});
+37 -23
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import {
CreateFieldMappingDto,
CreateLdapConfigDto,
@@ -53,19 +53,26 @@ export class LdapConfigService implements OnApplicationBootstrap {
* re-encrypted still authenticates, because the read path below tolerates a
* legacy plaintext value.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen,
* bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert
* strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser
* Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum
* Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3
* (Systemkontext), diese Umstellung entscheidet sie nicht.
* SYSTEMGEBUNDEN LESEN, JE ZEILE GEBUNDEN SCHREIBEN (Etappe 3c,
* 260914-eym; vorher bewusst ungebunden, 260909-ipc Befund B): dieser
* Durchlauf muss ALLE Konfigurationen ALLER Mandanten sehen, bevor je ein
* einzelner Mandantenkontext feststeht — beim Boot existiert strukturell
* noch keiner. Das Lesen laeuft deshalb ueber `forSystem()`
* (`system_read_policy ... FOR SELECT` auf "LdapConfig", Migration
* 20260914120000): das Verstummen nach dem Scharfschalten ist strukturell
* ausgeschlossen. Die Schreibzeile je Altzeile laeuft ueber
* `forTenant(this.prisma, config.tenantId)` — unter Systemkontext ist
* Schreiben abgewiesen (gemessen: `update` per id -> P2025, INSERT ->
* 42501), und der Mandant steht in der gelesenen Zeile. Eine LEERE Liste
* ist Nichtstun (kein Loeschen, kein Deaktivieren).
*/
async onApplicationBootstrap(): Promise<void> {
try {
const configs = await this.prisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
});
const systemPrisma = forSystem(this.prisma) as any;
const configs: { id: string; tenantId: string; encryptedBindPassword: string | null }[] =
await systemPrisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
});
const legacy = configs.filter(
(config) =>
@@ -75,7 +82,10 @@ export class LdapConfigService implements OnApplicationBootstrap {
if (legacy.length === 0) return;
for (const config of legacy) {
await this.prisma.ldapConfig.update({
// Schreiben je Altzeile GEBUNDEN an den Mandanten der Zeile — unter
// Systemkontext wuerde die Datenbank das Update abweisen (P2025).
const tenantPrisma = forTenant(this.prisma, config.tenantId) as any;
await tenantPrisma.ldapConfig.update({
where: { id: config.id },
data: {
encryptedBindPassword: this.crypto.encrypt(
@@ -295,21 +305,25 @@ export class LdapConfigService implements OnApplicationBootstrap {
* Get all active LDAP configs. Used by the scheduler to determine which
* tenants need auto-sync.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER
* Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist
* die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf.
* Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der
* LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne
* Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E,
* docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung
* (Systemkontext) gehoert nach Etappe 3.
* SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; vorher bewusst ungebunden,
* 260909-ipc Befund B): der Planer braucht die Liste ALLER aktiven
* Konfigurationen ALLER Mandanten, um daraus je Mandant einen gebundenen
* Sync-Lauf anzustossen — `forSystem()` liest sie ueber
* `system_read_policy ... FOR SELECT` (Migration 20260914120000).
* `LdapFieldMapping` wird ueber `include: { fieldMappings }` mitgelesen
* (WINDOWS-#27-Form) und traegt deshalb dieselbe Regel; `Tenant` traegt
* in keiner Migration eine Regel und braucht keine Oeffnung. Das
* Verstummen nach dem Scharfschalten (Befund E) ist damit strukturell
* ausgeschlossen; eine LEERE Liste startet keinen Sync-Lauf — der
* Loeschzweig in ldap.service.ts liegt INNERHALB eines gebundenen Laufs,
* den es dann nicht gibt.
*/
async getAllActiveConfigs() {
const configs = await this.prisma.ldapConfig.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const configs = await systemPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
return configs.map((config) => this.withDecryptedPassword(config));
return configs.map((config: any) => this.withDecryptedPassword(config));
}
}
+14 -84
View File
@@ -1,102 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { SettingsModule } from '../settings/settings.module';
import { SettingsService } from '../settings/settings.service';
import { MailService } from './mail.service';
/**
* MailModule — system email delivery (password reset, welcome emails).
*
* D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1)
* with an env-var fallback (priority 2) when no DB row exists.
* KEIN STARTPFAD MEHR (Etappe 3c, 260914-eym, WINDOWS #30 GESCHLOSSEN):
* die Mailer-Fabrik (`MailerModule.forRootAsync`) und ihr Lesezugriff
* `findFirst()` auf SmtpConfig beim Boot sind ersatzlos entfernt.
* `MailService` baut je Versand einen nodemailer-Transport nach dem
* Mandanten des Empfaengers (siehe dessen Kopfkommentar). Der sechste Fall
* der Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md)
* EXISTIERT damit NICHT MEHR — deshalb traegt SmtpConfig keine
* `system_read_policy` (Migration 20260914120000).
*
* Transport priority:
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
* siehe deren Kopfkommentar in settings.service.ts fuer beide
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
* `null` und diese Rueckfallkette greift — WINDOWS #30)
* Transport-Prioritaet JE VERSAND:
* 1. SmtpConfig des Empfaenger-Mandanten (gebunden, `getDecryptedSmtpConfig(tenantId)`)
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
*
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
* from the DB. No circular import risk: MailModule → SettingsModule →
* CalendarModule (no reverse edges).
* No circular import risk: MailModule -> SettingsModule -> CalendarModule
* (no reverse edges). `@nestjs-modules/mailer` bleibt als Paket installiert,
* wird aber von keinem Modul mehr benutzt.
*/
@Module({
imports: [
SettingsModule,
MailerModule.forRootAsync({
imports: [SettingsModule],
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
if (db) {
// T-07-11: DB password used only to build transport; never logged
return {
transport: {
host: db.host,
port: db.port,
secure: db.secure,
requireTLS: db.requireTLS,
auth: db.username
? { user: db.username, pass: db.password ?? '' }
: undefined,
},
defaults: {
from: db.fromAddress,
},
};
}
// Priority 2: Env vars (new names first, legacy TESSERA_SMTP_* as secondary fallback)
const host =
configService.get<string>('MAIL_HOST') ??
configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
configService.get<number>('MAIL_PORT') ??
configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
configService.get<string>('MAIL_USER') ??
configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
configService.get<string>('MAIL_PASS') ??
configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
transport: {
host,
port,
secure,
auth: { user, pass },
},
defaults: { from },
};
},
inject: [SettingsService, ConfigService],
}),
],
imports: [SettingsModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service';
/**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
* `mail` hatte VOR diesem Durchlauf KEINE Testdatei. Festgenagelt wird die
* Bauform "Transport je Versand nach Mandant des Empfaengers":
*
* 1. IDENTITAET FUER EINEN MANDANTEN MIT SmtpConfig: Transport aus GENAU
* dieser Config, `from` = deren fromAddress, `close()` gerufen.
* 2. Mandant OHNE SmtpConfig: die bisherige Umgebungs-Kette (MAIL_* vor
* TESSERA_SMTP_* vor localhost:1025), `from` aus TESSERA_SMTP_FROM bzw.
* Vorgabe.
* 3. ZWEI Mandanten nacheinander -> zwei verschiedene Transporte, keiner
* sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen).
* 4. `sendMail` wirft -> kein Throw nach aussen (T-02-12), Fehler
* protokolliert, `close()` trotzdem gerufen.
*
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`.
*/
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
const mockClose = vi.fn();
vi.mock('nodemailer', () => ({
createTransport: vi.fn(() => ({
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
close: (...args: unknown[]) => (mockClose as any)(...args),
})),
}));
interface FakeDecrypted {
host: string;
port: number;
encryption: string;
username: string | null;
fromAddress: string;
decryptedPassword: string | null;
}
function makeFakeSettings(configsByTenant: Record<string, FakeDecrypted>) {
return {
getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null),
};
}
function makeFakeConfig(values: Record<string, string | number | undefined>) {
return {
get: vi.fn((key: string, fallback?: unknown) => (values[key] !== undefined ? values[key] : fallback)),
};
}
const configA: FakeDecrypted = {
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
fromAddress: 'noreply@a.example.invalid',
decryptedPassword: 'geheim-a',
};
const configB: FakeDecrypted = {
host: 'smtp-b.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-b',
fromAddress: 'noreply@b.example.invalid',
decryptedPassword: 'geheim-b',
};
beforeEach(() => {
vi.clearAllMocks();
mockSendMail = vi.fn(async (_mail: unknown) => ({}));
});
describe('MailService — Transport je Versand nach Mandant des Empfaengers (260914-eym, WINDOWS #30)', () => {
it('Test 1: Mandant MIT SmtpConfig -> getDecryptedSmtpConfig genau einmal mit dieser tenantId, createTransport mit deren host/port/secure/requireTLS/auth, from = deren fromAddress, close() gerufen (Identitaet zu heute)', async () => {
const settings = makeFakeSettings({ t1: configA });
const config = makeFakeConfig({ MAIL_HOST: 'env-darf-nicht-greifen' });
const service = new MailService(settings as any, config as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1');
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledTimes(1);
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledWith('t1');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledTimes(1);
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledWith({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
auth: { user: 'user-a', pass: 'geheim-a' },
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('alice@a.example.invalid');
expect(sent.text).toContain('/reset-password/tok-1');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 2: Mandant OHNE SmtpConfig -> Umgebungs-Kette: MAIL_* vor TESSERA_SMTP_* vor localhost:1025, from aus TESSERA_SMTP_FROM bzw. Vorgabe', async () => {
// (a) MAIL_* gesetzt -> gewinnt vor TESSERA_SMTP_*
const svcA = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
MAIL_HOST: 'mail.example.invalid',
MAIL_PORT: 2525,
MAIL_USER: 'mail-user',
MAIL_PASS: 'mail-pass',
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_FROM: 'Tessera <from@example.invalid>',
}) as any,
);
await svcA.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'mail.example.invalid',
port: 2525,
secure: false,
auth: { user: 'mail-user', pass: 'mail-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <from@example.invalid>');
// (b) nur TESSERA_SMTP_* gesetzt -> zweite Stufe
const svcB = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_PORT: 587,
TESSERA_SMTP_USER: 'legacy-user',
TESSERA_SMTP_PASSWORD: 'legacy-pass',
TESSERA_SMTP_SECURE: 'true',
}) as any,
);
await svcB.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'legacy.example.invalid',
port: 587,
secure: true,
auth: { user: 'legacy-user', pass: 'legacy-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <tessera@tessera.local>');
// (c) nichts gesetzt -> localhost:1025
const svcC = new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any);
await svcC.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'localhost',
port: 1025,
secure: false,
auth: { user: '', pass: '' },
});
expect(mockClose).toHaveBeenCalledTimes(3);
});
it('Test 3: zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen)', async () => {
const settings = makeFakeSettings({ t1: configA, t2: configB });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
expect(transports).toHaveLength(2);
expect(transports[0].host).toBe('smtp-a.example.invalid');
expect(transports[0].auth).toEqual({ user: 'user-a', pass: 'geheim-a' });
expect(transports[1].host).toBe('smtp-b.example.invalid');
expect(transports[1].requireTLS).toBe(true);
expect(transports[1].auth).toEqual({ user: 'user-b', pass: 'geheim-b' });
expect(JSON.stringify(transports[0])).not.toContain('geheim-b');
expect(JSON.stringify(transports[1])).not.toContain('geheim-a');
const sentMails = mockSendMail.mock.calls.map((c) => c[0] as any);
expect(sentMails[0].from).toBe('noreply@a.example.invalid');
expect(sentMails[1].from).toBe('noreply@b.example.invalid');
expect(sentMails[1].text).toContain('bob');
expect(mockClose).toHaveBeenCalledTimes(2);
});
it('Test 4: sendMail wirft -> kein Throw nach aussen (T-02-12), Fehler protokolliert ohne Kennwort, close() trotzdem gerufen', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(String(errorSpy.mock.calls[0][0])).toContain('Failed to send Password reset email to alice@a.example.invalid');
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
+156 -31
View File
@@ -1,6 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import * as nodemailer from 'nodemailer';
import { SettingsService } from '../settings/settings.service';
/**
* MailService — Systemmails (Kennwort-Zuruecksetzung, Willkommensmail).
*
* TRANSPORT JE VERSAND NACH MANDANT DES EMPFAENGERS (Etappe 3c, 260914-eym,
* WINDOWS #30 GESCHLOSSEN):
*
* Vorher baute `mail.module.ts` beim Start EINEN Transport aus einer
* beliebigen SmtpConfig (`findFirst()` ohne Bedingung) und alle
* Systemmails aller Mandanten liefen ueber den SMTP-Server und die
* Absenderadresse DIESES einen Mandanten (T-GWH-03). Zwei Gruende, warum
* der Transport jetzt JE VERSAND entsteht:
*
* 1. Pitfall 3 (Research): ein Start-Transport kann nicht wechseln — eine
* Aenderung der SMTP-Einstellungen im UI griff erst nach einem Neustart.
* 2. Mandantentrennung: der Mandant des EMPFAENGERS entscheidet, welche
* Zugangsdaten benutzt werden — nie ein beliebiger. Der Mandant ist an
* der einzigen produktiven Versandstelle bekannt
* (`AuthService.requestPasswordReset`: `user.tenantId` steht eine Zeile
* vor dem Versand). Vorlage: `DkvMailService`/`TenderMailService`
* (`getDecryptedSmtpConfig(tenantId)`, gebunden, `nodemailer.createTransport`,
* `transport.close()` im `finally`).
*
* Die Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) ist NUR
* noch der Rueckfall fuer Mandanten OHNE eigene SmtpConfig — nicht mehr
* der Ersatz fuer einen verstummten Startpfad. Es gibt keinen Startpfad
* mehr, deshalb braucht `SmtpConfig` auch keine `system_read_policy`.
*
* Was mit EINEM Mandanten identisch bleibt (mail.service.spec.ts): Mandant
* MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren
* fromAddress; Mandant OHNE -> dieselbe Umgebungs-Kette wie bisher;
* Transportfehler werden weiter verschluckt und protokolliert (T-02-12 —
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*/
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
from: string;
}
@Injectable()
export class MailService {
@@ -8,8 +54,8 @@ export class MailService {
private readonly appUrl: string;
constructor(
private mailerService: MailerService,
private configService: ConfigService,
private readonly settingsService: SettingsService,
private readonly configService: ConfigService,
) {
this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL',
@@ -17,14 +63,113 @@ export class MailService {
);
}
/**
* Transport-Optionen fuer den Mandanten des Empfaengers: die SmtpConfig
* des Mandanten (gebunden ueber `getDecryptedSmtpConfig(tenantId)`),
* sonst die bisherige Umgebungs-Kette aus `mail.module.ts` unveraendert.
*/
private async resolveTransport(tenantId: string): Promise<ResolvedTransport> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) {
return {
source: 'tenant',
options: {
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.encryption === 'ssl-tls',
requireTLS: smtpConfig.encryption === 'starttls',
auth: smtpConfig.username
? {
user: smtpConfig.username,
// T-07-10/T-07-11: entschluesseltes Kennwort nur hier, nie protokolliert
pass: smtpConfig.decryptedPassword ?? '',
}
: undefined,
},
from: smtpConfig.fromAddress,
};
}
// Rueckfall: Umgebungsvariablen (neue Namen zuerst, TESSERA_SMTP_* als
// zweite Stufe, zuletzt localhost:1025 — Mailhog / dev default).
const host =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
this.configService.get<number>('MAIL_PORT') ??
this.configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
this.configService.get<string>('MAIL_USER') ??
this.configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
this.configService.get<string>('MAIL_PASS') ??
this.configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
this.configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
this.configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
source: 'env',
options: { host, port, secure, auth: { user, pass } },
from,
};
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
transport = nodemailer.createTransport(resolved.options as any);
await transport.sendMail({
from: resolved.from,
to: mail.to,
subject: mail.subject,
text: mail.text,
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendPasswordResetEmail(
email: string,
token: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`;
@@ -66,28 +211,20 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Password reset email sent to ${email}`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send password reset email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset');
}
/**
* Send a welcome email to a newly created user (optional).
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendWelcomeEmail(
email: string,
username: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const isGerman = locale === 'de';
@@ -117,18 +254,6 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Welcome email sent to ${email}`);
} catch (error) {
this.logger.error(
`Failed to send welcome email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
}
}
@@ -133,12 +133,29 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* ist die Liste kein "mindestens", sondern ein "genau": jede Datei mit
* `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein
* ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag
* (Datei weg oder Zahl gesunken) machen die Spec rot. Aufgabe 1 traegt den
* ersten Aufrufer (DKV-Planer-Startpfad); Aufgabe 2 erweitert auf die vier
* Dateien der sechs Hintergrunddienst-Faelle.
* (Datei weg oder Zahl gesunken) machen die Spec rot.
*
* Die sechs Faelle der Hintergrunddienst-Falle
* (docs/mandantentrennung-zugriffsklassifikation.md) und wo sie stehen:
* (1) DKV-Planer-Startpfad -> dkv.service.ts (1 Aufruf,
* `loadActiveConfigsForScheduler`); (2) Mailmodul-Startpfad -> NICHT in der
* Liste: der Startpfad ist ENTFERNT, `MailService` baut je Versand einen
* Transport gebunden ueber `getDecryptedSmtpConfig(tenantId)`
* (settings.service.ts/mail.service.ts rufen `forSystem` nie); (3) ldap ->
* ldap-config.service.ts (2 Aufrufe: `getAllActiveConfigs` und die
* Nachverschluesselung in `onApplicationBootstrap`, je eigene Methode);
* (4) tender-digest -> tender-digest.scheduler.ts (1, Kandidatenabfrage);
* (5) tender-matching -> tender-matching.service.ts (1, Profilabfrage);
* (6) admin-seed -> NICHT in der Liste: der einzige Lesezugriff ausserhalb
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
* Summe: 4 Dateien, 5 Aufrufe.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
['apps/api/src/tenders/tender-matching.service.ts', 1],
]);
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt', 'system-gebunden'] as const;
@@ -1023,13 +1040,13 @@ class ProbeService {
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT');
});
it('Probe C (260914-eym, Systemkontext): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => {
it('Probe C (260914-eym, Systemkontext, Empfaengername absichtlich nicht systemPrisma): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => {
const probe = `
class ProbeService {
constructor(private readonly prisma: any) {}
async getAllActiveConfigs() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany({
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { fieldMappings: true },
});
@@ -1052,8 +1069,8 @@ class ProbeService {
class ProbeService {
constructor(private readonly prisma: any) {}
async readAll() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany();
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany();
}
async writeOne(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
@@ -1070,8 +1087,8 @@ class ProbeService {
class ProbeService {
constructor(private readonly prisma: any) {}
async readAll() {
const systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany();
const sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.ldapConfig.findMany();
}
async readRaw() {
return this.prisma.ldapConfig.findMany();
+7 -87
View File
@@ -7,11 +7,13 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) —
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg
* ("X is not a function" statt eines stillen Fallbacks).
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` — KEIN `findUnique`,
* KEIN `upsert`; der GEBUNDENE Klient bietet AUSSCHLIESSLICH
* `findUnique`/`upsert` — KEIN `findFirst`. Ein ungebundener Anfrageweg
* scheitert damit hart ("X is not a function" statt eines stillen
* Fallbacks). Der ungebundene Startpfad des Mailmoduls (findFirst beim
* Boot) und sein describe-Block sind seit 260914-eym (WINDOWS #30)
* GELOESCHT — der ungebundene Nachbau bleibt als Falsifizierungsform stehen.
*
* `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
* gibt es keinen `mailhog`).
@@ -416,88 +418,6 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
});
});
describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => {
it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
},
]);
const crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toEqual({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
username: 'user-a',
password: 'geheim',
fromAddress: 'a@example.invalid',
});
});
it('requireTLS bei starttls', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result?.secure).toBe(false);
expect(result?.requireTLS).toBe(true);
});
it('leerer Nachbau -> null', async () => {
const prisma = makeFakePrisma([]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toBeNull();
});
it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
await service.loadAnySmtpConfigForStartupTransport();
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('Wachhund je Anfrageweg', () => {
const storedRow: FakeSmtpRow = {
id: 'smtp-a',
+4 -73
View File
@@ -91,8 +91,10 @@ export class SettingsService {
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService to build a nodemailer transport
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
* MailService (Systemmails, Transport je Versand nach Mandant des
* Empfaengers, WINDOWS #30) — to build a nodemailer transport at send
* time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* NEVER log the decrypted password (T-07-10 / T-05-13).
*
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
@@ -190,75 +192,4 @@ export class SettingsService {
return { success: false };
}
}
/**
* Tenant-agnostic startup accessor for the MailModule factory.
*
* BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der
* Hintergrunddienst-Falle — gleicher Bauart wie
* `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe
* dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren
* genannt:
*
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
* Bedingung zieht bei mehreren Mandanten den SMTP-Server und die
* Absenderadresse EINES beliebigen Mandanten fuer ALLE
* Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten
* (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
* - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage
* `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt
* `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport
* statt einer Meldung; `MailService` faengt jeden Transportfehler
* (T-02-12) und der Controller antwortet `200`. Das Verstummen ist
* damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch
* das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap`
* (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu
* `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT
* Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE
* Protokollzeile.
*
* Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start
* gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport
* je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
* `DkvMailService`/`TenderMailService` bereits haben, `MailService`
* muesste den Mandanten nur von `requestPasswordReset` entgegennehmen —
* ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau,
* NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt
* Anschluss an #21 (andere Datei, andere Reparatur, andere
* Verdeckungsform) — siehe WINDOWS #30 und
* `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
* "## Bereich settings", (s4)(a).
*
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
* T-07-11: Decrypted password is used only to build the transport — never logged.
*/
async loadAnySmtpConfigForStartupTransport(): Promise<{
host: string;
port: number;
secure: boolean;
requireTLS: boolean;
username: string | null;
password: string | null;
fromAddress: string;
} | null> {
const config = await this.prisma.smtpConfig.findFirst();
if (!config) return null;
let password: string | null = null;
if (config.encryptedPassword) {
// T-07-11: Used only to build transport at startup; never logged
password = this.crypto.decrypt(config.encryptedPassword);
}
return {
host: config.host,
port: config.port,
secure: config.encryption === 'ssl-tls',
requireTLS: config.encryption === 'starttls',
username: config.username,
password,
fromAddress: config.fromAddress,
};
}
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderDigestScheduler } from './tender-digest.scheduler';
/**
@@ -29,6 +29,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Kandidatenabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const MONDAY = new Date('2026-07-27T10:00:00Z');
@@ -84,6 +86,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
const systemCallLog: { model: string; method: string }[] = [];
return {
tenderMatch,
@@ -91,6 +94,19 @@ function makeFakePrisma(opts: {
user,
__store: { matches, prefs, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog,
// tenderMatch.findMany unveraendert (dieselbe Fake-Implementierung).
__makeSystemClient() {
return {
tenderMatch: {
findMany: async (args: any) => {
systemCallLog.push({ model: 'tenderMatch', method: 'findMany' });
return tenderMatch.findMany(args);
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -380,6 +396,26 @@ describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () =>
expect(forTenant).toHaveBeenCalledTimes(1);
});
it('die Kandidatenabfrage laeuft ueber den System-Klienten: __systemCallLog enthaelt genau tenderMatch.findMany, nichts aus der Schleife (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
const matches = [makeMatch({ userId: 'user-1', tenantId: 'tenant-1' })];
const prisma = makeFakePrisma({ matches, prefs, users });
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
await scheduler.runDigest(TUESDAY);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderMatch', method: 'findMany' }]);
// Die Schleife (Praeferenz, Treffer, Benutzer, Stempelung) lief gebunden,
// nicht ueber den System-Klienten.
expect(prisma.__boundCallLog.length).toBeGreaterThan(0);
expect(mail.sendDigest).toHaveBeenCalledTimes(1);
});
it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', async () => {
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailItem, TenderMailService } from './tender-mail.service';
/**
@@ -105,9 +105,13 @@ export class TenderDigestScheduler implements OnModuleInit {
async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration.
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen).
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
// ALLER Mandanten NUR lesend (`system_read_policy ... FOR SELECT`,
// Migration 20260914120000) — ohne diese Regel saehe der Digest nach dem
// Scharfschalten 0 Kandidaten und wuerde stumm. Die Schleife unten
// bleibt je Kandidatenzeile GEBUNDEN (bewusst ohne Benutzer, wie in 3b).
// Eine LEERE Kandidatenliste ist Nichtstun: `notifiedAt` bleibt NULL.
//
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
@@ -117,7 +121,8 @@ export class TenderDigestScheduler implements OnModuleInit {
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const candidates = await this.prisma.tenderMatch.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true, tenantId: true },
distinct: ['userId'],
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMatchingService } from './tender-matching.service';
/**
@@ -24,6 +24,8 @@ import { TenderMatchingService } from './tender-matching.service';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Profilabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const PROFILE_A = {
@@ -125,6 +127,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, user };
const systemCallLog: { model: string; method: string }[] = [];
const prisma = {
tenderSavedSearch,
@@ -133,6 +136,20 @@ function makeFakePrisma(opts: {
user,
__store: { matches, savedSearches, tenders, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog;
// nur tenderSavedSearch — ein Katalogzugriff ueber diesen Klienten
// wuerde hart scheitern ("tender is undefined").
__makeSystemClient() {
return {
tenderSavedSearch: {
findMany: async () => {
systemCallLog.push({ model: 'tenderSavedSearch', method: 'findMany' });
return tenderSavedSearch.findMany();
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -445,6 +462,23 @@ describe('TenderMatchingService.matchDelta — Bindung an forTenant() (260909-la
expect(forTenant).toHaveBeenCalledWith(prisma, PROFILE_A.tenantId);
});
it('die Profilabfrage laeuft ueber den System-Klienten, der Katalog-Lesezugriff NICHT (weiter roher Client) (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const matchingTenderIds = new Set(['new-1']);
const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds });
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderSavedSearch', method: 'findMany' }]);
// Katalog: roher Client, genau einmal (ein Profil).
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
// Treffer-Anlage gebunden.
expect(prisma.__boundCallLog.some((c: any) => c.model === 'tenderMatch' && c.method === 'upsert')).toBe(true);
});
it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => {
vi.mocked(forTenant).mockClear();
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailService } from './tender-mail.service';
import { buildTenderWhere } from './tender-query.builder';
import type { TenderQueryDto } from './dto/tender-query.dto';
@@ -64,11 +64,17 @@ export class TenderMatchingService {
async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return;
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe
// wird dort entschieden, hier NICHT vorweggenommen).
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
// neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
// lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
// ohne diese Regel saehe der Abgleich nach dem Scharfschalten 0 Profile
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
const systemPrisma = forSystem(this.prisma) as any;
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
await systemPrisma.tenderSavedSearch.findMany();
for (const search of savedSearches) {
try {
@@ -11,6 +11,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): Profil- und Kandidatenabfrage laufen ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
/**
@@ -136,6 +138,14 @@ function makeSharedFakePrisma(opts: {
user: userModel,
__store: { matches },
__boundCallLog: boundCallLog,
// Systemkontext-Klient (260914-eym): dieselben Fake-Implementierungen,
// ohne Bindungsprotokoll.
__makeSystemClient() {
return {
tenderSavedSearch: { findMany: async () => [savedSearch] },
tenderMatch: { findMany: async (args: any) => tenderMatch.findMany(args) },
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -617,9 +617,9 @@ werden.
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung vor jedem Erteilen (`assertTargetBelongsToTenant`) bleibt zusätzlich bestehen. Bis 20260910120000_rls_widen_membership_grant_and_platform_read prüfte die Regel auf dieser Tabelle nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe/den referenzierten Benutzer (Befund F, T-JTS-03, Aufgabe 1) — seit 260910-jab prüft sie beide Ziele zusätzlich, die Anwendungsprüfung bleibt trotzdem der erste Schutz (Schalter weiterhin aus, #18). |
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden. |
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | gebunden | Zielbenutzer eines Grants innerhalb des Mandanten. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung bleibt bestehen. |
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | gemischt | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` bleiben bewusst uebergreifend: beide laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md). Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. |
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | beides | gemischt | Klassenkorrektur (260911-mkj, WINDOWS #27): wechselt von `muss-mandantengebunden` auf `beides`. Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. Die vierte Erkennung (260911-mkj) macht sichtbar, dass der bewusst uebergreifende Planer-Lesepfad `getAllActiveConfigs()` ueber `include: { fieldMappings: true }` in `LdapFieldMapping` hineinreicht — dieselbe Unterabfrage-Form wie die drei `tenant`-Zaehler aus WINDOWS #27, hier aber KEIN neuer Befund: der Elternpfad ist als erster Fall der Hintergrunddienst-Falle bereits an Etappe 3 uebergeben, die Feldzuordnungen teilen nach dem Scharfschalten sein Schicksal (Regel auf `LdapFieldMapping` ueber Join auf `LdapConfig`, Migration 20260618112133). Die Klasse folgt der Elternzeile `ldapConfig` (`beides`). |
| apps/api/src/ldap/ldap-config.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getAllActiveConfigs()` (Zeile 311) `include: { tenant: true, fieldMappings: true }` auf `this.prisma.ldapConfig.findMany`; `Tenant` traegt keinen Zeilenschutz (260911-e2s Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | system-gebunden | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md) — seit 260914-eym lesen beide ueber `forSystem()` (`system_read_policy`), die Schreibzeile je Altzeile der Nachverschluesselung laeuft ueber `forTenant(this.prisma, config.tenantId)`; kein ungebundener Zugriff mehr. Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. |
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | beides | system-gebunden | Klassenkorrektur (260911-mkj, WINDOWS #27): wechselt von `muss-mandantengebunden` auf `beides`. Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. Die vierte Erkennung (260911-mkj) macht sichtbar, dass der bewusst uebergreifende Planer-Lesepfad `getAllActiveConfigs()` ueber `include: { fieldMappings: true }` in `LdapFieldMapping` hineinreicht — dieselbe Unterabfrage-Form wie die drei `tenant`-Zaehler aus WINDOWS #27, hier aber KEIN neuer Befund: der Elternpfad ist als erster Fall der Hintergrunddienst-Falle bereits an Etappe 3 uebergeben, die Feldzuordnungen teilen nach dem Scharfschalten sein Schicksal (Regel auf `LdapFieldMapping` ueber Join auf `LdapConfig`, Migration 20260618112133). Die Klasse folgt der Elternzeile `ldapConfig` (`beides`). Seit 260914-eym liest der Elternpfad ueber `forSystem()`, das Relationsziel ist damit `system-gebunden` und traegt eine eigene `system_read_policy` (Migration 20260914120000). |
| apps/api/src/ldap/ldap-config.service.ts | tenant | keine-mandantengebundene-tabelle | system-gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getAllActiveConfigs()` `include: { tenant: true, fieldMappings: true }` auf `ldapConfig.findMany`; `Tenant` traegt keinen Zeilenschutz (260911-e2s Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Seit 260914-eym laeuft der Ankeraufruf ueber `forSystem()` — der Stand folgt dem Empfaenger (`system-gebunden`), eine Regel braucht `Tenant` weiterhin nicht. |
| apps/api/src/ldap/ldap.service.ts | group | beides | gebunden | AD-Abgleich: `listGroups` (die "bereits importiert"-Markierung) und `importGroupsByDn` (die Idempotenzpruefung ueber `ldapObjectGuid`) sind mit Aufgabe 3 (260909-ipc) auf `forTenant()` umgestellt — zusammen mit den bereits vorher gebundenen Stellen (Anlage, Mitgliedschafts- und Gruppenabgleich) ist damit jeder `group`-Zugriff dieser Datei gebunden. Die Klasse bleibt `beides`, weil ein zukuenftiger uebergreifender Lesezugriff (z. B. ein neuer Planer-Pfad) hier ebenso legitim waere wie bei `ldapConfig` unten — nicht, weil heute noch ein ungebundener Zugriff bestuende. |
| apps/api/src/ldap/ldap.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `Group`. `syncGroupMembershipsForTenant` laeuft vollstaendig ueber `forTenant()` (Plan 16-03/16-05) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
@@ -631,7 +631,7 @@ werden.
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. |
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Modulkatalog ist plattformweit. Der ungebundene Anteil bleibt bewusst so (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. Die neu sichtbare gebundene Haelfte stammt ausschliesslich aus `include: { module: true }` auf `tenantPrisma.tenantModuleActivation` (Zeilen 56, 97, 148) — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. |
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gemischt | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — keine uebersehene Fundstelle, dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Der bis 260914-eym einzige ungebundene Zugriff — der Startpfad des Mailmoduls (`findFirst()` beim Boot, WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — ist GELOESCHT: `MailService` baut je Versand einen Transport aus `getDecryptedSmtpConfig(tenantId)` des Empfaenger-Mandanten. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts`/`mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. |
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. |
| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. |
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. |
@@ -640,7 +640,7 @@ werden.
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tender | keine-mandantengebundene-tabelle | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { tender: true, savedSearch: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `Tender` ist der plattformglobale Katalog (D-03) — die Bindung des Elternaufrufs ist fuer die Unterabfrage wirkungslos, nicht schaedlich. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | gemischt | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) bleibt die Kandidatenabfrage (`findMany` mit `distinct`) bewusst ungebunden, die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile laufen über `forTenant()`. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | system-gebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) laufen die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile über `forTenant()`; seit 260914-eym liest die Kandidatenabfrage (`findMany` mit `distinct`) über `forSystem()` (`system_read_policy`, nur lesend) statt ungebunden. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | gebunden | Präferenzen werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten der jeweiligen Zeile. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderSavedSearch | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { savedSearch: true }` plus `orderBy` ueber `savedSearch` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `TenderSavedSearch` ist mandantengebunden und ueber denselben gebundenen Klienten erreicht. |
| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | gebunden | E-Mail-Adressen für den Versand werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`. |
@@ -650,7 +650,7 @@ werden.
| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. Die neu sichtbare gebundene Haelfte stammt aus `include: { tender: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 139) im Sofortmeldungs-Dispatch — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. |
| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | gebunden | Sofortmeldung — siehe Abschnitt "Der Hintergrunddienst als Falle". Seit 260909-laa (Aufgabe 3) laufen sowohl die Treffer-Anlage (`upsert`) als auch der Instant-Dispatch (`findMany`/`updateMany`) vollständig über `forTenant()`, EIN gebundener Client je Profil. |
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend, Etappe-3-Übergabe (260909-laa, Aufgabe 3, unverändert). |
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | system-gebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend; seit 260914-eym über `forSystem()` (`system_read_policy`, nur lesend), die Etappe-3-Übergabe aus 260909-laa ist damit eingelöst. Treffer-Anlage und Sofortmeldung bleiben je Profil gebunden. |
| apps/api/src/tenders/tender-matching.service.ts | user | beides | gebunden | E-Mail-Adressen für die Sofortmeldung. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten des jeweiligen Profils. |
| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getForUser`/`setForUser` vollständig über `forTenant()`; `setForUser` übersetzt eine P2002-Verletzung (Befund F) in eine verständliche deutsche Meldung. |
| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | beides | gemischt | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`). Seit 260909-laa (Aufgabe 2) bindet `createForUser` (Zähler + Anlage, beide ausschließlich auf persönlichen Zeilen mit gesetztem Mandanten) über `forTenant()`. Seit 260910-jab (Aufgabe 2, WINDOWS #19 geschlossen) bindet zusätzlich `listForUser` — die neue Leseregel (`tenant_platform_read_policy`, 20260910120000_rls_widen_membership_grant_and_platform_read) schließt die plattformweite Zeile ausdrücklich ein, ungebunden hätte die Reparatur den Pfad sonst still auf nur die plattformweiten Zeilen reduziert (Befund F). `createPlatform`/`remove` bleiben bewusst ungebunden — beide Pfade lassen sich unter der Anwendungsrolle grundsätzlich nicht anlegen/entfernen, weil jede Schreibregel einen Mandanten verlangt (WINDOWS #24, eigener offener Punkt). Klasse `beides` bleibt korrekt: zwei gebundene, zwei bewusst ungebundene Zugriffe in derselben Datei. |