From 9d1323fe971f35736f973652a6ab5273d8693547 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 14 Jul 2026 09:34:01 +0200 Subject: [PATCH] feat(ldap): per-user exclude/denylist filter for sync Add a per-username denylist so individual accounts (service accounts like administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync, independent of the group/OU include-filter which only scopes the search. - schema: LdapConfig.userExcludeList String[] (+ migration) - sync: skip excluded usernames (case-insensitive) before recording the DN, so an already-imported user added to the list gets deactivated next sync - DTO / config service / controller / scheduler: thread userExcludeList through - web: exclude-list admin UI section (add/remove/save) + de/en translations - tests: 3 specs covering empty list, case-insensitive skip, deactivation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + apps/api/src/ldap/dto/ldap-config.dto.ts | 5 + apps/api/src/ldap/ldap-config.service.ts | 4 + apps/api/src/ldap/ldap-sync.scheduler.ts | 1 + apps/api/src/ldap/ldap.controller.ts | 1 + apps/api/src/ldap/ldap.service.spec.ts | 115 ++++++++++++++++++ apps/api/src/ldap/ldap.service.ts | 24 +++- apps/web/src/app/(portal)/admin/ldap/page.tsx | 113 +++++++++++++++++ apps/web/src/messages/de.json | 9 ++ apps/web/src/messages/en.json | 9 ++ 11 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql create mode 100644 apps/api/src/ldap/ldap.service.spec.ts diff --git a/apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql b/apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql new file mode 100644 index 0000000..9f4e9c5 --- /dev/null +++ b/apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LdapConfig" ADD COLUMN IF NOT EXISTS "userExcludeList" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index aff61d9..19034f5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -70,6 +70,7 @@ model LdapConfig { syncIntervalMin Int @default(60) isActive Boolean @default(true) groupFilterDns String[] @default([]) + userExcludeList String[] @default([]) lastSyncAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/ldap/dto/ldap-config.dto.ts b/apps/api/src/ldap/dto/ldap-config.dto.ts index a659871..81a6e06 100644 --- a/apps/api/src/ldap/dto/ldap-config.dto.ts +++ b/apps/api/src/ldap/dto/ldap-config.dto.ts @@ -46,6 +46,11 @@ export class CreateLdapConfigDto { @IsString({ each: true }) @IsOptional() groupFilterDns?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + userExcludeList?: string[]; } /** diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts index 034d151..155538e 100644 --- a/apps/api/src/ldap/ldap-config.service.ts +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -40,6 +40,7 @@ export class LdapConfigService { syncIntervalMin: dto.syncIntervalMin ?? 60, isActive: dto.isActive ?? true, groupFilterDns: dto.groupFilterDns ?? [], + userExcludeList: dto.userExcludeList ?? [], fieldMappings: { create: [ { @@ -83,6 +84,9 @@ export class LdapConfigService { ...(dto.groupFilterDns !== undefined && { groupFilterDns: dto.groupFilterDns, }), + ...(dto.userExcludeList !== undefined && { + userExcludeList: dto.userExcludeList, + }), }, include: { fieldMappings: true }, }); diff --git a/apps/api/src/ldap/ldap-sync.scheduler.ts b/apps/api/src/ldap/ldap-sync.scheduler.ts index 11dd8aa..7cd14b2 100644 --- a/apps/api/src/ldap/ldap-sync.scheduler.ts +++ b/apps/api/src/ldap/ldap-sync.scheduler.ts @@ -65,6 +65,7 @@ export class LdapSyncScheduler { bindPassword: config.bindPassword, searchFilter: config.searchFilter, groupFilterDns: config.groupFilterDns, + userExcludeList: config.userExcludeList, fieldMappings: config.fieldMappings, }, config.tenantId, diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 2b00820..ff53989 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -193,6 +193,7 @@ export class LdapController { bindPassword: config.bindPassword, searchFilter: config.searchFilter, groupFilterDns: config.groupFilterDns, + userExcludeList: config.userExcludeList, fieldMappings: config.fieldMappings, }, tenantId, diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts new file mode 100644 index 0000000..af08a61 --- /dev/null +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock ldapts so no real directory connection is attempted. The single shared +// search mock is re-programmed per test. +const mockBind = vi.fn().mockResolvedValue(undefined); +const mockSearch = vi.fn(); +const mockUnbind = vi.fn().mockResolvedValue(undefined); + +vi.mock('ldapts', () => ({ + Client: vi.fn().mockImplementation(() => ({ + bind: mockBind, + search: mockSearch, + unbind: mockUnbind, + })), +})); + +// forTenant just returns the same client in these tests (tenant scoping is not +// under test here). +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + +import { LdapService } from './ldap.service'; + +describe('LdapService.syncUsersForTenant — per-user exclude list', () => { + let service: LdapService; + let prisma: any; + let userService: any; + + const baseConfig = { + 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' }, + ], + }; + + 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('imports every user when the exclude list is empty', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=admin', sAMAccountName: 'Administrator' }, + { dn: 'cn=alice', sAMAccountName: 'alice' }, + ], + }); + + const result = await service.syncUsersForTenant(baseConfig as any, 't1'); + + expect(result.created).toBe(2); + expect(userService.create).toHaveBeenCalledTimes(2); + }); + + it('skips excluded usernames (case-insensitive match)', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=admin', sAMAccountName: 'Administrator' }, + { dn: 'cn=krbtgt', sAMAccountName: 'krbtgt' }, + { dn: 'cn=alice', sAMAccountName: 'alice' }, + ], + }); + + const result = await service.syncUsersForTenant( + { ...baseConfig, userExcludeList: ['administrator', 'KRBTGT'] } as any, + 't1', + ); + + expect(result.created).toBe(1); + expect(userService.create).toHaveBeenCalledTimes(1); + expect(userService.create).toHaveBeenCalledWith( + expect.objectContaining({ username: 'alice' }), + ); + }); + + it('deactivates a previously-imported user once they are excluded', async () => { + // AD still returns "guest", but it is now on the exclude list, so it must + // not stay in syncedDns and therefore gets deactivated. + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=guest', sAMAccountName: 'guest' }], + }); + prisma.user.findMany.mockResolvedValue([{ id: 'u-guest', ldapDn: 'cn=guest' }]); + + const result = await service.syncUsersForTenant( + { ...baseConfig, userExcludeList: ['guest'] } as any, + 't1', + ); + + expect(result.created).toBe(0); + expect(userService.create).not.toHaveBeenCalled(); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'u-guest' }, + data: { isActive: false }, + }); + expect(result.deactivated).toBe(1); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index e0e6ea4..8cb818b 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -26,6 +26,7 @@ interface LdapConfigData { bindPassword?: string | null; searchFilter: string; groupFilterDns: string[]; + userExcludeList: string[]; fieldMappings: Array<{ ldapField: string; tesseraField: string; @@ -205,11 +206,21 @@ export class LdapService { // Track all DNs found in this sync for deactivation logic const syncedDns: string[] = []; + // Per-user exclude/denylist: individual usernames (sAMAccountName) the + // admin never wants imported, e.g. service accounts like administrator, + // krbtgt, guest, ldap$. Distinct from groupFilterDns, which only limits + // which OUs/groups are searched. Normalized to lowercase to match the + // case-insensitive username handling below. + const excludeSet = new Set( + (config.userExcludeList ?? []) + .map((u) => u.trim().toLowerCase()) + .filter(Boolean), + ); + // 4. Process each LDAP entry for (const entry of searchEntries) { try { const dn = entry.dn; - syncedDns.push(dn); // Map LDAP fields to Tessera fields. // ldapts represents a missing/absent attribute as an empty array @@ -231,6 +242,17 @@ export class LdapService { // 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 + // excluded entry out of syncedDns means that if the admin adds an + // already-imported user to the exclude list, the deactivation pass + // below will deactivate them on the next sync. + if (username && excludeSet.has(username)) { + continue; + } + + syncedDns.push(dn); + if (!username) { result.errors.push( `Entry ${dn}: no username mapped (check sAMAccountName mapping)`, diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx index 9205a71..66a5c52 100644 --- a/apps/web/src/app/(portal)/admin/ldap/page.tsx +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -24,6 +24,7 @@ interface LdapConfig { syncIntervalMin: number; isActive: boolean; groupFilterDns: string[]; + userExcludeList: string[]; lastSyncAt: string | null; fieldMappings: FieldMapping[]; } @@ -83,6 +84,11 @@ export default function AdminLdapPage() { const [savingFilter, setSavingFilter] = useState(false); const [discoverSearch, setDiscoverSearch] = useState(''); + // Per-user exclude/denylist (individual usernames never imported) + const [userExcludeList, setUserExcludeList] = useState([]); + const [newExcludeUser, setNewExcludeUser] = useState(''); + const [savingExclude, setSavingExclude] = useState(false); + const filteredDiscovered = discovered?.filter((entry) => { const q = discoverSearch.trim().toLowerCase(); if (!q) return true; @@ -113,6 +119,7 @@ export default function AdminLdapPage() { isActive: data.isActive ?? true, }); setGroupFilterDns(data.groupFilterDns ?? []); + setUserExcludeList(data.userExcludeList ?? []); } } } catch { @@ -298,6 +305,36 @@ export default function AdminLdapPage() { } }; + const handleAddExcludeUser = () => { + const name = newExcludeUser.trim().toLowerCase(); + if (!name || userExcludeList.includes(name)) return; + setUserExcludeList((prev) => [...prev, name]); + setNewExcludeUser(''); + }; + + const handleRemoveExcludeUser = (name: string) => { + setUserExcludeList((prev) => prev.filter((u) => u !== name)); + }; + + const handleSaveExcludeList = async () => { + setSavingExclude(true); + try { + const res = await fetch(`${API_URL}/ldap/config`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ userExcludeList }), + }); + if (res.ok) { + await fetchConfig(); + } + } catch { + // silently fail + } finally { + setSavingExclude(false); + } + }; + if (!hasAccess) { return (
@@ -666,6 +703,82 @@ export default function AdminLdapPage() { )} + {/* Section 2.6: Per-user exclude/denylist */} + {config && ( +
+

+ {t('userExclude.title')} +

+

+ {t('userExclude.description')} +

+ +
+
+ + setNewExcludeUser(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddExcludeUser(); + } + }} + placeholder="administrator, krbtgt, guest, ldap$ ..." + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm font-mono" + /> +
+ +
+ +
+

