Let admins resync a single LDAP-blocked user on demand (#210576)
What does this MR do and why?
Closes #210576 (closed).
Admins currently have no way to force-recheck a single LDAP-blocked user against LDAP via the UI. The "Unblock" action explicitly refuses to act on ldap_blocked users. The only paths that clear the ldap_blocked state are interactive sign-in (which triggers User#check_ldap_if_ldap_blocked!) or the daily LdapSyncWorker cron job. This is a real pain point for unattended service accounts that hold their own LDAP identity — nobody logs into them interactively, so they have no way to self-heal, and they stay blocked for up to 24 hours even after whatever caused the block (a bind-account password rotation, a transient LDAP outage, a stale group sync) has cleared.
The fix adds a new "Resync with LDAP" action to the admin Users page dropdown, shown only for ldap_blocked users. Clicking it PUTs to the existing unblock route — no new API route is added. Admin::UsersController#unblock already special-cased ldap_blocked? users (previously just to refuse the request); it now dispatches to a resync_ldap helper instead, which calls Gitlab::Auth::Ldap::Access.allowed?(user) — the exact same recheck method that already runs synchronously inline on every sign-in and on every request via ApplicationController#ldap_security_check. This unblocks the user (or correctly leaves them blocked) exactly the way a real LDAP-checked sign-in would, not a blind state flip. If the user has no LDAP identity at all, it declines with a clear message instead of erroring.
This design (force a real resync, not a bare unblock button) matches a design note left by backend maintainer @dblessing on the issue in 2020: a bare "let admins unblock" button would defeat the purpose of the ldap_blocked state, since an invalid user would just get reblocked on their next check anyway — the fix has to force a genuine resync, the same way a sign-in does.
As a secondary fix found while touching this exact code path: the backend admin_actions helper has always pushed the literal string 'ldap' for ldap_blocked users, but the frontend's dead-code branch checked for 'ldapBlocked' instead. The two never matched in production, so ldap_blocked users saw no action at all in the admin dropdown — not even the intended informational "Cannot unblock LDAP blocked users" text. This whole inert mechanism is removed and replaced by the new real action, rather than patching the string mismatch in place.
Files changed: app/controllers/admin/users_controller.rb, app/helpers/admin/user_actions_helper.rb, app/helpers/users_helper.rb, app/assets/javascripts/admin/users/constants.js, app/assets/javascripts/admin/users/components/user_actions.vue, new app/assets/javascripts/admin/users/components/actions/resync_ldap.vue, app/assets/javascripts/admin/users/components/actions/index.js, matching spec files, plus regenerated locale/gitlab.pot. No routing files change — the frontend's new "Resync with LDAP" action simply resolves to the existing unblock_admin_user_path.
No database or schema changes — this is a pure controller/UI feature built on an existing, already-trusted method.
Screenshots or screen recordings
Captured against a real local GDK instance, with a real openldap test entry linked to a test GitLab user — see the setup script below for exactly how.
Before — user is ldap_blocked, no way to unblock via the UI:
Actions dropdown now shows "Resync with LDAP":
Confirmation modal:
After confirming — real LDAP recheck ran, user unblocked:
Disclosure
This MR, including the root-cause analysis, implementation, and tests, was prepared with the assistance of Claude Code (Anthropic). All changes were reviewed by the human contributor before submission and verified via the test suite below.
How to set up and validate locally
bundle exec rspec spec/controllers/admin/users_controller_spec.rb spec/helpers/admin/user_actions_helper_spec.rb
yarn jest spec/frontend/admin/users/components/user_actions_spec.js spec/frontend/admin/users/components/actions/actions_spec.jsThe controller specs cover the unblock action's new ldap_blocked branches: no LDAP identity, LDAP access allowed again, and LDAP access still denied — plus the existing manually-blocked-user case, unchanged. The Jest specs cover the new resync_ldap.vue action component (via the existing generic parametrized action-component test) and the AdminUserActions component integration. All green: 130 RSpec examples / 0 failures, 39 Jest examples passing. Rubocop and ESLint clean on all touched files.
For an end-to-end check against a real LDAP server — no AD required — run the script below against a GDK instance with openldap enabled. It creates a test LDAP entry, links it to a fresh GitLab user, and puts that user into ldap_blocked state (simulating what a bind-account rotation or a transient outage would do to an unattended service account), so the new "Resync with LDAP" action can be exercised from the admin UI against a real, live LDAP lookup rather than a stub. This is exactly the script used to produce the screenshots above.
Setup script (click to expand)
#!/usr/bin/env bash
# Setup/repro script for issue #210576 (admin "Resync with LDAP" action).
#
# Prepares everything needed on a plain GDK checkout + its bundled
# openldap to exercise the "Resync with LDAP" admin action end to end,
# against a real LDAP server (not a stub):
#
# setup creates an LDAP test user + a linked GitLab user,
# then puts the GitLab user into ldap_blocked state --
# simulating what a bind-account rotation, a transient
# LDAP outage, or a stale group sync would do to an
# unattended service account with no way to sign in
# and self-heal.
# break-ldap deletes the LDAP entry, so the next resync correctly
# finds the user still doesn't exist in LDAP and stays
# blocked -- demonstrates the "still blocked" path.
# restore-ldap re-creates the same LDAP entry, so the next resync
# finds it again and unblocks -- demonstrates the
# "LDAP allows access again" path.
# cleanup removes the LDAP entry and the GitLab user.
#
# After `setup`, log into the local GDK as root, go to Admin > Users,
# find the "repro210576svc" user, open its actions dropdown -- it
# should show "Resync with LDAP" (not the old dead "Cannot unblock"
# text, which never actually rendered due to the ldap/ldapBlocked
# string mismatch this MR also fixes). Click it and confirm the result
# matches whichever of break-ldap/restore-ldap was run last.
#
# Usage: ./scratch/repro_210576_ldap_resync.sh {setup|break-ldap|restore-ldap|cleanup}
# Requires: GDK running with openldap enabled.
set -euo pipefail
GDK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
GITLAB_ROOT="$GDK_ROOT/gitlab"
LDAP_BIN="$GDK_ROOT/gitlab-openldap/bin"
LDAP_HOST="ldap://127.0.0.1:3890/"
LDAP_ADMIN_DN="cn=admin,dc=example,dc=com"
LDAP_ADMIN_PW="password"
BASE_DN="dc=example,dc=com"
UID_NAME="repro210576svc"
USER_DN="uid=$UID_NAME,$BASE_DN"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
create_ldap_entry() {
echo "==> Creating LDAP entry $USER_DN"
cat > "$WORKDIR/user.ldif" <<EOF
dn: $USER_DN
objectClass: inetOrgPerson
cn: Repro Service Account 210576
sn: ServiceAccount
uid: $UID_NAME
mail: ${UID_NAME}@example.com
userPassword: password
EOF
"$LDAP_BIN/ldapadd" -H "$LDAP_HOST" -D "$LDAP_ADMIN_DN" -w "$LDAP_ADMIN_PW" -f "$WORKDIR/user.ldif"
}
delete_ldap_entry() {
echo "==> Deleting LDAP entry $USER_DN"
"$LDAP_BIN/ldapdelete" -H "$LDAP_HOST" -D "$LDAP_ADMIN_DN" -w "$LDAP_ADMIN_PW" "$USER_DN" || true
}
rails_runner() {
(cd "$GITLAB_ROOT" && bin/rails runner -e development "$1")
}
case "${1:-}" in
setup)
create_ldap_entry
echo "==> Creating a linked GitLab user and putting it into ldap_blocked state"
rails_runner "
uid = '$UID_NAME'
user = User.find_by(username: uid)
unless user
org = Organizations::Organization.default_organization
result = Users::CreateService.new(nil, name: 'Repro Service Account 210576', username: uid,
email: \"#{uid}@example.com\", password: SecureRandom.hex(16),
organization_id: org.id, skip_confirmation: true).execute
user = result.payload[:user]
raise result.message.inspect unless user&.persisted?
end
identity = user.identities.find_or_initialize_by(provider: 'ldapmain')
identity.extern_uid = '$USER_DN'
identity.save!
user.ldap_block
puts \"#{user.username}: state=#{user.state} ldap_blocked?=#{user.ldap_blocked?}\"
"
echo ""
echo "Done. Log into the local GDK as root, open Admin > Users, find"
echo "'$UID_NAME', and confirm its actions dropdown shows 'Resync with LDAP'."
echo "The LDAP entry is currently intact, so clicking it right now should"
echo "unblock the user immediately (the 'LDAP allows access again' path)."
echo "Run '$0 break-ldap' first if you want to demo the still-blocked path instead."
;;
break-ldap)
delete_ldap_entry
rails_runner "
user = User.find_by(username: '$UID_NAME')
raise 'user not found, run setup first' unless user
user.ldap_block
puts \"#{user.username}: state=#{user.state} ldap_blocked?=#{user.ldap_blocked?}\"
"
echo ""
echo "LDAP entry removed and user re-blocked. Clicking 'Resync with LDAP' now"
echo "should correctly leave the user blocked (LDAP lookup finds nothing)."
;;
restore-ldap)
create_ldap_entry
echo ""
echo "LDAP entry restored. Clicking 'Resync with LDAP' now should unblock the user."
;;
cleanup)
delete_ldap_entry
rails_runner "
user = User.find_by(username: '$UID_NAME')
destroyed = user.present?
user&.destroy
puts destroyed ? 'Destroyed $UID_NAME' : 'No GitLab user to remove.'
"
;;
*)
echo "Usage: $0 {setup|break-ldap|restore-ldap|cleanup}" >&2
exit 1
;;
esac


