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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user