Skip to content

bug: LDAP - Synchronization failed: RuntimeError: Multiple entries found - aborting #73

Description

@juergen-laschak

Summary

downsyncUser() in services/ldap.py searches for a user's LDAP entry using only (objectID=<externID>) (e.g. (uid=<externID>)), with no additional type or objectClass scoping. Against an authentik LDAP outpost backend, this bare ID search returns two entries for every single user — the real user object and authentik's automatically-generated per-user "virtual group" — causing downsyncUser() to raise RuntimeError("Multiple entries found - aborting") and aborting the whole sync/import job.

This is not a directory misconfiguration: authentik's per-user virtual groups (ou=virtual-groups,<base DN>) are a documented, standard feature (Unix-style "user private group" emulation), and they carry the same uid value as the owning user. See: https://docs.goauthentik.io/add-secure-apps/providers/ldap/

Environment

  • grommunio-admin-api version: grommunio-admin-api-1.20.19.m690dc38-lp160.14.1.noarch
  • OS: openSUSE Leap 16.0
  • LDAP backend: authentik LDAP outpost (Base DN dc=<domain> with sibling OUs ou=users, ou=groups, ou=virtual-groups)
  • Relevant ldap_adaptor.cfg values:
    ldap_object_id=uid
    ldap_search_base=dc=example,dc=org
    ldap_user_filter=(&(objectClass=InetOrgPerson)(memberOf=cn=grommunio_user,ou=groups,dc=example,dc=org))
    ldap_contact_filter=(&(objectClass=InetOrgPerson)(memberOf=cn=some_contact_group,ou=groups,dc=example,dc=org))
    

Steps to Reproduce

  1. Connect grommunio to an authentik LDAP outpost as the LDAP backend, with ldap_object_id set to uid (or any attribute authentik also mirrors onto its per-user virtual groups).
  2. Import/link at least one user via the normal LDAP filter (ldap_user_filter), which correctly scopes by objectClass/memberOf and works fine.
  3. Trigger a per-user re-sync / "downsync" for that already-linked user (in our case via the "Benutzer synchronisieren in LDAP-Verzeichnis" action in the admin GUI, which appears to call downsyncUser(ID) for each already-linked user).
  4. Observe the job fail with RuntimeError: Multiple entries found - aborting.

Root Cause

In services/ldap.py:

def downsyncUser(self, ID, props=None):
    ...
    response = self._search(self._matchFilters(ID), attributes="all")
    if len(response) > 1:
        raise RuntimeError("Multiple entries found - aborting")
def _matchFilters(self, ID):
    return "({}={})".format(self._config["objectID"], self.escape_filter_chars(ID))

_matchFilters() builds a bare (uid=<ID>) filter with no objectClass/type constraint. _search() uses a single shared search base (_sbase, derived from ldap_search_base plus an internal, not-externally-configurable users.subtree override) for every search type — users, contacts, and groups all search the same subtree. There is no way, via existing config, to scope this specific lookup away from ou=virtual-groups without also breaking group search (which lives in a sibling OU under the same base).

Reproduced directly via ldapsearch using the exact same filter grommunio builds:

$ ldapsearch -x -H "$LDAP_HOST" -D "$BIND_DN" -w "$BIND_PW" -b "$SEARCH_BASE" "(uid=<externID>)" dn uid objectClass memberOf

# person, users, example.org
dn: cn=exampleuser,ou=users,dc=example,dc=org
uid: <externID>
objectClass: inetOrgPerson
objectClass: user
...
memberOf: cn=grommunio_user,ou=groups,dc=example,dc=org
...

# exampleuser, virtual-groups, example.org
dn: cn=exampleuser,ou=virtual-groups,dc=example,dc=org
uid: <externID>
objectClass: group
objectClass: goauthentik.io/ldap/virtual-group
...

# numEntries: 2

Every single linked user reproduces this identically — it is not an isolated duplicate directory entry, it is systemic for this backend.

Suggested Fix

downsyncUser() (and _matchFilters()) should exclude authentik-style virtual/service group objects from ID-based user lookups, e.g. by ANDing in an explicit type constraint:

def _matchFilters(self, ID):
    return "(&({}={})(!(objectClass=goauthentik.io/ldap/virtual-group)))".format(
        self._config["objectID"], self.escape_filter_chars(ID))

or, more generally, reuse the already-configured ldap_user_filter/ldap_contact_filter (which correctly scope by objectClass/memberOf) as an additional AND-clause in downsyncUser()'s search, the same way the rest of _search() already does for type-scoped queries. Alternatively, expose users.subtree (already implemented in _searchBase()) as an actual configurable field, so deployments with sibling OUs like this can scope the base DN appropriately per search type.

Impact

Any grommunio deployment backed by authentik's LDAP outpost (a fairly common combination, since authentik is a popular self-hosted IdP) will hit this on the very first per-user LDAP re-sync/downsync action, aborting the whole job for every user, not just one.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions