3523e43a13
Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).
Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
@@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
explicitBufferAttributes and flags alreadyImported per tenant;
new importGroupsByDn() creates a Group per checked DN with
name/ldapDn/ldapObjectGuid, reject-with-report on name collision
(P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
skipped), never aborts the batch on one DN's error; new static
escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
(ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
own discovery/import handlers with a visible error state
(Owner decision 2026-08-06 — no silent catch{} for these two
handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
listGroups sort order and alreadyImported.
Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
363 lines
11 KiB
TypeScript
363 lines
11 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,
|
|
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;
|
|
}
|
|
}
|
|
}
|