fix(proxy): skip only the LDAP group with a deleted member entry

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mrinal 2026-11-05 04:18:45 +00:00
parent c5b420d4f8
commit c020a2fd0b
3 changed files with 50 additions and 11 deletions

View file

@ -40,7 +40,7 @@ Start with `dry_run: true`, restart the proxy, and review the planned team creat
`group_name_attribute` supplies the new team's display name. Team creation inherits `default_team_params`, the same way SCIM-created teams do. Existing team names, model permissions, budgets and other settings remain unchanged.
By default, `member` contains full user DNs. The sync reads each referenced user entry and compares its `mail` attribute to existing LiteLLM `user_email` values, ignoring email case. `litellm_match_field` may instead be `user_id` or `sso_user_id`, matched exactly. Set `user_match_attribute` to the LDAP attribute carrying the corresponding identity. Missing LiteLLM accounts are skipped and counted in the logs, then picked up by a later run after normal onboarding. Duplicate matches skip the affected group rather than choosing an account.
By default, `member` contains full user DNs. The sync reads each referenced user entry and compares its `mail` attribute to existing LiteLLM `user_email` values, ignoring email case. `litellm_match_field` may instead be `user_id` or `sso_user_id`, matched exactly. Set `user_match_attribute` to the LDAP attribute carrying the corresponding identity. Missing LiteLLM accounts are skipped and counted in the logs, then picked up by a later run after normal onboarding. Changing a user's matched LDAP attribute, such as their `mail`, without updating LiteLLM makes them unmatched, so the next run removes them from every synchronized team and deletes their keys for those teams. Update the matching LiteLLM field first, for example `user_email` through `/user/update`. Duplicate matches skip the affected group rather than choosing an account.
For POSIX groups, set `member_attribute: memberUid`. These values are looked up under `user_search_base` using `member_lookup_attribute`, which defaults to `uid`. Other membership attributes such as `uniqueMember` or `roleOccupant` are treated as full DNs. Only direct users are supported. Binary group identifiers, nested groups and AD ranged membership responses are rejected.
@ -50,7 +50,7 @@ LDAP is authoritative for the membership of every matched team, including manual
A group no longer returned by LDAP leaves its team and memberships unchanged. A successfully read group with no members removes all memberships from its corresponding team. No team or user is ever deleted, and no user is provisioned by this sync.
Failed or incomplete group searches, referrals, duplicate group IDs, invalid responses and LDAP operation exceptions abort the directory snapshot without applying it. This includes missing or unreadable entries when the LDAP client raises an exception. Other member validation failures, such as ambiguous identities or nested groups, skip their affected group. A membership-write failure for one valid group does not prevent other valid groups from synchronizing. A user disappearing during the membership write causes that group's transaction to make no changes. Repeated successful runs with the same roster do not rewrite it.
Failed or incomplete group searches, referrals, duplicate group IDs, invalid responses and LDAP operation exceptions abort the directory snapshot without applying it. A member DN whose entry no longer exists skips only its group, so other groups still synchronize and that team keeps its current members until the DN is removed or restored. Unreadable entries still abort the snapshot. Other member validation failures, such as ambiguous identities or nested groups, also skip their affected group. A membership-write failure for one valid group does not prevent other valid groups from synchronizing. A user disappearing during the membership write causes that group's transaction to make no changes. Repeated successful runs with the same roster do not rewrite it.
The LDAP identity must have permission to read every selected group, its complete membership attributes, and the user identity attributes. LDAP can omit attributes hidden by access controls just as it omits empty attributes, so the sync cannot distinguish every permission restriction from an empty group. Verify the bind account and dry-run output before enabling writes.

View file

