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 { CreateFieldMappingDto, CreateLdapConfigDto, UpdateLdapConfigDto, } from './dto/ldap-config.dto'; /** * Shape of a stored AES-256-GCM value as CryptoService writes it: * `iv:authTag:ciphertext`, all hex. Used to tell an encrypted value apart from * a legacy plaintext one that predates the encryption of this column. */ const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i; /** * Per-tenant LDAP configuration CRUD (D-18). * Manages LDAP connection settings and field mappings. * * The bind password is stored AES-256-GCM-encrypted in * `LdapConfig.encryptedBindPassword`, the same way CalendarSource, SmtpConfig, * DkvModuleConfig and TenderEmailConfig store theirs. It cannot be hashed: * Tessera has to replay this password to bind against the directory, so it * needs to be recoverable. Encryption at rest protects the one case a hash * cannot help with anyway — a database dump or backup leaving the host without * the key that lives in the application environment. * * Every consumer reads the config through `getConfig()` or * `getAllActiveConfigs()`, so decryption happens in exactly those two places * and callers keep seeing a plain `bindPassword` field. The controller still * masks it to '********' in API responses (T-02-17). */ @Injectable() export class LdapConfigService implements OnApplicationBootstrap { private readonly logger = new Logger(LdapConfigService.name); constructor( private prisma: PrismaService, private readonly crypto: CryptoService, ) {} /** * One-time, idempotent backfill of rows written before this column was * encrypted. SQL cannot do this — the key lives in the application * environment, not in the database — so the migration only renames the * column and the actual encryption happens here on the next start. * * Runs on every boot and is a no-op once every row is encrypted. A failure * is logged and swallowed: a tenant whose bind password could not be * 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. */ async onApplicationBootstrap(): Promise { try { const configs = await this.prisma.ldapConfig.findMany({ select: { id: true, tenantId: true, encryptedBindPassword: true }, }); const legacy = configs.filter( (config) => config.encryptedBindPassword && !ENCRYPTED_VALUE_SHAPE.test(config.encryptedBindPassword), ); if (legacy.length === 0) return; for (const config of legacy) { await this.prisma.ldapConfig.update({ where: { id: config.id }, data: { encryptedBindPassword: this.crypto.encrypt( config.encryptedBindPassword as string, ), }, }); } this.logger.log( `LDAP-Bind-Passwort verschluesselt: ${legacy.length} Konfiguration(en) nachgezogen`, ); } catch (err) { this.logger.error( `Backfill der LDAP-Bind-Passwoerter fehlgeschlagen: ${(err as Error).message}`, ); } } /** * Decrypt for internal use. A value that is not in `iv:authTag:ciphertext` * form predates the encryption and is returned unchanged — that window * exists between the column rename and the bootstrap backfill above, and * must not break the sync. */ private decryptBindPassword(stored: string | null): string | null { if (!stored) return null; if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored; try { return this.crypto.decrypt(stored); } catch (err) { // A wrong or rotated key must not read as "no password configured" — // that would silently turn an authenticated bind into an anonymous one. this.logger.error( `LDAP-Bind-Passwort konnte nicht entschluesselt werden (falscher TESSERA_ENCRYPTION_KEY?): ${(err as Error).message}`, ); throw err; } } /** Map a stored row to what callers expect: a plain `bindPassword` field. */ private withDecryptedPassword< T extends { encryptedBindPassword: string | null }, >(config: T): Omit & { bindPassword: string | null } { const { encryptedBindPassword, ...rest } = config; return { ...rest, bindPassword: this.decryptBindPassword(encryptedBindPassword), }; } /** * Get LDAP config for a tenant, including field mappings. * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): der Mandant ist * hier bereits aus der Anfrage bekannt (Parameter), also ueber * `forTenant()` gebunden — anders als `getAllActiveConfigs()` unten, die * bewusst ueber alle Mandanten liest. */ async getConfig(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const config = await tenantPrisma.ldapConfig.findUnique({ where: { tenantId }, include: { fieldMappings: true }, }); return config ? this.withDecryptedPassword(config) : null; } /** * Create LDAP config for a tenant with default field mappings (D-16). * Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc). Das verschachtelte * Anlegen der drei Vorgabe-Zuordnungen bleibt eine einzige Prisma-Operation * und laeuft damit in derselben `forTenant()`-Transaktion wie das Setzen * des Kontexts — dass diese Schreibweise unter der Policy traegt, ist in * Aufgabe 1 (260909-ipc-PLAN.md) gegen die echte, ausgelieferte Policy * gemessen. */ async createConfig(tenantId: string, dto: CreateLdapConfigDto) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const created = await tenantPrisma.ldapConfig.create({ data: { tenantId, serverUrl: dto.serverUrl, baseDn: dto.baseDn, bindDn: dto.bindDn, encryptedBindPassword: dto.bindPassword ? this.crypto.encrypt(dto.bindPassword) : null, searchFilter: dto.searchFilter ?? '(objectClass=person)', syncIntervalMin: dto.syncIntervalMin ?? 60, isActive: dto.isActive ?? true, tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true, groupFilterDns: dto.groupFilterDns ?? [], userExcludeList: dto.userExcludeList ?? [], fieldMappings: { create: [ { ldapField: 'displayName', tesseraField: 'displayName', isDefault: true, }, { ldapField: 'mail', tesseraField: 'email', isDefault: true }, { ldapField: 'sAMAccountName', tesseraField: 'username', isDefault: true, }, ], }, }, include: { fieldMappings: true }, }); return this.withDecryptedPassword(created); } /** * Update LDAP config for a tenant. * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc). */ async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const updated = await tenantPrisma.ldapConfig.update({ where: { tenantId }, data: { ...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }), ...(dto.baseDn !== undefined && { baseDn: dto.baseDn }), ...(dto.bindDn !== undefined && { bindDn: dto.bindDn }), // An empty string means "clear the password", not "encrypt nothing". ...(dto.bindPassword !== undefined && { encryptedBindPassword: dto.bindPassword ? this.crypto.encrypt(dto.bindPassword) : null, }), ...(dto.searchFilter !== undefined && { searchFilter: dto.searchFilter, }), ...(dto.syncIntervalMin !== undefined && { syncIntervalMin: dto.syncIntervalMin, }), ...(dto.isActive !== undefined && { isActive: dto.isActive }), ...(dto.tlsRejectUnauthorized !== undefined && { tlsRejectUnauthorized: dto.tlsRejectUnauthorized, }), ...(dto.groupFilterDns !== undefined && { groupFilterDns: dto.groupFilterDns, }), ...(dto.userExcludeList !== undefined && { userExcludeList: dto.userExcludeList, }), }, include: { fieldMappings: true }, }); return this.withDecryptedPassword(updated); } /** * Add a custom field mapping to an LDAP config (D-17). * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): `LdapFieldMapping` * hat keine eigene `tenantId`-Spalte, ihre RLS-Sichtbarkeit kommt ueber * den Join auf `LdapConfig`. Der Mandant ist typseitig Pflicht (erster * Parameter) — ein Aufruf ohne Mandant ist damit nicht mehr moeglich. */ async addFieldMapping( tenantId: string, configId: string, dto: CreateFieldMappingDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; return tenantPrisma.ldapFieldMapping.create({ data: { ldapConfigId: configId, ldapField: dto.ldapField, tesseraField: dto.tesseraField, isDefault: dto.isDefault ?? false, }, }); } /** * Remove a field mapping. Only non-default mappings can be deleted. * System-provided defaults (isDefault=true) are protected. * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc, T-IPC-01): schliesst * die bisherige Fremdzugriffsluecke — `DELETE /ldap/config/mappings/:id` * nahm bislang ausschliesslich die Kennung entgegen, ein Administrator des * Mandanten A konnte damit die Feldzuordnung des Mandanten B loeschen, * wenn er deren Kennung kannte. Sowohl das Lesen als auch das Loeschen * laufen jetzt ueber `forTenant()`; eine Zuordnung, die unter diesem * Mandanten nicht sichtbar ist (RLS-Join auf `LdapConfig`), liefert * `findUnique` null zurueck — die Steuerung macht daraus 404 statt einer * Loeschung. */ async removeFieldMapping(tenantId: string, mappingId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const mapping = await tenantPrisma.ldapFieldMapping.findUnique({ where: { id: mappingId }, }); if (!mapping) { return null; } if (mapping.isDefault) { throw new Error('Cannot delete default field mappings'); } return tenantPrisma.ldapFieldMapping.delete({ where: { id: mappingId }, }); } /** * 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. */ async getAllActiveConfigs() { const configs = await this.prisma.ldapConfig.findMany({ where: { isActive: true }, include: { tenant: true, fieldMappings: true }, }); return configs.map((config) => this.withDecryptedPassword(config)); } }