feat: add LDAP group synchronization support (#27263)

* feat: expose LDAP group sync settings in admin config

LDAP group synchronization was already wired into the login flow but its
settings (group management, auto-creation, and the group attribute) could
only be set via environment variables. OAuth, by contrast, exposes its
group-mapping settings through the admin config API and UI.

Bring LDAP to parity:
- Add enable_group_management, enable_group_creation and
  attribute_for_groups to LdapServerConfig and LDAP_SERVER_CONFIG_KEYS so
  the /admin/config/ldap/server endpoint reads and persists them.
- Add a "Group Mapping / Auto-Create Groups / Group Attribute" section to
  the LDAP admin settings UI, mirroring the OAuth group-mapping controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: harden LDAP group sync config and login flow

Address review findings on the LDAP group-sync settings:

- ldap_auth: move the auto-create-groups call inside the try/except that
  wraps group sync, so a group-creation error is logged instead of
  bubbling to the broad handler and failing the whole login.
- update_ldap_server: reject saving with group management enabled but an
  empty group attribute, which would otherwise make sync silently no-op
  (mirrors the existing required-field validation).
- Authentication.svelte: merge the LDAP server config response into the
  client defaults instead of replacing the object, so any key an older
  backend omits keeps its default value.

Note: the empty-directory-groups behavior was reviewed and already
matches OAuth (both skip removal when no groups are returned), so it was
left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: default blank LDAP group attribute to memberOf before save

The Group Attribute field advertises "Default to memberOf", but the
backend now rejects an empty group attribute when group management is
enabled. Fall back to the memberOf default client-side when the field is
left blank, so the advertised default holds and the save isn't rejected.
The backend validation remains as defense-in-depth for direct API calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: initialize LDAP port default as null instead of empty string

The backend LdapServerConfig types port as `int | None`, but the frontend
initialized it to an empty string. If a save carried that default (e.g.
when the backend response omits port under version skew), Pydantic would
reject the empty string. `null` matches the model and is also what the
type="number" input yields when the field is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: parse LDAP group DNs correctly instead of splitting on commas

Group CN extraction split the DN on raw commas and sliced off "CN=",
which mangles any group whose name contains an escaped separator (e.g.
"CN=Sales\, EMEA,OU=...") into a truncated, wrong name that then fails to
match the intended Open WebUI group. Use ldap3's parse_dn to split the DN
respecting RFC 4514 escaping, and unescape the resulting value so the CN
matches what an administrator sees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* chore: address review feedback on _unescape_ldap_dn_value

Trim the docstring and rename the loop index to a more descriptive name
(i -> pos) per review feedback on the group DN unescaping helper. No
behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Juan Calderon-Perez
2026-07-23 14:20:55 -04:00
committed by GitHub
parent 0f82f40b70
commit ca2d7c9deb
2 changed files with 105 additions and 13 deletions
+61 -10
View File
@@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from ldap3 import NONE, Connection, Server, Tls
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import parse_dn
from open_webui.config import (
ENABLE_PASSWORD_AUTH,
OAUTH_PROVIDERS,
@@ -128,6 +129,9 @@ LDAP_SERVER_CONFIG_KEYS = {
'certificate_path': 'ldap.server.ca_cert_file',
'validate_cert': 'ldap.server.validate_cert',
'ciphers': 'ldap.server.ciphers',
'enable_group_management': 'ldap.group.enable_management',
'enable_group_creation': 'ldap.group.enable_creation',
'attribute_for_groups': 'ldap.server.attribute_for_groups',
}
@@ -398,6 +402,52 @@ async def update_password(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
def _unescape_ldap_dn_value(value: str) -> str:
"""Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``.
Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together.
"""
hexdigits = '0123456789abcdefABCDEF'
result = []
pos = 0
length = len(value)
while pos < length:
char = value[pos]
if char == '\\' and pos + 1 < length:
if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits:
byte_values = bytearray()
while (
pos + 2 < length
and value[pos] == '\\'
and value[pos + 1] in hexdigits
and value[pos + 2] in hexdigits
):
byte_values.append(int(value[pos + 1 : pos + 3], 16))
pos += 3
result.append(byte_values.decode('utf-8', errors='replace'))
else:
# Backslash escaping a literal special char, e.g. "\," or "\+".
result.append(value[pos + 1])
pos += 2
else:
result.append(char)
pos += 1
return ''.join(result)
def extract_group_cn_from_dn(group_dn: str) -> str | None:
"""Return the first CN component of an LDAP group DN, or None.
Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose
name contains a comma) are handled correctly instead of naively splitting
on ``,``.
"""
for attr_type, attr_value, _ in parse_dn(group_dn):
if attr_type.upper() == 'CN':
return _unescape_ldap_dn_value(attr_value)
return None
############################
# LDAP Authentication
############################
@@ -545,17 +595,10 @@ async def ldap_auth(
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
try:
group_cn = None
for item in group_dn.split(','):
item = item.strip()
if item.upper().startswith('CN='):
group_cn = item[3:]
break
group_cn = extract_group_cn_from_dn(group_dn)
if group_cn:
user_groups.append(group_cn)
else:
log.warning(f'Could not extract CN from group DN: {group_dn}')
except Exception as e:
@@ -627,9 +670,9 @@ async def ldap_auth(
if user:
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
if ENABLE_LDAP_GROUP_CREATION:
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try:
if ENABLE_LDAP_GROUP_CREATION:
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
except Exception as e:
@@ -1188,6 +1231,9 @@ class LdapServerConfig(BaseModel):
certificate_path: str | None = None
validate_cert: bool = True
ciphers: str | None = 'ALL'
enable_group_management: bool = False
enable_group_creation: bool = False
attribute_for_groups: str = 'memberOf'
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
@@ -1209,6 +1255,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
if not value:
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
# The group attribute is what group management reads from the directory
# entry; an empty value would make group sync silently do nothing.
if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip():
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups'))
updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
updates['ldap.server.app_dn'] = form_data.app_dn or ''
updates['ldap.server.app_password'] = form_data.app_dn_password or ''
@@ -32,7 +32,7 @@
let LDAP_SERVER = {
label: '',
host: '',
port: '',
port: null,
attribute_for_mail: 'mail',
attribute_for_username: 'uid',
app_dn: '',
@@ -42,7 +42,10 @@
use_tls: false,
validate_cert: false,
certificate_path: '',
ciphers: ''
ciphers: '',
enable_group_management: false,
enable_group_creation: false,
attribute_for_groups: 'memberOf'
};
let oauthConfig: any = null;
@@ -55,6 +58,13 @@
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
if (!ENABLE_LDAP) return true;
// Honor the "Default to memberOf" hint: fall back to the default group
// attribute when it is left blank while group management is enabled, so
// the save isn't rejected by the backend's required-field check.
if (LDAP_SERVER.enable_group_management && !LDAP_SERVER.attribute_for_groups?.trim()) {
LDAP_SERVER.attribute_for_groups = 'memberOf';
}
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(`${error}`);
return null;
@@ -104,7 +114,9 @@
groups = await getGroups(localStorage.token);
})(),
(async () => {
LDAP_SERVER = await getLdapServer(localStorage.token);
// Merge into the defaults so any key the backend omits (e.g. an
// older backend without the group settings) keeps its default.
LDAP_SERVER = { ...LDAP_SERVER, ...(await getLdapServer(localStorage.token)) };
})(),
(async () => {
oauthConfig = await getOAuthConfig(localStorage.token).catch(() => null);
@@ -465,6 +477,35 @@
</Tooltip>
</AdminSettingField>
{/if}
<AdminSettingRow
label={$i18n.t('Group Mapping')}
description={$i18n.t('Map LDAP groups to Open WebUI groups.')}
>
<Switch bind:state={LDAP_SERVER.enable_group_management} />
</AdminSettingRow>
{#if LDAP_SERVER.enable_group_management}
<AdminSettingRow
label={$i18n.t('Auto-Create Groups')}
description={$i18n.t('Create missing groups from LDAP groups.')}
>
<Switch bind:state={LDAP_SERVER.enable_group_creation} />
</AdminSettingRow>
<AdminSettingField
label={$i18n.t('Group Attribute')}
description={$i18n.t('LDAP attribute containing the user group memberships.')}
>
<Tooltip content={$i18n.t('Default to memberOf')} placement="top-start">
<input
class={inputClass}
placeholder="memberOf"
bind:value={LDAP_SERVER.attribute_for_groups}
/>
</Tooltip>
</AdminSettingField>
{/if}
{/if}
</AdminSettingSection>