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
- 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).
- Import/link at least one user via the normal LDAP filter (
ldap_user_filter), which correctly scopes by objectClass/memberOf and works fine.
- 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).
- 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.
Summary
downsyncUser()inservices/ldap.pysearches for a user's LDAP entry using only(objectID=<externID>)(e.g.(uid=<externID>)), with no additional type orobjectClassscoping. 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" — causingdownsyncUser()to raiseRuntimeError("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 sameuidvalue as the owning user. See: https://docs.goauthentik.io/add-secure-apps/providers/ldap/Environment
grommunio-admin-api-1.20.19.m690dc38-lp160.14.1.noarchdc=<domain>with sibling OUsou=users,ou=groups,ou=virtual-groups)ldap_adaptor.cfgvalues:Steps to Reproduce
ldap_object_idset touid(or any attribute authentik also mirrors onto its per-user virtual groups).ldap_user_filter), which correctly scopes byobjectClass/memberOfand works fine.downsyncUser(ID)for each already-linked user).RuntimeError: Multiple entries found - aborting.Root Cause
In
services/ldap.py:_matchFilters()builds a bare(uid=<ID>)filter with noobjectClass/type constraint._search()uses a single shared search base (_sbase, derived fromldap_search_baseplus an internal, not-externally-configurableusers.subtreeoverride) 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 fromou=virtual-groupswithout also breaking group search (which lives in a sibling OU under the same base).Reproduced directly via
ldapsearchusing the exact same filter grommunio builds: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:or, more generally, reuse the already-configured
ldap_user_filter/ldap_contact_filter(which correctly scope byobjectClass/memberOf) as an additional AND-clause indownsyncUser()'s search, the same way the rest of_search()already does for type-scoped queries. Alternatively, exposeusers.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.