Files
tessera-ctl/apps/api/src/ldap/ldap.service.ts
T
schalli 614de2815a feat(15-04): AD-Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener
  Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein
  Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP)
  wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen
  unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...])
  bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als
  "Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter.
- Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5)
  und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter,
  kein separater Job, kein zweiter Button (D-21).
- LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert.
- ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty,
  encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der
  drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die
  erweiterte LdapSyncResult-Form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:50:42 +02:00

986 lines
33 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Client, Entry } from 'ldapts';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { UserService } from '../user/user.service';
/**
* LDAP sync result returned after each sync operation.
*/
export interface LdapSyncResult {
created: number;
updated: number;
deactivated: number;
// D-21: how many GroupMembership(source: LDAP) rows this run added/removed
// while reconciling every AD-bound Group in the same pass (no separate
// sync job, no second button).
groupMembershipsAdded: number;
groupMembershipsRemoved: number;
errors: string[];
}
/**
* LDAP configuration shape as stored in the database.
*/
interface LdapConfigData {
id: string;
tenantId: string;
serverUrl: string;
baseDn: string;
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<{
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';
}
/**
* A single AD user matched by searchUsers() for the admin to import
* individually. `alreadyImported` is true when a Tessera user for this
* tenant already exists with the same ldapDn or username — so the UI can
* show it as already present and importUsersByDn() skips it (no duplicates).
*/
export interface LdapUserSearchResult {
dn: string;
username: string;
displayName: string;
email: string;
alreadyImported: boolean;
}
/** Result of importUsersByDn() — a manual, non-deactivating single-user import. */
export interface LdapUserImportResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* LDAP Service - DIRECTORY SYNC ONLY.
*
* CRITICAL ANTI-PATTERN AVOIDANCE: This service is used exclusively for
* importing/syncing user directories from LDAP/AD into Tessera. It is NEVER
* used for authentication. Users authenticate against local password hashes.
*
* See: RESEARCH.md anti-pattern guidance, T-02-18.
*/
@Injectable()
export class LdapService {
private readonly logger = new Logger(LdapService.name);
constructor(
private prisma: PrismaService,
private userService: UserService,
) {}
/**
* Bind a client, falling back to an anonymous bind (empty DN/password,
* per RFC 4513) when no bindDn/bindPassword is configured. Lets tenants
* connect to directories that allow anonymous read access without
* requiring a service account.
*/
private async bind(
client: Client,
bindDn?: string | null,
bindPassword?: string | null,
): Promise<void> {
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.
*/
async testConnection(config: {
serverUrl: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
}): Promise<{ success: boolean; error?: string }> {
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
return { success: true };
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown LDAP error';
this.logger.warn(`LDAP connection test failed: ${message}`);
return { success: false, error: message };
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Authenticate a user for LOGIN by binding as their OWN DN with the password
* they entered (distinct from the service-account bind used for sync/search).
* Returns true only on a successful authenticated bind.
*
* SECURITY: an empty password is rejected up front — many AD servers treat a
* bind with a DN and empty password as an unauthenticated/anonymous bind that
* "succeeds", which would let anyone log in as any LDAP user. Never allow it.
*/
async verifyUserCredentials(
config: { serverUrl: string; tlsRejectUnauthorized?: boolean | null },
userDn: string,
password: string,
): Promise<boolean> {
if (!userDn || !password) {
return false;
}
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await client.bind(userDn, password);
return true;
} catch {
return false;
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Split a `\n`-separated Base-DN admin field into a trimmed, non-empty DN
* list. The baseDn column stays a single String (no schema change) — this
* is the sole place that turns it into the list every search path loops
* over. Blank/whitespace-only lines are dropped; undefined/empty input
* yields [].
*/
private parseBaseDns(baseDn?: string | null): string[] {
if (!baseDn) {
return [];
}
return baseDn
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
/**
* Discover groups and organizational units under EVERY configured base DN.
* Used by the admin UI to build a selective import filter (groupFilterDns).
* Read-only directory query using the service-account bind. Results from
* all base DNs are merged and deduped by entry dn.
*/
async listGroups(config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
}): Promise<LdapDirectoryEntry[]> {
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
const baseDns = this.parseBaseDns(config.baseDn);
const entriesByDn = new Map<string, Entry>();
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
attributes: ['cn', 'ou', 'dn'],
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
return Array.from(entriesByDn.values()).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
}
}
}
/**
* Map one LDAP entry to Tessera fields via the configured fieldMappings.
*
* ldapts represents a missing/absent attribute as an empty array ([]),
* not undefined -- naively doing String(value[0]) on that produces the
* literal string "undefined", identical across every entry lacking the
* attribute (e.g. no `mail` set), which then collides on unique constraints
* like email. Resolve to the first array element (or the raw value) and skip
* when it's actually missing/empty. Username is lowercased so logins stay
* case-insensitive regardless of AD casing.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both derive the
* same identity from an entry.
*/
private mapEntry(
entry: Record<string, unknown>,
fieldMappings: { ldapField: string; tesseraField: string }[],
): { username?: string; mappedData: Record<string, string> } {
const mappedData: Record<string, string> = {};
for (const mapping of fieldMappings) {
const value = entry[mapping.ldapField];
const resolved = Array.isArray(value) ? value[0] : value;
if (resolved !== undefined && resolved !== null && resolved !== '') {
mappedData[mapping.tesseraField] = String(resolved);
}
}
return { username: mappedData['username']?.toLowerCase(), mappedData };
}
/**
* Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both use IDENTICAL
* identity resolution: a user imported one way is NEVER duplicated by the
* other. A manually-imported user (ldapDn set) is matched by ldapDn on a
* later department/group sync and updated in place, not re-created.
*/
private async upsertMappedUser(
dn: string,
username: string,
mappedData: Record<string, string>,
tenantId: string,
): Promise<'created' | 'updated'> {
const existingByDn = await this.prisma.user.findFirst({
where: { ldapDn: dn, tenantId },
});
const existingByUsername = existingByDn
? null
: await this.prisma.user.findFirst({ where: { username, tenantId } });
const existing = existingByDn || existingByUsername;
if (existing) {
await this.prisma.user.update({
where: { id: existing.id },
data: {
...(mappedData['displayName'] && {
displayName: mappedData['displayName'],
}),
...(mappedData['email'] && { email: mappedData['email'] }),
...(mappedData['username'] && { username }),
ldapDn: dn,
isActive: true,
},
});
return 'updated';
}
await this.userService.create({
username,
email: mappedData['email'] || `${username}@ldap.local`,
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
return 'created';
}
/**
* Search AD for individual users by a free-text query (substring match on
* cn, sAMAccountName, displayName, mail). Read-only, service-account bind.
* Each result is flagged `alreadyImported` so the admin sees who is already
* present and cannot import a duplicate. The query is RFC-4515-escaped
* before interpolation (T-02-16, LDAP injection prevention).
*/
async searchUsers(
config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
},
tenantId: string,
query: string,
): Promise<LdapUserSearchResult[]> {
const trimmed = (query ?? '').trim();
if (trimmed.length === 0) {
return [];
}
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) : '';
try {
await this.bind(client, config.bindDn, config.bindPassword);
const q = LdapService.escapeLdapFilterValue(trimmed);
const filter = `(&(objectClass=person)(|(cn=*${q}*)(sAMAccountName=*${q}*)(displayName=*${q}*)(mail=*${q}*)))`;
const baseDns = this.parseBaseDns(config.baseDn);
const entriesByDn = new Map<string, Entry>();
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter,
attributes: ['cn', 'displayName', 'sAMAccountName', 'mail', 'dn'],
scope: 'sub',
sizeLimit: 50,
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
const entries = Array.from(entriesByDn.values()).map((entry) => ({
dn: entry.dn,
username: first(entry['sAMAccountName']),
displayName: first(entry['displayName']) || first(entry['cn']),
email: first(entry['mail']),
}));
// Flag entries already present for this tenant (by ldapDn or username) in
// a single query, so the UI marks them and import stays idempotent.
const dns = entries.map((e) => e.dn);
const usernames = entries
.map((e) => e.username.toLowerCase())
.filter(Boolean);
const existing = await this.prisma.user.findMany({
where: {
tenantId,
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
},
select: { ldapDn: true, username: true },
});
const dnSet = new Set(
existing.map((u) => u.ldapDn).filter((d): d is string => !!d),
);
const usernameSet = new Set(
existing.map((u) => u.username.toLowerCase()),
);
return entries.map((e) => ({
...e,
alreadyImported:
dnSet.has(e.dn) ||
(!!e.username && usernameSet.has(e.username.toLowerCase())),
}));
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Import specific AD users by DN (from searchUsers results). Idempotent and
* NON-deactivating: unlike syncUsersForTenant this never deactivates other
* users. A user that already exists (by ldapDn or username) is SKIPPED — its
* ldapDn is linked if missing so a later department/group sync recognizes it
* and never creates a duplicate. Respects the userExcludeList denylist.
*/
async importUsersByDn(
config: LdapConfigData,
tenantId: string,
dns: string[],
): Promise<LdapUserImportResult> {
const result: LdapUserImportResult = {
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
.filter(Boolean),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
const attributes = config.fieldMappings.map((m) => m.ldapField);
if (!attributes.includes('dn')) {
attributes.push('dn');
}
for (const dn of dns) {
try {
// Base-scoped lookup of exactly this DN.
const { searchEntries } = await client.search(dn, {
filter: '(objectClass=person)',
attributes,
scope: 'base',
});
if (searchEntries.length === 0) {
result.errors.push(`${dn}: not found`);
continue;
}
const { username, mappedData } = this.mapEntry(
searchEntries[0] as Record<string, unknown>,
config.fieldMappings,
);
if (!username) {
result.errors.push(
`${dn}: no username mapped (check sAMAccountName mapping)`,
);
continue;
}
if (excludeSet.has(username)) {
result.skipped++;
continue;
}
// Dedup: if a user already exists (by ldapDn or username) skip it,
// but link the ldapDn so a later group/OU sync matches it and never
// duplicates.
const existing = await this.prisma.user.findFirst({
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
});
if (existing) {
if (existing.ldapDn !== dn) {
await this.prisma.user.update({
where: { id: existing.id },
data: { ldapDn: dn },
});
}
result.skipped++;
continue;
}
await this.userService.create({
username,
email: mappedData['email'] || `${username}@ldap.local`,
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
result.created++;
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error importing entry';
result.errors.push(`${dn}: ${msg}`);
}
}
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Sync users from LDAP directory for a specific tenant.
*
* CRITICAL per Pitfall 2: This method receives tenantId explicitly.
* When called from the scheduler, the caller sets the tenant context
* BEFORE any DB operations using forTenant.
*
* Flow:
* 1. Connect and bind to LDAP server
* 2. Search for users with configured filter
* 3. Map LDAP fields to Tessera fields using config.fieldMappings
* 4. Upsert users: create new, update existing
* 5. Deactivate users removed from LDAP (D-15)
* 6. Update lastSyncAt timestamp
*/
async syncUsersForTenant(
config: LdapConfigData,
tenantId: string,
): Promise<LdapSyncResult> {
const result: LdapSyncResult = {
created: 0,
updated: 0,
deactivated: 0,
groupMembershipsAdded: 0,
groupMembershipsRemoved: 0,
errors: [],
};
// CRITICAL SAFETY GUARD: the Base-DN(s) are now the sync scope. Returning
// here BEFORE any LDAP bind/search and BEFORE the deactivation loop below
// is what prevents an unconfigured/blank config from mass-deactivating
// every existing LDAP user (there would be no synced DNs to compare
// against, so every local LDAP user would look "removed"). This is the
// SOLE no-op condition: an empty groupFilterDns no longer short-circuits
// here — with >=1 base DN configured, a normal multi-base search runs
// (see collectSearchEntries), it just applies no memberOf restriction.
// lastSyncAt is intentionally left untouched on this no-op path.
const baseDns = this.parseBaseDns(config.baseDn);
if (baseDns.length === 0) {
return result;
}
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;
try {
// 1. Bind with service account (anonymous when not configured)
await this.bind(client, config.bindDn, config.bindPassword);
// 2. Build attributes list from field mappings + dn
const attributes = config.fieldMappings.map((m) => m.ldapField);
// Always include dn for tracking
if (!attributes.includes('dn')) {
attributes.push('dn');
}
// Sanitize search filter (T-02-16: LDAP injection prevention)
const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter);
// 3. Search LDAP directory, applying the group/OU filter if configured
const searchEntries = await this.collectSearchEntries(
client,
config,
sanitizedFilter,
attributes,
);
// Track all DNs found in this sync for deactivation logic
const syncedDns: string[] = [];
// Per-user exclude/denylist: individual usernames (sAMAccountName) the
// admin never wants imported, e.g. service accounts like administrator,
// krbtgt, guest, ldap$. Distinct from groupFilterDns, which only limits
// which OUs/groups are searched. Normalized to lowercase to match the
// case-insensitive username handling below.
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
.filter(Boolean),
);
// 4. Process each LDAP entry
for (const entry of searchEntries) {
try {
const dn = entry.dn;
// Map LDAP fields to Tessera fields (shared mapEntry helper);
// username is lowercased for case-insensitive logins.
const { username, mappedData } = this.mapEntry(
entry as Record<string, unknown>,
config.fieldMappings,
);
// Skip excluded users before recording the DN as synced. Leaving an
// excluded entry out of syncedDns means that if the admin adds an
// already-imported user to the exclude list, the deactivation pass
// below will deactivate them on the next sync.
if (username && excludeSet.has(username)) {
continue;
}
syncedDns.push(dn);
if (!username) {
result.errors.push(
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
);
continue;
}
// Find-or-upsert by (ldapDn, then username) via the shared helper so
// sync and manual import dedupe identically (never a duplicate row).
const status = await this.upsertMappedUser(
dn,
username,
mappedData,
tenantId,
);
if (status === 'created') {
result.created++;
} else {
result.updated++;
}
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error processing entry';
result.errors.push(`Entry ${entry.dn}: ${msg}`);
}
}
// 5. Deactivation per D-15: Deactivate users removed from LDAP
const localLdapUsers = await this.prisma.user.findMany({
where: {
tenantId,
ldapDn: { not: null },
isActive: true,
},
select: { id: true, ldapDn: true },
});
for (const localUser of localLdapUsers) {
if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) {
await this.prisma.user.update({
where: { id: localUser.id },
data: { isActive: false },
});
result.deactivated++;
}
}
// 5b. D-21: reconcile GroupMembership rows for every AD-bound Group in
// this same run — no separate sync job, no second button. Runs behind
// the Base-DN no-op guard above, exactly like the rest of this method.
await this.syncGroupMembershipsForTenant(
client,
config,
sanitizedFilter,
attributes,
tenantId,
result,
);
// 6. Update lastSyncAt
await this.prisma.ldapConfig.update({
where: { id: config.id },
data: { lastSyncAt: new Date() },
});
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown LDAP sync error';
this.logger.error(`LDAP sync failed for tenant ${tenantId}: ${message}`);
result.errors.push(`Sync failed: ${message}`);
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Run the directory search for syncUsersForTenant across EVERY configured
* Base DN (the primary sync scope — see syncUsersForTenant's early-return
* guard, which is the sole no-op path and is keyed on the parsed base-DN
* list, not on groupFilterDns). groupFilterDns is an OPTIONAL extra
* restriction:
*
* - Empty groupFilterDns: each base DN is searched with the plain
* sanitizedFilter — no memberOf restriction, every user under the base
* DN(s) is synced.
* - Non-empty groupFilterDns: split into OU DNs (searched as ADDITIONAL
* extra bases with the plain filter) and group DNs (turned into a
* memberOf OR-clause ANDed with sanitizedFilter and applied to every
* base DN search).
*
* All results across every base DN (and any extra OU bases) are merged
* and deduped by entry dn.
*/
private async collectSearchEntries(
client: Client,
config: LdapConfigData,
sanitizedFilter: string,
attributes: string[],
) {
const baseDns = this.parseBaseDns(config.baseDn);
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>();
// Build the base-search filter: plain sanitizedFilter, optionally ANDed
// with a memberOf OR-clause when group DNs are configured.
let baseFilter = sanitizedFilter;
if (groupDns.length > 0) {
const memberOfClauses = groupDns
.map(
(dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`,
)
.join('');
baseFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`;
}
// Search every configured base DN with the (possibly memberOf-restricted)
// base filter.
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter: baseFilter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
// ou= group-filter entries are ADDITIONAL search bases, searched with
// the plain sanitizedFilter (no memberOf restriction).
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);
}
}
return Array.from(entriesByDn.values());
}
/**
* D-21: reconcile GroupMembership rows for every Tessera Group bound to an
* AD group (Group.ldapDn set), called from syncUsersForTenant's existing
* run instead of a separate job/button. For each bound group, members are
* found via a memberOf REVERSE-QUERY — `(memberOf=<groupDn>)` as a search
* FILTER, exactly the collectSearchEntries pattern above — never by reading
* memberOf/member off an entry as a return attribute, which AD silently
* truncates to `attribute;range=0-1499` for large groups (Pitfall 1,
* 15-RESEARCH.md): a bound group would then permanently show ~1500 members
* with no error anywhere. Only source: 'LDAP' rows are ever added or
* removed here; source: 'MANUAL' rows (D-20 mixed membership) are never
* touched — that filter is the sole protection for hand-added members
* (D-19).
*
* Nested AD groups are DELIBERATELY not resolved: only direct memberOf
* membership is considered via the plain (memberOf=<dn>) filter, not the
* AD-specific LDAP_MATCHING_RULE_IN_CHAIN extension. This mirrors the
* existing groupFilterDns behavior from Phase 2, is not required by D-19,
* and a vendor-specific matching rule would silently return zero hits
* against a non-AD directory instead of failing loudly.
*
* A tenant with no AD-bound groups runs no additional LDAP search at all.
* Each group is reconciled in its own try/catch so one failing group's
* search error (recorded as `Gruppe <name>: <message>` in result.errors)
* never stops the remaining groups from being processed.
*/
private async syncGroupMembershipsForTenant(
client: Client,
config: LdapConfigData,
sanitizedFilter: string,
attributes: string[],
tenantId: string,
result: LdapSyncResult,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const boundGroups: { id: string; name: string; ldapDn: string | null }[] =
await tenantPrisma.group.findMany({
where: { tenantId, ldapDn: { not: null } },
select: { id: true, name: true, ldapDn: true },
});
if (boundGroups.length === 0) {
return;
}
const baseDns = this.parseBaseDns(config.baseDn);
for (const group of boundGroups) {
try {
// The group DN is admin-selected input (D-18 discovery picker), but
// is always escaped before interpolation, never trusted raw (T-15-07).
const filter = `(&${sanitizedFilter}(memberOf=${LdapService.escapeLdapFilterValue(group.ldapDn as string)}))`;
// Set<username> so the AD hit ORDER never affects the outcome — the
// reconciliation below is a pure set operation over usernames.
const usernames = new Set<string>();
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
const { username } = this.mapEntry(
entry as Record<string, unknown>,
config.fieldMappings,
);
if (username) {
usernames.add(username);
}
}
}
// Resolve AD hits to local users in ONE query (tenantId-scoped, never
// a cross-tenant match — T-15-15). A username with no matching local
// user is neither an error nor a membership: it was excluded or lies
// outside the sync base.
let matchedUserIds: string[] = [];
if (usernames.size > 0) {
const localUsers: { id: string }[] =
await tenantPrisma.user.findMany({
where: { tenantId, username: { in: [...usernames] } },
select: { id: true },
});
matchedUserIds = localUsers.map((u) => u.id);
}
// createMany + skipDuplicates against @@unique([groupId, userId]) is
// exactly what leaves a pre-existing MANUAL row untouched instead of
// upgrading it to LDAP (D-19/D-20 mixed membership).
if (matchedUserIds.length > 0) {
const created = await tenantPrisma.groupMembership.createMany({
data: matchedUserIds.map((userId) => ({
groupId: group.id,
userId,
source: 'LDAP',
})),
skipDuplicates: true,
});
result.groupMembershipsAdded += created.count;
}
// The source: 'LDAP' filter here is the ONLY thing protecting MANUAL
// memberships from this delete — an empty matchedUserIds list (zero
// AD hits) correctly removes every LDAP membership of this group and
// leaves every MANUAL one behind (notIn: [] matches all rows).
const removed = await tenantPrisma.groupMembership.deleteMany({
where: {
groupId: group.id,
source: 'LDAP',
userId: { notIn: matchedUserIds },
},
});
result.groupMembershipsRemoved += removed.count;
} catch (groupError: unknown) {
const msg =
groupError instanceof Error
? groupError.message
: 'Unknown error syncing group';
result.errors.push(`Gruppe ${group.name}: ${msg}`);
}
}
}
/**
* Sanitize LDAP search filter to prevent injection (T-02-16).
* Escapes special characters per RFC 4515.
*
* Note: We validate the overall filter structure but escape any
* user-injectable portions. The base filter itself is admin-configured.
*/
private sanitizeSearchFilter(filter: string): string {
// The filter is configured by admins, not end-users.
// We still validate it contains balanced parentheses as a sanity check.
if (!filter || filter.trim().length === 0) {
return '(objectClass=person)';
}
// Count parentheses - they must be balanced
let depth = 0;
for (const char of filter) {
if (char === '(') depth++;
if (char === ')') depth--;
if (depth < 0) {
this.logger.warn(
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
);
return '(objectClass=person)';
}
}
if (depth !== 0) {
this.logger.warn(
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
);
return '(objectClass=person)';
}
return filter;
}
/**
* Escape a value for use in an LDAP search filter per RFC 4515.
* Used when constructing filters with user-provided attribute values.
*/
static escapeLdapFilterValue(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\x00/g, '\\00');
}
}