Fix LDAP sync member removal on server errors

Resolves Introduce additional connection logic to LDAP s... (#6054).

What does this MR do and why?

When LDAP group sync runs and the LDAP server returns a non-zero error response code (e.g., bind account locked, invalid credentials, operations error), Adapter#check_empty_response_code only logged a warning and returned an empty result set. The sync interpreted this as "the LDAP group has zero members" and removed all existing group members.

This MR adds a raise_on_error keyword to ldap_search (defaults to false) and opts in the EE group sync methods (groups, group_members_in_range, nested_groups, filter_search). When raise_on_error: true and the LDAP server returns a non-zero response code, check_empty_response_code raises Net::LDAP::Error, triggering the existing retry logic and eventually LdapConnectionError. The sync then correctly marks the group as failed and preserves existing membership. All other callers retain the previous behavior (log a warning and return []).

Additionally, an audit event (ldap_group_sync_failed) is emitted on sync failure so admins can see failures in group Audit Events without digging through application logs.

References

How to set up and validate locally

Setup

  1. Enable LDAP in config/gitlab.yml:
     ldap:
       enabled: true
       prevent_ldap_sign_in: false
       servers:
         main:
           label: "LDAP"
           host: "127.0.0.1"
           port: 3890
           uid: "uid"
           encryption: "plain"
           base: "dc=example,dc=com"
           user_filter: ""
           group_base: "ou=groups,dc=example,dc=com"
           admin_group: ""
           active_directory: false
  2. Restart GDK
  3. Create test data in the Rails console:
    group = Group.last # or reuse existing group with Group.find
    user = User.last # or reuse existing user with User.find
    extern_uid = "uid=#{user.username.downcase},ou=people,dc=example,dc=com"
    Identity.find_or_create_by!(user: user, provider: 'ldapmain', extern_uid: extern_uid)
    LdapGroupLink.find_or_create_by!(group: group, cn: 'developers', group_access: Gitlab::Access::DEVELOPER, provider: 'ldapmain')
    group.add_member(user, Gitlab::Access::DEVELOPER, ldap: true)
  4. Verify the user is a member:
    group.member?(user) # => true

Scenario 1: LDAP error response — membership should be preserved

  1. Monkeypatch Net::LDAP to simulate a bind failure (response code 49):
    mock_result = Struct.new(:code, :message).new(49, 'Invalid credentials')
    
    fake_ldap = Object.new
    fake_ldap.define_singleton_method(:search) { |*args| nil }
    fake_ldap.define_singleton_method(:get_operation_result) { mock_result }
    fake_ldap.define_singleton_method(:auth) { |*args| nil }
    fake_ldap.define_singleton_method(:encryption) { |*args| nil }
    
    Net::LDAP.define_singleton_method(:open) do |*args, &block|
      block.call(fake_ldap)
    end
    
    Net::LDAP.define_singleton_method(:new) do |*args|
      fake_ldap
    end
  2. Run the sync:
    EE::Gitlab::Auth::Ldap::Sync::Group.execute_all_providers(group)
  3. Verify that the sync fails and the user is still a member:
    group.reload
    group.ldap_sync_status # => "failed"
    group.member?(user)    # => true
    AuditEvent.where(entity_id: group.id, entity_type: 'Group').last&.details
    # => {:event_name=>"ldap_group_sync_failed", ...}

Scenario 2: Successful sync — sanity check

  1. Start a fresh Rails console (to clear the monkeypatch), then set up the mock proxy:
    group = Group.last # or reuse existing group with Group.find
    group.update_column(:ldap_sync_status, 'ready')
    user = User.last # or reuse existing user with User.find
    $test_user = user
    
    mock_adapter_class = Class.new do
      def config
        mock_config = Object.new
        def mock_config.group_base; 'ou=groups,dc=example,dc=com'; end
        mock_config
      end
    end
    $mock_adapter_class = mock_adapter_class
    
    mock_proxy_class = Class.new do
      attr_reader :provider, :adapter
      def initialize(provider)
        @provider = provider
        @adapter = $mock_adapter_class.new
      end
      def dns_for_group_cn(cn)
        [$test_user.identities.find_by(provider: 'ldapmain').extern_uid]
      end
    end
    
    EE::Gitlab::Auth::Ldap::Sync::Proxy.define_singleton_method(:open) do |provider, &block|
      block.call(mock_proxy_class.new(provider))
    end
  2. Run the sync:
    EE::Gitlab::Auth::Ldap::Sync::Group.execute_all_providers(group)
  3. Verify that the sync succeeds and the user remains a member:
    group.reload
    group.ldap_sync_status # => "ready"
    group.member?(user)    # => true

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Paulo Barros

Merge request reports

Loading