Log and surface the real reason LDAP group sync fails

What does this MR do and why?

When EE::Gitlab::Auth::Ldap::Sync::Group.execute/.execute_all_providers finish an LDAP group sync, they call group.finish_ldap_sync to persist the started -> ready state transition. If the group is invalid at that exact moment — most commonly because a synced member's email violates the group's "Restrict membership by email domain" setting — that save fails.

The ldap_sync_status state machine's after_transition callbacks only run after a successful save (that's how the state_machines-activerecord integration works), so when the save fails, the callback never runs at all: nothing gets persisted, and the group is left stuck in the started state forever, with no error anywhere — not in the logs, not in the UI, not even queryable without a Rails console session.

This MR:

  1. Checks group.finish_ldap_sync's return value at the call site and, on failure, routes through the existing mark_ldap_sync_as_failed mechanism (skip_validation: true, the same approach already used for the "stuck for over an hour" case) so the sync actually fails instead of silently hanging.
  2. Surfaces the real, specific reason instead of the generic "Members and requesters is invalid" association-validation rollup — which member, and why — by reading the already-loaded, already-invalid Member/Requester records off group.members_and_requesters (the same objects an admin previously had to dig for via group.members.map(&:errors) in a Rails console) and including their own validation messages. Capped at 5 members per message to avoid an unbounded string if many fail at once.
  3. Logs that detailed reason via Gitlab::AppLogger.warn.
  4. Surfaces the same ldap_sync_error to group admins as a danger alert on the group members page — the column already existed but nothing displayed it.

Closes #348226 (closed) and #504298 (closed), which are the same underlying bug reported from two different angles: #348226 (closed) is the original 2021 feature request to "send these errors to the logs and/or the UI", and #504298 (closed) is a 2024 customer-reported case of the sync getting silently stuck with zero diagnostics.

Reproduction

Before this fix, the following bin/rails runner script reproduces the silent-stuck-sync bug described in both issues (a domain-restricted group plus an LDAP-synced member whose email doesn't match the allowed domain):

group = Group.find_by(path: 'some-group')
FactoryBot.create(:allowed_email_domain, group: group, domain: 'allowed-domain.example')

group.start_ldap_sync
group.add_member(some_user_with_a_disallowed_email, Gitlab::Access::DEVELOPER, ldap: true)
result = group.finish_ldap_sync

puts result                       # => false
puts group.ldap_sync_status       # => "started"  (stuck, forever, no retry will fix it)
puts group.ldap_sync_error        # => nil
# `group.errors.full_messages` is also unpopulated by the time anything
# outside this method could observe it -- the only way to see *why* was
# `group.members.map(&:errors).map(&:full_messages)` in a live console
# session, exactly as the original #348226 reporter had to do.

After this fix, the same script gives:

puts result                       # => false (still, since save legitimately failed)
puts group.ldap_sync_status       # => "failed"  (no longer stuck)
puts group.ldap_sync_error
# => "user 'jdoe': The member's email address is not allowed for this
#     group. Check with your administrator."

and the same message is written to Gitlab::AppLogger and shown to group admins on the group members page (see screenshot below).

Runnable reproduction script

📎 repro_ldap_sync_stuck.rb

Please see the script that sets up required data fixtures for GDK — to minimize the verification time and effort. Run it with bin/rails runner repro_ldap_sync_stuck.rb. It creates its own group, domain restriction, and disallowed-email user from scratch, then drives the sync through the real EE::Gitlab::Auth::Ldap::Sync::Group.execute_all_providers entry point (only the LDAP network round trip is stubbed out; every other line — start/finish transition, failure handling, the error message — runs unmodified production code). Verified genuinely red on master (prints STUCK) and green on this branch (prints FIXED with the real per-member error message).

Screenshots or screen recordings

Reproduced end-to-end against a local GDK: a group with a group_allowed_email_domains restriction, LDAP-synced against a member whose email doesn't match, driven through the real EE::Gitlab::Auth::Ldap::Sync::Group.execute code path (not stubbed).

Updated 2026-08-31 after the UX/TechWriting round (title + bulleted list + admin guidance):

ldap-sync-error-banner

How to set up and validate locally

bin/rspec ee/spec/lib/ee/gitlab/auth/ldap/sync/group_spec.rb
bin/rspec ee/spec/views/groups/group_members/_ldap_sync.html.haml_spec.rb

Two new specs in group_spec.rb reproduce the bug end-to-end (not just via mocks) using the real Members::CreatorService/domain-restriction validation path and assert the sync fails with the specific member message. I verified these are genuinely red without the fix (reverted the ee/lib and .haml changes locally, confirmed 5/6 new examples failed for the expected reason, then restored the fix and confirmed all green again) before committing.

Manual verification on GDK (no real LDAP server needed — this drives the same state-machine/model methods the sync code itself calls, so it exercises the real banner rendering path):

Gitlab.config.ldap['enabled'] = true

group = Group.create!(
  name: 'ldap-sync-error-demo', path: 'ldap-sync-error-demo',
  organization: Organizations::Organization.first,
  visibility_level: Gitlab::VisibilityLevel::PRIVATE
)
group.ldap_group_links.create!(cn: 'developers', group_access: Gitlab::Access::DEVELOPER, provider: 'ldapmain')

group.start_ldap_sync
group.mark_ldap_sync_as_failed(
  "user 'alice': Email address is not allowed for this group. Check with your administrator.; " \
  "user 'bob': Email address is not allowed for this group. Check with your administrator.",
  skip_validation: true
)

Then visit that group's /-/group_members page as an owner/admin to see the new title + bulleted banner. Output captured on this branch on a local GDK:

ldap_synced?: true
ldap_sync_failed?: true
ldap_sync_error: user 'alice': Email address is not allowed for this group. Check with your administrator.; user 'bob': Email address is not allowed for this group. Check with your administrator.

Disclosure

Root cause investigation, implementation, and the reproduction script above were AI-assisted (Claude Code). All claims about the failure mechanism (the after_transition callback timing relative to a failed save) were verified empirically against this GDK checkout, not taken on faith — see the reproduction section. Test coverage was written first, confirmed red against the pre-fix code, then confirmed green after the fix, using the real domain-restriction validation path rather than pure mocks.

Edited by Sergey Pechenko

Merge request reports

Loading
Loading