feat(ldap): per-user exclude/denylist filter for sync
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 43s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m46s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:34:01 +02:00
parent ef3e41769e
commit 9d1323fe97
11 changed files with 283 additions and 1 deletions
+115
View File
@@ -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);
});
});