feat(ldap): encrypt the bind password at rest
The LDAP bind password was the only credential still stored in clear text. CalendarSource, SmtpConfig, DkvModuleConfig and TenderEmailConfig have been AES-256-GCM encrypted for a while; LDAP simply predated the encryption service and was never brought along. Hashing is not an option here: Tessera has to replay this password to bind against the directory, so it must stay recoverable. Encryption at rest covers the case a hash cannot help with either way -- a database dump or backup leaving the host without the key, which lives in the application environment. It does not protect against a compromised host, and does not pretend to. Reuses CalendarCryptoService, the same provider SettingsModule, DkvModule and TendersModule already inject, rather than introducing a second crypto path. The name is a historical accident and is noted as such in LdapModule; renaming it touches five modules and belongs in its own change. Decryption sits in getConfig()/getAllActiveConfigs(), the two methods every consumer already goes through, so callers keep reading a plain `bindPassword` and the controller keeps masking it to '********' in responses. The migration only renames the column -- SQL cannot encrypt, since the key is not in the database. An idempotent bootstrap backfill encrypts rows written before this change, and until it has run the read path passes a legacy plaintext value through unchanged so the sync does not break in that window. A failed decrypt throws rather than returning null: a wrong key must not read as "no password configured" and silently turn an authenticated bind into an anonymous one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateFieldMappingDto,
|
||||
@@ -6,22 +7,125 @@ import {
|
||||
UpdateLdapConfigDto,
|
||||
} from './dto/ldap-config.dto';
|
||||
|
||||
/**
|
||||
* Shape of a stored AES-256-GCM value as CalendarCryptoService 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 {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
export class LdapConfigService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(LdapConfigService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 CALENDAR_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.
|
||||
*/
|
||||
async getConfig(tenantId: string) {
|
||||
return this.prisma.ldapConfig.findUnique({
|
||||
const config = await this.prisma.ldapConfig.findUnique({
|
||||
where: { tenantId },
|
||||
include: { fieldMappings: true },
|
||||
});
|
||||
return config ? this.withDecryptedPassword(config) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,13 +133,15 @@ export class LdapConfigService {
|
||||
* Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username
|
||||
*/
|
||||
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
|
||||
return this.prisma.ldapConfig.create({
|
||||
const created = await this.prisma.ldapConfig.create({
|
||||
data: {
|
||||
tenantId,
|
||||
serverUrl: dto.serverUrl,
|
||||
baseDn: dto.baseDn,
|
||||
bindDn: dto.bindDn,
|
||||
bindPassword: dto.bindPassword,
|
||||
encryptedBindPassword: dto.bindPassword
|
||||
? this.crypto.encrypt(dto.bindPassword)
|
||||
: null,
|
||||
searchFilter: dto.searchFilter ?? '(objectClass=person)',
|
||||
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
||||
isActive: dto.isActive ?? true,
|
||||
@@ -60,20 +166,24 @@ export class LdapConfigService {
|
||||
},
|
||||
include: { fieldMappings: true },
|
||||
});
|
||||
return this.withDecryptedPassword(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update LDAP config for a tenant.
|
||||
*/
|
||||
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
||||
return this.prisma.ldapConfig.update({
|
||||
const updated = await this.prisma.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 && {
|
||||
bindPassword: dto.bindPassword,
|
||||
encryptedBindPassword: dto.bindPassword
|
||||
? this.crypto.encrypt(dto.bindPassword)
|
||||
: null,
|
||||
}),
|
||||
...(dto.searchFilter !== undefined && {
|
||||
searchFilter: dto.searchFilter,
|
||||
@@ -94,6 +204,7 @@ export class LdapConfigService {
|
||||
},
|
||||
include: { fieldMappings: true },
|
||||
});
|
||||
return this.withDecryptedPassword(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,9 +248,10 @@ export class LdapConfigService {
|
||||
* tenants need auto-sync.
|
||||
*/
|
||||
async getAllActiveConfigs() {
|
||||
return this.prisma.ldapConfig.findMany({
|
||||
const configs = await this.prisma.ldapConfig.findMany({
|
||||
where: { isActive: true },
|
||||
include: { tenant: true, fieldMappings: true },
|
||||
});
|
||||
return configs.map((config) => this.withDecryptedPassword(config));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user