diff --git a/apps/api/src/ldap/ldap-sync.scheduler.ts b/apps/api/src/ldap/ldap-sync.scheduler.ts index 6c30936..11dd8aa 100644 --- a/apps/api/src/ldap/ldap-sync.scheduler.ts +++ b/apps/api/src/ldap/ldap-sync.scheduler.ts @@ -64,6 +64,7 @@ export class LdapSyncScheduler { bindDn: config.bindDn, bindPassword: config.bindPassword, searchFilter: config.searchFilter, + groupFilterDns: config.groupFilterDns, fieldMappings: config.fieldMappings, }, config.tenantId, diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 61358d8..f61e674 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -131,6 +131,31 @@ export class LdapController { }); } + /** + * GET /ldap/groups - Discover AD groups/OUs under the configured base DN, + * for building a selective import filter (groupFilterDns). + */ + @Get('groups') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async listGroups(@Req() req: any) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + return this.ldapService.listGroups({ + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + }); + } + /** * POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button). * Returns sync results with created/updated/deactivated counts. @@ -157,6 +182,7 @@ export class LdapController { bindDn: config.bindDn, bindPassword: config.bindPassword, searchFilter: config.searchFilter, + groupFilterDns: config.groupFilterDns, fieldMappings: config.fieldMappings, }, tenantId, diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 7722e32..2c09112 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Client } from 'ldapts'; +import { Client, Entry } from 'ldapts'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { UserService } from '../user/user.service'; @@ -25,12 +25,23 @@ interface LdapConfigData { bindDn: string; bindPassword: string; searchFilter: string; + groupFilterDns: string[]; fieldMappings: Array<{ ldapField: string; tesseraField: string; }>; } +/** + * A discovered AD group or organizational unit, returned by listGroups() + * for the admin to pick from when building a selective import filter. + */ +export interface LdapDirectoryEntry { + dn: string; + name: string; + type: 'group' | 'ou'; +} + /** * LDAP Service - DIRECTORY SYNC ONLY. * @@ -77,6 +88,53 @@ export class LdapService { } } + /** + * Discover groups and organizational units under the configured base DN. + * Used by the admin UI to build a selective import filter (groupFilterDns). + * Read-only directory query using the service-account bind. + */ + async listGroups(config: { + serverUrl: string; + baseDn: string; + bindDn: string; + bindPassword: string; + }): Promise { + const client = new Client({ url: config.serverUrl }); + + try { + await client.bind(config.bindDn, config.bindPassword); + + const { searchEntries } = await client.search(config.baseDn, { + filter: '(|(objectClass=group)(objectClass=organizationalUnit))', + attributes: ['cn', 'ou', 'dn'], + scope: 'sub', + }); + + return searchEntries.map((entry) => { + const dn = entry.dn; + const isOu = /^ou=/i.test(dn); + const rawName = isOu ? entry['ou'] : entry['cn']; + const name = Array.isArray(rawName) + ? String(rawName[0]) + : rawName + ? String(rawName) + : dn; + + return { + dn, + name, + type: isOu ? ('ou' as const) : ('group' as const), + }; + }); + } finally { + try { + await client.unbind(); + } catch { + // Ignore unbind errors + } + } + } + /** * Sync users from LDAP directory for a specific tenant. * @@ -122,12 +180,13 @@ export class LdapService { // Sanitize search filter (T-02-16: LDAP injection prevention) const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter); - // 3. Search LDAP directory - const { searchEntries } = await client.search(config.baseDn, { - filter: sanitizedFilter, + // 3. Search LDAP directory, applying the group/OU filter if configured + const searchEntries = await this.collectSearchEntries( + client, + config, + sanitizedFilter, attributes, - scope: 'sub', - }); + ); // Track all DNs found in this sync for deactivation logic const syncedDns: string[] = []; @@ -252,6 +311,68 @@ export class LdapService { return result; } + /** + * Run the directory search for syncUsersForTenant, applying the selective + * group/OU import filter (groupFilterDns) when one is configured. + * + * - Empty groupFilterDns: single search of baseDn with sanitizedFilter + * (identical to pre-filter behavior, backward compatible). + * - Non-empty groupFilterDns: split into OU DNs (used as extra search + * bases) and group DNs (matched via memberOf on the base search). + * Results are merged and deduped by entry dn. + */ + private async collectSearchEntries( + client: Client, + config: LdapConfigData, + sanitizedFilter: string, + attributes: string[], + ) { + if (!config.groupFilterDns || config.groupFilterDns.length === 0) { + const { searchEntries } = await client.search(config.baseDn, { + filter: sanitizedFilter, + attributes, + scope: 'sub', + }); + return searchEntries; + } + + const ouBases = config.groupFilterDns.filter((dn) => /^ou=/i.test(dn)); + const groupDns = config.groupFilterDns.filter((dn) => !/^ou=/i.test(dn)); + + const entriesByDn = new Map(); + + for (const ouBase of ouBases) { + const { searchEntries } = await client.search(ouBase, { + filter: sanitizedFilter, + attributes, + scope: 'sub', + }); + for (const entry of searchEntries) { + entriesByDn.set(entry.dn, entry); + } + } + + if (groupDns.length > 0) { + const memberOfClauses = groupDns + .map( + (dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`, + ) + .join(''); + const combinedFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`; + + const { searchEntries } = await client.search(config.baseDn, { + filter: combinedFilter, + attributes, + scope: 'sub', + }); + for (const entry of searchEntries) { + entriesByDn.set(entry.dn, entry); + } + } + + return Array.from(entriesByDn.values()); + } + /** * Sanitize LDAP search filter to prevent injection (T-02-16). * Escapes special characters per RFC 4515.