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:
@@ -64,6 +64,7 @@ export class LdapSyncScheduler {
|
|||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
|
groupFilterDns: config.groupFilterDns,
|
||||||
fieldMappings: config.fieldMappings,
|
fieldMappings: config.fieldMappings,
|
||||||
},
|
},
|
||||||
config.tenantId,
|
config.tenantId,
|
||||||
|
|||||||
@@ -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).
|
* POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button).
|
||||||
* Returns sync results with created/updated/deactivated counts.
|
* Returns sync results with created/updated/deactivated counts.
|
||||||
@@ -157,6 +182,7 @@ export class LdapController {
|
|||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
|
groupFilterDns: config.groupFilterDns,
|
||||||
fieldMappings: config.fieldMappings,
|
fieldMappings: config.fieldMappings,
|
||||||
},
|
},
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Client } from 'ldapts';
|
import { Client, Entry } from 'ldapts';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
@@ -25,12 +25,23 @@ interface LdapConfigData {
|
|||||||
bindDn: string;
|
bindDn: string;
|
||||||
bindPassword: string;
|
bindPassword: string;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
|
groupFilterDns: string[];
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
ldapField: string;
|
ldapField: string;
|
||||||
tesseraField: 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.
|
* 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.
|
* 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)
|
// Sanitize search filter (T-02-16: LDAP injection prevention)
|
||||||
const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter);
|
const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter);
|
||||||
|
|
||||||
// 3. Search LDAP directory
|
// 3. Search LDAP directory, applying the group/OU filter if configured
|
||||||
const { searchEntries } = await client.search(config.baseDn, {
|
const searchEntries = await this.collectSearchEntries(
|
||||||
filter: sanitizedFilter,
|
client,
|
||||||
|
config,
|
||||||
|
sanitizedFilter,
|
||||||
attributes,
|
attributes,
|
||||||
scope: 'sub',
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Track all DNs found in this sync for deactivation logic
|
// Track all DNs found in this sync for deactivation logic
|
||||||
const syncedDns: string[] = [];
|
const syncedDns: string[] = [];
|
||||||
@@ -252,6 +311,68 @@ export class LdapService {
|
|||||||
return result;
|
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).
|
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
||||||
* Escapes special characters per RFC 4515.
|
* Escapes special characters per RFC 4515.
|
||||||
|
|||||||
Reference in New Issue
Block a user