04fc33fc2a
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>
425 lines
12 KiB
TypeScript
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');
|
|
}
|
|
}
|