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'); report(results, 'system-context-migration-gefunden', false, 'Migration "_rls_system_context_read" nicht gefunden');
return; 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(); const remainingTablesMigrationSql = readRemainingTenantTablesMigrationSql();
if (!remainingTablesMigrationSql) { if (!remainingTablesMigrationSql) {
report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden'); report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden');
return; 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. // Vier Funktionsfaelle, je in einer eigenen Transaktion.
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); 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 }, createAttemptData: { id: 'cfg-system-schreibversuch', tenantId: 'TENANT-A', isActive: true },
updateManyData: { folder: 'SYSTEM-SCHREIBVERSUCH' }, 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), 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( expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com', 'bob@example.com',
expect.any(String), 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) // Send the reset email (fire-and-forget, errors logged by MailService).
await this.mailService.sendPasswordResetEmail(email, token); // 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): * AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN):
* *
* Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber * Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden, `forSystem()`, * `DkvService.loadActiveConfigsForScheduler()` (systemgebunden ueber den
* nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem * Systemkontext-Helfer, nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem
* Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen * Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen
* `dkv-inbox-poll:<tenantId>`. Der Tick eines Auftrags ruft * `dkv-inbox-poll:<tenantId>`. Der Tick eines Auftrags ruft
* `processInbox(tenantId)` fuer GENAU diesen Mandanten — der Tick selbst * `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. // Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p), 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 * 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(); expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
}); });
it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => { it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => {
await service.getAllActiveConfigs(); 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(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 () => { it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([]); const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } };
prisma.__systemClient = systemClient;
await service.onApplicationBootstrap(); await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).not.toHaveBeenCalled(); 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);
}); });
}); });
+35 -21
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service'; import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { import {
CreateFieldMappingDto, CreateFieldMappingDto,
CreateLdapConfigDto, CreateLdapConfigDto,
@@ -53,17 +53,24 @@ export class LdapConfigService implements OnApplicationBootstrap {
* re-encrypted still authenticates, because the read path below tolerates a * re-encrypted still authenticates, because the read path below tolerates a
* legacy plaintext value. * legacy plaintext value.
* *
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B): * SYSTEMGEBUNDEN LESEN, JE ZEILE GEBUNDEN SCHREIBEN (Etappe 3c,
* dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen, * 260914-eym; vorher bewusst ungebunden, 260909-ipc Befund B): dieser
* bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert * Durchlauf muss ALLE Konfigurationen ALLER Mandanten sehen, bevor je ein
* strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser * einzelner Mandantenkontext feststeht — beim Boot existiert strukturell
* Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum * noch keiner. Das Lesen laeuft deshalb ueber `forSystem()`
* Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3 * (`system_read_policy ... FOR SELECT` auf "LdapConfig", Migration
* (Systemkontext), diese Umstellung entscheidet sie nicht. * 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> { async onApplicationBootstrap(): Promise<void> {
try { try {
const configs = await this.prisma.ldapConfig.findMany({ 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 }, select: { id: true, tenantId: true, encryptedBindPassword: true },
}); });
@@ -75,7 +82,10 @@ export class LdapConfigService implements OnApplicationBootstrap {
if (legacy.length === 0) return; if (legacy.length === 0) return;
for (const config of legacy) { 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 }, where: { id: config.id },
data: { data: {
encryptedBindPassword: this.crypto.encrypt( 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 * Get all active LDAP configs. Used by the scheduler to determine which
* tenants need auto-sync. * tenants need auto-sync.
* *
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B): * SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; vorher bewusst ungebunden,
* der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER * 260909-ipc Befund B): der Planer braucht die Liste ALLER aktiven
* Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist * Konfigurationen ALLER Mandanten, um daraus je Mandant einen gebundenen
* die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf. * Sync-Lauf anzustossen — `forSystem()` liest sie ueber
* Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der * `system_read_policy ... FOR SELECT` (Migration 20260914120000).
* LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne * `LdapFieldMapping` wird ueber `include: { fieldMappings }` mitgelesen
* Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E, * (WINDOWS-#27-Form) und traegt deshalb dieselbe Regel; `Tenant` traegt
* docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung * in keiner Migration eine Regel und braucht keine Oeffnung. Das
* (Systemkontext) gehoert nach Etappe 3. * 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() { 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 }, where: { isActive: true },
include: { tenant: true, fieldMappings: true }, include: { tenant: true, fieldMappings: true },
}); });
return configs.map((config) => this.withDecryptedPassword(config)); return configs.map((config: any) => this.withDecryptedPassword(config));
} }
} }
+13 -83
View File
@@ -1,102 +1,32 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { SettingsModule } from '../settings/settings.module'; import { SettingsModule } from '../settings/settings.module';
import { SettingsService } from '../settings/settings.service';
import { MailService } from './mail.service'; import { MailService } from './mail.service';
/** /**
* MailModule — system email delivery (password reset, welcome emails). * MailModule — system email delivery (password reset, welcome emails).
* *
* D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1) * KEIN STARTPFAD MEHR (Etappe 3c, 260914-eym, WINDOWS #30 GESCHLOSSEN):
* with an env-var fallback (priority 2) when no DB row exists. * 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: * Transport-Prioritaet JE VERSAND:
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst * 1. SmtpConfig des Empfaenger-Mandanten (gebunden, `getDecryptedSmtpConfig(tenantId)`)
* 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)
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS * 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 * 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) * 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
* *
* The factory is async because loadAnySmtpConfigForStartupTransport() reads * No circular import risk: MailModule -> SettingsModule -> CalendarModule
* from the DB. No circular import risk: MailModule → SettingsModule → * (no reverse edges). `@nestjs-modules/mailer` bleibt als Paket installiert,
* CalendarModule (no reverse edges). * wird aber von keinem Modul mehr benutzt.
*/ */
@Module({ @Module({
imports: [
SettingsModule,
MailerModule.forRootAsync({
imports: [SettingsModule], 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],
}),
],
providers: [MailService], providers: [MailService],
exports: [MailService], exports: [MailService],
}) })
export class MailModule {} 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 { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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() @Injectable()
export class MailService { export class MailService {
@@ -8,8 +54,8 @@ export class MailService {
private readonly appUrl: string; private readonly appUrl: string;
constructor( constructor(
private mailerService: MailerService, private readonly settingsService: SettingsService,
private configService: ConfigService, private readonly configService: ConfigService,
) { ) {
this.appUrl = this.configService.get<string>( this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL', '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. * Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds * T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration). * (no email enumeration).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/ */
async sendPasswordResetEmail( async sendPasswordResetEmail(
email: string, email: string,
token: string, token: string,
tenantId: string,
locale: string = 'de', locale: string = 'de',
): Promise<void> { ): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`; const resetLink = `${this.appUrl}/reset-password/${token}`;
@@ -66,28 +211,20 @@ export class MailService {
'The Tessera Team', 'The Tessera Team',
].join('\n'); ].join('\n');
try { await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset');
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),
);
}
} }
/** /**
* 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( async sendWelcomeEmail(
email: string, email: string,
username: string, username: string,
tenantId: string,
locale: string = 'de', locale: string = 'de',
): Promise<void> { ): Promise<void> {
const isGerman = locale === 'de'; const isGerman = locale === 'de';
@@ -117,18 +254,6 @@ export class MailService {
'The Tessera Team', 'The Tessera Team',
].join('\n'); ].join('\n');
try { await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
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),
);
}
} }
} }
@@ -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 * ist die Liste kein "mindestens", sondern ein "genau": jede Datei mit
* `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein * `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein
* ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag * ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag
* (Datei weg oder Zahl gesunken) machen die Spec rot. Aufgabe 1 traegt den * (Datei weg oder Zahl gesunken) machen die Spec rot.
* ersten Aufrufer (DKV-Planer-Startpfad); Aufgabe 2 erweitert auf die vier *
* Dateien der sechs Hintergrunddienst-Faelle. * 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>([ const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1], ['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; const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt', 'system-gebunden'] as const;
@@ -1023,13 +1040,13 @@ class ProbeService {
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1); expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT'); 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 = ` const probe = `
class ProbeService { class ProbeService {
constructor(private readonly prisma: any) {} constructor(private readonly prisma: any) {}
async getAllActiveConfigs() { async getAllActiveConfigs() {
const systemPrisma = forSystem(this.prisma) as any; const sysPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany({ return sysPrisma.ldapConfig.findMany({
where: { isActive: true }, where: { isActive: true },
include: { fieldMappings: true }, include: { fieldMappings: true },
}); });
@@ -1052,8 +1069,8 @@ class ProbeService {
class ProbeService { class ProbeService {
constructor(private readonly prisma: any) {} constructor(private readonly prisma: any) {}
async readAll() { async readAll() {
const systemPrisma = forSystem(this.prisma) as any; const sysPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany(); return sysPrisma.ldapConfig.findMany();
} }
async writeOne(tenantId: string) { async writeOne(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId) as any;
@@ -1070,8 +1087,8 @@ class ProbeService {
class ProbeService { class ProbeService {
constructor(private readonly prisma: any) {} constructor(private readonly prisma: any) {}
async readAll() { async readAll() {
const systemPrisma = forSystem(this.prisma) as any; const sysPrisma = forSystem(this.prisma) as any;
return systemPrisma.ldapConfig.findMany(); return sysPrisma.ldapConfig.findMany();
} }
async readRaw() { async readRaw() {
return this.prisma.ldapConfig.findMany(); 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 * SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit * diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau * einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) — * bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` — KEIN `findUnique`,
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet * KEIN `upsert`; der GEBUNDENE Klient bietet AUSSCHLIESSLICH
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener * `findUnique`/`upsert` — KEIN `findFirst`. Ein ungebundener Anfrageweg
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg * scheitert damit hart ("X is not a function" statt eines stillen
* ("X is not a function" statt eines stillen Fallbacks). * 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 * `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
* gibt es keinen `mailhog`). * 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', () => { describe('Wachhund je Anfrageweg', () => {
const storedRow: FakeSmtpRow = { const storedRow: FakeSmtpRow = {
id: 'smtp-a', id: 'smtp-a',
+4 -73
View File
@@ -91,8 +91,10 @@ export class SettingsService {
/** /**
* Internal: Get the decrypted SMTP config for a tenant. * Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService to build a nodemailer transport * Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir). * 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). * NEVER log the decrypted password (T-07-10 / T-05-13).
* *
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient * Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
@@ -190,75 +192,4 @@ export class SettingsService {
return { success: false }; 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 { 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'; import { TenderDigestScheduler } from './tender-digest.scheduler';
/** /**
@@ -29,6 +29,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/ */
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), 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'); 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 modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
const systemCallLog: { model: string; method: string }[] = [];
return { return {
tenderMatch, tenderMatch,
@@ -91,6 +94,19 @@ function makeFakePrisma(opts: {
user, user,
__store: { matches, prefs, users }, __store: { matches, prefs, users },
__boundCallLog: boundCallLog, __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) { __makeBoundClient(tenantId: string) {
const bound: any = {}; const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) { for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -380,6 +396,26 @@ describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () =>
expect(forTenant).toHaveBeenCalledTimes(1); 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 () => { 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 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 prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; 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'; import { TenderMailItem, TenderMailService } from './tender-mail.service';
/** /**
@@ -105,9 +105,13 @@ export class TenderDigestScheduler implements OnModuleInit {
async runDigest(now: Date = new Date()): Promise<void> { async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match, // Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration. // across ALL tenants — a single findMany, never a per-tenant iteration.
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out // SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext // 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen). // 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 // Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten // 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 // `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge // tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md. // 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 }, where: { notifiedAt: null },
select: { userId: true, tenantId: true }, select: { userId: true, tenantId: true },
distinct: ['userId'], distinct: ['userId'],
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; 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'; import { TenderMatchingService } from './tender-matching.service';
/** /**
@@ -24,6 +24,8 @@ import { TenderMatchingService } from './tender-matching.service';
*/ */
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), 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 = { const PROFILE_A = {
@@ -125,6 +127,7 @@ function makeFakePrisma(opts: {
}; };
const modelsByName: Record<string, any> = { tenderMatch, user }; const modelsByName: Record<string, any> = { tenderMatch, user };
const systemCallLog: { model: string; method: string }[] = [];
const prisma = { const prisma = {
tenderSavedSearch, tenderSavedSearch,
@@ -133,6 +136,20 @@ function makeFakePrisma(opts: {
user, user,
__store: { matches, savedSearches, tenders, users }, __store: { matches, savedSearches, tenders, users },
__boundCallLog: boundCallLog, __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) { __makeBoundClient(tenantId: string) {
const bound: any = {}; const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) { 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); 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 () => { it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => {
vi.mocked(forTenant).mockClear(); vi.mocked(forTenant).mockClear();
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']); const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; 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 { TenderMailService } from './tender-mail.service';
import { buildTenderWhere } from './tender-query.builder'; import { buildTenderWhere } from './tender-query.builder';
import type { TenderQueryDto } from './dto/tender-query.dto'; import type { TenderQueryDto } from './dto/tender-query.dto';
@@ -64,11 +64,17 @@ export class TenderMatchingService {
async matchDelta(newTenderIds: string[]): Promise<void> { async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return; if (!newTenderIds.length) return;
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten // SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses // 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe // neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
// wird dort entschieden, hier NICHT vorweggenommen). // lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
const savedSearches = await this.prisma.tenderSavedSearch.findMany(); // 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) { for (const search of savedSearches) {
try { try {
@@ -11,6 +11,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/ */
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), 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, user: userModel,
__store: { matches }, __store: { matches },
__boundCallLog: boundCallLog, __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) { __makeBoundClient(tenantId: string) {
const bound: any = {}; const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) { 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 | 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 | 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/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 | 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 | 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 | 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 | 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 | 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 | 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 | 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. | | 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-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 | 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/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 | 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.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`. | | 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 | 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-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 | 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 | 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 | 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()`. | | 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-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 | 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 | 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-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-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. | | 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. |