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:
@@ -17,6 +17,7 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"@tessera/shared": "workspace:*",
|
"@tessera/shared": "workspace:*",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
|||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
|
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { LdapModule } from './ldap/ldap.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TenantMiddleware } from './tenant/tenant.middleware';
|
import { TenantMiddleware } from './tenant/tenant.middleware';
|
||||||
@@ -21,6 +22,7 @@ import { UserModule } from './user/user.module';
|
|||||||
TenantModule,
|
TenantModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
LdapModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new LDAP configuration per tenant (D-18).
|
||||||
|
*/
|
||||||
|
export class CreateLdapConfigDto {
|
||||||
|
@IsUrl({ protocols: ['ldap', 'ldaps'], require_tld: false })
|
||||||
|
serverUrl!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
baseDn!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
bindDn!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
bindPassword!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
searchFilter?: string = '(objectClass=person)';
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(0)
|
||||||
|
syncIntervalMin?: number = 60;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating an existing LDAP configuration.
|
||||||
|
*/
|
||||||
|
export class UpdateLdapConfigDto extends PartialType(CreateLdapConfigDto) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a field mapping entry (D-17).
|
||||||
|
*/
|
||||||
|
export class CreateFieldMappingDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
ldapField!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
tesseraField!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { LdapConfigService } from './ldap-config.service';
|
||||||
|
import { LdapService } from './ldap.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP Sync Scheduler (D-14 auto-sync).
|
||||||
|
*
|
||||||
|
* Runs every minute and checks each active LDAP config to determine
|
||||||
|
* if a sync is due based on syncIntervalMin.
|
||||||
|
*
|
||||||
|
* CRITICAL per Pitfall 2: Each tenant sync is an independent operation
|
||||||
|
* with its own tenant context. We do NOT share database connections
|
||||||
|
* across tenant syncs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LdapSyncScheduler {
|
||||||
|
private readonly logger = new Logger(LdapSyncScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ldapService: LdapService,
|
||||||
|
private ldapConfigService: LdapConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron job running every minute. Checks all active LDAP configs
|
||||||
|
* and triggers sync for those whose interval has elapsed.
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async handleCron() {
|
||||||
|
const configs = await this.ldapConfigService.getAllActiveConfigs();
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
try {
|
||||||
|
// Skip configs with auto-sync disabled (interval = 0)
|
||||||
|
if (config.syncIntervalMin <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync is due
|
||||||
|
const now = new Date();
|
||||||
|
if (config.lastSyncAt) {
|
||||||
|
const elapsed =
|
||||||
|
(now.getTime() - config.lastSyncAt.getTime()) / 1000 / 60;
|
||||||
|
if (elapsed < config.syncIntervalMin) {
|
||||||
|
continue; // Not yet time for next sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If lastSyncAt is null, sync has never run -- trigger now
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Starting LDAP sync for tenant ${config.tenantId} (interval: ${config.syncIntervalMin}min)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRITICAL per Pitfall 2: Each tenant sync gets its own context.
|
||||||
|
// The ldapService.syncUsersForTenant method receives tenantId explicitly
|
||||||
|
// and creates a forTenant scoped client for all DB operations.
|
||||||
|
const result = await 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,
|
||||||
|
},
|
||||||
|
config.tenantId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`LDAP sync completed for tenant ${config.tenantId}: ` +
|
||||||
|
`created=${result.created}, updated=${result.updated}, deactivated=${result.deactivated}` +
|
||||||
|
(result.errors.length > 0
|
||||||
|
? `, errors=${result.errors.length}`
|
||||||
|
: ''),
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// One tenant's failure must not block others
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.logger.error(
|
||||||
|
`LDAP sync failed for tenant ${config.tenantId}: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { LdapConfigService } from './ldap-config.service';
|
||||||
|
import { LdapSyncScheduler } from './ldap-sync.scheduler';
|
||||||
|
import { LdapController } from './ldap.controller';
|
||||||
|
import { LdapService } from './ldap.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP Module - Directory sync for user import (AUTH-06).
|
||||||
|
*
|
||||||
|
* Provides per-tenant LDAP configuration (D-18), manual sync (D-14),
|
||||||
|
* auto-sync scheduler (D-14), and configurable field mapping (D-16/D-17).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [ScheduleModule.forRoot(), UserModule],
|
||||||
|
controllers: [LdapController],
|
||||||
|
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
||||||
|
exports: [LdapService],
|
||||||
|
})
|
||||||
|
export class LdapModule {}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Client } from 'ldapts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP sync result returned after each sync operation.
|
||||||
|
*/
|
||||||
|
export interface LdapSyncResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deactivated: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP configuration shape as stored in the database.
|
||||||
|
*/
|
||||||
|
interface LdapConfigData {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
serverUrl: string;
|
||||||
|
baseDn: string;
|
||||||
|
bindDn: string;
|
||||||
|
bindPassword: string;
|
||||||
|
searchFilter: string;
|
||||||
|
fieldMappings: Array<{
|
||||||
|
ldapField: string;
|
||||||
|
tesseraField: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP Service - DIRECTORY SYNC ONLY.
|
||||||
|
*
|
||||||
|
* CRITICAL ANTI-PATTERN AVOIDANCE: This service is used exclusively for
|
||||||
|
* importing/syncing user directories from LDAP/AD into Tessera. It is NEVER
|
||||||
|
* used for authentication. Users authenticate against local password hashes.
|
||||||
|
*
|
||||||
|
* See: RESEARCH.md anti-pattern guidance, T-02-18.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LdapService {
|
||||||
|
private readonly logger = new Logger(LdapService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test LDAP connection with given configuration.
|
||||||
|
* Returns success/failure with optional error message.
|
||||||
|
*/
|
||||||
|
async testConnection(config: {
|
||||||
|
serverUrl: string;
|
||||||
|
bindDn: string;
|
||||||
|
bindPassword: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const client = new Client({ url: config.serverUrl });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.bind(config.bindDn, config.bindPassword);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Unknown LDAP error';
|
||||||
|
this.logger.warn(`LDAP connection test failed: ${message}`);
|
||||||
|
return { success: false, error: message };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.unbind();
|
||||||
|
} catch {
|
||||||
|
// Ignore unbind errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync users from LDAP directory for a specific tenant.
|
||||||
|
*
|
||||||
|
* CRITICAL per Pitfall 2: This method receives tenantId explicitly.
|
||||||
|
* When called from the scheduler, the caller sets the tenant context
|
||||||
|
* BEFORE any DB operations using forTenant.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Connect and bind to LDAP server
|
||||||
|
* 2. Search for users with configured filter
|
||||||
|
* 3. Map LDAP fields to Tessera fields using config.fieldMappings
|
||||||
|
* 4. Upsert users: create new, update existing
|
||||||
|
* 5. Deactivate users removed from LDAP (D-15)
|
||||||
|
* 6. Update lastSyncAt timestamp
|
||||||
|
*/
|
||||||
|
async syncUsersForTenant(
|
||||||
|
config: LdapConfigData,
|
||||||
|
tenantId: string,
|
||||||
|
): Promise<LdapSyncResult> {
|
||||||
|
const result: LdapSyncResult = {
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
deactivated: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new Client({ url: config.serverUrl });
|
||||||
|
|
||||||
|
// Create tenant-scoped Prisma client per Pitfall 2
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Bind with service account
|
||||||
|
await client.bind(config.bindDn, config.bindPassword);
|
||||||
|
|
||||||
|
// 2. Build attributes list from field mappings + dn
|
||||||
|
const attributes = config.fieldMappings.map((m) => m.ldapField);
|
||||||
|
// Always include dn for tracking
|
||||||
|
if (!attributes.includes('dn')) {
|
||||||
|
attributes.push('dn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize search filter (T-02-16: LDAP injection prevention)
|
||||||
|
const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter);
|
||||||
|
|
||||||
|
// 3. Search LDAP directory
|
||||||
|
const { searchEntries } = await client.search(config.baseDn, {
|
||||||
|
filter: sanitizedFilter,
|
||||||
|
attributes,
|
||||||
|
scope: 'sub',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track all DNs found in this sync for deactivation logic
|
||||||
|
const syncedDns: string[] = [];
|
||||||
|
|
||||||
|
// 4. Process each LDAP entry
|
||||||
|
for (const entry of searchEntries) {
|
||||||
|
try {
|
||||||
|
const dn = entry.dn;
|
||||||
|
syncedDns.push(dn);
|
||||||
|
|
||||||
|
// Map LDAP fields to Tessera fields
|
||||||
|
const mappedData: Record<string, string> = {};
|
||||||
|
for (const mapping of config.fieldMappings) {
|
||||||
|
const value = entry[mapping.ldapField];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
// LDAP attributes can be arrays; take first value
|
||||||
|
mappedData[mapping.tesseraField] = Array.isArray(value)
|
||||||
|
? String(value[0])
|
||||||
|
: String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at minimum a username
|
||||||
|
const username = mappedData['username'];
|
||||||
|
if (!username) {
|
||||||
|
result.errors.push(
|
||||||
|
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists by ldapDn or username
|
||||||
|
const existingByDn = await this.prisma.user.findFirst({
|
||||||
|
where: { ldapDn: dn, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingByUsername = existingByDn
|
||||||
|
? null
|
||||||
|
: await this.prisma.user.findFirst({
|
||||||
|
where: { username, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = existingByDn || existingByUsername;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing user
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(mappedData['displayName'] && {
|
||||||
|
displayName: mappedData['displayName'],
|
||||||
|
}),
|
||||||
|
...(mappedData['email'] && { email: mappedData['email'] }),
|
||||||
|
...(mappedData['username'] && {
|
||||||
|
username: mappedData['username'],
|
||||||
|
}),
|
||||||
|
ldapDn: dn,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.updated++;
|
||||||
|
} else {
|
||||||
|
// Create new user with role USER, passwordHash null (LDAP-only per A6)
|
||||||
|
await this.userService.create({
|
||||||
|
username,
|
||||||
|
email: mappedData['email'] || `${username}@ldap.local`,
|
||||||
|
displayName: mappedData['displayName'],
|
||||||
|
role: 'USER',
|
||||||
|
tenantId,
|
||||||
|
ldapDn: dn,
|
||||||
|
// No password: LDAP-only user
|
||||||
|
});
|
||||||
|
result.created++;
|
||||||
|
}
|
||||||
|
} catch (entryError: unknown) {
|
||||||
|
const msg =
|
||||||
|
entryError instanceof Error
|
||||||
|
? entryError.message
|
||||||
|
: 'Unknown error processing entry';
|
||||||
|
result.errors.push(`Entry ${entry.dn}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Deactivation per D-15: Deactivate users removed from LDAP
|
||||||
|
const localLdapUsers = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
ldapDn: { not: null },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true, ldapDn: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const localUser of localLdapUsers) {
|
||||||
|
if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: localUser.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
result.deactivated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Update lastSyncAt
|
||||||
|
await this.prisma.ldapConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: { lastSyncAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Unknown LDAP sync error';
|
||||||
|
this.logger.error(`LDAP sync failed for tenant ${tenantId}: ${message}`);
|
||||||
|
result.errors.push(`Sync failed: ${message}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.unbind();
|
||||||
|
} catch {
|
||||||
|
// Ignore unbind errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
||||||
|
* Escapes special characters per RFC 4515.
|
||||||
|
*
|
||||||
|
* Note: We validate the overall filter structure but escape any
|
||||||
|
* user-injectable portions. The base filter itself is admin-configured.
|
||||||
|
*/
|
||||||
|
private sanitizeSearchFilter(filter: string): string {
|
||||||
|
// The filter is configured by admins, not end-users.
|
||||||
|
// We still validate it contains balanced parentheses as a sanity check.
|
||||||
|
if (!filter || filter.trim().length === 0) {
|
||||||
|
return '(objectClass=person)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count parentheses - they must be balanced
|
||||||
|
let depth = 0;
|
||||||
|
for (const char of filter) {
|
||||||
|
if (char === '(') depth++;
|
||||||
|
if (char === ')') depth--;
|
||||||
|
if (depth < 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
|
||||||
|
);
|
||||||
|
return '(objectClass=person)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth !== 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
|
||||||
|
);
|
||||||
|
return '(objectClass=person)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a value for use in an LDAP search filter per RFC 4515.
|
||||||
|
* Used when constructing filters with user-provided attribute values.
|
||||||
|
*/
|
||||||
|
static escapeLdapFilterValue(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\5c')
|
||||||
|
.replace(/\*/g, '\\2a')
|
||||||
|
.replace(/\(/g, '\\28')
|
||||||
|
.replace(/\)/g, '\\29')
|
||||||
|
.replace(/\x00/g, '\\00');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,3 +30,24 @@ services:
|
|||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
|
|
||||||
|
openldap:
|
||||||
|
image: osixia/openldap:1.5.0
|
||||||
|
environment:
|
||||||
|
LDAP_ORGANISATION: Tessera
|
||||||
|
LDAP_DOMAIN: tessera.local
|
||||||
|
LDAP_ADMIN_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "389:389"
|
||||||
|
- "636:636"
|
||||||
|
networks:
|
||||||
|
- data-net
|
||||||
|
|
||||||
|
phpldapadmin:
|
||||||
|
image: osixia/phpldapadmin:0.9.0
|
||||||
|
environment:
|
||||||
|
PHPLDAPADMIN_LDAP_HOSTS: openldap
|
||||||
|
ports:
|
||||||
|
- "6443:443"
|
||||||
|
networks:
|
||||||
|
- backend-net
|
||||||
|
|||||||
Generated
+35
@@ -44,6 +44,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)
|
version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)
|
||||||
|
'@nestjs/schedule':
|
||||||
|
specifier: ^6.1.3
|
||||||
|
version: 6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
||||||
@@ -785,6 +788,12 @@ packages:
|
|||||||
'@nestjs/common': ^11.0.0
|
'@nestjs/common': ^11.0.0
|
||||||
'@nestjs/core': ^11.0.0
|
'@nestjs/core': ^11.0.0
|
||||||
|
|
||||||
|
'@nestjs/schedule@6.1.3':
|
||||||
|
resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
'@nestjs/schematics@11.1.0':
|
'@nestjs/schematics@11.1.0':
|
||||||
resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==}
|
resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1232,6 +1241,9 @@ packages:
|
|||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
|
'@types/luxon@3.7.1':
|
||||||
|
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
||||||
|
|
||||||
'@types/mjml-core@5.0.0':
|
'@types/mjml-core@5.0.0':
|
||||||
resolution: {integrity: sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==}
|
resolution: {integrity: sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==}
|
||||||
|
|
||||||
@@ -1717,6 +1729,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cron@4.4.0:
|
||||||
|
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
||||||
|
engines: {node: '>=18.x'}
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -2543,6 +2559,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@@ -4382,6 +4402,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nestjs/schedule@6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
cron: 4.4.0
|
||||||
|
|
||||||
'@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)':
|
'@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||||
@@ -4743,6 +4769,8 @@ snapshots:
|
|||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
'@types/node': 22.19.21
|
'@types/node': 22.19.21
|
||||||
|
|
||||||
|
'@types/luxon@3.7.1': {}
|
||||||
|
|
||||||
'@types/mjml-core@5.0.0':
|
'@types/mjml-core@5.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5293,6 +5321,11 @@ snapshots:
|
|||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cron@4.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/luxon': 3.7.1
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@epic-web/invariant': 1.0.0
|
'@epic-web/invariant': 1.0.0
|
||||||
@@ -6210,6 +6243,8 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@11.5.1: {}
|
lru-cache@11.5.1: {}
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
Reference in New Issue
Block a user