feat(ldap): group/OU discovery endpoint + selective sync filter

Adds listGroups() to browse AD groups/OUs under base DN, and
collectSearchEntries() to restrict syncUsersForTenant to members of
selected groups or users under selected OUs. Group DNs are matched
via escaped memberOf clauses (RFC 4515); OU DNs become extra search
bases. Empty groupFilterDns keeps the original single-base-DN search
unchanged. Controller sync endpoint and the sync scheduler both pass
groupFilterDns through so manual and scheduled syncs honor it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 09:36:58 +02:00
parent 3c057f863d
commit 04fc33fc2a
3 changed files with 154 additions and 6 deletions
+1
View File
@@ -64,6 +64,7 @@ export class LdapSyncScheduler {
bindDn: config.bindDn,
bindPassword: config.bindPassword,
searchFilter: config.searchFilter,
groupFilterDns: config.groupFilterDns,
fieldMappings: config.fieldMappings,
},
config.tenantId,
+26
View File
@@ -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,
+127 -6
View File
@@ -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<LdapDirectoryEntry[]> {
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<string, Entry>();
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.