From f928cd7713b1acdd0bee08751ec73351403034e9 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 19 Jun 2026 08:38:07 +0200 Subject: [PATCH] feat(02-04): LdapModule with sync service, config service, scheduler, and controller - LdapService uses ldapts for DIRECTORY SYNC ONLY (anti-pattern avoidance) - LdapConfigService creates default field mappings per D-16 (displayName, mail, sAMAccountName) - Custom field mappings can be added/removed per D-17 - Per-tenant LDAP config per D-18 - syncUsersForTenant deactivates users removed from LDAP per D-15 - LdapSyncScheduler sets tenant context explicitly per Pitfall 2 - Manual sync endpoint POST /ldap/sync per D-14 - Auto-sync cron checks syncIntervalMin per D-14 - Test connection endpoint for LDAP config validation - OpenLDAP + phpLDAPadmin added to docker-compose.dev.yml - LDAP search filter sanitization per T-02-16 - bindPassword never returned in API responses per T-02-17 --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 2 + apps/api/src/ldap/dto/ldap-config.dto.ts | 65 +++++ apps/api/src/ldap/ldap-config.service.ts | 133 ++++++++++ apps/api/src/ldap/ldap-sync.scheduler.ts | 89 +++++++ apps/api/src/ldap/ldap.controller.ts | 204 +++++++++++++++ apps/api/src/ldap/ldap.module.ts | 21 ++ apps/api/src/ldap/ldap.service.ts | 303 +++++++++++++++++++++++ docker-compose.dev.yml | 21 ++ pnpm-lock.yaml | 35 +++ 10 files changed, 874 insertions(+) create mode 100644 apps/api/src/ldap/dto/ldap-config.dto.ts create mode 100644 apps/api/src/ldap/ldap-config.service.ts create mode 100644 apps/api/src/ldap/ldap-sync.scheduler.ts create mode 100644 apps/api/src/ldap/ldap.controller.ts create mode 100644 apps/api/src/ldap/ldap.module.ts create mode 100644 apps/api/src/ldap/ldap.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index bd248fc..1232081 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "@prisma/client": "^6.0.0", "@tessera/shared": "workspace:*", "argon2": "^0.44.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9813da2..882ed2d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor'; import { HealthModule } from './health/health.module'; +import { LdapModule } from './ldap/ldap.module'; import { MailModule } from './mail/mail.module'; import { PrismaModule } from './prisma/prisma.module'; import { TenantMiddleware } from './tenant/tenant.middleware'; @@ -21,6 +22,7 @@ import { UserModule } from './user/user.module'; TenantModule, HealthModule, MailModule, + LdapModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/ldap/dto/ldap-config.dto.ts b/apps/api/src/ldap/dto/ldap-config.dto.ts new file mode 100644 index 0000000..c3d7a8a --- /dev/null +++ b/apps/api/src/ldap/dto/ldap-config.dto.ts @@ -0,0 +1,65 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + Min, +} from 'class-validator'; + +/** + * DTO for creating a new LDAP configuration per tenant (D-18). + */ +export class CreateLdapConfigDto { + @IsUrl({ protocols: ['ldap', 'ldaps'], require_tld: false }) + serverUrl!: string; + + @IsString() + @IsNotEmpty() + baseDn!: string; + + @IsString() + @IsNotEmpty() + bindDn!: string; + + @IsString() + @IsNotEmpty() + bindPassword!: string; + + @IsString() + @IsOptional() + searchFilter?: string = '(objectClass=person)'; + + @IsInt() + @IsOptional() + @Min(0) + syncIntervalMin?: number = 60; + + @IsBoolean() + @IsOptional() + isActive?: boolean = true; +} + +/** + * DTO for updating an existing LDAP configuration. + */ +export class UpdateLdapConfigDto extends PartialType(CreateLdapConfigDto) {} + +/** + * DTO for creating a field mapping entry (D-17). + */ +export class CreateFieldMappingDto { + @IsString() + @IsNotEmpty() + ldapField!: string; + + @IsString() + @IsNotEmpty() + tesseraField!: string; + + @IsBoolean() + @IsOptional() + isDefault?: boolean; +} diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts new file mode 100644 index 0000000..aa617e2 --- /dev/null +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateFieldMappingDto, + CreateLdapConfigDto, + UpdateLdapConfigDto, +} from './dto/ldap-config.dto'; + +/** + * Per-tenant LDAP configuration CRUD (D-18). + * Manages LDAP connection settings and field mappings. + */ +@Injectable() +export class LdapConfigService { + constructor(private prisma: PrismaService) {} + + /** + * Get LDAP config for a tenant, including field mappings. + */ + async getConfig(tenantId: string) { + return this.prisma.ldapConfig.findUnique({ + where: { tenantId }, + include: { fieldMappings: true }, + }); + } + + /** + * Create LDAP config for a tenant with default field mappings (D-16). + * Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username + */ + async createConfig(tenantId: string, dto: CreateLdapConfigDto) { + return this.prisma.ldapConfig.create({ + data: { + tenantId, + serverUrl: dto.serverUrl, + baseDn: dto.baseDn, + bindDn: dto.bindDn, + bindPassword: dto.bindPassword, + searchFilter: dto.searchFilter ?? '(objectClass=person)', + syncIntervalMin: dto.syncIntervalMin ?? 60, + isActive: dto.isActive ?? true, + fieldMappings: { + create: [ + { + ldapField: 'displayName', + tesseraField: 'displayName', + isDefault: true, + }, + { ldapField: 'mail', tesseraField: 'email', isDefault: true }, + { + ldapField: 'sAMAccountName', + tesseraField: 'username', + isDefault: true, + }, + ], + }, + }, + include: { fieldMappings: true }, + }); + } + + /** + * Update LDAP config for a tenant. + */ + async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) { + return this.prisma.ldapConfig.update({ + where: { tenantId }, + data: { + ...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }), + ...(dto.baseDn !== undefined && { baseDn: dto.baseDn }), + ...(dto.bindDn !== undefined && { bindDn: dto.bindDn }), + ...(dto.bindPassword !== undefined && { + bindPassword: dto.bindPassword, + }), + ...(dto.searchFilter !== undefined && { + searchFilter: dto.searchFilter, + }), + ...(dto.syncIntervalMin !== undefined && { + syncIntervalMin: dto.syncIntervalMin, + }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + }, + include: { fieldMappings: true }, + }); + } + + /** + * Add a custom field mapping to an LDAP config (D-17). + */ + async addFieldMapping(configId: string, dto: CreateFieldMappingDto) { + return this.prisma.ldapFieldMapping.create({ + data: { + ldapConfigId: configId, + ldapField: dto.ldapField, + tesseraField: dto.tesseraField, + isDefault: dto.isDefault ?? false, + }, + }); + } + + /** + * Remove a field mapping. Only non-default mappings can be deleted. + * System-provided defaults (isDefault=true) are protected. + */ + async removeFieldMapping(mappingId: string) { + const mapping = await this.prisma.ldapFieldMapping.findUnique({ + where: { id: mappingId }, + }); + + if (!mapping) { + return null; + } + + if (mapping.isDefault) { + throw new Error('Cannot delete default field mappings'); + } + + return this.prisma.ldapFieldMapping.delete({ + where: { id: mappingId }, + }); + } + + /** + * Get all active LDAP configs. Used by the scheduler to determine which + * tenants need auto-sync. + */ + async getAllActiveConfigs() { + return this.prisma.ldapConfig.findMany({ + where: { isActive: true }, + include: { tenant: true, fieldMappings: true }, + }); + } +} diff --git a/apps/api/src/ldap/ldap-sync.scheduler.ts b/apps/api/src/ldap/ldap-sync.scheduler.ts new file mode 100644 index 0000000..6c30936 --- /dev/null +++ b/apps/api/src/ldap/ldap-sync.scheduler.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { LdapConfigService } from './ldap-config.service'; +import { LdapService } from './ldap.service'; + +/** + * LDAP Sync Scheduler (D-14 auto-sync). + * + * Runs every minute and checks each active LDAP config to determine + * if a sync is due based on syncIntervalMin. + * + * CRITICAL per Pitfall 2: Each tenant sync is an independent operation + * with its own tenant context. We do NOT share database connections + * across tenant syncs. + */ +@Injectable() +export class LdapSyncScheduler { + private readonly logger = new Logger(LdapSyncScheduler.name); + + constructor( + private ldapService: LdapService, + private ldapConfigService: LdapConfigService, + ) {} + + /** + * Cron job running every minute. Checks all active LDAP configs + * and triggers sync for those whose interval has elapsed. + */ + @Cron(CronExpression.EVERY_MINUTE) + async handleCron() { + const configs = await this.ldapConfigService.getAllActiveConfigs(); + + for (const config of configs) { + try { + // Skip configs with auto-sync disabled (interval = 0) + if (config.syncIntervalMin <= 0) { + continue; + } + + // Check if sync is due + const now = new Date(); + if (config.lastSyncAt) { + const elapsed = + (now.getTime() - config.lastSyncAt.getTime()) / 1000 / 60; + if (elapsed < config.syncIntervalMin) { + continue; // Not yet time for next sync + } + } + // If lastSyncAt is null, sync has never run -- trigger now + + this.logger.log( + `Starting LDAP sync for tenant ${config.tenantId} (interval: ${config.syncIntervalMin}min)`, + ); + + // CRITICAL per Pitfall 2: Each tenant sync gets its own context. + // The ldapService.syncUsersForTenant method receives tenantId explicitly + // and creates a forTenant scoped client for all DB operations. + const result = await this.ldapService.syncUsersForTenant( + { + id: config.id, + tenantId: config.tenantId, + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + searchFilter: config.searchFilter, + fieldMappings: config.fieldMappings, + }, + config.tenantId, + ); + + this.logger.log( + `LDAP sync completed for tenant ${config.tenantId}: ` + + `created=${result.created}, updated=${result.updated}, deactivated=${result.deactivated}` + + (result.errors.length > 0 + ? `, errors=${result.errors.length}` + : ''), + ); + } catch (error: unknown) { + // One tenant's failure must not block others + const message = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `LDAP sync failed for tenant ${config.tenantId}: ${message}`, + ); + } + } + } +} diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts new file mode 100644 index 0000000..61358d8 --- /dev/null +++ b/apps/api/src/ldap/ldap.controller.ts @@ -0,0 +1,204 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Patch, + Post, + Req, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { + CreateFieldMappingDto, + CreateLdapConfigDto, + UpdateLdapConfigDto, +} from './dto/ldap-config.dto'; +import { LdapConfigService } from './ldap-config.service'; +import { LdapService } from './ldap.service'; + +/** + * LDAP Configuration and Sync Controller. + * All endpoints require ADMIN or SUPER_ADMIN role. + * Config is per-tenant (D-18). + */ +@Controller('ldap') +export class LdapController { + constructor( + private ldapConfigService: LdapConfigService, + private ldapService: LdapService, + ) {} + + /** + * GET /ldap/config - Get LDAP config for current tenant (D-18). + */ + @Get('config') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async getConfig(@Req() req: any) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + return null; + } + + // Never return bindPassword in API responses (T-02-17) + return { + ...config, + bindPassword: '********', + }; + } + + /** + * POST /ldap/config - Create LDAP config for current tenant (D-18). + * Creates default field mappings per D-16. + */ + @Post('config') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + // Check if config already exists + const existing = await this.ldapConfigService.getConfig(tenantId); + if (existing) { + throw new BadRequestException( + 'LDAP config already exists for this tenant. Use PATCH to update.', + ); + } + + const config = await this.ldapConfigService.createConfig(tenantId, dto); + + return { + ...config, + bindPassword: '********', + }; + } + + /** + * PATCH /ldap/config - Update LDAP config for current tenant. + */ + @Patch('config') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const existing = await this.ldapConfigService.getConfig(tenantId); + if (!existing) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + const config = await this.ldapConfigService.updateConfig(tenantId, dto); + + return { + ...config, + bindPassword: '********', + }; + } + + /** + * POST /ldap/test-connection - Test LDAP connection with current config. + * Returns success/failure with error message. + */ + @Post('test-connection') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async testConnection(@Req() req: any) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + return this.ldapService.testConnection({ + serverUrl: config.serverUrl, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + }); + } + + /** + * POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button). + * Returns sync results with created/updated/deactivated counts. + */ + @Post('sync') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async triggerSync(@Req() req: any) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + return this.ldapService.syncUsersForTenant( + { + id: config.id, + tenantId: config.tenantId, + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + searchFilter: config.searchFilter, + fieldMappings: config.fieldMappings, + }, + tenantId, + ); + } + + /** + * POST /ldap/config/mappings - Add a field mapping (D-17). + */ + @Post('config/mappings') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + return this.ldapConfigService.addFieldMapping(config.id, dto); + } + + /** + * DELETE /ldap/config/mappings/:id - Remove non-default field mapping. + */ + @Delete('config/mappings/:id') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async removeFieldMapping(@Param('id') id: string) { + try { + const result = await this.ldapConfigService.removeFieldMapping(id); + if (!result) { + throw new NotFoundException('Field mapping not found'); + } + return result; + } catch (error: unknown) { + if (error instanceof Error && error.message.includes('default')) { + throw new BadRequestException(error.message); + } + throw error; + } + } +} diff --git a/apps/api/src/ldap/ldap.module.ts b/apps/api/src/ldap/ldap.module.ts new file mode 100644 index 0000000..68bac86 --- /dev/null +++ b/apps/api/src/ldap/ldap.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { UserModule } from '../user/user.module'; +import { LdapConfigService } from './ldap-config.service'; +import { LdapSyncScheduler } from './ldap-sync.scheduler'; +import { LdapController } from './ldap.controller'; +import { LdapService } from './ldap.service'; + +/** + * LDAP Module - Directory sync for user import (AUTH-06). + * + * Provides per-tenant LDAP configuration (D-18), manual sync (D-14), + * auto-sync scheduler (D-14), and configurable field mapping (D-16/D-17). + */ +@Module({ + imports: [ScheduleModule.forRoot(), UserModule], + controllers: [LdapController], + providers: [LdapService, LdapConfigService, LdapSyncScheduler], + exports: [LdapService], +}) +export class LdapModule {} diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts new file mode 100644 index 0000000..7722e32 --- /dev/null +++ b/apps/api/src/ldap/ldap.service.ts @@ -0,0 +1,303 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Client } 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; + fieldMappings: Array<{ + ldapField: string; + tesseraField: 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, + ) {} + + /** + * 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 + } + } + } + + /** + * 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 + const { searchEntries } = await client.search(config.baseDn, { + filter: sanitizedFilter, + attributes, + scope: 'sub', + }); + + // 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; + } + + /** + * 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'); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0d13e2c..39c6a64 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -30,3 +30,24 @@ services: - "8025:8025" networks: - backend-net + + openldap: + image: osixia/openldap:1.5.0 + environment: + LDAP_ORGANISATION: Tessera + LDAP_DOMAIN: tessera.local + LDAP_ADMIN_PASSWORD: admin + ports: + - "389:389" + - "636:636" + networks: + - data-net + + phpldapadmin: + image: osixia/phpldapadmin:0.9.0 + environment: + PHPLDAPADMIN_LDAP_HOSTS: openldap + ports: + - "6443:443" + networks: + - backend-net diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6600f9f..e0d0912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + '@nestjs/schedule': + specifier: ^6.1.3 + version: 6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) '@prisma/client': specifier: ^6.0.0 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) @@ -785,6 +788,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.3': + resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.1.0': resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} peerDependencies: @@ -1232,6 +1241,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mjml-core@5.0.0': resolution: {integrity: sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==} @@ -1717,6 +1729,10 @@ packages: typescript: optional: true + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -2543,6 +2559,10 @@ packages: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -4382,6 +4402,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)': + dependencies: + '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -4743,6 +4769,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.19.21 + '@types/luxon@3.7.1': {} + '@types/mjml-core@5.0.0': optional: true @@ -5293,6 +5321,11 @@ snapshots: typescript: 5.9.3 optional: true + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -6210,6 +6243,8 @@ snapshots: lru-cache@11.5.1: {} + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5