From da0cadc50c1e76c605a5a57b8f33bae4892d7729 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 14 May 2026 15:06:47 +0300 Subject: [PATCH] Handle existing LDAP users during Django sync --- backend/api/v1/directory/services.py | 28 ++++- backend/employees/admin.py | 85 +++++++++++--- backend/tests/api/v1/test_directory_login.py | 28 +++++ .../test_entities_roundtrip.py | 107 +++++++++++++++++- 4 files changed, 229 insertions(+), 19 deletions(-) diff --git a/backend/api/v1/directory/services.py b/backend/api/v1/directory/services.py index 836c6951..aa0ac3b9 100644 --- a/backend/api/v1/directory/services.py +++ b/backend/api/v1/directory/services.py @@ -20,6 +20,26 @@ def _find_ldap_user(employee): from employees.ldap.orm_models import LdapUser employee_number = str(employee.pk) + employee_email = (employee.email or "").strip().lower() + + def find_by_email(): + if not employee_email: + return None + + email_candidates = list( + LdapUser.objects.filter(mail=employee_email) + ) + if len(email_candidates) == 1: + return email_candidates[0] + + upn_candidates = list( + LdapUser.objects.filter(user_principal_name=employee_email) + ) + if len(upn_candidates) == 1: + return upn_candidates[0] + + return None + sync_state = LdapSyncState.objects.filter( model="employee", object_pk=employee_number ).first() @@ -29,15 +49,19 @@ def _find_ldap_user(employee): except LdapUser.DoesNotExist: pass + if not employee.is_ldap_managed: + email_match = find_by_email() + if email_match is not None: + return email_match + candidates = list( LdapUser.objects.filter(employee_number=employee_number) ) if not candidates: - return None + return find_by_email() if len(candidates) == 1: return candidates[0] - employee_email = (employee.email or "").strip().lower() if employee_email: for candidate in candidates: if (getattr(candidate, "mail", "") or "").strip().lower() == employee_email: diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 73572373..eb9836ed 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -632,26 +632,29 @@ class EmployeeAdmin(DjangoUserAdmin): }: is_ldap_managed={employee.is_ldap_managed}" ) - if employee.is_ldap_managed: - # Пробуем найти по employee_number + # Пробуем найти существующий объект даже для сотрудников, + # которые еще не помечены как LDAP-managed: при ручной + # синхронизации Django считается источником истины, поэтому + # найденную AD-запись нужно привязать и обновить, а не + # пытаться создать повторно. + logger.info( + f"[sync_from_django_to_ldap] Employee { + employee.pk + }: поиск в LDAP по sync-state/employeeNumber/email" + ) + ldap_user = _resolve_ldap_user_for_employee(employee) + if ldap_user: logger.info( f"[sync_from_django_to_ldap] Employee { employee.pk - }: поиск в LDAP по employee_number={employee.pk}" + }: найден LDAP user с DN={ldap_user.dn}" + ) + else: + logger.info( + f"[sync_from_django_to_ldap] Employee { + employee.pk + }: LDAP пользователь не найден" ) - ldap_user = _resolve_ldap_user_for_employee(employee) - if ldap_user: - logger.info( - f"[sync_from_django_to_ldap] Employee { - employee.pk - }: найден LDAP user с DN={ldap_user.dn}" - ) - else: - logger.info( - f"[sync_from_django_to_ldap] Employee { - employee.pk - }: не найден по employee_number" - ) if ldap_user: # ОБНОВЛЕНИЕ существующего LDAP пользователя @@ -661,6 +664,56 @@ class EmployeeAdmin(DjangoUserAdmin): "изменений" ) try: + from django.utils import timezone + from ldap3 import MODIFY_REPLACE + from employees.ldap.infrastructure.connections import ( + _ldap, + ) + + LdapSyncState.objects.update_or_create( + model="employee", + object_pk=str(employee.pk), + defaults={ + "ldap_dn": ldap_user.dn, + "last_sync_dir": "django", + "last_django_modify_ts": timezone.now(), + }, + ) + + current_employee_number = get_ldap_str( + getattr(ldap_user, "employee_number", "") + ) + if current_employee_number != str(employee.pk): + with _ldap() as conn: + ok = conn.modify( + ldap_user.dn, + { + "employeeNumber": [ + ( + MODIFY_REPLACE, + [str(employee.pk)], + ) + ] + }, + ) + if not ok: + logger.warning( + "[sync_from_django_to_ldap] Employee " + "%s: не удалось записать " + "employeeNumber в LDAP: %s", + employee.pk, + conn.result, + ) + else: + ldap_user.employee_number = str( + employee.pk + ) + + if not employee.is_ldap_managed: + employee.is_ldap_managed = True + employee._skip_ldap_sync = True + employee.save(update_fields=["is_ldap_managed"]) + changes = {} ldap_first = get_ldap_str(ldap_user.given_name) diff --git a/backend/tests/api/v1/test_directory_login.py b/backend/tests/api/v1/test_directory_login.py index 11b7a778..eef029e3 100644 --- a/backend/tests/api/v1/test_directory_login.py +++ b/backend/tests/api/v1/test_directory_login.py @@ -271,3 +271,31 @@ def test_find_ldap_user_prefers_email_match_among_multiple_candidates(settings): resolved = _find_ldap_user(user) assert resolved is preferred + + +@pytest.mark.django_db +def test_find_ldap_user_falls_back_to_email_without_employee_number(settings): + settings.LDAP_ENABLED = True + user = make_user( + username="", + is_ldap_managed=False, + email="existing@example.com", + ) + + preferred = make_ldap_user( + dn="CN=Existing,OU=Users,OU=company,DC=robotail,DC=local", + sam_account_name="existing.login", + mail="existing@example.com", + user_principal_name="existing@robotail.local", + ) + + with patch( + "employees.ldap.orm_models.LdapUser.objects.filter" + ) as filter_mock: + filter_mock.side_effect = [[], [preferred]] + + from api.v1.directory.services import _find_ldap_user + + resolved = _find_ldap_user(user) + + assert resolved is preferred diff --git a/backend/tests/manual/ldap_integration/test_entities_roundtrip.py b/backend/tests/manual/ldap_integration/test_entities_roundtrip.py index d399e206..8390a1a6 100644 --- a/backend/tests/manual/ldap_integration/test_entities_roundtrip.py +++ b/backend/tests/manual/ldap_integration/test_entities_roundtrip.py @@ -1,18 +1,33 @@ from __future__ import annotations +import uuid + import pytest +from django.contrib import admin +from django.contrib.auth import get_user_model from django.conf import settings from django.contrib.auth.models import Group +from django.test import RequestFactory from rest_framework.test import APIClient +from employees.admin import EmployeeAdmin from employees.ldap import ( DepartmentService, LdapGroup, LdapOrganizationalUnit, LdapOrganizationalUnitGroup, LdapUser, + _ldap, +) +from employees.ldap.repositories.ldap_repository import ensure_container_exists +from employees.ldap.services.constants import UserAccountControl +from employees.models import ( + Department, + DepartmentRole, + EmployeeDepartment, + LdapSyncState, + Position, ) -from employees.models import Department, DepartmentRole, LdapSyncState, Position """ Живой LDAP integration-suite. @@ -35,6 +50,8 @@ TINY_PNG_BASE64 = ( "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" ) +User = get_user_model() + def _employee_dn(employee) -> str: return LdapSyncState.objects.get( @@ -88,6 +105,94 @@ def test_employee_profile_patch_updates_live_ldap( assert bool(ldap_user.thumbnail_photo) is True +def test_django_to_ldap_sync_reuses_existing_unlinked_user( + ensure_live_ldap, + ldap_cleanup, + unique_name, +): + dept_name = unique_name("qa-link-dept") + department = Department.objects.create( + name=dept_name, + description="Target department for existing LDAP user", + ) + ldap_cleanup(_department_dn(department)) + + suffix = unique_name("existing") + email = f"{suffix}@example.com" + phone = f"+79992{uuid.uuid4().int % 10**6:06d}" + cn = f"Existing User {suffix}" + sam = suffix.replace("-", "")[:20] + user_base = settings.LDAP_USERS_BASE + existing_dn = f"CN={cn},{user_base}" + ldap_cleanup(existing_dn) + + with _ldap() as conn: + ensure_container_exists(conn, user_base) + ok = conn.add( + existing_dn, + ["top", "person", "organizationalPerson", "user"], + { + "cn": cn, + "sAMAccountName": sam, + "userPrincipalName": email, + "userAccountControl": UserAccountControl.DISABLED, + "givenName": "Existing", + "sn": f"User {suffix}", + "displayName": cn, + "mail": email, + "telephoneNumber": phone, + }, + ) + assert ok, conn.result + + employee = User.objects.create_user( + email=email, + password="TestPass123!", + first_name="Existing", + last_name=f"User {suffix}", + phone_number=phone, + send_activation_email=False, + ) + employee.email_verified = True + employee.is_active = True + employee.is_ldap_managed = False + employee.save( + update_fields=["email_verified", "is_active", "is_ldap_managed"] + ) + + link = EmployeeDepartment( + employee=employee, + department=department, + is_active=True, + ) + link._skip_ldap_sync = True + link.save() + + model_admin = EmployeeAdmin(User, admin.site) + model_admin.message_user = lambda *args, **kwargs: None + request = RequestFactory().post("/admin/employees/employee/") + + model_admin.sync_from_django_to_ldap( + request, + User.objects.filter(pk=employee.pk), + ) + + employee.refresh_from_db() + sync_state = LdapSyncState.objects.get( + model="employee", + object_pk=str(employee.pk), + ) + ldap_cleanup(sync_state.ldap_dn) + + assert employee.is_ldap_managed is True + assert _norm_dn(sync_state.ldap_dn).endswith(_norm_dn(_department_dn(department))) + assert not LdapUser.objects.filter(dn=existing_dn).exists() + + ldap_user = LdapUser.objects.get(dn=sync_state.ldap_dn) + assert ldap_user.mail == email + assert ldap_user.employee_number == str(employee.pk) + + def test_department_api_roundtrip_syncs_ou_head_and_membership( ensure_live_ldap, superuser_client,