Fix: Handle RDN rename in ModifyDnMixin.save()

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.
This commit is contained in:
lizerk 2026-03-20 01:21:03 +03:00
parent afd66662ce
commit 999ecaaadf
2 changed files with 105 additions and 80 deletions

View File

@ -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

View File

@ -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: