feat(ldap): support anonymous bind (no bind DN/password required)
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:
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
-- Allow anonymous LDAP binds: bindDn/bindPassword become optional.
|
||||
ALTER TABLE "LdapConfig" ALTER COLUMN "bindDn" DROP NOT NULL;
|
||||
ALTER TABLE "LdapConfig" ALTER COLUMN "bindPassword" DROP NOT NULL;
|
||||
@@ -64,8 +64,8 @@ model LdapConfig {
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
serverUrl String
|
||||
baseDn String
|
||||
bindDn String
|
||||
bindPassword String
|
||||
bindDn String?
|
||||
bindPassword String?
|
||||
searchFilter String @default("(objectClass=person)")
|
||||
syncIntervalMin Int @default(60)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@ -22,12 +22,12 @@ export class CreateLdapConfigDto {
|
||||
baseDn!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bindDn!: string;
|
||||
@IsOptional()
|
||||
bindDn?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bindPassword!: string;
|
||||
@IsOptional()
|
||||
bindPassword?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@@ -52,7 +52,7 @@ export class LdapController {
|
||||
// Never return bindPassword in API responses (T-02-17)
|
||||
return {
|
||||
...config,
|
||||
bindPassword: '********',
|
||||
bindPassword: config.bindPassword ? '********' : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class LdapController {
|
||||
|
||||
return {
|
||||
...config,
|
||||
bindPassword: '********',
|
||||
bindPassword: config.bindPassword ? '********' : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class LdapController {
|
||||
|
||||
return {
|
||||
...config,
|
||||
bindPassword: '********',
|
||||
bindPassword: config.bindPassword ? '********' : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,8 +114,9 @@ export class LdapController {
|
||||
* Accepts optional ad-hoc serverUrl/bindDn/bindPassword so an admin can
|
||||
* validate connection details before ever saving a config. Any field left
|
||||
* out (e.g. bindPassword, which the form never re-sends once masked)
|
||||
* falls back to the tenant's saved config. If no config is saved yet and
|
||||
* the body doesn't supply all three fields, there is nothing to test.
|
||||
* falls back to the tenant's saved config. bindDn/bindPassword are fully
|
||||
* optional -- omitting both attempts an anonymous bind. Only serverUrl is
|
||||
* required (directly, or from a saved config).
|
||||
*/
|
||||
@Post('test-connection')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
@@ -131,9 +132,9 @@ export class LdapController {
|
||||
const bindDn = dto.bindDn || config?.bindDn;
|
||||
const bindPassword = dto.bindPassword || config?.bindPassword;
|
||||
|
||||
if (!serverUrl || !bindDn || !bindPassword) {
|
||||
if (!serverUrl) {
|
||||
throw new BadRequestException(
|
||||
'serverUrl, bindDn, and bindPassword are required (either provided directly or from a saved config)',
|
||||
'serverUrl is required (either provided directly or from a saved config)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user