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:
2026-06-19 08:38:07 +02:00
parent ac617f4fe5
commit f928cd7713
10 changed files with 874 additions and 0 deletions
+133
View File
@@ -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 },
});
}
}