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; errors: string[]; } /** * LDAP configuration shape as stored in the database. */ interface LdapConfigData { id: string; tenantId: string; serverUrl: string; baseDn: string; bindDn: string; bindPassword: string; searchFilter: string; groupFilterDns: string[]; fieldMappings: Array<{ ldapField: string; tesseraField: string; }>; } /** * A discovered AD group or organizational unit, returned by listGroups() * for the admin to pick from when building a selective import filter. */ export interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; } /** * LDAP Service - DIRECTORY SYNC ONLY. * * 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, ) {} /** * Test LDAP connection with given configuration. * Returns success/failure with optional error message. */ async testConnection(config: { serverUrl: string; bindDn: string; bindPassword: string; }): Promise<{ success: boolean; error?: string }> { const client = new Client({ url: config.serverUrl }); try { await client.bind(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 } } } /** * Discover groups and organizational units under the configured base DN. * Used by the admin UI to build a selective import filter (groupFilterDns). * Read-only directory query using the service-account bind. */ async listGroups(config: { serverUrl: string; baseDn: string; bindDn: string; bindPassword: string; }): Promise { const client = new Client({ url: config.serverUrl }); try { await client.bind(config.bindDn, config.bindPassword); const { searchEntries } = await client.search(config.baseDn, { filter: '(|(objectClass=group)(objectClass=organizationalUnit))', attributes: ['cn', 'ou', 'dn'], scope: 'sub', }); return searchEntries.map((entry) => { const dn = entry.dn; const isOu = /^ou=/i.test(dn); const rawName = isOu ? entry['ou'] : entry['cn']; const name = Array.isArray(rawName) ? String(rawName[0]) : rawName ? String(rawName) : dn; return { dn, name, type: isOu ? ('ou' as const) : ('group' as const), }; }); } finally { try { await client.unbind(); } catch { // Ignore unbind errors } } } /** * Sync users from LDAP directory for a specific tenant. * * 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, errors: [], }; const client = new Client({ url: config.serverUrl }); // Create tenant-scoped Prisma client per Pitfall 2 const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { // 1. Bind with service account await client.bind(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[] = []; // 4. Process each LDAP entry for (const entry of searchEntries) { try { const dn = entry.dn; syncedDns.push(dn); // Map LDAP fields to Tessera fields const mappedData: Record = {}; for (const mapping of config.fieldMappings) { const value = entry[mapping.ldapField]; if (value !== undefined && value !== null) { // LDAP attributes can be arrays; take first value mappedData[mapping.tesseraField] = Array.isArray(value) ? String(value[0]) : String(value); } } // Require at minimum a username const username = mappedData['username']; if (!username) { result.errors.push( `Entry ${dn}: no username mapped (check sAMAccountName mapping)`, ); continue; } // Check if user exists by ldapDn or username 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) { // Update existing user await this.prisma.user.update({ where: { id: existing.id }, data: { ...(mappedData['displayName'] && { displayName: mappedData['displayName'], }), ...(mappedData['email'] && { email: mappedData['email'] }), ...(mappedData['username'] && { username: mappedData['username'], }), ldapDn: dn, isActive: true, }, }); result.updated++; } else { // Create new user with role USER, passwordHash null (LDAP-only per A6) await this.userService.create({ username, email: mappedData['email'] || `${username}@ldap.local`, displayName: mappedData['displayName'], role: 'USER', tenantId, ldapDn: dn, // No password: LDAP-only user }); result.created++; } } 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++; } } // 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, applying the selective * group/OU import filter (groupFilterDns) when one is configured. * * - Empty groupFilterDns: single search of baseDn with sanitizedFilter * (identical to pre-filter behavior, backward compatible). * - Non-empty groupFilterDns: split into OU DNs (used as extra search * bases) and group DNs (matched via memberOf on the base search). * Results are merged and deduped by entry dn. */ private async collectSearchEntries( client: Client, config: LdapConfigData, sanitizedFilter: string, attributes: string[], ) { if (!config.groupFilterDns || config.groupFilterDns.length === 0) { const { searchEntries } = await client.search(config.baseDn, { filter: sanitizedFilter, attributes, scope: 'sub', }); return searchEntries; } const ouBases = config.groupFilterDns.filter((dn) => /^ou=/i.test(dn)); const groupDns = config.groupFilterDns.filter((dn) => !/^ou=/i.test(dn)); const entriesByDn = new Map(); for (const ouBase of ouBases) { const { searchEntries } = await client.search(ouBase, { filter: sanitizedFilter, attributes, scope: 'sub', }); for (const entry of searchEntries) { entriesByDn.set(entry.dn, entry); } } if (groupDns.length > 0) { const memberOfClauses = groupDns .map( (dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`, ) .join(''); const combinedFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`; const { searchEntries } = await client.search(config.baseDn, { filter: combinedFilter, attributes, scope: 'sub', }); for (const entry of searchEntries) { entriesByDn.set(entry.dn, entry); } } return Array.from(entriesByDn.values()); } /** * Sanitize LDAP search filter to prevent injection (T-02-16). * Escapes special characters per RFC 4515. * * 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'); } }