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
@@ -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<string[]>([]);
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 (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -666,6 +703,82 @@ export default function AdminLdapPage() {
</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) */}
{config && (
<section className="rounded-lg border border-border p-6">
+9
View File
@@ -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"
}
}
},
+9
View File
@@ -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"
}
}
},