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:
@@ -0,0 +1,22 @@
|
|||||||
|
-- Das LDAP-Bind-Passwort lag als einziges Zugangsdatum im Klartext in der
|
||||||
|
-- Datenbank. CalendarSource, SmtpConfig, DkvModuleConfig und TenderEmailConfig
|
||||||
|
-- speichern ihre Zugangsdaten laengst AES-256-GCM-verschluesselt; LDAP war
|
||||||
|
-- schlicht frueher da als der Verschluesselungsdienst und wurde nie nachgezogen.
|
||||||
|
--
|
||||||
|
-- Hashen scheidet hier aus: Tessera muss sich mit genau diesem Passwort am
|
||||||
|
-- Domain Controller anmelden, braucht es also im Original. Deshalb symmetrische
|
||||||
|
-- Verschluesselung mit einem Schluessel ausserhalb der Datenbank.
|
||||||
|
--
|
||||||
|
-- Diese Migration benennt nur die Spalte um, damit am Schema ablesbar ist, was
|
||||||
|
-- drinsteht -- gleiche Namensform wie bei den vier anderen Feldern. Die
|
||||||
|
-- eigentliche Verschluesselung des vorhandenen Werts kann SQL nicht leisten
|
||||||
|
-- (der Schluessel liegt in der Anwendungsumgebung, nicht in der Datenbank):
|
||||||
|
-- die uebernimmt ein einmaliger, idempotenter Backfill beim naechsten Start
|
||||||
|
-- der API (LdapConfigService.encryptLegacyBindPasswordsOnBootstrap).
|
||||||
|
--
|
||||||
|
-- Bis dieser Backfill gelaufen ist, steht in der umbenannten Spalte weiterhin
|
||||||
|
-- Klartext. Der Lesepfad erkennt das an der fehlenden iv:authTag:ciphertext-Form
|
||||||
|
-- und reicht den Wert unveraendert durch, damit der LDAP-Sync in dem Zeitfenster
|
||||||
|
-- nicht bricht.
|
||||||
|
|
||||||
|
ALTER TABLE "LdapConfig" RENAME COLUMN "bindPassword" TO "encryptedBindPassword";
|
||||||
@@ -69,7 +69,7 @@ model LdapConfig {
|
|||||||
serverUrl String
|
serverUrl String
|
||||||
baseDn String
|
baseDn String
|
||||||
bindDn String?
|
bindDn String?
|
||||||
bindPassword String?
|
encryptedBindPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource/SmtpConfig
|
||||||
searchFilter String @default("(objectClass=person)")
|
searchFilter String @default("(objectClass=person)")
|
||||||
syncIntervalMin Int @default(0)
|
syncIntervalMin Int @default(0)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LdapConfigService } from './ldap-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
|
||||||
|
* kann — Tessera muss sich damit am Domain Controller anmelden, braucht es
|
||||||
|
* also im Original. Deshalb Verschluesselung, und deshalb diese Tests: sie
|
||||||
|
* halten fest, dass der Klartext nie in die Datenbank geschrieben wird, dass
|
||||||
|
* Aufrufer trotzdem weiterhin ein entschluesseltes `bindPassword` sehen, und
|
||||||
|
* dass ein Wert aus der Zeit vor der Verschluesselung den Sync nicht bricht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ein durchschaubarer Ersatz fuer AES-256-GCM, der die gespeicherte Form
|
||||||
|
// iv:authTag:ciphertext nachbildet — die Tests pruefen das Zusammenspiel,
|
||||||
|
// nicht die Krypto-Bibliothek.
|
||||||
|
const crypto = {
|
||||||
|
encrypt: vi.fn((plaintext: string) =>
|
||||||
|
['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'),
|
||||||
|
),
|
||||||
|
decrypt: vi.fn((stored: string) => {
|
||||||
|
const [, , ciphertext] = stored.split(':');
|
||||||
|
return Buffer.from(ciphertext, 'hex').toString('utf8');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIG_ROW = {
|
||||||
|
id: 'cfg1',
|
||||||
|
tenantId: 't1',
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
bindDn: 'svc@example',
|
||||||
|
encryptedBindPassword: null as string | null,
|
||||||
|
searchFilter: '(objectClass=person)',
|
||||||
|
syncIntervalMin: 0,
|
||||||
|
isActive: true,
|
||||||
|
tlsRejectUnauthorized: true,
|
||||||
|
groupFilterDns: [] as string[],
|
||||||
|
userExcludeList: [] as string[],
|
||||||
|
fieldMappings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: LdapConfigService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
prisma = {
|
||||||
|
ldapConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn((args: any) =>
|
||||||
|
Promise.resolve({ ...CONFIG_ROW, ...args.data }),
|
||||||
|
),
|
||||||
|
update: vi.fn((args: any) =>
|
||||||
|
Promise.resolve({ ...CONFIG_ROW, ...args.data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
service = new LdapConfigService(prisma, crypto as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt beim Anlegen den Chiffretext, nie den Klartext', async () => {
|
||||||
|
await service.createConfig('t1', {
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
bindDn: 'svc@example',
|
||||||
|
bindPassword: 'geheim',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const written = prisma.ldapConfig.create.mock.calls[0][0].data;
|
||||||
|
expect(written.encryptedBindPassword).toBe(
|
||||||
|
'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(written)).not.toContain('geheim');
|
||||||
|
// Die alte Klartext-Spalte darf nicht wieder auftauchen.
|
||||||
|
expect(written).not.toHaveProperty('bindPassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschluesselt auch beim Aktualisieren und laesst das Feld sonst unberuehrt', async () => {
|
||||||
|
await service.updateConfig('t1', { bindPassword: 'neu' } as any);
|
||||||
|
const first = prisma.ldapConfig.update.mock.calls[0][0].data;
|
||||||
|
expect(first.encryptedBindPassword).toBe(
|
||||||
|
'aa11:bb22:' + Buffer.from('neu').toString('hex'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
|
||||||
|
const second = prisma.ldapConfig.update.mock.calls[1][0].data;
|
||||||
|
expect(second).not.toHaveProperty('encryptedBindPassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leeres Passwort loescht den Wert, statt Leerstring zu verschluesseln', async () => {
|
||||||
|
await service.updateConfig('t1', { bindPassword: '' } as any);
|
||||||
|
const written = prisma.ldapConfig.update.mock.calls[0][0].data;
|
||||||
|
expect(written.encryptedBindPassword).toBeNull();
|
||||||
|
expect(crypto.encrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => {
|
||||||
|
prisma.ldapConfig.findUnique.mockResolvedValue({
|
||||||
|
...CONFIG_ROW,
|
||||||
|
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: any = await service.getConfig('t1');
|
||||||
|
|
||||||
|
expect(config.bindPassword).toBe('geheim');
|
||||||
|
expect(config).not.toHaveProperty('encryptedBindPassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reicht einen Altbestand-Klartext unveraendert durch, statt den Sync zu brechen', async () => {
|
||||||
|
// Zeitfenster zwischen Spalten-Umbenennung und Bootstrap-Backfill.
|
||||||
|
prisma.ldapConfig.findUnique.mockResolvedValue({
|
||||||
|
...CONFIG_ROW,
|
||||||
|
encryptedBindPassword: 'nochKlartext',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: any = await service.getConfig('t1');
|
||||||
|
|
||||||
|
expect(config.bindPassword).toBe('nochKlartext');
|
||||||
|
expect(crypto.decrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet einen fehlgeschlagenen Entschluesselungsversuch, statt still kein Passwort zu liefern', async () => {
|
||||||
|
// Ein falscher Schluessel darf nicht wie "kein Passwort konfiguriert"
|
||||||
|
// aussehen — das wuerde einen authentifizierten Bind unbemerkt in einen
|
||||||
|
// anonymen verwandeln.
|
||||||
|
crypto.decrypt.mockImplementationOnce(() => {
|
||||||
|
throw new Error('Unsupported state or unable to authenticate data');
|
||||||
|
});
|
||||||
|
prisma.ldapConfig.findUnique.mockResolvedValue({
|
||||||
|
...CONFIG_ROW,
|
||||||
|
encryptedBindPassword: 'aa11:bb22:ffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.getConfig('t1')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschluesselt beim Start genau die Zeilen, die noch Klartext tragen', async () => {
|
||||||
|
prisma.ldapConfig.findMany.mockResolvedValue([
|
||||||
|
{ id: 'a', tenantId: 't1', encryptedBindPassword: 'klartext' },
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
tenantId: 't2',
|
||||||
|
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('schon').toString('hex'),
|
||||||
|
},
|
||||||
|
{ id: 'c', tenantId: 't3', encryptedBindPassword: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(prisma.ldapConfig.update).toHaveBeenCalledTimes(1);
|
||||||
|
const call = prisma.ldapConfig.update.mock.calls[0][0];
|
||||||
|
expect(call.where).toEqual({ id: 'a' });
|
||||||
|
expect(call.data.encryptedBindPassword).toBe(
|
||||||
|
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist beim zweiten Start ein No-Op', async () => {
|
||||||
|
prisma.ldapConfig.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
tenantId: 't1',
|
||||||
|
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('x').toString('hex'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst einen fehlgeschlagenen Backfill den Start nicht verhindern', async () => {
|
||||||
|
prisma.ldapConfig.findMany.mockRejectedValue(new Error('db weg'));
|
||||||
|
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
CreateFieldMappingDto,
|
CreateFieldMappingDto,
|
||||||
@@ -6,22 +7,125 @@ import {
|
|||||||
UpdateLdapConfigDto,
|
UpdateLdapConfigDto,
|
||||||
} from './dto/ldap-config.dto';
|
} 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).
|
* Per-tenant LDAP configuration CRUD (D-18).
|
||||||
* Manages LDAP connection settings and field mappings.
|
* 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()
|
@Injectable()
|
||||||
export class LdapConfigService {
|
export class LdapConfigService implements OnApplicationBootstrap {
|
||||||
constructor(private prisma: PrismaService) {}
|
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.
|
* Get LDAP config for a tenant, including field mappings.
|
||||||
*/
|
*/
|
||||||
async getConfig(tenantId: string) {
|
async getConfig(tenantId: string) {
|
||||||
return this.prisma.ldapConfig.findUnique({
|
const config = await this.prisma.ldapConfig.findUnique({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
include: { fieldMappings: true },
|
include: { fieldMappings: true },
|
||||||
});
|
});
|
||||||
|
return config ? this.withDecryptedPassword(config) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,13 +133,15 @@ export class LdapConfigService {
|
|||||||
* Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username
|
* Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username
|
||||||
*/
|
*/
|
||||||
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
|
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
|
||||||
return this.prisma.ldapConfig.create({
|
const created = await this.prisma.ldapConfig.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
serverUrl: dto.serverUrl,
|
serverUrl: dto.serverUrl,
|
||||||
baseDn: dto.baseDn,
|
baseDn: dto.baseDn,
|
||||||
bindDn: dto.bindDn,
|
bindDn: dto.bindDn,
|
||||||
bindPassword: dto.bindPassword,
|
encryptedBindPassword: dto.bindPassword
|
||||||
|
? this.crypto.encrypt(dto.bindPassword)
|
||||||
|
: null,
|
||||||
searchFilter: dto.searchFilter ?? '(objectClass=person)',
|
searchFilter: dto.searchFilter ?? '(objectClass=person)',
|
||||||
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
@@ -60,20 +166,24 @@ export class LdapConfigService {
|
|||||||
},
|
},
|
||||||
include: { fieldMappings: true },
|
include: { fieldMappings: true },
|
||||||
});
|
});
|
||||||
|
return this.withDecryptedPassword(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update LDAP config for a tenant.
|
* Update LDAP config for a tenant.
|
||||||
*/
|
*/
|
||||||
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
||||||
return this.prisma.ldapConfig.update({
|
const updated = await this.prisma.ldapConfig.update({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
data: {
|
data: {
|
||||||
...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }),
|
...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }),
|
||||||
...(dto.baseDn !== undefined && { baseDn: dto.baseDn }),
|
...(dto.baseDn !== undefined && { baseDn: dto.baseDn }),
|
||||||
...(dto.bindDn !== undefined && { bindDn: dto.bindDn }),
|
...(dto.bindDn !== undefined && { bindDn: dto.bindDn }),
|
||||||
|
// An empty string means "clear the password", not "encrypt nothing".
|
||||||
...(dto.bindPassword !== undefined && {
|
...(dto.bindPassword !== undefined && {
|
||||||
bindPassword: dto.bindPassword,
|
encryptedBindPassword: dto.bindPassword
|
||||||
|
? this.crypto.encrypt(dto.bindPassword)
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
...(dto.searchFilter !== undefined && {
|
...(dto.searchFilter !== undefined && {
|
||||||
searchFilter: dto.searchFilter,
|
searchFilter: dto.searchFilter,
|
||||||
@@ -94,6 +204,7 @@ export class LdapConfigService {
|
|||||||
},
|
},
|
||||||
include: { fieldMappings: true },
|
include: { fieldMappings: true },
|
||||||
});
|
});
|
||||||
|
return this.withDecryptedPassword(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,9 +248,10 @@ export class LdapConfigService {
|
|||||||
* tenants need auto-sync.
|
* tenants need auto-sync.
|
||||||
*/
|
*/
|
||||||
async getAllActiveConfigs() {
|
async getAllActiveConfigs() {
|
||||||
return this.prisma.ldapConfig.findMany({
|
const configs = await this.prisma.ldapConfig.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
include: { tenant: true, fieldMappings: true },
|
include: { tenant: true, fieldMappings: true },
|
||||||
});
|
});
|
||||||
|
return configs.map((config) => this.withDecryptedPassword(config));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { CalendarModule } from '../calendar/calendar.module';
|
||||||
import { GroupsModule } from '../groups/groups.module';
|
import { GroupsModule } from '../groups/groups.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { LdapConfigService } from './ldap-config.service';
|
import { LdapConfigService } from './ldap-config.service';
|
||||||
@@ -17,9 +18,15 @@ import { LdapService } from './ldap.service';
|
|||||||
* GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from
|
* GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from
|
||||||
* syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule
|
* syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule
|
||||||
* imports neither LdapModule nor UserModule.
|
* 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({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule],
|
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule, CalendarModule],
|
||||||
controllers: [LdapController],
|
controllers: [LdapController],
|
||||||
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
||||||
exports: [LdapService, LdapConfigService],
|
exports: [LdapService, LdapConfigService],
|
||||||
|
|||||||
Reference in New Issue
Block a user