diff --git a/apps/api/prisma/migrations/20260722180000_ldap_tls_reject_unauthorized/migration.sql b/apps/api/prisma/migrations/20260722180000_ldap_tls_reject_unauthorized/migration.sql new file mode 100644 index 0000000..01364c2 --- /dev/null +++ b/apps/api/prisma/migrations/20260722180000_ldap_tls_reject_unauthorized/migration.sql @@ -0,0 +1,2 @@ +-- Add opt-in TLS verification skip for ldaps:// (internal/private CA AD certs). +ALTER TABLE "LdapConfig" ADD COLUMN IF NOT EXISTS "tlsRejectUnauthorized" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3a79141..cdf4764 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -69,6 +69,7 @@ model LdapConfig { searchFilter String @default("(objectClass=person)") syncIntervalMin Int @default(60) isActive Boolean @default(true) + tlsRejectUnauthorized Boolean @default(true) groupFilterDns String[] @default([]) userExcludeList String[] @default([]) lastSyncAt DateTime? diff --git a/apps/api/src/ldap/dto/ldap-config.dto.ts b/apps/api/src/ldap/dto/ldap-config.dto.ts index 593c8a6..efdddb0 100644 --- a/apps/api/src/ldap/dto/ldap-config.dto.ts +++ b/apps/api/src/ldap/dto/ldap-config.dto.ts @@ -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; } /** diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts index 155538e..1b0e5b7 100644 --- a/apps/api/src/ldap/ldap-config.service.ts +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -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, }), diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 5608f8a..a324825 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -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, diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index 5319953..9d696c4 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 7a1e25a..cc7b882 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -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[0] { + const options: ConstructorParameters[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 { - 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; diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx index cf751ec..d54f6bb 100644 --- a/apps/web/src/app/(portal)/admin/ldap/page.tsx +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -23,6 +23,7 @@ interface LdapConfig { searchFilter: string; syncIntervalMin: number; isActive: boolean; + tlsRejectUnauthorized: boolean; groupFilterDns: string[]; userExcludeList: string[]; lastSyncAt: string | null; @@ -85,6 +86,7 @@ export default function AdminLdapPage() { searchFilter: '(objectClass=person)', syncIntervalMin: 60, isActive: true, + tlsRejectUnauthorized: true, }); // New mapping form @@ -143,6 +145,7 @@ export default function AdminLdapPage() { searchFilter: data.searchFilter || '(objectClass=person)', syncIntervalMin: data.syncIntervalMin ?? 60, isActive: data.isActive ?? true, + tlsRejectUnauthorized: data.tlsRejectUnauthorized ?? true, }); setGroupFilterDns(data.groupFilterDns ?? []); setUserExcludeList(data.userExcludeList ?? []); @@ -201,10 +204,12 @@ export default function AdminLdapPage() { // before ever saving a config. bindPassword is omitted when blank so // the backend falls back to the saved config's password (masked // fields never get re-sent once a config already exists). - const body: Record = {}; + const body: Record = {}; if (formData.serverUrl) body.serverUrl = formData.serverUrl; if (formData.bindDn) body.bindDn = formData.bindDn; if (formData.bindPassword) body.bindPassword = formData.bindPassword; + // Always send the current TLS-verification choice so the test reflects it. + body.tlsRejectUnauthorized = formData.tlsRejectUnauthorized; const res = await fetch(`${API_URL}/ldap/test-connection`, { method: 'POST', @@ -504,6 +509,23 @@ export default function AdminLdapPage() { /> +
+ +

{t('tlsSkip.hint')}

+
+