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) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,11 @@ export class CreateLdapConfigDto {
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
groupFilterDns?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
userExcludeList?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export class LdapSyncScheduler {
|
||||
bindPassword: config.bindPassword,
|
||||
searchFilter: config.searchFilter,
|
||||
groupFilterDns: config.groupFilterDns,
|
||||
userExcludeList: config.userExcludeList,
|
||||
fieldMappings: config.fieldMappings,
|
||||
},
|
||||
config.tenantId,
|
||||
|
||||
@@ -193,6 +193,7 @@ export class LdapController {
|
||||
bindPassword: config.bindPassword,
|
||||
searchFilter: config.searchFilter,
|
||||
groupFilterDns: config.groupFilterDns,
|
||||
userExcludeList: config.userExcludeList,
|
||||
fieldMappings: config.fieldMappings,
|
||||
},
|
||||
tenantId,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)`,
|
||||
|
||||
Reference in New Issue
Block a user