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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user