Files
tessera-ctl/apps/api/src/ldap/ldap.controller.ts
T
schalli 9a57fa79f5 feat(quick-260909-ipc): ldap-config.service.ts an forTenant() binden, Loesch-Fremdzugriff schliessen
Aufgabe 2 der Etappe 2: getConfig/createConfig/updateConfig sowie
addFieldMapping/removeFieldMapping laufen jetzt ueber forTenant(), gebunden
an den aus der Anfrage bekannten Mandanten. removeFieldMapping nimmt den
Mandanten neu als Pflichtparameter entgegen und der Controller holt ihn aus
dem Sitzungsnachweis statt nur die URL-Kennung weiterzureichen (T-IPC-01) --
ein Administrator konnte bisher die Feldzuordnung eines fremden Mandanten
loeschen, wenn er ihre Kennung kannte. getAllActiveConfigs() und die
Start-Nachverschluesselung bleiben bewusst uebergreifend, mit ausgeschriebener
Begruendung im Code (Befund B).

rls-access-inventory.spec.ts erkennt jetzt neben `this.prisma.<Modell>` auch
gebundene `<Name>.<Modell>`-Zugriffe (Befund F/G) und prueft eine neue
Stand-Spalte (gebunden/ungebunden/gemischt) im Klassifikationsdokument gegen
den Quelltext. Das macht zwei bisher unsichtbare, weil schon laenger
gebundene Fundstellen sichtbar (auth.service.ts/passwordResetToken,
ldap.service.ts/groupMembership) und deckt auf, dass
(ldap-config.service.ts, ldapConfig) tatsaechlich "beides" ist, nicht
"muss-mandantengebunden" (Befund B).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 14:01:28 +02:00

377 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(tenantId, config.id, dto);
}
/**
* DELETE /ldap/config/mappings/:id - Remove non-default field mapping.
*
* Der Mandant kommt aus dem Sitzungsnachweis, NICHT aus der URL (T-IPC-01,
* WINDOWS #20 Etappe 2, 260909-ipc): vorher nahm diese Route
* ausschliesslich die Kennung entgegen und reichte sie ungebunden an den
* Dienst weiter — ein Administrator des Mandanten A konnte damit die
* Feldzuordnung des Mandanten B loeschen, wenn er deren Kennung kannte.
*/
@Delete('config/mappings/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeFieldMapping(@Req() req: any, @Param('id') id: string) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
try {
const result = await this.ldapConfigService.removeFieldMapping(
tenantId,
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;
}
}
}