Files
tessera-ctl/apps/api/src/ldap/ldap-config.service.ts
T
schalli 9a57fa79f5 feat(quick-260909-ipc): ldap-config.service.ts an forTenant() binden, Loesch-Fremdzugriff schliessen
Aufgabe 2 der Etappe 2: getConfig/createConfig/updateConfig sowie
addFieldMapping/removeFieldMapping laufen jetzt ueber forTenant(), gebunden
an den aus der Anfrage bekannten Mandanten. removeFieldMapping nimmt den
Mandanten neu als Pflichtparameter entgegen und der Controller holt ihn aus
dem Sitzungsnachweis statt nur die URL-Kennung weiterzureichen (T-IPC-01) --
ein Administrator konnte bisher die Feldzuordnung eines fremden Mandanten
loeschen, wenn er ihre Kennung kannte. getAllActiveConfigs() und die
Start-Nachverschluesselung bleiben bewusst uebergreifend, mit ausgeschriebener
Begruendung im Code (Befund B).

rls-access-inventory.spec.ts erkennt jetzt neben `this.prisma.<Modell>` auch
gebundene `<Name>.<Modell>`-Zugriffe (Befund F/G) und prueft eine neue
Stand-Spalte (gebunden/ungebunden/gemischt) im Klassifikationsdokument gegen
den Quelltext. Das macht zwei bisher unsichtbare, weil schon laenger
gebundene Fundstellen sichtbar (auth.service.ts/passwordResetToken,
ldap.service.ts/groupMembership) und deckt auf, dass
(ldap-config.service.ts, ldapConfig) tatsaechlich "beides" ist, nicht
"muss-mandantengebunden" (Befund B).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 14:01:28 +02:00

316 lines
12 KiB
TypeScript

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<void> {
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<T, 'encryptedBindPassword'> & { 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));
}
}