Skip to content

feat(group): wire up ldap driver in modern GroupService - #225

Merged
ralflang merged 1 commit into
horde:FRAMEWORK_6_0from
jcdelepine:feat/groupservice-ldap-driver
Sep 14, 2026
Merged

ralflang merged 1 commit into
horde:FRAMEWORK_6_0from
jcdelepine:feat/groupservice-ldap-driver

Conversation

@jcdelepine

Copy link
Copy Markdown
Contributor

Context

Found while continuing my OIDC/OAuth2 authentication work on Horde6:
constructing Horde\Horde\Settings\OAuthAccountController failed with
Horde\Injector\NotFoundException:

Dependency chain: OAuthAccountController → TopbarBuilder → PermissionService → GroupService
Root cause: Unsupported group driver: ldap. Modern GroupService currently supports 'sql' only.

LdapGroupService and LdapGroupServiceFactory already exist and are
largely complete — they were simply never called by
GroupServiceFactory::create(), which only handled 'sql'. This is
not a regression fix: the legacy driver (Horde_Group_Ldap) has
always worked fine; only the modern port was never wired in.

What this fixes

  • GroupServiceFactory: adds the 'ldap' case.
  • LdapGroupServiceFactory: was reading groups.params (typo)
    instead of group.params; was resolving the default LDAP
    connection instead of the 'horde:groups' service, so it ignored
    a dedicated $conf['ldap']['service']['groups'] if one was set.
  • LdapGroupService::buildFilter(): was hand-building an
    objectClass filter (OR-only, no support for
    filter_type = 'filter') instead of delegating to
    Horde_Ldap_Filter::build() the way the legacy driver does — same
    result for a single objectclass, but diverges as soon as there are
    several objectclasses or a raw filter configured.
  • LdapGroupService::get() had two bugs that silently produced
    wrong data rather than erroring, both found by exercising it
    against a real directory:
    • the attributes parameter passed to Horde_Ldap::getEntry()
      was double-wrapped (['attributes' => [...]] instead of the
      flat list getEntry() expects), so the member attribute was
      never actually requested from the server — it silently came
      back empty even when the entry had members.
    • the member attribute was read with getValue()'s default
      'single' mode, which collapses a multi-valued attribute to
      just its first value as a string; reading a real 3-member group
      silently returned only 1 (then 0, once the first bug above was
      also fixed and is_array() rejected the resulting string).
    • both member and mail reads are now guarded with exists(),
      since Horde_Ldap_Entry::getValue() throws when an attribute is
      genuinely absent rather than returning null/empty — matching
      legacy Horde_Group_Ldap's own guard for the same reason.

Deliberately out of scope

Two things were found while testing against a real directory and are
being tracked separately in #224 rather than folded into this PR:

  • attrisdn (LDAP members stored as full DNs rather than uids) — the
    config schema already accounts for it, but LdapGroupService
    doesn't resolve DNs on write or normalize them on read. A prior
    attempt at this is sitting in a dropped commit if useful as a
    starting point, but it's not upstream-ready and not yet used by
    any real deployment, so it seemed better to check with the
    maintainers on the LDAP side before proposing something concrete.
  • GroupService's LDAP-backed group IDs are a bare cn value
    (LdapGroupService::buildDN() reconstructs the DN from it), while
    legacy Horde_Group_Ldap uses the full DN as the group ID
    throughout its API. The two are not interchangeable, which could
    matter for any permissions/ACL data created via the legacy admin
    UI.

See #224 for reproduction steps on both.

Tests

There was no existing test for GroupServiceFactory or
LdapGroupServiceFactory before this PR. Both new test files follow
the pattern already established in HordeLdapServiceFactoryTest
(injector stubbed via willReturnMap, a real
Horde\Core\Config\State instead of mocking it, no deep-mocked
ConfigLoader) rather than inventing a new approach for this PR.
LdapGroupServiceTest (pre-existing) gained coverage for the two
get() bugs above, including a regression test reproducing the
real directory entry that surfaced them.

GroupServiceFactory only supported 'sql'; LdapGroupService and
LdapGroupServiceFactory already existed but were never wired in.

- Add 'ldap' case to GroupServiceFactory::create()
- Fix LdapGroupServiceFactory reading 'groups.params' instead of
  the correct 'group.params' config key
- LdapGroupService now delegates filter building to
  Horde_Ldap_Filter::build(), matching legacy Horde_Group_Ldap,
  instead of a narrower objectClass-only reimplementation
- Fix LdapGroupService::get() passing a malformed attributes
  parameter to Horde_Ldap::getEntry() (double-wrapped in an
  'attributes' key instead of the flat list getEntry() expects),
  which silently dropped the member attribute from every fetched
  group entry even when it was present in LDAP
- Guard the 'member' and 'mail' reads in get() with exists(),
  since Horde_Ldap_Entry::getValue() throws when an attribute is
  genuinely absent rather than returning null/empty
- Read the member attribute with explicit mode 'all': without it,
  getValue() defaults to 'single' and silently collapses a
  multi-valued attribute to just its first value as a string,
  dropping every other member with no error at all
- Add GroupServiceFactoryTest and LdapGroupServiceFactoryTest
  covering the new 'ldap' wiring and the per-service LDAP
  connection resolution
@ralflang
ralflang merged commit 5a67e03 into horde:FRAMEWORK_6_0 Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants