feat(ldap): individual user search + selective import with dedup
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s

Add an AD single-user search (by cn/sAMAccountName/displayName/mail) and a
selective import to the LDAP admin page, alongside the existing group/OU
filter. Imported users are deduped against existing ones by (ldapDn, then
username): a manually-imported user carries its ldapDn, so a later
department/group sync matches and updates it in place instead of creating a
duplicate. Search results flag alreadyImported; import skips existing users
and links a missing ldapDn. Extracted shared mapEntry/upsertMappedUser
helpers so sync and manual import resolve identity identically.

Backend: GET /ldap/users/search, POST /ldap/users/import (RFC-4515 escaped
query, ADMIN-guarded). 6 new service specs (search flags, create, skip,
ldapDn-link, denylist). Full API suite 215 green, both apps tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:52:50 +02:00
parent 0dd104054b
commit 38face43b4
7 changed files with 744 additions and 59 deletions
+305 -59
View File
@@ -43,6 +43,28 @@ export interface LdapDirectoryEntry {
type: 'group' | 'ou';
}
/**
* A single AD user matched by searchUsers() for the admin to import
* individually. `alreadyImported` is true when a Tessera user for this
* tenant already exists with the same ldapDn or username — so the UI can
* show it as already present and importUsersByDn() skips it (no duplicates).
*/
export interface LdapUserSearchResult {
dn: string;
username: string;
displayName: string;
email: string;
alreadyImported: boolean;
}
/** Result of importUsersByDn() — a manual, non-deactivating single-user import. */
export interface LdapUserImportResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* LDAP Service - DIRECTORY SYNC ONLY.
*
@@ -150,6 +172,272 @@ export class LdapService {
}
}
/**
* Map one LDAP entry to Tessera fields via the configured fieldMappings.
*
* ldapts represents a missing/absent attribute as an empty array ([]),
* not undefined -- naively doing String(value[0]) on that produces the
* literal string "undefined", identical across every entry lacking the
* attribute (e.g. no `mail` set), which then collides on unique constraints
* like email. Resolve to the first array element (or the raw value) and skip
* when it's actually missing/empty. Username is lowercased so logins stay
* case-insensitive regardless of AD casing.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both derive the
* same identity from an entry.
*/
private mapEntry(
entry: Record<string, unknown>,
fieldMappings: { ldapField: string; tesseraField: string }[],
): { username?: string; mappedData: Record<string, string> } {
const mappedData: Record<string, string> = {};
for (const mapping of fieldMappings) {
const value = entry[mapping.ldapField];
const resolved = Array.isArray(value) ? value[0] : value;
if (resolved !== undefined && resolved !== null && resolved !== '') {
mappedData[mapping.tesseraField] = String(resolved);
}
}
return { username: mappedData['username']?.toLowerCase(), mappedData };
}
/**
* Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both use IDENTICAL
* identity resolution: a user imported one way is NEVER duplicated by the
* other. A manually-imported user (ldapDn set) is matched by ldapDn on a
* later department/group sync and updated in place, not re-created.
*/
private async upsertMappedUser(
dn: string,
username: string,
mappedData: Record<string, string>,
tenantId: string,
): Promise<'created' | 'updated'> {
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) {
await this.prisma.user.update({
where: { id: existing.id },
data: {
...(mappedData['displayName'] && {
displayName: mappedData['displayName'],
}),
...(mappedData['email'] && { email: mappedData['email'] }),
...(mappedData['username'] && { username }),
ldapDn: dn,
isActive: true,
},
});
return 'updated';
}
await this.userService.create({
username,
email: mappedData['email'] || `${username}@ldap.local`,
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
return 'created';
}
/**
* Search AD for individual users by a free-text query (substring match on
* cn, sAMAccountName, displayName, mail). Read-only, service-account bind.
* Each result is flagged `alreadyImported` so the admin sees who is already
* present and cannot import a duplicate. The query is RFC-4515-escaped
* before interpolation (T-02-16, LDAP injection prevention).
*/
async searchUsers(
config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
},
tenantId: string,
query: string,
): Promise<LdapUserSearchResult[]> {
const trimmed = (query ?? '').trim();
if (trimmed.length === 0) {
return [];
}
const client = new Client({ url: config.serverUrl });
const first = (v: unknown): string =>
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
try {
await this.bind(client, config.bindDn, config.bindPassword);
const q = LdapService.escapeLdapFilterValue(trimmed);
const filter = `(&(objectClass=person)(|(cn=*${q}*)(sAMAccountName=*${q}*)(displayName=*${q}*)(mail=*${q}*)))`;
const { searchEntries } = await client.search(config.baseDn, {
filter,
attributes: ['cn', 'displayName', 'sAMAccountName', 'mail', 'dn'],
scope: 'sub',
sizeLimit: 50,
});
const entries = searchEntries.map((entry) => ({
dn: entry.dn,
username: first(entry['sAMAccountName']),
displayName: first(entry['displayName']) || first(entry['cn']),
email: first(entry['mail']),
}));
// Flag entries already present for this tenant (by ldapDn or username) in
// a single query, so the UI marks them and import stays idempotent.
const dns = entries.map((e) => e.dn);
const usernames = entries
.map((e) => e.username.toLowerCase())
.filter(Boolean);
const existing = await this.prisma.user.findMany({
where: {
tenantId,
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
},
select: { ldapDn: true, username: true },
});
const dnSet = new Set(
existing.map((u) => u.ldapDn).filter((d): d is string => !!d),
);
const usernameSet = new Set(
existing.map((u) => u.username.toLowerCase()),
);
return entries.map((e) => ({
...e,
alreadyImported:
dnSet.has(e.dn) ||
(!!e.username && usernameSet.has(e.username.toLowerCase())),
}));
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Import specific AD users by DN (from searchUsers results). Idempotent and
* NON-deactivating: unlike syncUsersForTenant this never deactivates other
* users. A user that already exists (by ldapDn or username) is SKIPPED — its
* ldapDn is linked if missing so a later department/group sync recognizes it
* and never creates a duplicate. Respects the userExcludeList denylist.
*/
async importUsersByDn(
config: LdapConfigData,
tenantId: string,
dns: string[],
): Promise<LdapUserImportResult> {
const result: LdapUserImportResult = {
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
const client = new Client({ url: config.serverUrl });
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
.filter(Boolean),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
const attributes = config.fieldMappings.map((m) => m.ldapField);
if (!attributes.includes('dn')) {
attributes.push('dn');
}
for (const dn of dns) {
try {
// Base-scoped lookup of exactly this DN.
const { searchEntries } = await client.search(dn, {
filter: '(objectClass=person)',
attributes,
scope: 'base',
});
if (searchEntries.length === 0) {
result.errors.push(`${dn}: not found`);
continue;
}
const { username, mappedData } = this.mapEntry(
searchEntries[0] as Record<string, unknown>,
config.fieldMappings,
);
if (!username) {
result.errors.push(
`${dn}: no username mapped (check sAMAccountName mapping)`,
);
continue;
}
if (excludeSet.has(username)) {
result.skipped++;
continue;
}
// Dedup: if a user already exists (by ldapDn or username) skip it,
// but link the ldapDn so a later group/OU sync matches it and never
// duplicates.
const existing = await this.prisma.user.findFirst({
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
});
if (existing) {
if (existing.ldapDn !== dn) {
await this.prisma.user.update({
where: { id: existing.id },
data: { ldapDn: dn },
});
}
result.skipped++;
continue;
}
await this.userService.create({
username,
email: mappedData['email'] || `${username}@ldap.local`,
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
result.created++;
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error importing entry';
result.errors.push(`${dn}: ${msg}`);
}
}
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Sync users from LDAP directory for a specific tenant.
*
@@ -222,26 +510,12 @@ export class LdapService {
try {
const dn = entry.dn;
// Map LDAP fields to Tessera fields.
// ldapts represents a missing/absent attribute as an empty array
// ([]), not undefined -- naively doing String(value[0]) on that
// produces the literal string "undefined", identical across every
// entry lacking the attribute (e.g. no `mail` set), which then
// collides on unique constraints like email. Resolve to the first
// array element (or the raw value) and skip when it's actually
// missing/empty.
const mappedData: Record<string, string> = {};
for (const mapping of config.fieldMappings) {
const value = entry[mapping.ldapField];
const resolved = Array.isArray(value) ? value[0] : value;
if (resolved !== undefined && resolved !== null && resolved !== '') {
mappedData[mapping.tesseraField] = String(resolved);
}
}
// Require at minimum a username. Normalize to lowercase so
// logins stay case-insensitive regardless of AD casing.
const username = mappedData['username']?.toLowerCase();
// Map LDAP fields to Tessera fields (shared mapEntry helper);
// username is lowercased for case-insensitive logins.
const { username, mappedData } = this.mapEntry(
entry as Record<string, unknown>,
config.fieldMappings,
);
// Skip excluded users before recording the DN as synced. Leaving an
// excluded entry out of syncedDns means that if the admin adds an
@@ -260,46 +534,18 @@ export class LdapService {
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 }),
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
});
// Find-or-upsert by (ldapDn, then username) via the shared helper so
// sync and manual import dedupe identically (never a duplicate row).
const status = await this.upsertMappedUser(
dn,
username,
mappedData,
tenantId,
);
if (status === 'created') {
result.created++;
} else {
result.updated++;
}
} catch (entryError: unknown) {
const msg =