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
+1
View File
@@ -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",
+2
View File
@@ -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()
+65
View File
@@ -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;
}
+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 },
});
}
}
+89
View File
@@ -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}`,
);
}
}
}
}
+204
View File
@@ -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;
}
}
}
+21
View File
@@ -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 {}
+303
View File
@@ -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');
}
}
+21
View File
@@ -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
+35
View File
@@ -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