feat(ldap): individual user search + selective import with dedup
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:
@@ -1,5 +1,6 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import {
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -93,3 +94,13 @@ export class CreateFieldMappingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for POST /ldap/users/import — the DNs of AD users to import individually.
|
||||||
|
*/
|
||||||
|
export class ImportUsersDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
dns!: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
@@ -15,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import {
|
import {
|
||||||
CreateFieldMappingDto,
|
CreateFieldMappingDto,
|
||||||
CreateLdapConfigDto,
|
CreateLdapConfigDto,
|
||||||
|
ImportUsersDto,
|
||||||
TestConnectionDto,
|
TestConnectionDto,
|
||||||
UpdateLdapConfigDto,
|
UpdateLdapConfigDto,
|
||||||
} from './dto/ldap-config.dto';
|
} from './dto/ldap-config.dto';
|
||||||
@@ -166,6 +168,71 @@ export class LdapController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /ldap/users/search?q=... - Search AD for individual users by a
|
||||||
|
* free-text query. Read-only. Each result is flagged `alreadyImported`.
|
||||||
|
*/
|
||||||
|
@Get('users/search')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async searchUsers(@Req() req: any, @Query('q') q: string) {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('No tenant context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.ldapConfigService.getConfig(tenantId);
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('No LDAP config found for this tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ldapService.searchUsers(
|
||||||
|
{
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
baseDn: config.baseDn,
|
||||||
|
bindDn: config.bindDn,
|
||||||
|
bindPassword: config.bindPassword,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
q ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /ldap/users/import - Import specific AD users by DN (from the user
|
||||||
|
* search). Idempotent and non-deactivating: existing users are skipped, so
|
||||||
|
* a later department/group sync never creates a duplicate.
|
||||||
|
*/
|
||||||
|
@Post('users/import')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('No tenant context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.ldapConfigService.getConfig(tenantId);
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('No LDAP config found for this tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ldapService.importUsersByDn(
|
||||||
|
{
|
||||||
|
id: config.id,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
baseDn: config.baseDn,
|
||||||
|
bindDn: config.bindDn,
|
||||||
|
bindPassword: config.bindPassword,
|
||||||
|
searchFilter: config.searchFilter,
|
||||||
|
groupFilterDns: config.groupFilterDns,
|
||||||
|
userExcludeList: config.userExcludeList,
|
||||||
|
fieldMappings: config.fieldMappings,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
dto.dns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button).
|
* POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button).
|
||||||
* Returns sync results with created/updated/deactivated counts.
|
* Returns sync results with created/updated/deactivated counts.
|
||||||
|
|||||||
@@ -113,3 +113,155 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
|||||||
expect(result.deactivated).toBe(1);
|
expect(result.deactivated).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LdapService — individual user search & import (dedup)', () => {
|
||||||
|
let service: LdapService;
|
||||||
|
let prisma: any;
|
||||||
|
let userService: any;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
id: 'cfg1',
|
||||||
|
tenantId: 't1',
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
searchFilter: '(objectClass=person)',
|
||||||
|
groupFilterDns: [] as string[],
|
||||||
|
userExcludeList: [] as string[],
|
||||||
|
fieldMappings: [
|
||||||
|
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
|
||||||
|
{ ldapField: 'displayName', tesseraField: 'displayName' },
|
||||||
|
{ ldapField: 'mail', tesseraField: 'email' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockBind.mockResolvedValue(undefined);
|
||||||
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
|
prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
|
service = new LdapService(prisma, userService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchUsers flags results already present by username or ldapDn', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=alice,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'alice',
|
||||||
|
displayName: 'Alice A',
|
||||||
|
mail: 'alice@x',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dn: 'cn=bob,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'bob',
|
||||||
|
displayName: 'Bob B',
|
||||||
|
mail: 'bob@x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ ldapDn: null, username: 'alice' }]);
|
||||||
|
|
||||||
|
const res = await service.searchUsers(cfg as any, 't1', 'a');
|
||||||
|
|
||||||
|
expect(res).toHaveLength(2);
|
||||||
|
expect(res.find((r) => r.username === 'alice')?.alreadyImported).toBe(true);
|
||||||
|
expect(res.find((r) => r.username === 'bob')?.alreadyImported).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchUsers returns [] for an empty query without binding', async () => {
|
||||||
|
const res = await service.searchUsers(cfg as any, 't1', ' ');
|
||||||
|
expect(res).toEqual([]);
|
||||||
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importUsersByDn creates a new user with ldapDn set', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await service.importUsersByDn(cfg as any, 't1', [
|
||||||
|
'cn=carol,dc=example,dc=com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(res.created).toBe(1);
|
||||||
|
expect(res.skipped).toBe(0);
|
||||||
|
expect(userService.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'carol',
|
||||||
|
ldapDn: 'cn=carol,dc=example,dc=com',
|
||||||
|
tenantId: 't1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importUsersByDn skips an already-imported user (no duplicate)', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=dave,dc=example,dc=com', sAMAccountName: 'dave' }],
|
||||||
|
});
|
||||||
|
prisma.user.findFirst.mockResolvedValue({
|
||||||
|
id: 'u9',
|
||||||
|
username: 'dave',
|
||||||
|
ldapDn: 'cn=dave,dc=example,dc=com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.importUsersByDn(cfg as any, 't1', [
|
||||||
|
'cn=dave,dc=example,dc=com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(res.skipped).toBe(1);
|
||||||
|
expect(res.created).toBe(0);
|
||||||
|
expect(userService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importUsersByDn links ldapDn on a user previously matched only by username', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=erin,dc=example,dc=com', sAMAccountName: 'erin' }],
|
||||||
|
});
|
||||||
|
prisma.user.findFirst.mockResolvedValue({
|
||||||
|
id: 'u10',
|
||||||
|
username: 'erin',
|
||||||
|
ldapDn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.importUsersByDn(cfg as any, 't1', [
|
||||||
|
'cn=erin,dc=example,dc=com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(res.skipped).toBe(1);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'u10' },
|
||||||
|
data: { ldapDn: 'cn=erin,dc=example,dc=com' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(userService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importUsersByDn respects the userExcludeList denylist', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=svc,dc=example,dc=com', sAMAccountName: 'Administrator' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.importUsersByDn(
|
||||||
|
{ ...cfg, userExcludeList: ['administrator'] } as any,
|
||||||
|
't1',
|
||||||
|
['cn=svc,dc=example,dc=com'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.skipped).toBe(1);
|
||||||
|
expect(userService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ export interface LdapDirectoryEntry {
|
|||||||
type: 'group' | 'ou';
|
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.
|
* 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.
|
* Sync users from LDAP directory for a specific tenant.
|
||||||
*
|
*
|
||||||
@@ -222,26 +510,12 @@ export class LdapService {
|
|||||||
try {
|
try {
|
||||||
const dn = entry.dn;
|
const dn = entry.dn;
|
||||||
|
|
||||||
// Map LDAP fields to Tessera fields.
|
// Map LDAP fields to Tessera fields (shared mapEntry helper);
|
||||||
// ldapts represents a missing/absent attribute as an empty array
|
// username is lowercased for case-insensitive logins.
|
||||||
// ([]), not undefined -- naively doing String(value[0]) on that
|
const { username, mappedData } = this.mapEntry(
|
||||||
// produces the literal string "undefined", identical across every
|
entry as Record<string, unknown>,
|
||||||
// entry lacking the attribute (e.g. no `mail` set), which then
|
config.fieldMappings,
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Skip excluded users before recording the DN as synced. Leaving an
|
// Skip excluded users before recording the DN as synced. Leaving an
|
||||||
// excluded entry out of syncedDns means that if the admin adds an
|
// excluded entry out of syncedDns means that if the admin adds an
|
||||||
@@ -260,46 +534,18 @@ export class LdapService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists by ldapDn or username
|
// Find-or-upsert by (ldapDn, then username) via the shared helper so
|
||||||
const existingByDn = await this.prisma.user.findFirst({
|
// sync and manual import dedupe identically (never a duplicate row).
|
||||||
where: { ldapDn: dn, tenantId },
|
const status = await this.upsertMappedUser(
|
||||||
});
|
dn,
|
||||||
|
username,
|
||||||
const existingByUsername = existingByDn
|
mappedData,
|
||||||
? null
|
tenantId,
|
||||||
: await this.prisma.user.findFirst({
|
);
|
||||||
where: { username, tenantId },
|
if (status === 'created') {
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
result.created++;
|
result.created++;
|
||||||
|
} else {
|
||||||
|
result.updated++;
|
||||||
}
|
}
|
||||||
} catch (entryError: unknown) {
|
} catch (entryError: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ interface LdapDirectoryEntry {
|
|||||||
type: 'group' | 'ou';
|
type: 'group' | 'ou';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LdapUserSearchResult {
|
||||||
|
dn: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
alreadyImported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserImportResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncResult {
|
interface SyncResult {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
@@ -89,6 +104,17 @@ export default function AdminLdapPage() {
|
|||||||
const [newExcludeUser, setNewExcludeUser] = useState('');
|
const [newExcludeUser, setNewExcludeUser] = useState('');
|
||||||
const [savingExclude, setSavingExclude] = useState(false);
|
const [savingExclude, setSavingExclude] = useState(false);
|
||||||
|
|
||||||
|
// Individual user search & import
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState('');
|
||||||
|
const [userSearchResults, setUserSearchResults] = useState<
|
||||||
|
LdapUserSearchResult[] | null
|
||||||
|
>(null);
|
||||||
|
const [userSearching, setUserSearching] = useState(false);
|
||||||
|
const [selectedUserDns, setSelectedUserDns] = useState<string[]>([]);
|
||||||
|
const [importingUsers, setImportingUsers] = useState(false);
|
||||||
|
const [userImportResult, setUserImportResult] =
|
||||||
|
useState<UserImportResult | null>(null);
|
||||||
|
|
||||||
const filteredDiscovered = discovered?.filter((entry) => {
|
const filteredDiscovered = discovered?.filter((entry) => {
|
||||||
const q = discoverSearch.trim().toLowerCase();
|
const q = discoverSearch.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
@@ -316,6 +342,59 @@ export default function AdminLdapPage() {
|
|||||||
setUserExcludeList((prev) => prev.filter((u) => u !== name));
|
setUserExcludeList((prev) => prev.filter((u) => u !== name));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearchUsers = async () => {
|
||||||
|
const q = userSearchQuery.trim();
|
||||||
|
if (!q) return;
|
||||||
|
setUserSearching(true);
|
||||||
|
setUserImportResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/ldap/users/search?q=${encodeURIComponent(q)}`,
|
||||||
|
{ credentials: 'include' },
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUserSearchResults(data);
|
||||||
|
setSelectedUserDns([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setUserSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleUserDn = (dn: string) => {
|
||||||
|
setSelectedUserDns((prev) =>
|
||||||
|
prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportUsers = async (dns: string[]) => {
|
||||||
|
if (dns.length === 0) return;
|
||||||
|
setImportingUsers(true);
|
||||||
|
setUserImportResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/ldap/users/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ dns }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUserImportResult(data);
|
||||||
|
setSelectedUserDns([]);
|
||||||
|
// Re-run the search so alreadyImported flags refresh.
|
||||||
|
await handleSearchUsers();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setImportingUsers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveExcludeList = async () => {
|
const handleSaveExcludeList = async () => {
|
||||||
setSavingExclude(true);
|
setSavingExclude(true);
|
||||||
try {
|
try {
|
||||||
@@ -703,6 +782,110 @@ export default function AdminLdapPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Section 2.55: Individual user search & import */}
|
||||||
|
{config && (
|
||||||
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('userSearch.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('userSearch.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3 mb-4">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSearchQuery}
|
||||||
|
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSearchUsers();
|
||||||
|
}}
|
||||||
|
placeholder={t('userSearch.placeholder')}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearchUsers}
|
||||||
|
disabled={userSearching || !userSearchQuery.trim()}
|
||||||
|
className="h-9 rounded-md border border-border px-4 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{userSearching ? tCommon('loading') : t('userSearch.search')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userSearchResults && userSearchResults.length > 0 && (
|
||||||
|
<div className="mb-4 max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{userSearchResults.map((u) => (
|
||||||
|
<label
|
||||||
|
key={u.dn}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2 text-sm ${
|
||||||
|
u.alreadyImported
|
||||||
|
? 'opacity-60'
|
||||||
|
: 'hover:bg-muted/30 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={u.alreadyImported}
|
||||||
|
checked={selectedUserDns.includes(u.dn)}
|
||||||
|
onChange={() => toggleUserDn(u.dn)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{u.displayName || u.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
{u.email && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{u.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{u.alreadyImported && (
|
||||||
|
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{t('userSearch.alreadyImported')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userSearchResults && userSearchResults.length === 0 && (
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t('userSearch.noResults')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userSearchResults && userSearchResults.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleImportUsers(selectedUserDns)}
|
||||||
|
disabled={importingUsers || selectedUserDns.length === 0}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importingUsers
|
||||||
|
? tCommon('loading')
|
||||||
|
: `${t('userSearch.importSelected')} (${selectedUserDns.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userImportResult && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{userImportResult.created} {t('userSearch.created')},{' '}
|
||||||
|
{userImportResult.skipped} {t('userSearch.skipped')}
|
||||||
|
{userImportResult.errors.length > 0 && (
|
||||||
|
<>
|
||||||
|
, {userImportResult.errors.length} {t('userSearch.errors')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 2.6: Per-user exclude/denylist */}
|
{/* Section 2.6: Per-user exclude/denylist */}
|
||||||
{config && (
|
{config && (
|
||||||
<section className="rounded-lg border border-border p-6">
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
|||||||
@@ -334,6 +334,19 @@
|
|||||||
"excluded": "Ausgeschlossen",
|
"excluded": "Ausgeschlossen",
|
||||||
"empty": "Keine ausgeschlossen - alle gefundenen Benutzer werden importiert.",
|
"empty": "Keine ausgeschlossen - alle gefundenen Benutzer werden importiert.",
|
||||||
"save": "Ausschlussliste speichern"
|
"save": "Ausschlussliste speichern"
|
||||||
|
},
|
||||||
|
"userSearch": {
|
||||||
|
"title": "Einzelbenutzer suchen & importieren",
|
||||||
|
"description": "Sucht einzelne AD-Benutzer und importiert sie gezielt. Bereits vorhandene Benutzer werden uebersprungen - kein Doppelimport, auch wenn spaeter die Abteilung synchronisiert wird.",
|
||||||
|
"placeholder": "Name, Benutzername oder E-Mail...",
|
||||||
|
"search": "Suchen",
|
||||||
|
"noResults": "Keine Benutzer gefunden.",
|
||||||
|
"alreadyImported": "Bereits importiert",
|
||||||
|
"import": "Importieren",
|
||||||
|
"importSelected": "Ausgewaehlte importieren",
|
||||||
|
"created": "importiert",
|
||||||
|
"skipped": "uebersprungen",
|
||||||
|
"errors": "Fehler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -334,6 +334,19 @@
|
|||||||
"excluded": "Excluded",
|
"excluded": "Excluded",
|
||||||
"empty": "None excluded - every discovered user is imported.",
|
"empty": "None excluded - every discovered user is imported.",
|
||||||
"save": "Save exclude list"
|
"save": "Save exclude list"
|
||||||
|
},
|
||||||
|
"userSearch": {
|
||||||
|
"title": "Search & import individual users",
|
||||||
|
"description": "Search for individual AD users and import them selectively. Users that already exist are skipped - no duplicate import, even when their department is synced later.",
|
||||||
|
"placeholder": "Name, username or email...",
|
||||||
|
"search": "Search",
|
||||||
|
"noResults": "No users found.",
|
||||||
|
"alreadyImported": "Already imported",
|
||||||
|
"import": "Import",
|
||||||
|
"importSelected": "Import selected",
|
||||||
|
"created": "imported",
|
||||||
|
"skipped": "skipped",
|
||||||
|
"errors": "errors"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user