From f007a5643345098fe5f5a123e305ce42cb6a8652 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 7 May 2026 17:07:49 +0200 Subject: [PATCH 1/5] Support forward member lookup for LDAP group synchronization https://community.openproject.org/wp/32812 OpenProject previously only supported reverse member lookup: finding group members by searching for users with a (memberOf=) filter. This requires the memberOf attribute to be maintained on user entries, which is not available on all LDAP servers (e.g. servers using groupOfUniqueNames without a memberof overlay). This change adds an optional "Group member attribute" field on synchronized LDAP filters. When set, OpenProject switches to forward lookup: it reads the list of member DNs directly from the group entry using the configured attribute (e.g. uniqueMember, member), then resolves each DN individually to retrieve user attributes. When left empty, the existing memberOf reverse lookup is used unchanged, ensuring full backward compatibility. This resolves the inability to synchronize groups on LDAP servers that store membership on group entries rather than on user entries, such as directories using the groupOfUniqueNames object class (RFC 2256). --- .../synchronized_filters_controller.rb | 3 +- .../models/ldap_groups/synchronized_filter.rb | 4 + .../ldap_groups/synchronize_groups_service.rb | 112 ++++++++++++------ .../synchronized_filters/_form.html.erb | 12 ++ modules/ldap_groups/config/locales/en.yml | 8 ++ ...ookup_attribute_to_synchronized_filters.rb | 7 ++ .../ldap_groups/lib/tasks/ldap_groups.rake | 66 +++++++++++ spec/fixtures/ldap/users_unique_member.ldif | 86 ++++++++++++++ 8 files changed, 263 insertions(+), 35 deletions(-) create mode 100644 modules/ldap_groups/db/migrate/20260504154415_add_member_lookup_attribute_to_synchronized_filters.rb create mode 100644 spec/fixtures/ldap/users_unique_member.ldif diff --git a/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb b/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb index 5f57f998b099..8767ded6bfc4 100644 --- a/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb +++ b/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb @@ -86,7 +86,8 @@ def find_filter def permitted_params params .require(:synchronized_filter) - .permit(:filter_string, :name, :ldap_auth_source_id, :group_name_attribute, :sync_users, :base_dn) + .permit(:filter_string, :name, :ldap_auth_source_id, :group_name_attribute, :sync_users, :base_dn, + :member_lookup_attribute) end end end diff --git a/modules/ldap_groups/app/models/ldap_groups/synchronized_filter.rb b/modules/ldap_groups/app/models/ldap_groups/synchronized_filter.rb index 10a4e563ff93..68d39ebbc5ae 100644 --- a/modules/ldap_groups/app/models/ldap_groups/synchronized_filter.rb +++ b/modules/ldap_groups/app/models/ldap_groups/synchronized_filter.rb @@ -24,6 +24,10 @@ def used_base_dn base_dn.presence || ldap_auth_source.base_dn end + def forward_member_lookup? + member_lookup_attribute.present? + end + def seeded_from_env? return false if ldap_auth_source.nil? diff --git a/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb b/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb index bb9b5fec488e..170ec4f83406 100644 --- a/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb +++ b/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb @@ -6,7 +6,7 @@ def initialize(ldap) @ldap = ldap # Get current synced groups in OP - @synced_groups = ::LdapGroups::SynchronizedGroup.where(ldap_auth_source: ldap) + @synced_groups = ::LdapGroups::SynchronizedGroup.where(ldap_auth_source: ldap).includes(:filter) end def call @@ -93,27 +93,14 @@ def update_memberships!(sync, users) end ## - # Get the current members from the ldap group + # Get the current members from the ldap group. + # Dispatches to forward or reverse lookup based on filter configuration. def get_members(ldap_con, group) - # Get user login attribute and base dn which are private - base_dn = ldap.base_dn - - users = {} - # Override the default search attributes from the ldap - # if we have sync_users enabled, to also get user attributes - search_attributes = ldap.search_attributes - ldap_con.search(base: base_dn, - filter: memberof_filter(group), - attributes: search_attributes) do |entry| - data = ldap.get_user_attributes_from_ldap_entry(entry) - if data[:login].present? - users[data[:login]] = data.except(:dn) - else - Rails.logger.warn { "Tried to add user but mapped login is empty for #{entry.dn}. Ignoring this user."} - end + if group.filter&.forward_member_lookup? + get_members_forward(ldap_con, group) + else + get_members_reverse(ldap_con, group) end - - users end ## @@ -129,20 +116,6 @@ def add_memberships!(ldap_member_ids, sync) sync.add_members! ldap_member_ids end - ## - # Get the memberof filter to use for querying members - def memberof_filter(group) - # memberOf filter to identify member entries of the group - filter = Net::LDAP::Filter.eq("memberOf", group.dn) - - # Add the LDAP auth source own filter if present - if ldap.filter_string.present? - filter = filter & ldap.parsed_filter_string - end - - filter - end - ## # Remove a set of memberships def remove_memberships!(memberships, sync) @@ -157,5 +130,76 @@ def remove_memberships!(memberships, sync) sync.remove_members! user_ids end + + private + + ## + # Reverse lookup (default): search users in the LDAP base with (memberOf=). + # Requires the memberOf attribute to be present on user entries (AD, OpenLDAP with memberof overlay). + def get_members_reverse(ldap_con, group) + users = {} + ldap_con.search(base: ldap.base_dn, + filter: memberof_filter(group), + attributes: ldap.search_attributes) do |entry| + map_entry_to_users(entry, users) + end + users + end + + ## + # Forward lookup: read member DNs from the group entry itself, then resolve each DN. + # Supports servers using groupOfUniqueNames (uniqueMember), groupOfNames (member), etc. + def get_members_forward(ldap_con, group) + member_attribute = group.filter.member_lookup_attribute + member_dns = read_group_member_dns(ldap_con, group.dn, member_attribute) + + Rails.logger.debug { "[LDAP groups] Forward lookup for #{group.dn}: #{member_dns.count} member DNs found" } + + users = {} + member_dns.each do |member_dn| + ldap_con.search(base: member_dn, + scope: Net::LDAP::SearchScope_BaseObject, + filter: Net::LDAP::Filter.present("objectClass"), + attributes: ldap.search_attributes) do |entry| + map_entry_to_users(entry, users) + end + end + users + end + + ## + # Read the list of member DNs from a group entry using the configured attribute. + def read_group_member_dns(ldap_con, group_dn, member_attribute) + dns = [] + ldap_con.search(base: group_dn, + scope: Net::LDAP::SearchScope_BaseObject, + filter: Net::LDAP::Filter.present("objectClass"), + attributes: [member_attribute]) do |entry| + # entry[attr] returns an array of all values for multi-valued attributes + dns = Array(entry[member_attribute]) + end + dns + end + + def map_entry_to_users(entry, users) + data = ldap.get_user_attributes_from_ldap_entry(entry) + if data[:login].present? + users[data[:login]] = data.except(:dn) + else + Rails.logger.warn { "Tried to add user but mapped login is empty for #{entry.dn}. Ignoring this user." } + end + end + + ## + # Build the memberOf filter for reverse lookup, combined with the auth source filter if set. + def memberof_filter(group) + filter = Net::LDAP::Filter.eq("memberOf", group.dn) + + if ldap.filter_string.present? + filter = filter & ldap.parsed_filter_string + end + + filter + end end end diff --git a/modules/ldap_groups/app/views/ldap_groups/synchronized_filters/_form.html.erb b/modules/ldap_groups/app/views/ldap_groups/synchronized_filters/_form.html.erb index 8944fcde6a74..4d35691f54ba 100644 --- a/modules/ldap_groups/app/views/ldap_groups/synchronized_filters/_form.html.erb +++ b/modules/ldap_groups/app/views/ldap_groups/synchronized_filters/_form.html.erb @@ -51,6 +51,18 @@ +
+ <%= @filter.class.human_attribute_name :member_lookup_attribute %> + +
+ <%= f.text_field :member_lookup_attribute, + container_class: "-middle" %> +
+ <%= t("ldap_groups.synchronized_filters.form.member_lookup_attribute_text") %> +
+
+
+
<%= @filter.class.human_attribute_name :filter_string %>
diff --git a/modules/ldap_groups/config/locales/en.yml b/modules/ldap_groups/config/locales/en.yml index 25a8545b50d5..692a58335449 100644 --- a/modules/ldap_groups/config/locales/en.yml +++ b/modules/ldap_groups/config/locales/en.yml @@ -21,6 +21,7 @@ en: group_name_attribute: "Group name attribute" sync_users: 'Sync users' base_dn: "Search base DN" + member_lookup_attribute: "Group member attribute" models: ldap_groups/synchronized_group: 'Synchronized LDAP group' ldap_groups/synchronized_filter: 'LDAP Group synchronization filter' @@ -57,6 +58,13 @@ en: Enter the search base DN to use for this filter. It needs to be below the base DN of the selected LDAP connection. Leave this option empty to reuse the base DN of the connection + member_lookup_attribute_text: > + Leave empty to use the default reverse lookup: OpenProject searches for users whose memberOf attribute matches the group DN. + This requires the memberOf attribute to be present on user entries (Active Directory, OpenLDAP with memberof overlay). + + Set this to the attribute name on group entries that lists member DNs to use forward lookup instead. + Examples: "uniqueMember" (groupOfUniqueNames), "member" (groupOfNames). + Use this for LDAP servers that do not maintain the memberOf attribute on user entries. synchronized_groups: add_new: 'Add synchronized LDAP group' diff --git a/modules/ldap_groups/db/migrate/20260504154415_add_member_lookup_attribute_to_synchronized_filters.rb b/modules/ldap_groups/db/migrate/20260504154415_add_member_lookup_attribute_to_synchronized_filters.rb new file mode 100644 index 000000000000..8667afa78eb0 --- /dev/null +++ b/modules/ldap_groups/db/migrate/20260504154415_add_member_lookup_attribute_to_synchronized_filters.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddMemberLookupAttributeToSynchronizedFilters < ActiveRecord::Migration[7.1] + def change + add_column :ldap_groups_synchronized_filters, :member_lookup_attribute, :string + end +end diff --git a/modules/ldap_groups/lib/tasks/ldap_groups.rake b/modules/ldap_groups/lib/tasks/ldap_groups.rake index 24a1c78c2b90..18ffdbffca7b 100644 --- a/modules/ldap_groups/lib/tasks/ldap_groups.rake +++ b/modules/ldap_groups/lib/tasks/ldap_groups.rake @@ -145,5 +145,71 @@ namespace :ldap_groups do ldap_server.stop end + + desc "Create a development LDAP server using groupOfUniqueNames/uniqueMember (forward lookup, no memberOf on users)" + task ldap_server_forward: :environment do + require "ladle" + ldif = ENV.fetch("LDIF_FILE") { Rails.root.join("spec/fixtures/ldap/users_unique_member.ldif") } + ldap_server = Ladle::Server.new(quiet: false, port: "12389", domain: "dc=example,dc=com", ldif:).start + + source = LdapAuthSource.find_or_initialize_by(name: "ladle forward lookup") + + source.attributes = { + host: "localhost", + port: "12389", + tls_mode: "plain_ldap", + account: "uid=admin,ou=system", + account_password: "secret", + base_dn: "dc=example,dc=com", + onthefly_register: true, + attr_login: "uid", + attr_firstname: "givenName", + attr_lastname: "sn", + attr_mail: "mail" + } + + source.save! + + filter = LdapGroups::SynchronizedFilter.find_or_initialize_by(ldap_auth_source: source, name: "All groups") + filter.group_name_attribute = "dn" + filter.sync_users = true + filter.filter_string = "(cn=*)" + filter.base_dn = "ou=groups,dc=example,dc=com" + filter.member_lookup_attribute = "uniqueMember" + + filter.save! + + LdapGroups::SynchronizationJob.perform_now + + puts <<~INFO + LDAP server ready at localhost:12389 (forward lookup mode) + + member_lookup_attribute: uniqueMember + Groups use groupOfUniqueNames — users have NO memberOf attribute. + + -------------------------------------------------------- + + Users + + uid=aa729,ou=people,dc=example,dc=com (Password: smada) → engineering, cross-functional + uid=bb459,ou=people,dc=example,dc=com (Password: niwdlab) → engineering + uid=cc414,ou=people,dc=example,dc=com (Password: retneprac) → management, cross-functional + + -------------------------------------------------------- + + Groups + + cn=engineering,ou=groups,dc=example,dc=com (Members: aa729, bb459) + cn=management,ou=groups,dc=example,dc=com (Members: cc414) + cn=cross-functional,ou=groups,dc=example,dc=com (Members: aa729, cc414) + + INFO + + puts "Send CTRL+D to stop the server" + require "irb" + binding.irb + + ldap_server.stop + end end end diff --git a/spec/fixtures/ldap/users_unique_member.ldif b/spec/fixtures/ldap/users_unique_member.ldif new file mode 100644 index 000000000000..b2c72a168041 --- /dev/null +++ b/spec/fixtures/ldap/users_unique_member.ldif @@ -0,0 +1,86 @@ +version: 1 + +dn: ou=groups,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: groups + +dn: ou=people,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: people + +# Groups use groupOfUniqueNames + uniqueMember (forward lookup). +# Users have NO memberOf attribute — this reproduces the Dassault scenario. + +dn: cn=engineering,ou=groups,dc=example,dc=com +objectClass: groupOfUniqueNames +objectClass: top +cn: engineering +uniqueMember: uid=aa729,ou=people,dc=example,dc=com +uniqueMember: uid=bb459,ou=people,dc=example,dc=com + +dn: cn=management,ou=groups,dc=example,dc=com +objectClass: groupOfUniqueNames +objectClass: top +cn: management +uniqueMember: uid=cc414,ou=people,dc=example,dc=com + +dn: cn=cross-functional,ou=groups,dc=example,dc=com +objectClass: groupOfUniqueNames +objectClass: top +cn: cross-functional +uniqueMember: uid=aa729,ou=people,dc=example,dc=com +uniqueMember: uid=cc414,ou=people,dc=example,dc=com + +dn: uid=aa729,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Alexandra Adams +sn: Adams +givenName: Alexandra +mail: alexandra@example.org +uid: aa729 +# Password: smada +userpassword:: e1NIQX1wR2xtWlgxVk9FZEhIYjMwSFplemVWTkZ4R009 + +dn: uid=bb459,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Belle Baldwin +sn: Baldwin +givenName: Belle +mail: belle@example.org +uid: bb459 +# Password: niwdlab +userpassword:: e1NIQX1MUmlmMk4rNVREU2FPL3Jka0gySEhGOGZGNzQ9 + +dn: uid=cc414,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Claire Carpenter +sn: Carpenter +givenName: Claire +mail: claire@example.org +uid: cc414 +# Password: retneprac +userpassword:: e1NIQX1VTC9pUysyUjdHaHdiaFhPV29USGQ0L3FvTUE9 + +dn: uid=dd945,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Dorothy Dawson +sn: Dawson +givenName: Dorothy +mail: dorothy@example.org +uid: dd945 +# Password: noswad +userpassword:: e1NIQX1EMFVsY1RmYkNkZEZMd2loMDRpZzRERWlsQWM9 From 0f3dc4793edceae9edb4becc1e524a3afeb48698 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 11 May 2026 17:31:00 +0200 Subject: [PATCH 2/5] Add detailed logging to LDAP forward member lookup https://community.openproject.org/wp/32812 Adds log messages at each step of the forward lookup to make it easier to diagnose synchronization issues: - Which lookup mode is used (forward/reverse) and for which group - How many member DNs were found on the group entry - Each member DN being resolved individually - When a member DN cannot be resolved (not found or permission issue) - Which login attribute is missing and what attributes are available on the entry, to help identify misconfigured attr_login - Final count of successfully resolved users per group --- .../ldap_groups/synchronize_groups_service.rb | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb b/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb index 170ec4f83406..b01a0685bc84 100644 --- a/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb +++ b/modules/ldap_groups/app/services/ldap_groups/synchronize_groups_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module LdapGroups class SynchronizeGroupsService attr_reader :ldap, :synced_groups @@ -97,8 +99,10 @@ def update_memberships!(sync, users) # Dispatches to forward or reverse lookup based on filter configuration. def get_members(ldap_con, group) if group.filter&.forward_member_lookup? + Rails.logger.info { "[LDAP groups] Using forward lookup (#{group.filter.member_lookup_attribute}) for #{group.dn}" } get_members_forward(ldap_con, group) else + Rails.logger.info { "[LDAP groups] Using reverse lookup (memberOf) for #{group.dn}" } get_members_reverse(ldap_con, group) end end @@ -152,17 +156,32 @@ def get_members_reverse(ldap_con, group) def get_members_forward(ldap_con, group) member_attribute = group.filter.member_lookup_attribute member_dns = read_group_member_dns(ldap_con, group.dn, member_attribute) + return {} if member_dns.empty? - Rails.logger.debug { "[LDAP groups] Forward lookup for #{group.dn}: #{member_dns.count} member DNs found" } + Rails.logger.info { "[LDAP groups] Forward lookup: #{member_dns.count} member DN(s) found on #{group.dn}" } + users = resolve_member_dns(ldap_con, member_dns) + Rails.logger.info { "[LDAP groups] Forward lookup complete for #{group.dn}: #{users.size} user(s) resolved" } + users + end + def resolve_member_dns(ldap_con, member_dns) users = {} member_dns.each do |member_dn| + Rails.logger.debug { "[LDAP groups] Resolving member DN: #{member_dn}" } + resolved = false ldap_con.search(base: member_dn, scope: Net::LDAP::SearchScope_BaseObject, filter: Net::LDAP::Filter.present("objectClass"), attributes: ldap.search_attributes) do |entry| + resolved = true map_entry_to_users(entry, users) end + unless resolved + Rails.logger.warn do + "[LDAP groups] Could not resolve member DN: #{member_dn}. " \ + "Entry not found or service account lacks read permission." + end + end end users end @@ -178,15 +197,31 @@ def read_group_member_dns(ldap_con, group_dn, member_attribute) # entry[attr] returns an array of all values for multi-valued attributes dns = Array(entry[member_attribute]) end + + if dns.empty? + Rails.logger.warn do + "[LDAP groups] No entries returned for group DN: #{group_dn}. " \ + "The group entry may not exist or the service account may lack read permission." + end + end + dns end def map_entry_to_users(entry, users) data = ldap.get_user_attributes_from_ldap_entry(entry) if data[:login].present? + Rails.logger.debug { "[LDAP groups] Mapped #{entry.dn} -> login=#{data[:login]}" } users[data[:login]] = data.except(:dn) else - Rails.logger.warn { "Tried to add user but mapped login is empty for #{entry.dn}. Ignoring this user." } + log_missing_login(entry) + end + end + + def log_missing_login(entry) + Rails.logger.warn do + "[LDAP groups] Login attribute '#{ldap.attr_login}' not found or empty for #{entry.dn}. " \ + "Available attributes: #{entry.attribute_names.join(', ')}" end end From 4352424e7ded720ee0083ad461dd4337266a8d8a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 22 May 2026 12:43:57 +0200 Subject: [PATCH 3/5] Update LDAP group sync docs and UI to reflect forward lookup support - Remove the memberOf-only restriction from docs, UI help text, and FAQ; both reverse lookup (memberOf) and forward lookup (Group member attribute) are now supported - Document the new "Group member attribute" filter field, including when to use forward vs reverse lookup - Clarify that forward lookup is only available via synchronized filters, not manually-created synchronized groups - Rename "Synchronize" button to "Discover LDAP groups" to make clear it only runs group discovery (phase 1), not member synchronization - Document that the Discover LDAP groups button does not sync members; point to the rake task for a full manual sync - Expand troubleshooting: login attribute mismatch, missing/empty required attributes - Replace packaged-installation-specific rake command with installation-agnostic form; link to console setup docs - Clarify Enterprise cloud availability and recommend SAML/SCIM as more secure alternatives when LDAP exposure to the internet is undesirable - Fix grammar, double spaces, and stale phrasing throughout --- .../authentication-faq/README.md | 2 +- .../ldap-group-synchronization/README.md | 46 ++++++++++++------- modules/ldap_groups/config/locales/en.yml | 9 +++- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/system-admin-guide/authentication/authentication-faq/README.md b/docs/system-admin-guide/authentication/authentication-faq/README.md index a34735ff478d..ebd31034c709 100644 --- a/docs/system-admin-guide/authentication/authentication-faq/README.md +++ b/docs/system-admin-guide/authentication/authentication-faq/README.md @@ -52,7 +52,7 @@ For context: The connection of custom SSO providers is also described [here](../ ## I want to connect AD and LDAP to OpenProject. Which attribute for authentication sources does OpenProject use? You can freely define the attributes that are taken from LDAP sources [in the LDAP auth source configuration screen](../ldap-connections/). -For group synchronization, OpenProject supports the AD/LDAP standard for groups via "member / memberOf". The attribute cannot be configured at this time. +For group synchronization, OpenProject defaults to reverse lookup via the `memberOf` attribute on user entries (Active Directory, OpenLDAP with memberof overlay). If your LDAP server does not maintain `memberOf` on user entries, you can configure forward lookup by setting the **Group member attribute** on a synchronized filter (e.g. `uniqueMember` for `groupOfUniqueNames`, or `member` for `groupOfNames`). See [LDAP group synchronization](../ldap-connections/ldap-group-synchronization/) for details. ## Is there an option to mass-create users in OpenProject via the LDAP? diff --git a/docs/system-admin-guide/authentication/ldap-connections/ldap-group-synchronization/README.md b/docs/system-admin-guide/authentication/ldap-connections/ldap-group-synchronization/README.md index dc034d2406f9..0b801ba5fe39 100644 --- a/docs/system-admin-guide/authentication/ldap-connections/ldap-group-synchronization/README.md +++ b/docs/system-admin-guide/authentication/ldap-connections/ldap-group-synchronization/README.md @@ -8,15 +8,15 @@ keywords: synchronize ldap groups # Synchronize LDAP and OpenProject groups (Enterprise add-on) -Note: This feature is available for the Enterprise on-premises only, for OpenProject versions 7.4+. For more information and differences to Community edition, [see this page](https://www.openproject.org/enterprise-edition/). +This feature is available for the Enterprise edition only. For more information and differences to Community edition, [see the Enterprise edition overview](https://www.openproject.org/enterprise-edition/). -In OpenProject Enterprise on-premises, you can provision and periodically synchronize groups and their members from your existing LDAP or Active Directory. Group memberships using the [groupOfNames](https://tools.ietf.org/html/rfc4519#section-3.5) LDAP object class are supported for this. This guide assumes that you: +In OpenProject Enterprise edition, you can provision and periodically synchronize groups and their members from your existing LDAP or Active Directory. This guide assumes that you: - have set up your LDAP authentication source (See the “[Manage LDAP authentication](../../ldap-connections/)” guide) -- have at least one LDAP entry with a *groupOfNames* object class and members of that group to contain the *`memberOf: `* attribute to determine the members of a group entry. Right now we do not support LDAP instances that only have *member* attributes, but not the inverse *memberOf* property. +- have LDAP group entries whose members can be discovered either via the `memberOf` attribute on user entries (reverse lookup, default) or via a member list attribute on the group entry such as `uniqueMember` or `member` (forward lookup, configurable). > [!NOTE] -> OpenProject does not support other attributes other than the `memberOf` property to define groups. Please make sure that user objects have the `memberOf` property for the synchronization to work. +> This feature is available for both Enterprise cloud and Enterprise on-premises. When using Enterprise cloud, your LDAP server must be reachable from the OpenProject cloud infrastructure, which typically means exposing it to the internet, which is not recommended. This is a network and security consideration for your organization to evaluate. Consider using [SAML](../../saml/) or [SCIM provisioning](../../scim/) as a more secure alternative. For the sake of simplicity, we assume that in this guide, your LDAP structure looks like the following: @@ -35,7 +35,7 @@ In order to get to the LDAP group sync administration pane, expand the LDAP auth Synchronizing a single LDAP group allows you to connect an existing group in OpenProject with one from LDAP. -LDAP group synchronization extends the memberships defined by administrators in an existing OpenProject group. Important things to note are: +LDAP group synchronization extends the memberships defined by administrators in an existing OpenProject group. Important things to note are: - You need to have created at least one manual group in the OpenProject administration before you continue. - Group synchronization for this group is enabled by an administrator creating a *synchronized LDAP group* that ties the OpenProject group to an LDAP entry. @@ -43,30 +43,29 @@ LDAP group synchronization extends the memberships defined by administrators in ### Single synchronized groups -To create a new synchronized group, use the button on the top right of the page. There, you will select your LDAP authentication source that contains the group, as well as the existing OpenProject group that members should be synchronized to. The following options can be set: +To create a new synchronized group, use the button on the top right of the page. There, you will select your LDAP authentication source that contains the group, as well as the existing OpenProject group that members should be synchronized to. The following options can be set: - **LDAP connection:** Select the LDAP connection you want this synchronized group to use. Users created by group synchronization will be tied to that LDAP and may bind against it for authentication. - **DN:** Enter the full distinguished name (DN) of the group you want to synchronize. For example: `cn=team1,ou=groups,dc=example,dc=com`. - **Sync users:** Check this option if you want members of this group to be automatically created in OpenProject. When unchecked, only members of the group that also are existing users in OpenProject can be synchronized. - **Group:** Select an OpenProject group you want the members of the LDAP group to synchronize to. -Click on *Create* to finish the creation of the synchronized group. The LDAP memberships of each user will be synchronized hourly through a background job on your packaged installation. Changes and output will be logged to */var/log/openproject/cron-hourly.log*. +Click on *Create* to finish the creation of the synchronized group. The LDAP memberships of each user will be synchronized hourly through a background job. -If you want to trigger the synchronization *manually* you can do so by running the respective rake task directly. -In the packaged installation, for instance, this would work like this: +To trigger a full synchronization manually (group discovery **and** member sync), run the following rake task in a [console](../../../../installation-and-operations/operation/control/): ```shell -sudo openproject run bundle exec rake ldap_groups:synchronize +bundle exec rake ldap_groups:synchronize ``` -This method of creating synchronized groups is well-suited for a small number of groups, or a very individual set of groups that you need to synchronize. It is very flexible by allowing individual groups to synchronize users into OpenProject. +This method of creating synchronized groups is well-suited for a small number of groups, or a carefully selected set of groups that you need to synchronize. It is very flexible by allowing individual groups to synchronize users into OpenProject. If you need to synchronize a large number of groups that follow a common pattern, consider using the following filter functionality. ## Automatic groups using synchronized filters -Instead of manually synchronizing groups from a given group DN in your LDAP, you can also create filter objects that will query the LDAP not only for group members, but the groups themselves. +Instead of manually synchronizing groups from a given group DN in your LDAP, you can also create filter objects that automatically discover matching groups in your LDAP and synchronize their members. When the synchronization task is executed, the filter is being queried against the LDAP and resulting group objects will be created as synchronized groups *and* as OpenProject groups. @@ -82,11 +81,23 @@ To create a new synchronized filter, use the button on the top right of the inde - **LDAP connection:** Select the LDAP connection you want this synchronized filter to use. Users created by group synchronization will be tied to that LDAP and may bind against it for authentication. - **Search base DN:** (optional) Enter the base DN of the LDAP subtree you want to perform the search in. If you leave this unset, the base DN of the LDAP connection will be used instead. The DN specified here must contain the base DN of the LDAP connection to be valid. - **LDAP filter:** The LDAP filter string to be used for identifying LDAP group entries to be synchronized with OpenProject. +- **Group member attribute:** (optional) The attribute on group entries that lists member DNs, used for forward lookup. Leave empty (default) to use reverse lookup via the `memberOf` attribute on user entries. Set this to `uniqueMember` for `groupOfUniqueNames` schemas, or `member` for `groupOfNames` schemas. Use forward lookup when your LDAP server does not maintain `memberOf` on user entries. -Click on *Create* to finish the creation of the synchronized filter. This filter is being executed hourly as part of the background job before the actual group synchronization runs. +Click on *Create* to finish the creation of the synchronized filter. This filter is being executed hourly as part of the background job before the actual group synchronization runs. + +Once the filter is created, the **Discover LDAP groups** button on the filter detail page runs the discovery immediately: it queries LDAP using the filter and creates the corresponding synchronized groups and OpenProject groups. It does **not** synchronize group members — member synchronization is handled by the hourly background job. + +To trigger a full synchronization (group discovery **and** member sync) manually, run the following rake task in a [console](../../../../installation-and-operations/operation/control/): + +```shell +bundle exec rake ldap_groups:synchronize +``` + +> [!NOTE] +> Forward lookup via the **Group member attribute** is only available for groups created through a synchronized filter. Manually-created synchronized groups always use reverse lookup (`memberOf`). > [!NOTE] -> If you manually create a synchronized group that is also found by a filter, its properties (such as the *Sync users* setting) is being overridden by the filter setting. +> If you manually create a synchronized group that is also found by a filter, its properties (such as the *Sync users* setting) are overridden by the filter settings. ## FAQ @@ -108,6 +119,7 @@ Please double check the DN of the groups and the LDAP connection. The base DN of For users to be automatically synchronized, the following conditions need to be met: 1. The connection, or the LDAP group need to have "Sync users" checked. This setting overrides the LDAP connection's "Sync users" attribute for fine-grained control over which groups will have users synchronized. -2. The group needs to define their members using the `member` LDAP property, and users need to have the `memberOf` property or virtual property. OpenProject will look for users with the following filter: `(memberOf=).` You can use `ldapsearch` to verify that this works as expected. -3. The users defined in the groups need to have all required attributes present to be created. These are at *login, email, first and last name*. If any of these attributes are missing, the user cannot be saved to the database. -4. If you enterprise license exceeds the user limit, new users can also not be synchronized through LDAP. A corresponding log entry will be logged. +2. OpenProject needs to be able to resolve group members. By default it uses **reverse lookup**: it searches for users with the filter `(memberOf=)`, which requires `memberOf` to be present on user entries (Active Directory, OpenLDAP with memberof overlay). If your LDAP server does not support `memberOf`, configure **forward lookup** by setting the **Group member attribute** on the synchronized filter (e.g. `uniqueMember` or `member`). You can use `ldapsearch` to verify either approach works as expected. +3. The users defined in the groups need to have all required attributes present and non-empty to be created: *login, email, first and last name*. If any of these attributes are missing or empty, the user cannot be saved to the database. +4. The **Login** attribute configured on the LDAP connection must match an attribute that is actually present on your LDAP user entries. If the attribute name is wrong or absent, users will be found in the group but not matched or created in OpenProject. Verify the attribute name against your LDAP schema and update the LDAP connection configuration if needed. +5. If your enterprise license exceeds the user limit, new users cannot be synchronized through LDAP. On-premises users will find a corresponding entry in the application logs. diff --git a/modules/ldap_groups/config/locales/en.yml b/modules/ldap_groups/config/locales/en.yml index 692a58335449..217ed9f9f028 100644 --- a/modules/ldap_groups/config/locales/en.yml +++ b/modules/ldap_groups/config/locales/en.yml @@ -32,7 +32,7 @@ en: ldap_groups: label_menu_item: 'LDAP group synchronization' label_group_key: 'LDAP group filter key' - label_synchronize: 'Synchronize' + label_synchronize: 'Discover LDAP groups' settings: name_attribute: 'LDAP groups name attribute' name_attribute_text: 'The LDAP attribute used for naming the OpenProject group when created by a filter' @@ -75,7 +75,12 @@ en: info: "The OpenProject group itself and members added outside this LDAP synchronization will not be removed." help_text_html: | This module allows you to set up a synchronization between LDAP and OpenProject groups. - It depends on LDAP groups need to use the groupOfNames / memberOf attribute set to be working with OpenProject. +
+ By default, OpenProject uses reverse lookup: it searches for users whose memberOf attribute matches the group DN. + This requires the memberOf attribute to be present on user entries (Active Directory, OpenLDAP with memberof overlay). +
+ If your LDAP server does not maintain memberOf on user entries (e.g. it uses groupOfUniqueNames with uniqueMember), + you can configure forward lookup by setting the Group member attribute on the synchronization filter.
Groups are synchronized hourly through a cron job. [Please see our documentation on this topic](docs_url). From 193ee7c00d8c84f69af2f41e0b0fe2afdfece3bb Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 26 May 2026 16:42:57 +0200 Subject: [PATCH 4/5] Fix rubocop warnings --- .../synchronized_filters_controller.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb b/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb index 8767ded6bfc4..007921864908 100644 --- a/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb +++ b/modules/ldap_groups/app/controllers/ldap_groups/synchronized_filters_controller.rb @@ -13,12 +13,12 @@ class SynchronizedFiltersController < ::ApplicationController layout "admin" menu_item :plugin_ldap_groups + def show; end + def new @filter = SynchronizedFilter.new end - def show; end - def end; end def destroy_info @@ -80,14 +80,17 @@ def synchronize private def find_filter - @filter = SynchronizedFilter.find(params[:ldap_filter_id]) + @filter = SynchronizedFilter.find(params.expect(:ldap_filter_id)) end def permitted_params - params - .require(:synchronized_filter) - .permit(:filter_string, :name, :ldap_auth_source_id, :group_name_attribute, :sync_users, :base_dn, - :member_lookup_attribute) + params.expect(synchronized_filter: %i[filter_string + name + ldap_auth_source_id + group_name_attribute + sync_users + base_dn + member_lookup_attribute]) end end end From 090355238ae2904fc6319ffa171b6fcb3fb053ca Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 26 May 2026 16:50:48 +0200 Subject: [PATCH 5/5] Fix rubocop warnings This is a legitimate use of binding.irb to let the dev ldap server open. --- modules/ldap_groups/lib/tasks/ldap_groups.rake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/ldap_groups/lib/tasks/ldap_groups.rake b/modules/ldap_groups/lib/tasks/ldap_groups.rake index 18ffdbffca7b..172177a895d4 100644 --- a/modules/ldap_groups/lib/tasks/ldap_groups.rake +++ b/modules/ldap_groups/lib/tasks/ldap_groups.rake @@ -141,7 +141,7 @@ namespace :ldap_groups do puts "Send CTRL+D to stop the server" require "irb" - binding.irb + binding.irb # rubocop:disable Lint/Debugger ldap_server.stop end @@ -207,7 +207,7 @@ namespace :ldap_groups do puts "Send CTRL+D to stop the server" require "irb" - binding.irb + binding.irb # rubocop:disable Lint/Debugger ldap_server.stop end