@ -14,6 +14,7 @@ from litellm.types.proxy.ldap_sync import LDAPSyncSettings
_ITEMS: Final = TypeAdapter(Iterable[object])
_STRINGS: Final = TypeAdapter(tuple[StrictStr, ...])
_GROUP_CLASSES: Final = frozenset({"group", "groupofnames", "groupofuniquenames", "posixgroup"})
_NO_SUCH_OBJECT: Final = 32
@dataclass(frozen=True, slots=True)
@ -184,15 +185,20 @@ def _resolve_member(
) -> str | DirectoryError:
_check_cancelled(stop)
usernames: Final = settings.member_attribute.lower() == "memberuid"
_ = connection.search(
search_base=settings.user_search_base if usernames else value,
search_filter=f"({settings.member_lookup_attribute}={_escape_filter(value)})"
if usernames
else "(objectClass=*)",
search_scope="SUBTREE" if usernames else "BASE",
attributes=(settings.user_match_attribute, "objectClass"),
time_limit=settings.timeout_seconds,
)
try:
_ = connection.search(
search_base=settings.user_search_base if usernames else value,
search_filter=f"({settings.member_lookup_attribute}={_escape_filter(value)})"
if usernames
else "(objectClass=*)",
search_scope="SUBTREE" if usernames else "BASE",
attributes=(settings.user_match_attribute, "objectClass"),
time_limit=settings.timeout_seconds,
)
except Exception: # noqa: BLE001 # ldap3 raises noSuchObject for a deleted member DN
if usernames or connection.result.get("result") != _NO_SUCH_OBJECT:
raise
return DirectoryError("LDAP member entry does not exist")
if connection.result.get("result") != 0:
return DirectoryError("LDAP member lookup did not complete successfully")
entries: Final = _entries(connection.response)

View file

@ -4,6 +4,7 @@ from threading import Event
from typing import Final
import pytest
from ldap3.core.exceptions import LDAPInsufficientAccessRightsResult, LDAPNoSuchObjectResult
from pydantic import ValidationError
from litellm.proxy.common_utils.ldap_directory import (
@ -45,9 +46,11 @@ class Connection:
result_code: int = 0,
tls_ok: bool = True,
stop_after_search: Event | None = None,
raising: Mapping[str, tuple[int, Exception]] | None = None,
) -> None:
self.groups = groups
self.users = users or {}
self.raising = raising or {}
self.result_code = result_code
self.result: Mapping[str, object] = {"result": result_code}
self.response: object = ()
@ -94,6 +97,10 @@ class Connection:
time_limit: int,
) -> bool:
self.searches.append((search_base, search_filter, search_scope))
if search_base in self.raising:
result_code, exception = self.raising[search_base]
self.result = {"result": result_code}
raise exception
self.response = self.users.get(search_base if search_scope == "BASE" else search_filter, ())
self.result = {"result": self.result_code}
if self.stop_after_search is not None:
@ -110,6 +117,32 @@ def test_direct_members_resolve_to_user_identities_and_connection_is_closed():
assert connection.closed
def test_deleted_member_entry_skips_only_its_group():
connection = Connection(
[group("project-a", member=[ALICE, BOB]), group("project-b", member=[ALICE])],
{ALICE: [user("alice@example.org")]},
raising={BOB: (32, LDAPNoSuchObjectResult(result=32, description="noSuchObject"))},
)
assert read_directory(SETTINGS, connection) == (
DirectoryError("LDAP member entry does not exist", "cn=project-a,ou=groups,dc=example,dc=org"),
DirectoryGroup("project-b", "project-b", ("alice@example.org",)),
)
assert connection.closed
def test_unreadable_member_entry_still_aborts_snapshot():
connection = Connection(
[group("project-a", member=[ALICE, BOB]), group("project-b", member=[ALICE])],
{ALICE: [user("alice@example.org")]},
raising={
BOB: (50, LDAPInsufficientAccessRightsResult(result=50, description="insufficientAccessRights"))
},
)
assert read_directory(SETTINGS, connection) == DirectoryError(
"LDAP read failed (LDAPInsufficientAccessRightsResult)"
)
@pytest.mark.parametrize("membership", [[], ()])
def test_successfully_read_empty_group_has_empty_roster(membership: Sequence[str]):
assert read_directory(SETTINGS, Connection([group(member=membership)])) == (