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