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
+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');
}
}