+ {t('userExclude.excluded')} +

+ {userExcludeList.length === 0 ? ( +

{t('userExclude.empty')}

+ ) : ( +
    + {userExcludeList.map((name) => ( +
  • + {name} + +
  • + ))} +
+ )} +
+ + +
+ )} + {/* Section 3: Sync Settings (D-14) */} {config && (
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index f64ead1..8b31a14 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -325,6 +325,15 @@ "selected": "Ausgewaehlt", "emptyMeansAll": "Keine Auswahl - importiert alle Benutzer unter der Basis-DN.", "save": "Filter speichern" + }, + "userExclude": { + "title": "Benutzer ausschliessen (Denylist)", + "description": "Einzelne Benutzernamen, die nie importiert werden - z. B. Dienstkonten wie administrator, krbtgt, guest oder ldap$. Wirkt zusaetzlich zum Gruppen-/OU-Filter.", + "username": "Benutzername", + "add": "Hinzufuegen", + "excluded": "Ausgeschlossen", + "empty": "Keine ausgeschlossen - alle gefundenen Benutzer werden importiert.", + "save": "Ausschlussliste speichern" } } }, diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 4b62c4f..9de3947 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -325,6 +325,15 @@ "selected": "Selected", "emptyMeansAll": "No selection - imports every user under the base DN.", "save": "Save filter" + }, + "userExclude": { + "title": "Exclude users (denylist)", + "description": "Individual usernames that are never imported - e.g. service accounts like administrator, krbtgt, guest or ldap$. Applies on top of the group/OU filter.", + "username": "Username", + "add": "Add", + "excluded": "Excluded", + "empty": "None excluded - every discovered user is imported.", + "save": "Save exclude list" } } },