Files
tessera-ctl/apps/api/src/ldap/ldap.controller.ts
T
schalli af9e968c6f
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s
feat(ldap): opt-in skip TLS verification for ldaps (internal CA)
Add a per-tenant "Skip TLS certificate verification" toggle to the LDAP
admin page so admins can connect to an AD whose ldaps:// certificate is
signed by an internal/self-signed CA (Node error: "unable to verify the
first certificate"). When enabled, ldapts is given
tlsOptions.rejectUnauthorized=false; the flag is ignored for plain ldap://
(no TLS). Defaults to full verification.

New Boolean column LdapConfig.tlsRejectUnauthorized (@default(true)) +
migration; wired through DTOs, config service, all Client creations
(test/groups/user-search/import/sync) and the test-connection endpoint. UI
checkbox with an insecure-network warning (de/en). 3 new service specs;
API 218 green, web 131 green, both apps tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:18:55 +02:00

321 lines
9.3 KiB
TypeScript

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,
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,
});
}
/**
* 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;
}
}
}