import { BadRequestException, Body, Controller, Delete, Get, NotFoundException, Param, Patch, Post, Query, Req, } from '@nestjs/common'; import { Role } from '@prisma/client'; import { Roles } from '../auth/decorators/roles.decorator'; import { CreateFieldMappingDto, CreateLdapConfigDto, ImportGroupsDto, ImportUsersDto, TestConnectionDto, 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: config.bindPassword ? '********' : null, }; } /** * 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: config.bindPassword ? '********' : null, }; } /** * 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: config.bindPassword ? '********' : null, }; } /** * POST /ldap/test-connection - Test an LDAP connection. * * Accepts optional ad-hoc serverUrl/bindDn/bindPassword so an admin can * validate connection details before ever saving a config. Any field left * out (e.g. bindPassword, which the form never re-sends once masked) * falls back to the tenant's saved config. bindDn/bindPassword are fully * optional -- omitting both attempts an anonymous bind. Only serverUrl is * required (directly, or from a saved config). */ @Post('test-connection') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); } const config = await this.ldapConfigService.getConfig(tenantId); const serverUrl = dto.serverUrl || config?.serverUrl; const bindDn = dto.bindDn || config?.bindDn; const bindPassword = dto.bindPassword || config?.bindPassword; // Explicit form value wins; otherwise fall back to the saved config. const tlsRejectUnauthorized = dto.tlsRejectUnauthorized ?? config?.tlsRejectUnauthorized; if (!serverUrl) { throw new BadRequestException( 'serverUrl is required (either provided directly or from a saved config)', ); } return this.ldapService.testConnection({ serverUrl, bindDn, bindPassword, tlsRejectUnauthorized, }); } /** * GET /ldap/groups - Discover AD groups/OUs under the configured base DN, * for building a selective import filter (groupFilterDns). */ @Get('groups') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async listGroups(@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.listGroups( { serverUrl: config.serverUrl, baseDn: config.baseDn, bindDn: config.bindDn, bindPassword: config.bindPassword, tlsRejectUnauthorized: config.tlsRejectUnauthorized, }, tenantId, ); } /** * POST /ldap/groups/import - Import specific AD groups by DN (from the * group discovery list, filtered to type: 'group') as Tessera groups * (SC-1/SC-2, D-01/D-02). Static route, placed before any future dynamic * `:id`-style route on this controller (project route-order convention). */ @Post('groups/import') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) { 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.importGroupsByDn( { id: config.id, tenantId: config.tenantId, serverUrl: config.serverUrl, baseDn: config.baseDn, bindDn: config.bindDn, bindPassword: config.bindPassword, tlsRejectUnauthorized: config.tlsRejectUnauthorized, searchFilter: config.searchFilter, groupFilterDns: config.groupFilterDns, userExcludeList: config.userExcludeList, fieldMappings: config.fieldMappings, }, tenantId, dto.dns, ); } /** * GET /ldap/users/search?q=... - Search AD for individual users by a * free-text query. Read-only. Each result is flagged `alreadyImported`. */ @Get('users/search') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async searchUsers(@Req() req: any, @Query('q') q: string) { 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.searchUsers( { serverUrl: config.serverUrl, baseDn: config.baseDn, bindDn: config.bindDn, bindPassword: config.bindPassword, tlsRejectUnauthorized: config.tlsRejectUnauthorized, }, tenantId, q ?? '', ); } /** * POST /ldap/users/import - Import specific AD users by DN (from the user * search). Idempotent and non-deactivating: existing users are skipped, so * a later department/group sync never creates a duplicate. */ @Post('users/import') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) { 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.importUsersByDn( { id: config.id, tenantId: config.tenantId, serverUrl: config.serverUrl, baseDn: config.baseDn, bindDn: config.bindDn, bindPassword: config.bindPassword, tlsRejectUnauthorized: config.tlsRejectUnauthorized, searchFilter: config.searchFilter, groupFilterDns: config.groupFilterDns, userExcludeList: config.userExcludeList, fieldMappings: config.fieldMappings, }, tenantId, dto.dns, ); } /** * 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, tlsRejectUnauthorized: config.tlsRejectUnauthorized, searchFilter: config.searchFilter, groupFilterDns: config.groupFilterDns, userExcludeList: config.userExcludeList, 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; } } }