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:
2026-08-11 14:10:52 +02:00
parent 149b5aa810
commit 4f687eaea9
5 changed files with 330 additions and 11 deletions
+8 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { CalendarModule } from '../calendar/calendar.module';
import { GroupsModule } from '../groups/groups.module';
import { UserModule } from '../user/user.module';
import { LdapConfigService } from './ldap-config.service';
@@ -17,9 +18,15 @@ import { LdapService } from './ldap.service';
* GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from
* syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule
* imports neither LdapModule nor UserModule.
*
* CalendarModule is imported for CalendarCryptoService, which encrypts the
* bind password at rest — the same provider SettingsModule, DkvModule and
* TendersModule already use for their own credentials. The name is a
* historical accident (the calendar module happened to need encryption
* first), not a statement about ownership.
*/
@Module({
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule],
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule, CalendarModule],
controllers: [LdapController],
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
exports: [LdapService, LdapConfigService],