import { Injectable, Logger } from '@nestjs/common'; import { Client, EqualityFilter, Entry } from 'ldapts'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { GroupsService } from '../groups/groups.service'; 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; // Plan 16-03: how many bound Groups this run adopted (legacy ldapDn-only // binding from Plan 15-06, backfilled with ldapObjectGuid, D-07), renamed // to match the current AD cn/dn (SC-3), deleted because the AD group // disappeared (SC-4/D-05), and how many times the default-group marker // moved to another group before one of those deletions (D-06). // "groupsAdopted" NOT "groupsImported": this sync never imports a group // (D-02, deliberate deviation from the UI-SPEC field name — see // 16-03-PLAN.md decisions) — it only takes an existing legacy binding // under management. groupsAdopted: number; groupsRenamed: number; groupsDeleted: number; defaultMarkerMoved: 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 (D-18) * or, filtered to type: 'group', when picking AD groups to import as * Tessera groups (SC-1/SC-2, D-01/D-02). `alreadyImported` is only ever * true for type: 'group' entries whose objectGUID already matches a * Group.ldapObjectGuid of the requesting tenant — OUs are never importable * and always report false. */ export interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; alreadyImported?: boolean; } /** * Result of importGroupsByDn() — a manual, selective AD-group-to-Tessera-group * import (SC-1/SC-2, D-01/D-02). Name collisions are reported separately from * generic errors so the frontend can translate the collision message * (admin.ldap.groupImport.nameCollisionError) instead of rendering a raw * German backend string. */ export interface LdapGroupImportResult { imported: number; skipped: number; nameCollisions: string[]; errors: string[]; } /** * 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, private groupsService: GroupsService, ) {} /** * 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 both to build a selective import filter * (groupFilterDns) and — filtered client-side to type: 'group' — to pick * AD groups to import as Tessera groups (D-01: one shared discovery * endpoint, no second one). Read-only directory query using the * service-account bind. Results from all base DNs are merged and deduped * by entry dn. * * objectGUID is requested via explicitBufferAttributes so ldapts returns * it as a Buffer instead of attempting a lossy UTF-8 decode of the raw * 16-byte value (Pitfall 2, RESEARCH.md). It is used ONLY to compute * alreadyImported for group entries against this tenant's * Group.ldapObjectGuid rows — the hex value itself is never returned to * the client (T-16-04, information disclosure). */ async listGroups( config: { serverUrl: string; baseDn: string; bindDn?: string | null; bindPassword?: string | null; tlsRejectUnauthorized?: boolean | null; }, tenantId: string, ): 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', 'objectGUID'], explicitBufferAttributes: ['objectGUID'], scope: 'sub', }); for (const entry of searchEntries) { entriesByDn.set(entry.dn, entry); } } const mapped = Array.from(entriesByDn.values()).map((entry) => { const dn = entry.dn; const isOu = /^ou=/i.test(dn); const record = entry as unknown as Record; const rawName = isOu ? record['ou'] : record['cn']; const name = Array.isArray(rawName) ? String(rawName[0]) : rawName ? String(rawName) : dn; const guidValue = record['objectGUID']; const guidHex = !isOu && Buffer.isBuffer(guidValue) ? guidValue.toString('hex') : null; return { dn, name, type: isOu ? ('ou' as const) : ('group' as const), guidHex, }; }); const guidHexes = mapped .map((e) => e.guidHex) .filter((h): h is string => !!h); let importedSet = new Set(); if (guidHexes.length > 0) { const existing = await this.prisma.group.findMany({ where: { tenantId, ldapObjectGuid: { in: guidHexes } }, select: { ldapObjectGuid: true }, }); importedSet = new Set( existing .map((g: { ldapObjectGuid: string | null }) => g.ldapObjectGuid) .filter((g: string | null): g is string => !!g), ); } return mapped .map(({ guidHex, ...rest }) => ({ ...rest, alreadyImported: !!guidHex && importedSet.has(guidHex), })) .sort( (a, b) => a.name.localeCompare(b.name) || a.dn.localeCompare(b.dn), ); } 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; } /** * Import specific AD groups by DN (from listGroups results, filtered to * type: 'group') as Tessera groups (SC-1/SC-2, D-01/D-02). Every DN is a * base-scoped lookup — the admin never bulk-imports an OU or a search * result, only the exact groups they checked. objectGUID is read via * explicitBufferAttributes (same Pitfall-2 requirement as listGroups) and * hex-encoded into Group.ldapObjectGuid, the rename-stable identity key * Plan 16-03's reconciliation depends on. `GroupsService.create()` is * deliberately NOT used here: its ConflictException is built for a single * interactive HTTP request and would abort the whole batch on the first * name collision (Pitfall 4, RESEARCH.md) — this loop instead collects a * per-DN outcome and never stops on one group's error. */ async importGroupsByDn( config: LdapConfigData, tenantId: string, dns: string[], ): Promise { const result: LdapGroupImportResult = { imported: 0, skipped: 0, nameCollisions: [], errors: [], }; const client = new Client( this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized), ); // Mandantengescopter Schreibpfad (T-16-02): app.current_tenant wird vor // jedem group.create() gesetzt, RLS ist das zweite Netz. const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { await this.bind(client, config.bindDn, config.bindPassword); for (const dn of dns) { try { // Base-scoped lookup of exactly this DN — the admin-selected DN is // the search BASE, never interpolated into a filter (T-16-01). const { searchEntries } = await client.search(dn, { filter: '(objectClass=group)', attributes: ['cn', 'dn', 'objectGUID'], explicitBufferAttributes: ['objectGUID'], scope: 'base', }); if (searchEntries.length === 0) { result.errors.push(`${dn}: not found`); continue; } const entry = searchEntries[0]; const record = entry as unknown as Record; const guidValue = record['objectGUID']; if (!Buffer.isBuffer(guidValue)) { result.errors.push(`${dn}: objectGUID not readable`); continue; } const ldapObjectGuid = guidValue.toString('hex'); const rawName = record['cn']; const name = Array.isArray(rawName) ? String(rawName[0]) : rawName ? String(rawName) : dn; // Idempotency: a second import of the same AD group is a skip, not // a duplicate row. const existingByGuid = await this.prisma.group.findFirst({ where: { tenantId, ldapObjectGuid }, }); if (existingByGuid) { result.skipped++; continue; } try { await tenantPrisma.group.create({ data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid }, }); result.imported++; } catch (createError: any) { if (createError?.code === 'P2002') { const target = createError?.meta?.target; const targetsGuid = Array.isArray(target) ? target.includes('ldapObjectGuid') : String(target ?? '').includes('ldapObjectGuid'); if (targetsGuid) { // Lost a race against a concurrent import of the same AD // group — treat identically to the pre-check skip above. result.skipped++; } else { // @@unique([tenantId, name]) violation: reject-with-report, // never abort the remaining DNs (Pitfall 4). result.nameCollisions.push(name); } } else { throw createError; } } } catch (entryError: unknown) { const msg = entryError instanceof Error ? entryError.message : 'Unknown error importing group'; 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, groupsAdopted: 0, groupsRenamed: 0, groupsDeleted: 0, defaultMarkerMoved: 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++; } } // 5a. Plan 16-03 (SC-3/SC-4/SC-5, D-05/D-06/D-07): reconcile every // AD-bound Group's name/ldapDn/existence BEFORE the membership // reconciliation below. This ordering is the central correctness // condition of Phase 16, not a style choice (RESEARCH.md Pitfall 1): // step 5b reads Group.ldapDn from the DB to build its memberOf // filter. If a rename detected in THIS SAME run hadn't already been // written back by the time 5b runs, the memberOf filter would still // use the stale pre-rename DN, AD would return zero hits for it, and // every LDAP membership of the renamed group would be misreported as // removed — a rename would look like a membership wipeout that never // happened in the directory. await this.syncBoundGroupsForTenant(client, config, tenantId, result); // 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. // Relies on 5a above having already written back any rename in this // run (see 5a's comment). 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}`); } } } /** * D-07/SC-3/SC-4/SC-5/D-05/D-06: reconcile every AD-bound Group against * the current directory state — the piece the D-21 membership sync above * depends on already being correct. MUST run BEFORE * syncGroupMembershipsForTenant() in the same pass (wired as step 5a in * syncUsersForTenant, Plan 16-03 Task 2): that step reads Group.ldapDn to * build its memberOf filter, so a rename that has not been written back * yet would make every LDAP membership of the renamed group look removed * (RESEARCH.md Pitfall 1). * * Candidates are every Group with EITHER ldapObjectGuid OR ldapDn set — * the OR is the D-07 hookup for legacy bindings from Plan 15-06 that * predate ldapObjectGuid. A Group with both columns null (never bound, or * a purely local group) never enters this query (SC-5) and is never * touched by any write in this method. * * Per candidate, in order: * 1. Legacy-binding backfill (ldapDn set, ldapObjectGuid still null): one * base-scoped lookup on the stored DN. A hit backfills * ldapObjectGuid (groupsAdopted++) and the candidate is reconciled * normally in the SAME pass below. No hit is NOT a deletion — without * a stable key a deletion here would be a guess, not proof (T-16-11) — * it is an error line and the candidate is skipped. * 2. Existence sweep: the stored hex ldapObjectGuid is validated as * exactly 32 [0-9a-f] characters BEFORE it is ever turned into a * filter (T-16-01) — an invalid value is an error line, never a filter * interpolation. A valid value is turned back into a Buffer and handed * to an EqualityFilter over objectGUID — raw bytes, never an escaped * filter string — searched across every configured base DN. * 3. A hit whose cn/dn differ from the stored name/ldapDn is a rename * (SC-3): Group.name/ldapDn are updated to the AD state, * groupsRenamed++. internalName is NEVER written here (D-04). A * resulting P2002 (the new name collides with an existing local group) * is caught, reported as an error line, and the group is left * unchanged — the run continues with the remaining candidates. * 4. No hit under any configured base DN is NOT automatically treated as a * disappearance (WR-03, 16-REVIEW.md). The base-DN sweep only proves * "not found under these specific base DNs" — an AD group that was * MOVED to an OU outside the configured subtree (still present in the * directory) would otherwise be misread as gone and deleted along with * its memberships/module grants, which is a bigger blast radius than * D-05 ("group genuinely disappeared") was accepted for. Before * concluding disappearance, a second, wider (objectGUID=...) sweep runs * against each base DN's own domain root (the trailing DC=... RDN * chain, e.g. "ou=Sales,dc=example,dc=com" -> "dc=example,dc=com"), * skipping any root already covered by a configured base DN (the * common case where baseDn already IS the domain root — nothing wider * to search). A hit there means the group still exists, just outside * the configured subtree: reported as an error line, NOT deleted — the * same conservative "absence must be established, never merely * unobserved" stance already taken for a legacy binding whose DN no * longer resolves (case 1 above). Only when the wide sweep ALSO finds * nothing (or there is no wider root left to search) is disappearance * (SC-4/D-05) actually established: the default-marker handoff * (reassignDefaultBeforeDelete) runs BEFORE the delete — this ordering * is the actual correctness guarantee of D-06, not a style choice. A * resulting P2025 (already gone, e.g. a concurrent manual delete) is * swallowed without double-counting. * * A single group's search/DB failure never stops the run — the same * per-group try/catch pattern as syncGroupMembershipsForTenant above. * * After the loop, if at least one deletion happened, ensureDefaultGroup() * runs once (not per-deletion — it is idempotent and returns immediately * once any group exists) to close the RESEARCH.md Pitfall 5 window: a * tenant must never be left with zero groups until the next API restart. */ private async syncBoundGroupsForTenant( client: Client, config: LdapConfigData, tenantId: string, result: LdapSyncResult, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const candidates: { id: string; name: string; ldapDn: string | null; ldapObjectGuid: string | null; isDefault: boolean; }[] = await tenantPrisma.group.findMany({ where: { tenantId, OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }], }, select: { id: true, name: true, ldapDn: true, ldapObjectGuid: true, isDefault: true, }, }); if (candidates.length === 0) { return; } const baseDns = this.parseBaseDns(config.baseDn); let anyDeleted = false; // WR-03 (16-REVIEW.md): domain root(s) NOT already covered by a // configured base DN — the fallback anchor for the wide existence sweep // below, so a group moved outside every configured base DN is not // misread as deleted. Computed once per run (identical for every // candidate), not per-group. const domainRoots = Array.from( new Set( baseDns .map((dn) => LdapService.domainRootOf(dn)) .filter( (dn): dn is string => dn !== null && !baseDns.includes(dn), ), ), ); for (const group of candidates) { try { let ldapObjectGuid = group.ldapObjectGuid; // 1. Legacy-binding backfill (D-07-Anschluss). if (!ldapObjectGuid && group.ldapDn) { const { searchEntries } = await client.search(group.ldapDn, { filter: '(objectClass=group)', attributes: ['cn', 'dn', 'objectGUID'], explicitBufferAttributes: ['objectGUID'], scope: 'base', }); if (searchEntries.length === 0) { result.errors.push( `Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} laesst sich nicht mehr aufloesen`, ); continue; } const backfillRecord = searchEntries[0] as unknown as Record< string, unknown >; const backfillGuid = backfillRecord['objectGUID']; if (!Buffer.isBuffer(backfillGuid)) { result.errors.push( `Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`, ); continue; } ldapObjectGuid = backfillGuid.toString('hex'); await tenantPrisma.group.update({ where: { id: group.id }, data: { ldapObjectGuid }, }); result.groupsAdopted++; } // 2. Existence sweep — validate BEFORE any filter interpolation // (T-16-01). if (!ldapObjectGuid || !/^[0-9a-f]{32}$/.test(ldapObjectGuid)) { result.errors.push( `Gruppe ${group.name}: ungültiger ldapObjectGuid-Wert`, ); continue; } const guidBuffer = Buffer.from(ldapObjectGuid, 'hex'); // The GUID goes onto the wire as raw bytes via an EqualityFilter, NOT // as a `\xx`-escaped filter string. A string filter is parsed by ldapts // before it is encoded, and the parser does not turn `\1e\4b...` back // into the 16 bytes it stands for — the assertion value that reaches the // directory is then a different value entirely and matches nothing. // Measured read-only against a real AD on 2026-08-11 (see the quick task // 260811-f9i): the escaped string returned 0 hits for an object whose // GUID had just been read from that same directory, while this // EqualityFilter returned exactly that object. Both sweeps below share // this value, so the WR-03 move-detection cannot silently inherit a // broken filter again. const filter = new EqualityFilter({ attribute: 'objectGUID', value: guidBuffer, }); let hit: Entry | null = null; for (const baseDn of baseDns) { const { searchEntries } = await client.search(baseDn, { filter, attributes: ['cn', 'dn'], scope: 'sub', }); if (searchEntries.length > 0) { hit = searchEntries[0]; break; } } if (hit) { // 3. Rename/DN reconciliation (SC-3). const hitRecord = hit as unknown as Record; const rawName = hitRecord['cn']; const name = Array.isArray(rawName) ? String(rawName[0]) : rawName ? String(rawName) : hit.dn; const dn = hit.dn; // WR-04 (16-REVIEW.md): the CHANGE CHECK is case-insensitive — // only the DECISION "is this a rename" is normalized, never the // value written below. AD returning the same cn/dn with different // casing between two runs (e.g. after a domain-controller switch) // must not look like a rename: that would break the idempotency // guarantee (Priority Check 7 — sync twice over an unchanged AD // state = no-op) and increment groupsRenamed / issue an update on // every subsequent run. This is a plain lowercase compare, not // full RFC 4514 DN canonicalization (per-attribute-type // case-sensitivity rules, escaped-character normalization, etc.) // — a pragmatic simplification, same uncertainty class as A1/A2 in // RESEARCH.md, not verified against a real AD. const nameChanged = name.toLowerCase() !== (group.name ?? '').toLowerCase(); const dnChanged = dn.toLowerCase() !== (group.ldapDn ?? '').toLowerCase(); if (nameChanged || dnChanged) { try { // The write below stores name/dn EXACTLY as the directory // reports them, byte-for-byte — never the lowercased // comparison values above. Group.name stays AD's, per D-03. await tenantPrisma.group.update({ where: { id: group.id }, data: { name, ldapDn: dn }, }); result.groupsRenamed++; } catch (updateError: any) { if (updateError?.code === 'P2002') { // WR-02 (16-REVIEW.md): this update() writes BOTH name and // ldapDn in one call — @@unique([tenantId, name]) AND // @@unique([tenantId, ldapDn]) are both potential triggers // of a P2002 here, so the collision is only actually a name // collision when updateError.meta.target says so. Mirrors // the discrimination importGroupsByDn() already does above // for its own create() call. const target = updateError?.meta?.target; const targetsName = Array.isArray(target) ? target.includes('name') : String(target ?? '').includes('name'); result.errors.push( targetsName ? `Gruppe ${group.name}: Umbenennung nach '${name}' kollidiert mit einer bestehenden Gruppe` : `Gruppe ${group.name}: Aktualisierung kollidiert mit einer bestehenden Bindung (${JSON.stringify(target)})`, ); } else { throw updateError; } } } } else { // WR-03 (16-REVIEW.md): before concluding disappearance, rule out // a directory-internal move by widening the SAME (objectGUID=...) // filter to each base DN's own domain root — outside the // configured base-DN restriction. A hit means the group still // exists; report and move on WITHOUT deleting or writing anything // (deliberately no auto-rename to the found location either — a // move outside the configured scope is an admin configuration // signal, not something this sync silently absorbs). let wideHit: Entry | null = null; for (const root of domainRoots) { const { searchEntries } = await client.search(root, { filter, attributes: ['cn', 'dn'], scope: 'sub', }); if (searchEntries.length > 0) { wideHit = searchEntries[0]; break; } } if (wideHit) { result.errors.push( `Gruppe ${group.name}: nicht mehr unter den konfigurierten Base-DNs gefunden, existiert aber weiterhin unter '${wideHit.dn}' — vermutlich im Verzeichnis verschoben, Base-DN-Konfiguration prüfen. Nicht gelöscht.`, ); continue; } // 4. Disappearance (SC-4/D-05/D-06) established — handoff BEFORE // delete. const movedDefault = await this.groupsService.reassignDefaultBeforeDelete( tenantId, group.id, ); if (movedDefault) { result.defaultMarkerMoved++; } try { await tenantPrisma.group.delete({ where: { id: group.id } }); result.groupsDeleted++; anyDeleted = true; } catch (deleteError: any) { if (deleteError?.code !== 'P2025') { throw deleteError; } // Already gone (e.g. a concurrent manual delete) — not // double-counted. } } } catch (groupError: unknown) { const msg = groupError instanceof Error ? groupError.message : 'Unknown error reconciling group'; result.errors.push(`Gruppe ${group.name}: ${msg}`); } } if (anyDeleted) { await this.groupsService.ensureDefaultGroup(tenantId); } } /** * 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'); } // NOTE: escapeLdapFilterBuffer() used to live here — a byte-wise `\XX` hex // escape for binary values, written under the assumption (RESEARCH.md A2) // that ldapts would pass such a string through to the directory unchanged. // It does not, and the resulting filter matched nothing; the existence sweep // in syncBoundGroupsForTenant() now builds an EqualityFilter over the raw // Buffer instead. Do not reintroduce it: escapeLdapFilterValue() below is for // STRING values and stays correct, but binary values belong in a filter // object, never in an interpolated filter string. /** * Split a DN into its individual RDN components, respecting a * backslash-escaped comma inside an RDN value (RFC 4514) so a value like * "cn=Sales\, Inc.,dc=example,dc=com" is not split in the wrong place. * Used only by domainRootOf() below — never for LDAP filter construction * (T-16-01 stays in force there; this is pure DN string bookkeeping). */ private static splitDnComponents(dn: string): string[] { const parts: string[] = []; let current = ''; let escaped = false; for (const ch of dn) { if (escaped) { current += ch; escaped = false; continue; } if (ch === '\\') { current += ch; escaped = true; continue; } if (ch === ',') { parts.push(current); current = ''; continue; } current += ch; } parts.push(current); return parts.map((p) => p.trim()).filter((p) => p.length > 0); } /** * Derive the domain root DN — the trailing DC=... component chain — from a * configured base DN, e.g. "ou=Sales,dc=example,dc=com" -> * "dc=example,dc=com". WR-03 (16-REVIEW.md) fallback search anchor for * syncBoundGroupsForTenant()'s existence sweep: widening a "not found" * result to the domain root before concluding a bound group has actually * disappeared, rather than merely moved outside a narrower configured * base DN. Returns null when the DN carries no DC= component at all * (nothing to widen the search to — e.g. a non-AD directory using a * different root naming scheme). */ private static domainRootOf(dn: string): string | null { const dcParts = LdapService.splitDnComponents(dn).filter((rdn) => /^dc=/i.test(rdn), ); return dcParts.length > 0 ? dcParts.join(',') : null; } }