From 999ecaaadf41d66872aca88c919c82b7e8d30943 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 20 Mar 2026 01:21:03 +0300 Subject: [PATCH] Fix: Handle RDN rename in ModifyDnMixin.save() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AD forbids modifying RDN (CN/OU) via modify_s - only via rename. ModifyDnMixin now: 1. Tracks _original_rdn_value (cn/ou) from __init__/from_db 2. On save(), if RDN changed: reverts to old value → save() → modify_dn 3. This fixes NOT_ALLOWED_ON_RDN for both admin and API edits Simplified user_service: just set ldap_user.cn = new_cn and call save(). The mixin handles the rest automatically. --- backend/employees/ldap/mixins.py | 144 +++++++++++------- .../employees/ldap/services/user_service.py | 41 ++--- 2 files changed, 105 insertions(+), 80 deletions(-) diff --git a/backend/employees/ldap/mixins.py b/backend/employees/ldap/mixins.py index 8e41a4df..e7cc696d 100644 --- a/backend/employees/ldap/mixins.py +++ b/backend/employees/ldap/mixins.py @@ -39,6 +39,24 @@ class ModifyDnMixin: super().__init__(*args, **kwargs) # Сохраняем оригинальный base_dn для отслеживания изменений self._original_base_dn = getattr(self, 'base_dn', None) + # Сохраняем оригинальное значение RDN-атрибута (cn/ou) + self._original_rdn_value = self._get_rdn_value() + + def _get_rdn_value(self): + """Возвращает текущее значение RDN-атрибута (cn или ou).""" + rdn_attrs = getattr(self, 'rdn_attributes', []) + for field in self._meta.fields: + if field.db_column and field.db_column in rdn_attrs: + return getattr(self, field.name, None) + return None + + def _set_rdn_value(self, value): + """Устанавливает значение RDN-атрибута.""" + rdn_attrs = getattr(self, 'rdn_attributes', []) + for field in self._meta.fields: + if field.db_column and field.db_column in rdn_attrs: + setattr(self, field.name, value) + return def build_rdn(self): """Build RDN — исправление бага ldapdb 1.5.1. @@ -64,85 +82,107 @@ class ModifyDnMixin: raise Exception("Could not build Distinguished Name") def save(self, *args, **kwargs): - """Переопределённый save с поддержкой modify_dn при изменении base_dn. + """Переопределённый save с поддержкой modify_dn. - Логика: - 1. Проверяем, изменился ли base_dn - 2. Если да - выполняем LDAP modify_dn для перемещения объекта - 3. Обновляем _saved_dn для корректной работы стандартного save() - 4. Вызываем родительский save() для обновления атрибутов + Обрабатывает два случая: + 1. Изменение base_dn → перемещение между OU (modify_dn с newsuperior) + 2. Изменение RDN-атрибута (cn/ou) → переименование (modify_dn) - Args: - *args: Позиционные аргументы для Model.save() - **kwargs: Именованные аргументы для Model.save() - - Raises: - RuntimeError: Если modify_dn операция не удалась + AD запрещает менять RDN-атрибут через modify_s, только через rename. + Поэтому: откатываем RDN к старому значению → save() → rename. """ - # Проверяем, нужно ли перемещение + from .infrastructure.connections import _ldap + + is_existing = ( + hasattr(self, '_saved_dn') and self._saved_dn + ) + + # --- 1) Перемещение между OU (base_dn изменился) --- current_base_dn = getattr(self, 'base_dn', None) needs_move = ( - hasattr(self, '_saved_dn') and # Объект уже существует в LDAP - self._saved_dn and # DN не пустой - self._original_base_dn and # Был оригинальный base_dn - current_base_dn and # Есть новый base_dn - current_base_dn != self._original_base_dn # И он изменился + is_existing and + self._original_base_dn and + current_base_dn and + current_base_dn != self._original_base_dn ) if needs_move: - # Выполняем перемещение через низкоуровневый modify_dn - from .infrastructure.connections import _ldap - old_dn = self._saved_dn new_rdn = self.build_rdn() new_superior = current_base_dn new_dn = f"{new_rdn},{new_superior}" - logger.debug( - f"ModifyDnMixin: Moving LDAP object\n" - f" Old DN: {old_dn}\n" - f" New RDN: {new_rdn}\n" - f" New superior: {new_superior}\n" - f" New DN: {new_dn}" - ) - with _ldap() as conn: - # LDAP modify_dn операция с newsuperior success = conn.modify_dn( - old_dn, - new_rdn, - new_superior=new_superior + old_dn, new_rdn, + new_superior=new_superior, ) - if not success: - error_msg = f"Failed to move LDAP object: {conn.result}" - logger.error(f"ModifyDnMixin: {error_msg}") - raise RuntimeError(error_msg) - - logger.info( - f"ModifyDnMixin: Successfully moved {old_dn} → {new_dn}" - ) + raise RuntimeError( + f"Failed to move LDAP object: {conn.result}" + ) + logger.info(f"ModifyDnMixin: Moved {old_dn} → {new_dn}") - # Обновляем _saved_dn для корректной работы последующего save() - # Это важно, чтобы стандартный _save_table() знал новый DN self._saved_dn = new_dn self.dn = new_dn - - # Обновляем _original_base_dn для следующих операций self._original_base_dn = current_base_dn - # Вызываем родительский save() для обновления атрибутов - return super().save(*args, **kwargs) + # --- 2) RDN-атрибут (cn/ou) изменился --- + new_rdn_value = self._get_rdn_value() + old_rdn_value = getattr(self, '_original_rdn_value', None) + needs_rename = ( + is_existing and + old_rdn_value and + new_rdn_value and + new_rdn_value != old_rdn_value + ) + + if needs_rename: + # Откатываем RDN к старому значению перед save(), + # иначе ldapdb включит cn в modlist → AD отклонит + self._set_rdn_value(old_rdn_value) + + # --- 3) Стандартный save (атрибуты, кроме RDN) --- + result = super().save(*args, **kwargs) + + # --- 4) Выполняем rename если RDN изменился --- + if needs_rename: + from .utils.text_utils import esc_rdn + rdn_attrs = getattr(self, 'rdn_attributes', ['cn']) + rdn_attr_name = rdn_attrs[0] # 'cn' или 'ou' + new_rdn = f"{rdn_attr_name.upper()}={esc_rdn(new_rdn_value)}" + + old_dn = self.dn + dn_parts = old_dn.split(",", 1) + container_dn = dn_parts[1] if len(dn_parts) == 2 else "" + + with _ldap() as conn: + success = conn.modify_dn(old_dn, new_rdn) + if success: + new_dn = f"{new_rdn},{container_dn}" + self.dn = new_dn + self._saved_dn = new_dn + self._set_rdn_value(new_rdn_value) + self._original_rdn_value = new_rdn_value + logger.info( + f"ModifyDnMixin: Renamed {old_dn} → {new_dn}" + ) + else: + logger.warning( + f"ModifyDnMixin: Failed to rename " + f"{old_dn} → {new_rdn}: {conn.result}" + ) + # Восстанавливаем новое значение в объекте + self._set_rdn_value(new_rdn_value) + + return result @classmethod def from_db(cls, db, field_names, values): - """Переопределяем from_db для сохранения оригинального base_dn при загрузке. - - Этот метод вызывается Django ORM при загрузке объекта из БД (или LDAP). - """ + """Переопределяем from_db для сохранения оригинальных значений при загрузке.""" instance = super().from_db(db, field_names, values) - # Сохраняем base_dn при загрузке из LDAP instance._original_base_dn = getattr(instance, 'base_dn', None) + instance._original_rdn_value = instance._get_rdn_value() return instance diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index f3e49437..fb4d9b17 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -756,7 +756,6 @@ class UserService(BaseService): # Вычисляем новый cn если изменились имя/фамилия new_cn = None if "first_name" in model_changes or "last_name" in model_changes: - # Формируем новый cn из имени и фамилии parts = [] given_name_str = get_ldap_str(ldap_user.given_name).strip() sn_str = get_ldap_str(ldap_user.sn).strip() @@ -766,12 +765,15 @@ class UserService(BaseService): 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) + new_cn = " ".join(parts) if parts else get_ldap_str( + ldap_user.sam_account_name + ) - # НЕ ставим cn до save() — AD запрещает modify CN через modify_s, - # только через rename (modify_dn). - # Вместо этого ставим displayName вручную. 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 @@ -785,30 +787,13 @@ class UserService(BaseService): avatar_saved = True if orm_dirty: + # ModifyDnMixin.save() автоматически: + # 1) Откатит cn к старому значению + # 2) Сохранит остальные атрибуты через modify_s + # 3) Переименует DN через modify_dn ldap_user.save() - - # Переименование DN если cn изменился (через modify_dn, не modify_s) - if new_cn and new_cn != old_cn: - # Извлекаем текущий контейнер (всё после первой запятой) - parts = dn.split(",", 1) - if len(parts) == 2: - container_dn = parts[1] - new_rdn = f"CN={esc_rdn(new_cn)}" # новый RDN с экранированием спецсимволов - - # modify_dn для переименования DN (атрибуты уже обновлены через save) - ok = conn.modify_dn(dn, new_rdn) - if ok: - dn = f"{new_rdn},{container_dn}" - # Обновляем _saved_dn для корректности последующих операций - ldap_user._saved_dn = dn - ldap_user.dn = dn - else: - # Если переименование не удалось, откатываем cn к старому значению - # (атрибуты уже обновлены с новым cn, но DN остался старым - несоответствие!) - logger.warning( - f"Failed to rename DN from {dn} to {new_rdn},{container_dn}. " - f"User attributes updated but DN unchanged." - ) + # Обновляем dn после возможного rename + dn = ldap_user.dn # 6) Группы (ORM) if group_cns is not None: