feat(ldap): opt-in skip TLS verification for ldaps (internal CA)
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s

Add a per-tenant "Skip TLS certificate verification" toggle to the LDAP
admin page so admins can connect to an AD whose ldaps:// certificate is
signed by an internal/self-signed CA (Node error: "unable to verify the
first certificate"). When enabled, ldapts is given
tlsOptions.rejectUnauthorized=false; the flag is ignored for plain ldap://
(no TLS). Defaults to full verification.

New Boolean column LdapConfig.tlsRejectUnauthorized (@default(true)) +
migration; wired through DTOs, config service, all Client creations
(test/groups/user-search/import/sync) and the test-connection endpoint. UI
checkbox with an insecure-network warning (de/en). 3 new service specs;
API 218 green, web 131 green, both apps tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 14:18:55 +02:00
parent 38face43b4
commit af9e968c6f
10 changed files with 144 additions and 7 deletions
+8
View File
@@ -43,6 +43,10 @@ export class CreateLdapConfigDto {
@IsOptional()
isActive?: boolean;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
@IsArray()
@IsString({ each: true })
@IsOptional()
@@ -76,6 +80,10 @@ export class TestConnectionDto {
@IsString()
@IsOptional()
bindPassword?: string;
@IsBoolean()
@IsOptional()
tlsRejectUnauthorized?: boolean;
}
/**
+4
View File
@@ -39,6 +39,7 @@ export class LdapConfigService {
searchFilter: dto.searchFilter ?? '(objectClass=person)',
syncIntervalMin: dto.syncIntervalMin ?? 60,
isActive: dto.isActive ?? true,
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
groupFilterDns: dto.groupFilterDns ?? [],
userExcludeList: dto.userExcludeList ?? [],
fieldMappings: {
@@ -81,6 +82,9 @@ export class LdapConfigService {
syncIntervalMin: dto.syncIntervalMin,
}),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
...(dto.tlsRejectUnauthorized !== undefined && {
tlsRejectUnauthorized: dto.tlsRejectUnauthorized,
}),
...(dto.groupFilterDns !== undefined && {
groupFilterDns: dto.groupFilterDns,
}),
+13 -1
View File
@@ -133,6 +133,9 @@ export class LdapController {
const serverUrl = dto.serverUrl || config?.serverUrl;
const bindDn = dto.bindDn || config?.bindDn;
const bindPassword = dto.bindPassword || config?.bindPassword;
// Explicit form value wins; otherwise fall back to the saved config.
const tlsRejectUnauthorized =
dto.tlsRejectUnauthorized ?? config?.tlsRejectUnauthorized;
if (!serverUrl) {
throw new BadRequestException(
@@ -140,7 +143,12 @@ export class LdapController {
);
}
return this.ldapService.testConnection({ serverUrl, bindDn, bindPassword });
return this.ldapService.testConnection({
serverUrl,
bindDn,
bindPassword,
tlsRejectUnauthorized,
});
}
/**
@@ -165,6 +173,7 @@ export class LdapController {
baseDn: config.baseDn,
bindDn: config.bindDn,
bindPassword: config.bindPassword,
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
});
}
@@ -191,6 +200,7 @@ export class LdapController {
baseDn: config.baseDn,
bindDn: config.bindDn,
bindPassword: config.bindPassword,
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
},
tenantId,
q ?? '',
@@ -223,6 +233,7 @@ export class LdapController {
baseDn: config.baseDn,
bindDn: config.bindDn,
bindPassword: config.bindPassword,
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
searchFilter: config.searchFilter,
groupFilterDns: config.groupFilterDns,
userExcludeList: config.userExcludeList,
@@ -258,6 +269,7 @@ export class LdapController {
baseDn: config.baseDn,
bindDn: config.bindDn,
bindPassword: config.bindPassword,
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
searchFilter: config.searchFilter,
groupFilterDns: config.groupFilterDns,
userExcludeList: config.userExcludeList,
+43
View File
@@ -20,6 +20,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { Client } from 'ldapts';
import { LdapService } from './ldap.service';
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
@@ -265,3 +266,45 @@ describe('LdapService — individual user search & import (dedup)', () => {
expect(userService.create).not.toHaveBeenCalled();
});
});
describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () => {
let service: LdapService;
beforeEach(() => {
vi.clearAllMocks();
mockBind.mockResolvedValue(undefined);
mockUnbind.mockResolvedValue(undefined);
service = new LdapService({} as any, {} as any);
});
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
await service.testConnection({
serverUrl: 'ldaps://ad:636',
tlsRejectUnauthorized: false,
});
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
url: 'ldaps://ad:636',
tlsOptions: { rejectUnauthorized: false },
}),
);
});
it('keeps verification on for ldaps when tlsRejectUnauthorized is true', async () => {
await service.testConnection({
serverUrl: 'ldaps://ad:636',
tlsRejectUnauthorized: true,
});
const opts = (Client as any).mock.calls.at(-1)[0];
expect(opts.tlsOptions).toBeUndefined();
});
it('ignores the flag for plain ldap:// (no TLS)', async () => {
await service.testConnection({
serverUrl: 'ldap://ad:389',
tlsRejectUnauthorized: false,
});
const opts = (Client as any).mock.calls.at(-1)[0];
expect(opts.tlsOptions).toBeUndefined();
});
});
+42 -5
View File
@@ -25,6 +25,9 @@ interface LdapConfigData {
bindDn?: string | null;
bindPassword?: string | null;
searchFilter: string;
// When false, skip TLS certificate verification for ldaps:// (internal CA
// / self-signed AD certs). Ignored for plain ldap://. Default true.
tlsRejectUnauthorized?: boolean | null;
groupFilterDns: string[];
userExcludeList: string[];
fieldMappings: Array<{
@@ -97,6 +100,27 @@ export class LdapService {
await client.bind(bindDn || '', bindPassword || '');
}
/**
* Build ldapts Client options. For ldaps:// connections, honor an opt-in
* "skip TLS verification" flag (tlsRejectUnauthorized === false) so admins
* can connect to an AD whose certificate is signed by an internal/private
* CA that Node doesn't trust ("unable to verify the first certificate").
* Ignored for plain ldap:// (no TLS). Default is full verification.
*/
private buildClientOptions(
serverUrl: string,
tlsRejectUnauthorized?: boolean | null,
): ConstructorParameters<typeof Client>[0] {
const options: ConstructorParameters<typeof Client>[0] = { url: serverUrl };
if (
serverUrl.toLowerCase().startsWith('ldaps') &&
tlsRejectUnauthorized === false
) {
options.tlsOptions = { rejectUnauthorized: false };
}
return options;
}
/**
* Test LDAP connection with given configuration.
* Returns success/failure with optional error message.
@@ -105,8 +129,11 @@ export class LdapService {
serverUrl: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
}): Promise<{ success: boolean; error?: string }> {
const client = new Client({ url: config.serverUrl });
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
@@ -135,8 +162,11 @@ export class LdapService {
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
}): Promise<LdapDirectoryEntry[]> {
const client = new Client({ url: config.serverUrl });
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
@@ -263,6 +293,7 @@ export class LdapService {
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
},
tenantId: string,
query: string,
@@ -272,7 +303,9 @@ export class LdapService {
return [];
}
const client = new Client({ url: config.serverUrl });
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
const first = (v: unknown): string =>
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
@@ -350,7 +383,9 @@ export class LdapService {
errors: [],
};
const client = new Client({ url: config.serverUrl });
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
@@ -464,7 +499,9 @@ export class LdapService {
errors: [],
};
const client = new Client({ url: config.serverUrl });
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
// Create tenant-scoped Prisma client per Pitfall 2
const tenantPrisma = forTenant(this.prisma, tenantId) as any;