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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LdapConfig" ADD COLUMN IF NOT EXISTS "userExcludeList" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -70,6 +70,7 @@ model LdapConfig {
|
|||||||
syncIntervalMin Int @default(60)
|
syncIntervalMin Int @default(60)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
groupFilterDns String[] @default([])
|
groupFilterDns String[] @default([])
|
||||||
|
userExcludeList String[] @default([])
|
||||||
lastSyncAt DateTime?
|
lastSyncAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export class CreateLdapConfigDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
groupFilterDns?: string[];
|
groupFilterDns?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
userExcludeList?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class LdapConfigService {
|
|||||||
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
groupFilterDns: dto.groupFilterDns ?? [],
|
groupFilterDns: dto.groupFilterDns ?? [],
|
||||||
|
userExcludeList: dto.userExcludeList ?? [],
|
||||||
fieldMappings: {
|
fieldMappings: {
|
||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
@@ -83,6 +84,9 @@ export class LdapConfigService {
|
|||||||
...(dto.groupFilterDns !== undefined && {
|
...(dto.groupFilterDns !== undefined && {
|
||||||
groupFilterDns: dto.groupFilterDns,
|
groupFilterDns: dto.groupFilterDns,
|
||||||
}),
|
}),
|
||||||
|
...(dto.userExcludeList !== undefined && {
|
||||||
|
userExcludeList: dto.userExcludeList,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
include: { fieldMappings: true },
|
include: { fieldMappings: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export class LdapSyncScheduler {
|
|||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
groupFilterDns: config.groupFilterDns,
|
groupFilterDns: config.groupFilterDns,
|
||||||
|
userExcludeList: config.userExcludeList,
|
||||||
fieldMappings: config.fieldMappings,
|
fieldMappings: config.fieldMappings,
|
||||||
},
|
},
|
||||||
config.tenantId,
|
config.tenantId,
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export class LdapController {
|
|||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
groupFilterDns: config.groupFilterDns,
|
groupFilterDns: config.groupFilterDns,
|
||||||
|
userExcludeList: config.userExcludeList,
|
||||||
fieldMappings: config.fieldMappings,
|
fieldMappings: config.fieldMappings,
|
||||||
},
|
},
|
||||||
tenantId,
|
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;
|
bindPassword?: string | null;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
groupFilterDns: string[];
|
groupFilterDns: string[];
|
||||||
|
userExcludeList: string[];
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
ldapField: string;
|
ldapField: string;
|
||||||
tesseraField: string;
|
tesseraField: string;
|
||||||
@@ -205,11 +206,21 @@ export class LdapService {
|
|||||||
// Track all DNs found in this sync for deactivation logic
|
// Track all DNs found in this sync for deactivation logic
|
||||||
const syncedDns: string[] = [];
|
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
|
// 4. Process each LDAP entry
|
||||||
for (const entry of searchEntries) {
|
for (const entry of searchEntries) {
|
||||||
try {
|
try {
|
||||||
const dn = entry.dn;
|
const dn = entry.dn;
|
||||||
syncedDns.push(dn);
|
|
||||||
|
|
||||||
// Map LDAP fields to Tessera fields.
|
// Map LDAP fields to Tessera fields.
|
||||||
// ldapts represents a missing/absent attribute as an empty array
|
// 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
|
// Require at minimum a username. Normalize to lowercase so
|
||||||
// logins stay case-insensitive regardless of AD casing.
|
// logins stay case-insensitive regardless of AD casing.
|
||||||
const username = mappedData['username']?.toLowerCase();
|
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) {
|
if (!username) {
|
||||||
result.errors.push(
|
result.errors.push(
|
||||||
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface LdapConfig {
|
|||||||
syncIntervalMin: number;
|
syncIntervalMin: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
groupFilterDns: string[];
|
groupFilterDns: string[];
|
||||||
|
userExcludeList: string[];
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
fieldMappings: FieldMapping[];
|
fieldMappings: FieldMapping[];
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,11 @@ export default function AdminLdapPage() {
|
|||||||
const [savingFilter, setSavingFilter] = useState(false);
|
const [savingFilter, setSavingFilter] = useState(false);
|
||||||
const [discoverSearch, setDiscoverSearch] = useState('');
|
const [discoverSearch, setDiscoverSearch] = useState('');
|
||||||
|
|
||||||
|
// Per-user exclude/denylist (individual usernames never imported)
|
||||||
|
const [userExcludeList, setUserExcludeList] = useState<string[]>([]);
|
||||||
|
const [newExcludeUser, setNewExcludeUser] = useState('');
|
||||||
|
const [savingExclude, setSavingExclude] = useState(false);
|
||||||
|
|
||||||
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;
|
||||||
@@ -113,6 +119,7 @@ export default function AdminLdapPage() {
|
|||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
});
|
});
|
||||||
setGroupFilterDns(data.groupFilterDns ?? []);
|
setGroupFilterDns(data.groupFilterDns ?? []);
|
||||||
|
setUserExcludeList(data.userExcludeList ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
@@ -666,6 +703,82 @@ export default function AdminLdapPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Section 2.6: Per-user exclude/denylist */}
|
||||||
|
{config && (
|
||||||
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('userExclude.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('userExclude.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3 mb-4">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t('userExclude.username')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newExcludeUser}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddExcludeUser}
|
||||||
|
className="h-9 rounded-md border border-border px-3 text-xs font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{t('userExclude.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
{t('userExclude.excluded')}
|
||||||
|
</p>
|
||||||
|
{userExcludeList.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('userExclude.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{userExcludeList.map((name) => (
|
||||||
|
<li
|
||||||
|
key={name}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-foreground">{name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveExcludeUser(name)}
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
{t('fieldMapping.remove')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveExcludeList}
|
||||||
|
disabled={savingExclude}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingExclude ? tCommon('loading') : t('userExclude.save')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 3: Sync Settings (D-14) */}
|
{/* Section 3: Sync Settings (D-14) */}
|
||||||
{config && (
|
{config && (
|
||||||
<section className="rounded-lg border border-border p-6">
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
|||||||
@@ -325,6 +325,15 @@
|
|||||||
"selected": "Ausgewaehlt",
|
"selected": "Ausgewaehlt",
|
||||||
"emptyMeansAll": "Keine Auswahl - importiert alle Benutzer unter der Basis-DN.",
|
"emptyMeansAll": "Keine Auswahl - importiert alle Benutzer unter der Basis-DN.",
|
||||||
"save": "Filter speichern"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -325,6 +325,15 @@
|
|||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"emptyMeansAll": "No selection - imports every user under the base DN.",
|
"emptyMeansAll": "No selection - imports every user under the base DN.",
|
||||||
"save": "Save filter"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user