"""Сервис для управления пользователями в LDAP и Django. Этот модуль содержит бизнес-логику для CRUD операций с пользователями, включая синхронизацию между Active Directory и Django ORM. Рефакторенная версия с улучшениями: - Наследуется от BaseService (логирование, _touch_state) - Делегирует работу подсервисам (Password, Login, Mapper) - Использует константы из constants.py """ from __future__ import annotations import logging from typing import Any, Dict, Iterable, List, Optional from django.conf import settings from django.db import transaction from django.utils import timezone from ldap3 import Connection from ...models import Employee, LdapSyncState from ..domain.dtos import DirectoryUserDTO from ..errors import ( DirectoryDbError, DirectoryLdapError, DirectoryServiceError, ) from ..infrastructure.connections import _ldap from ..orm_models import LdapUser from ..repositories.ldap_repository import ( ensure_container_exists, ) from ..utils.group_utils_orm import sync_user_groups_by_cns_orm from ..utils.ldap_utils import cn_candidates, get_ldap_str from ..utils.text_utils import esc_rdn from .base_service import BaseService from .constants import ( LdapErrorCode, SyncDirection, UserAccountControl, ) from .user_login_service import UserLoginService from .user_mapper_service import UserMapperService from .user_password_service import UserPasswordService logger = logging.getLogger(__name__) class UserService(BaseService): """Сервис для управления пользователями в LDAP и Django. Координирует работу подсервисов: - UserPasswordService — пароли - UserLoginService — генерация логинов - UserMapperService — маппинг атрибутов """ def __init__( self, ldap_repository=None, employee_repository=None, sync_state_repository=None, ): """ Инициализация сервиса. Args: ldap_repository: Репозиторий для работы с LDAP (optional) employee_repository: Репозиторий для работы с Employee (optional) sync_state_repository: Репозиторий для LdapSyncState (optional) """ super().__init__() self.ldap_repository = ldap_repository self.employee_repository = employee_repository self.sync_state_repository = sync_state_repository # Подсервисы self._password_service = UserPasswordService() self._login_service = UserLoginService() self._mapper_service = UserMapperService() def create_user(self, dto: DirectoryUserDTO) -> Employee: """Создаёт учётку в LDAP и запись в БД. DN и связь с LDAP хранятся только в LdapSyncState. Процесс: 1) LDAP: create → set password → optional enable. 2) DB (atomic): создаём Employee и фиксируем DN в LdapSyncState(model='employee'). 3) Post-actions: группы, аватар (best-effort). Args: dto (DirectoryUserDTO): Данные нового пользователя. Returns: Employee: Созданный сотрудник. Raises: DirectoryLdapError: Ошибка на этапе создания или настройки LDAP. DirectoryDbError: Ошибка записи в БД. В этом случае выполняется компенсирующее удаление в LDAP. """ with _ldap() as conn: dn: Optional[str] = None try: # 1) LDAP dn = self._create_user_in_ldap(conn, dto) self._password_service.set_password(conn, dn, dto.initial_password) if dto.is_active: self._enable_user(conn, dn) except Exception as e: raise DirectoryLdapError(f"LDAP create failed: {e}") from e # 2) DB + sync-state try: with transaction.atomic(): # Проверяем, существует ли уже пользователь с таким email emp = Employee.objects.filter(email=dto.email).first() if emp: # Обновляем существующего пользователя emp.first_name = dto.first_name emp.last_name = dto.last_name emp.phone_number = dto.phone_e164 emp.is_active = dto.is_active emp.is_ldap_managed = True emp._skip_ldap_sync = True emp.save() else: # Создаем нового пользователя emp = Employee.objects.create( first_name=dto.first_name, last_name=dto.last_name, email=dto.email, phone_number=dto.phone_e164, is_active=dto.is_active, is_ldap_managed=True, ) if hasattr(emp, "set_unusable_password"): emp.set_unusable_password() emp._skip_ldap_sync = True emp.save(update_fields=["password"]) # Запись employeeNumber в LDAP для связки через прямую # модификацию. Используем ldap3 напрямую, так как ORM # .save() пытается перестроить DN. from ldap3 import MODIFY_REPLACE conn.modify( dn, {"employeeNumber": [(MODIFY_REPLACE, [str(emp.pk)])]}, ) guid = None self._touch_state( model="employee", object_pk=emp.pk, ldap_dn=dn, ldap_guid=guid, sync_dir=SyncDirection.LDAP, last_django_modify_ts=timezone.now(), ) # TODO: Implement department assignment # DirectoryService был удален, требуется рефакторинг # if dto.department_dn and hasattr( # emp, "set_active_department" # ): # dept = Department.objects.filter( # ldap_group_dn=dto.department_dn # ).first() # if dept: # emp.set_active_department(dept) self._log_operation( "create", model="employee", object_id=emp.pk, dn=dn, success=True, ) except Exception as e: try: if dn: self._hard_delete_user_in_ldap(conn, dn) finally: pass raise DirectoryDbError(str(e)) from e # 3) Post-actions (best effort) try: if dto.group_cns: sync_user_groups_by_cns_orm(dn, set(dto.group_cns)) if dto.avatar_bytes: avatar = self._mapper_service.process_avatar(dto.avatar_bytes) if avatar: ldap_user_av = LdapUser.objects.get(dn=dn) ldap_user_av.thumbnail_photo = avatar ldap_user_av.save() except Exception: self._logger.warning( "LDAP user post-actions failed", extra={ "event": "ldap.user.post_actions_failed", "employee_id": emp.pk, }, exc_info=True, ) return emp def update_user( self, emp: Employee, changes: Dict[str, Any], group_cns: Optional[List[str]] = None, move_to_department_dn: Optional[str] = None, ) -> Employee: """Обновляет пользователя: сначала LDAP, затем БД. Args: emp (Employee): Сотрудник, которого обновляем. changes (Dict[str, Any]): Поля модели: first_name, last_name, email, phone_number, is_active, password, avatar_bytes, display_name и т.п. group_cns (Optional[List[str]]): Полный набор CN для синхронизации членства или None. move_to_department_dn (Optional[str]): DN OU, куда переместить запись. Returns: Employee: Обновлённая модель. Raises: DirectoryLdapError: Ошибка применения изменений в LDAP. DirectoryDbError: Ошибка фиксации изменений в БД. DirectoryServiceError: Нет DN пользователя. """ current_dn = self._get_employee_dn(emp) is_active_val: Optional[bool] = None if "is_active" in changes: try: is_active_val = bool(changes.get("is_active")) except Exception: is_active_val = None # Извлекаем position из changes — обработаем отдельно new_position = changes.pop("position", None) new_position_id = changes.pop("position_id", None) old_position = emp.position with _ldap() as conn: ldap_changes = dict(changes) try: result = self._update_user_in_ldap( conn=conn, current_dn=current_dn, model_changes=ldap_changes, move_to_department_dn=move_to_department_dn, group_cns=group_cns, emp_pk=emp.pk, # Передаём PK для поиска по employeeNumber ) # Распаковываем результат (dn, avatar_bytes или None) if isinstance(result, tuple): new_dn, saved_avatar_bytes = result else: # Обратная совместимость (если метод вернул только dn) new_dn = result saved_avatar_bytes = None except Exception as e: raise DirectoryLdapError(f"LDAP update failed: {e}") from e try: with transaction.atomic(): # обновим sync-state, если DN поменялся if new_dn and new_dn != current_dn: self._touch_state( model="employee", object_pk=emp.pk, ldap_dn=new_dn, sync_dir=SyncDirection.LDAP, last_django_modify_ts=timezone.now(), ) current_dn = new_dn # синхронизация поля is_active updated_fields: List[str] = [] if ( is_active_val is not None and getattr(emp, "is_active", None) != is_active_val ): emp.is_active = is_active_val updated_fields.append("is_active") for k, v in changes.items(): if k in {"password", "avatar_bytes", "is_active"}: continue if hasattr(emp, k) and getattr(emp, k) != v: setattr(emp, k, v) updated_fields.append(k) # Обновляем position в DB if new_position_id is not None: if emp.position_id != new_position_id: emp.position_id = new_position_id updated_fields.append("position_id") elif new_position is not None: pid = new_position.pk if new_position else None if emp.position_id != pid: emp.position_id = pid updated_fields.append("position_id") if move_to_department_dn and hasattr(emp, "set_active_department"): from .department_service import DepartmentService dept_svc = DepartmentService() dept = dept_svc._get_department_by_dn(move_to_department_dn) if dept: emp.set_active_department(dept) # Сохраняем аватар в БД, если он был успешно записан в LDAP if saved_avatar_bytes and hasattr(emp, "avatar"): from django.core.files.base import ContentFile filename = f"avatar_{emp.pk}.jpg" emp.avatar.save( filename, ContentFile(saved_avatar_bytes), save=False, ) if "avatar" not in updated_fields: updated_fields.append("avatar") if updated_fields: # Устанавливаем флаг чтобы не запустить LDAP sync # повторно emp._skip_ldap_sync = True emp.save(update_fields=list(set(updated_fields))) except Exception as e: raise DirectoryDbError(str(e)) from e # Position group membership sync (best-effort) self._sync_position_groups( emp, old_position, new_position, new_position_id, ) return emp def delete_user(self, emp: Employee) -> None: """Удаляет пользователя. Порядок: LDAP soft-disable -> DB delete -> LDAP hard delete. Args: emp (Employee): Удаляемый сотрудник. Raises: DirectoryLdapError: Ошибка при soft-disable/hard-delete. DirectoryDbError: Ошибка при удалении записи в БД. """ try: dn: Optional[str] = self._get_employee_dn(emp) except DirectoryServiceError: dn = None # ORM: soft-disable (UAC = disabled + password expired) if dn: try: ldap_user = LdapUser.objects.get(dn=dn) ldap_user.user_account_control = ( UserAccountControl.DISABLED_PASSWORD_EXPIRED ) ldap_user.save() self._log_operation( "soft_disable", model="employee", object_id=emp.pk, dn=dn, success=True, ) except LdapUser.DoesNotExist: self._logger.warning( "LDAP user already deleted", extra={ "event": "ldap.user.already_deleted", "employee_id": emp.pk, }, ) except Exception as e: raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e try: with transaction.atomic(): emp_pk = emp.pk emp.delete() LdapSyncState.objects.filter( model="employee", object_pk=str(emp_pk) ).delete() self._log_operation( "delete", model="employee", object_id=emp_pk, dn=dn, success=True, ) except Exception as e: raise DirectoryDbError(str(e)) from e # ORM: hard delete if dn: try: ldap_user = LdapUser.objects.get(dn=dn) ldap_user.delete() self._log_operation( "hard_delete", model="employee", object_id=emp_pk, dn=dn, success=True, ) except LdapUser.DoesNotExist: self._logger.info( "LDAP user already deleted", extra={ "event": "ldap.user.already_deleted", "employee_id": emp_pk, }, ) except Exception as e: raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e # ==================== DN ↔ ID Conversion Methods ==================== # def employee_ids_to_dns(self, ids: list[int]) -> list[str]: """Конвертирует ID сотрудников в их Distinguished Names. Args: ids: Список ID сотрудников. Returns: Список DN из LdapSyncState. """ if not ids: return [] id_strs = [str(i) for i in ids if isinstance(i, int)] return list( LdapSyncState.objects.filter(model="employee", object_pk__in=id_strs) .exclude(ldap_dn__isnull=True) .exclude(ldap_dn__exact="") .values_list("ldap_dn", flat=True) ) def dns_to_employee_ids(self, dns: list[str]) -> list[int]: """Конвертирует Distinguished Names в ID сотрудников. Args: dns: Список Distinguished Names. Returns: Список ID сотрудников. """ if not dns: return [] ids = list( LdapSyncState.objects.filter(model="employee", ldap_dn__in=dns).values_list( "object_pk", flat=True ) ) # object_pk хранится как str return [int(x) for x in ids if str(x).isdigit()] def employees_brief_by_dns(self, dns: list[str]) -> list[dict]: """Получает краткую информацию о сотрудниках по их DN. Args: dns: Список Distinguished Names. Returns: Список словарей с полями: id, email, first_name, last_name. """ ids = self.dns_to_employee_ids(dns) return list( Employee.objects.filter(id__in=ids).values( "id", "email", "first_name", "last_name" ) ) # ==================== Private Helper Methods ==================== # def _set_password(self, conn: Connection, dn: str, new_password: str) -> None: """Делегирует в UserPasswordService.""" self._password_service.set_password(conn, dn, new_password) def _enable_user(self, conn: Connection, dn: str) -> None: """Включает учётку (UAC=ENABLED) через низкоуровневую модификацию.""" from ldap3 import MODIFY_REPLACE conn.modify( dn, { "userAccountControl": [ (MODIFY_REPLACE, [str(UserAccountControl.ENABLED)]) ] }, ) if not conn.result["result"] == 0: raise DirectoryLdapError( f"Failed to enable user {dn}: {conn.result['description']}" ) def _unique_logins( self, conn: Connection, dto: DirectoryUserDTO, upn_suffix: str, ) -> tuple[str, str]: """Делегирует в UserLoginService.""" return self._login_service.generate_unique_logins( first_name=dto.first_name, last_name=dto.last_name, email=dto.email, upn_suffix=upn_suffix, ldap_guid=getattr(dto, "ldap_guid", None), ) def _phone_write_attr(self) -> str: """Делегирует в UserMapperService.""" return self._mapper_service._get_phone_write_attribute() def _build_user_attrs( self, dto: DirectoryUserDTO, sam: str, upn: str, cn_text: str, ) -> Dict[str, Any]: """Делегирует в UserMapperService.""" return self._mapper_service.build_creation_attributes(dto, sam, upn, cn_text) def _try_add_with_cn_list( self, conn: Connection, base_dn: str, object_classes: List[str], dto: DirectoryUserDTO, sam: str, upn: str, cns: List[str], ) -> Optional[str]: """Пробует создать запись по списку CN-кандидатов.""" if not isinstance(cns, list): raise TypeError("cns должен быть списком строк") for cn_txt in cns: rdn = f"CN={esc_rdn(cn_txt)}" dn_try = f"{rdn},{base_dn}" attrs = self._build_user_attrs(dto, sam, upn, cn_txt) if conn.add(dn_try, object_classes, attrs): return dn_try return None def _sync_position_groups( self, emp: Employee, old_position, new_position, new_position_id, ) -> None: """Синхронизирует членство в POS-группах при смене должности. Best-effort: ошибки логируются, но не прерывают обновление пользователя. """ from ...models import Position from .group_service import GroupService from .position_service import PositionService # Определяем новую должность if new_position is None and new_position_id is not None: try: new_position = Position.objects.get( pk=new_position_id, ) except Position.DoesNotExist: new_position = None if new_position is None and new_position_id is None: # position не передавался в changes return if old_position == new_position: return try: pos_svc = PositionService( group_service=GroupService(), user_service=self, ) if old_position and old_position.ldap_group_dn: pos_svc.unassign_position(emp, old_position) if new_position and new_position.ldap_group_dn: pos_svc.assign_position(emp, new_position) except Exception as error: logger.warning( "LDAP employee position group sync failed", exc_info=True, extra={ "event": "ldap.employee.position_group_sync_failed", "employee_id": emp.pk, "old_position_id": getattr(old_position, "pk", None), "new_position_id": getattr(new_position, "pk", None), "error_type": type(error).__name__, }, ) def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str: """Создаёт объект user в LDAP (AD) и возвращает его DN. NOTE: Используется low-level ldap3 (conn.add) из-за необходимости: - Перебора вариантов CN при коллизиях имён - Проверки/создания контейнера (ensure_container_exists) django-ldapdb ORM не поддерживает эти сценарии. """ base_dn = ( dto.department_dn or getattr(settings, "LDAP_USERS_BASE", None) or getattr(settings, "LDAP_BASE_DN", None) ) if not base_dn: raise RuntimeError( "Не задан контейнер для создания пользователя " "(department_dn / LDAP_USERS_BASE / LDAP_BASE_DN)." ) ensure_container_exists(conn, base_dn) upn_suffix = getattr(settings, "LDAP_UPN_SUFFIX", "") or ( dto.email.split("@", 1)[1] if dto.email and "@" in dto.email else "" ) if not upn_suffix: raise ValueError( "Не задан UPN-суффикс (LDAP_UPN_SUFFIX) и его нельзя вывести из email." ) sam, upn = self._unique_logins(conn, dto, upn_suffix) pretty_cn = ( " ".join(filter(None, [dto.first_name, dto.last_name])).strip() ) or sam safe_cn = sam cn_list = cn_candidates(pretty_cn, safe_cn) object_classes = self._mapper_service.get_object_classes_for_user() dn = self._try_add_with_cn_list( conn, base_dn, object_classes, dto, sam, upn, cn_list ) if dn: return dn raise RuntimeError(f"LDAP add user failed: {conn.result}") def _hard_delete_user_in_ldap(self, conn: Connection, dn: str) -> None: """Безусловное удаление user (ignore noSuchObject).""" ok = conn.delete(dn) if not ok: desc = (conn.result or {}).get("description", "") if desc not in {LdapErrorCode.NO_SUCH_OBJECT}: raise RuntimeError(f"LDAP delete failed for {dn}: {conn.result}") self._logger.info( "LDAP user already deleted", extra={ "event": "ldap.user.already_deleted", "has_ldap_dn": bool(dn), }, ) def _update_user_in_ldap( self, conn: Connection, current_dn: str, model_changes: Dict[str, Any], *, move_to_department_dn: Optional[str] = None, group_cns: Optional[Iterable[str]] = None, emp_pk: Optional[int] = None, # Employee PK для поиска по employeeNumber. ) -> str: """Применяет LDAP-изменения к существующему пользователю. Возвращает возможный новый DN. NOTE: Этот метод слишком большой (~150 строк) и должен быть рефакторен в будущем. Сейчас просто перенесём как есть для совместимости. Args: conn: LDAP соединение current_dn: Текущий DN пользователя (может быть устаревшим) model_changes: Изменения полей move_to_department_dn: DN для перемещения group_cns: Список групп emp_pk: PK Employee для поиска по employeeNumber если DN устарел """ # TODO: DirectoryService был удален, требуется рефакторинг if not isinstance(current_dn, str) or not current_dn.strip(): raise TypeError("current_dn должен быть непустой строкой") if not isinstance(model_changes, dict): raise TypeError("model_changes должен быть словарём") dn = current_dn new_password = model_changes.pop("password", None) avatar_bytes = model_changes.pop("avatar_bytes", None) is_active_val = model_changes.pop("is_active", None) # 1) Пароль if new_password: self._set_password(conn, dn, new_password) # 2) Перемещение между OU — обработается в ORM save(), # см. шаг 3-5 ниже (ldap_user.base_dn = ...) # 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save) try: ldap_user = LdapUser.objects.get(dn=dn) except LdapUser.DoesNotExist: # DN неверный - пытаемся найти пользователя по employeeNumber if emp_pk: logger.warning( "LDAP user DN is stale; searching by employee number", extra={ "event": "ldap.user.stale_dn_detected", "employee_id": emp_pk, }, ) try: # Поиск через filter с employeeNumber users = LdapUser.objects.filter(employee_number=str(emp_pk)) if users: ldap_user = users[0] dn = ldap_user.dn # Обновляем DN на реальный logger.info( "LDAP user found using employee number", extra={ "event": "ldap.user.stale_dn_recovered", "employee_id": emp_pk, }, ) else: raise DirectoryLdapError( f"User not found by employeeNumber={emp_pk}. " "User may have been deleted from LDAP." ) except Exception as error: logger.error( "LDAP user fallback lookup failed", extra={ "event": "ldap.user.fallback_lookup_failed", "employee_id": emp_pk, "error_type": type(error).__name__, }, ) raise DirectoryLdapError( f"User not found at DN={dn} and search by employeeNumber failed" ) from error else: raise DirectoryLdapError( f"User not found at DN={dn}. DN in database doesn't " "match LDAP. Please run LDAP sync to fix sync state." ) orm_dirty = False # 2) Перемещение между OU через ORM # ModifyDnMixin.save() автоматически выполнит modify_dn if move_to_department_dn: ldap_user.base_dn = move_to_department_dn orm_dirty = True # 3) UAC if is_active_val is not None: uac_val = ( UserAccountControl.ENABLED if is_active_val else UserAccountControl.DISABLED ) ldap_user.user_account_control = uac_val orm_dirty = True # 4) Прочие атрибуты phone_write = self._phone_write_attr() orm_field_map = { "first_name": "given_name", "last_name": "sn", "email": "mail", "phone_number": ( "mobile" if phone_write == "mobile" else "telephone_number" ), "display_name": "display_name", } # Запоминаем старый cn для проверки изменения old_cn = ldap_user.cn for model_key, orm_field in orm_field_map.items(): if model_key in model_changes: val = model_changes[model_key] if val or orm_field == "sn": setattr(ldap_user, orm_field, val if val else ".") orm_dirty = True # Вычисляем новый cn если изменились имя/фамилия new_cn = None if "first_name" in model_changes or "last_name" in model_changes: parts = [] given_name_str = get_ldap_str(ldap_user.given_name).strip() sn_str = get_ldap_str(ldap_user.sn).strip() if given_name_str: parts.append(given_name_str) if sn_str and sn_str != ".": parts.append(sn_str) new_cn = ( " ".join(parts) if parts else get_ldap_str(ldap_user.sam_account_name) ) if new_cn != old_cn: # Устанавливаем cn и displayName — # ModifyDnMixin.save() автоматически обработает # rename CN через modify_dn (не modify_s) ldap_user.cn = new_cn ldap_user.display_name = new_cn orm_dirty = True # 5) Аватар avatar_saved = False if avatar_bytes: avatar = self._mapper_service.process_avatar(avatar_bytes) if avatar: ldap_user.thumbnail_photo = avatar orm_dirty = True avatar_saved = True if orm_dirty: # ModifyDnMixin.save() автоматически: # 1) Откатит cn к старому значению # 2) Сохранит остальные атрибуты через modify_s # 3) Переименует DN через modify_dn ldap_user.save() # Обновляем dn после возможного rename dn = ldap_user.dn # 6) Группы (ORM) if group_cns is not None: sync_user_groups_by_cns_orm(dn, set(group_cns)) # Возвращаем dn и флаг сохранения аватара return (dn, avatar_bytes if avatar_saved else None) def _get_employee_dn(self, employee: Employee) -> str: """Возвращает DN сотрудника из LdapSyncState или модели. Args: employee: Экземпляр Employee. Returns: str: Полный DN пользователя в LDAP. Raises: DirectoryServiceError: Если DN не найден. """ # Сначала проверяем LdapSyncState dn = ( LdapSyncState.objects.filter(model="employee", object_pk=str(employee.pk)) .values_list("ldap_dn", flat=True) .first() ) # Если не найден в LdapSyncState, проверяем поле модели if not dn and hasattr(employee, "ldap_dn"): dn = employee.ldap_dn if not dn: raise DirectoryServiceError("Employee has no ldap_dn") return dn def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str: """Перемещает пользовательский объект в указанный контейнер. NOTE: Используется low-level ldap3 для атомарного move-only (без обновления атрибутов). ModifyDnMixin решает ту же задачу через ORM (user.base_dn = ...; user.save()), но совмещает move с обновлением атрибутов. Args: conn: LDAP соединение. user_dn: Текущий DN пользователя. base_dn: DN целевого контейнера. Returns: str: Новый DN пользователя после перемещения. Raises: RuntimeError: Если операция перемещения не удалась. """ rdn = user_dn.split(",", 1)[0] ok = conn.modify_dn(user_dn, rdn, new_superior=base_dn) if not ok: raise RuntimeError(f"LDAP move user to base failed: {conn.result}") return f"{rdn},{base_dn}" __all__ = ["UserService"]