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 { 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. */ 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 { 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 { 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(); 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, fieldMappings: { ldapField: string; tesseraField: string }[], ): { username?: string; mappedData: Record } { const mappedData: Record = {}; 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, 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 { 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(); 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 { 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, 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 { 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, 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(); // 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=)` 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=) 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 : ` 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 { 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 so the AD hit ORDER never affects the outcome — the // reconciliation below is a pure set operation over usernames. const usernames = new Set(); 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, 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'); } }