Files
tessera-ctl/apps/api/src/ldap/ldap.service.ts
T
schalli 04fc33fc2a feat(ldap): group/OU discovery endpoint + selective sync filter
Adds listGroups() to browse AD groups/OUs under base DN, and
collectSearchEntries() to restrict syncUsersForTenant to members of
selected groups or users under selected OUs. Group DNs are matched
via escaped memberOf clauses (RFC 4515); OU DNs become extra search
bases. Empty groupFilterDns keeps the original single-base-DN search
unchanged. Controller sync endpoint and the sync scheduler both pass
groupFilterDns through so manual and scheduled syncs honor it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:36:58 +02:00

425 lines
12 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Client, Entry } 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;
groupFilterDns: string[];
fieldMappings: Array<{
ldapField: string;
tesseraField: string;
}>;
}
/**
* A discovered AD group or organizational unit, returned by listGroups()
* for the admin to pick from when building a selective import filter.
*/
export interface LdapDirectoryEntry {
dn: string;
name: string;
type: 'group' | 'ou';
}
/**
* 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
}
}
}
/**
* Discover groups and organizational units under the configured base DN.
* Used by the admin UI to build a selective import filter (groupFilterDns).
* Read-only directory query using the service-account bind.
*/
async listGroups(config: {
serverUrl: string;
baseDn: string;
bindDn: string;
bindPassword: string;
}): Promise<LdapDirectoryEntry[]> {
const client = new Client({ url: config.serverUrl });
try {
await client.bind(config.bindDn, config.bindPassword);
const { searchEntries } = await client.search(config.baseDn, {
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
attributes: ['cn', 'ou', 'dn'],
scope: 'sub',
});
return searchEntries.map((entry) => {
const dn = entry.dn;
const isOu = /^ou=/i.test(dn);
const rawName = isOu ? entry['ou'] : entry['cn'];
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
? String(rawName)
: dn;
return {
dn,
name,
type: isOu ? ('ou' as const) : ('group' as const),
};
});
} 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, applying the group/OU filter if configured
const searchEntries = await this.collectSearchEntries(
client,
config,
sanitizedFilter,
attributes,
);
// 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;
}
/**
* Run the directory search for syncUsersForTenant, applying the selective
* group/OU import filter (groupFilterDns) when one is configured.
*
* - Empty groupFilterDns: single search of baseDn with sanitizedFilter
* (identical to pre-filter behavior, backward compatible).
* - Non-empty groupFilterDns: split into OU DNs (used as extra search
* bases) and group DNs (matched via memberOf on the base search).
* Results are merged and deduped by entry dn.
*/
private async collectSearchEntries(
client: Client,
config: LdapConfigData,
sanitizedFilter: string,
attributes: string[],
) {
if (!config.groupFilterDns || config.groupFilterDns.length === 0) {
const { searchEntries } = await client.search(config.baseDn, {
filter: sanitizedFilter,
attributes,
scope: 'sub',
});
return searchEntries;
}
const ouBases = config.groupFilterDns.filter((dn) => /^ou=/i.test(dn));
const groupDns = config.groupFilterDns.filter((dn) => !/^ou=/i.test(dn));
const entriesByDn = new Map<string, Entry>();
for (const ouBase of ouBases) {
const { searchEntries } = await client.search(ouBase, {
filter: sanitizedFilter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
if (groupDns.length > 0) {
const memberOfClauses = groupDns
.map(
(dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`,
)
.join('');
const combinedFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`;
const { searchEntries } = await client.search(config.baseDn, {
filter: combinedFilter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
return Array.from(entriesByDn.values());
}
/**
* 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');
}
}