feat(ldap): support anonymous bind (no bind DN/password required)
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s

bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.

Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.

Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:57:26 +02:00
parent 39aa4bff2a
commit 010aceb1ac
7 changed files with 194 additions and 31 deletions
+24 -10
View File
@@ -22,8 +22,8 @@ interface LdapConfigData {
tenantId: string;
serverUrl: string;
baseDn: string;
bindDn: string;
bindPassword: string;
bindDn?: string | null;
bindPassword?: string | null;
searchFilter: string;
groupFilterDns: string[];
fieldMappings: Array<{
@@ -60,19 +60,33 @@ export class LdapService {
private userService: UserService,
) {}
/**
* Bind a client, falling back to an anonymous bind (empty DN/password,
* per RFC 4513) when no bindDn/bindPassword is configured. Lets tenants
* connect to directories that allow anonymous read access without
* requiring a service account.
*/
private async bind(
client: Client,
bindDn?: string | null,
bindPassword?: string | null,
): Promise<void> {
await client.bind(bindDn || '', bindPassword || '');
}
/**
* Test LDAP connection with given configuration.
* Returns success/failure with optional error message.
*/
async testConnection(config: {
serverUrl: string;
bindDn: string;
bindPassword: string;
bindDn?: string | null;
bindPassword?: string | null;
}): Promise<{ success: boolean; error?: string }> {
const client = new Client({ url: config.serverUrl });
try {
await client.bind(config.bindDn, config.bindPassword);
await this.bind(client, config.bindDn, config.bindPassword);
return { success: true };
} catch (error: unknown) {
const message =
@@ -96,13 +110,13 @@ export class LdapService {
async listGroups(config: {
serverUrl: string;
baseDn: string;
bindDn: string;
bindPassword: string;
bindDn?: string | null;
bindPassword?: string | null;
}): Promise<LdapDirectoryEntry[]> {
const client = new Client({ url: config.serverUrl });
try {
await client.bind(config.bindDn, config.bindPassword);
await this.bind(client, config.bindDn, config.bindPassword);
const { searchEntries } = await client.search(config.baseDn, {
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
@@ -167,8 +181,8 @@ export class LdapService {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
// 1. Bind with service account
await client.bind(config.bindDn, config.bindPassword);
// 1. Bind with service account (anonymous when not configured)
await this.bind(client, config.bindDn, config.bindPassword);
// 2. Build attributes list from field mappings + dn
const attributes = config.fieldMappings.map((m) => m.ldapField);