Corp/backend/employees/ldap/orm_services.py
lizerk 8cdfbfb6ef feat(ldap): добавлена интеграция с LdapSyncState
Добавлена полная поддержка связи Employee ↔ LdapUser через LdapSyncState.

Ключевые изменения:
- orm_services.py теперь автоматически создает/обновляет/удаляет LdapSyncState
- create_user() создает LdapSyncState при указании employee_pk
- update_user() обновляет last_django_modify_ts в LdapSyncState
- delete_user() удаляет LdapSyncState при hard delete
- Добавлен метод get_user_dn_by_employee_pk() для быстрого получения DN
- Улучшен метод get_user_by_employee_pk() - сначала ищет через LdapSyncState

Архитектура связи:
Employee (pk=123, PostgreSQL)
    ↓ object_pk
LdapSyncState (ldap_dn='CN=...', PostgreSQL)
    ↓ ldap_dn
LdapUser (dn='CN=...', employee_number='123', LDAP server)

Обновлена документация:
- ORM_INTEGRATION.md теперь объясняет связь моделей
- Добавлена диаграмма архитектуры
- Описаны роли каждой модели
- Объяснено почему нет ForeignKey (разные БД)
2026-03-18 13:53:22 +03:00

425 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ORM сервисы для работы с LDAP через django-ldapdb.
Упрощенная альтернатива низкоуровневым ldap3 операциям.
Использует LDAP модели (LdapUser, LdapGroup, LdapOrganizationalUnit) для CRUD.
Преимущества:
- Чистый Django ORM вместо ldap3 костылей
- Автоматическая валидация
- Унифицированный API
- Меньше кода
Использование:
- Только для WRITE операций (POST/PUT/DELETE)
- Чтение идет из обычных Django моделей (Employee, Department)
"""
import logging
from typing import Optional
from django.conf import settings
from django.utils import timezone
from employees.models import LdapSyncState
from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
from .utils.text_utils import esc_rdn
logger = logging.getLogger(__name__)
# User Account Control values для Active Directory
UAC_ENABLED = 512
UAC_DISABLED = 514
class LdapOrmUserService:
"""Сервис для управления пользователями в LDAP через ORM."""
def create_user(
self,
*,
sam_account_name: str,
first_name: str,
last_name: str,
email: str,
employee_pk: Optional[int] = None,
phone: Optional[str] = None,
is_active: bool = True,
ou_dn: Optional[str] = None,
) -> LdapUser:
"""
Создает пользователя в LDAP используя django-ldapdb ORM.
Args:
sam_account_name: sAMAccountName (логин без домена)
first_name: Имя
last_name: Фамилия
email: Email адрес
employee_pk: ID сотрудника в Django (для employeeNumber)
phone: Телефон (опционально)
is_active: Активен ли пользователь
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapUser объект
"""
# Генерация CN и UPN
cn = f"{first_name} {last_name}".strip() or sam_account_name
cn_safe = esc_rdn(cn)
upn_suffix = getattr(settings, 'LDAP_UPN_SUFFIX', 'robotail.local')
upn = f"{sam_account_name}@{upn_suffix}"
# Определение OU для размещения
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_USERS_BASE',
getattr(settings, 'LDAP_USER_BASE',
'OU=company,DC=robotail,DC=local'))
# DN нового пользователя
dn = f"CN={cn_safe},{ou_dn}"
# Создание через ORM
user = LdapUser()
user.dn = dn
user.cn = cn
user.sam_account_name = sam_account_name
user.user_principal_name = upn
user.given_name = first_name
user.sn = last_name or "." # AD требует sn
user.display_name = cn
user.mail = email
if phone:
user.mobile = phone
if employee_pk:
user.employee_number = str(employee_pk)
# UserAccountControl
user.user_account_control = UAC_ENABLED if is_active else UAC_DISABLED
# Сохранение в LDAP
user.save()
# Создание/обновление LdapSyncState для связи Employee ↔ LDAP
if employee_pk:
# Получаем objectGUID после создания (если доступен)
guid_str = str(user.object_guid) if hasattr(user, 'object_guid') and user.object_guid else None
LdapSyncState.objects.update_or_create(
model='employee',
object_pk=str(employee_pk),
defaults={
'ldap_dn': dn,
'ldap_guid': guid_str,
'last_django_modify_ts': timezone.now(),
'last_sync_dir': 'django',
}
)
logger.info(f"Created LdapSyncState for Employee.pk={employee_pk}, DN={dn}")
logger.info(f"Created LDAP user: {dn} (sAMAccountName={sam_account_name})")
return user
def update_user(
self,
user_dn: str,
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
phone: Optional[str] = None,
is_active: Optional[bool] = None,
) -> LdapUser:
"""
Обновляет пользователя в LDAP через ORM.
Args:
user_dn: DN пользователя для обновления
first_name: Новое имя (если нужно изменить)
last_name: Новая фамилия
email: Новый email
phone: Новый телефон
is_active: Новый статус активности
Returns:
Обновленный LdapUser объект
"""
# Получаем пользователя по DN
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
raise ValueError(f"LDAP user not found: {user_dn}")
# Обновляем поля
updated = False
if first_name is not None and user.given_name != first_name:
user.given_name = first_name
updated = True
if last_name is not None and user.sn != last_name:
user.sn = last_name or "."
updated = True
if email is not None and user.mail != email:
user.mail = email
updated = True
if phone is not None and user.mobile != phone:
user.mobile = phone
updated = True
if is_active is not None:
new_uac = UAC_ENABLED if is_active else UAC_DISABLED
if user.user_account_control != new_uac:
user.user_account_control = new_uac
updated = True
# Обновление displayName если изменились имя/фамилия
if first_name is not None or last_name is not None:
new_display = f"{user.given_name} {user.sn}".strip()
if new_display and user.display_name != new_display:
user.display_name = new_display
updated = True
if updated:
user.save()
# Обновление LdapSyncState (если есть employee_number)
if user.employee_number:
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=user.employee_number
)
sync_state.last_django_modify_ts = timezone.now()
sync_state.last_sync_dir = 'django'
sync_state.save(update_fields=['last_django_modify_ts', 'last_sync_dir', 'updated_at'])
except LdapSyncState.DoesNotExist:
logger.warning(f"LdapSyncState not found for employee_pk={user.employee_number}")
logger.info(f"Updated LDAP user: {user_dn}")
return user
def delete_user(self, user_dn: str, *, soft: bool = True) -> None:
"""
Удаляет пользователя из LDAP.
Args:
user_dn: DN пользователя для удаления
soft: Если True, только деактивирует (userAccountControl=DISABLED)
Если False, полностью удаляет из LDAP
"""
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
logger.warning(f"LDAP user not found for deletion: {user_dn}")
return
if soft:
# Soft delete - деактивация
user.user_account_control = UAC_DISABLED
user.save()
logger.info(f"Soft deleted (disabled) LDAP user: {user_dn}")
else:
# Hard delete - удаление из LDAP
employee_pk = user.employee_number if hasattr(user, 'employee_number') else None
user.delete()
# Удаление LdapSyncState при полном удалении
if employee_pk:
deleted_count = LdapSyncState.objects.filter(
model='employee',
object_pk=employee_pk
).delete()[0]
if deleted_count:
logger.info(f"Deleted LdapSyncState for employee_pk={employee_pk}")
logger.info(f"Deleted LDAP user: {user_dn}")
def get_user_by_dn(self, dn: str) -> Optional[LdapUser]:
"""Получает пользователя из LDAP по DN."""
try:
return LdapUser.objects.get(dn=dn)
except LdapUser.DoesNotExist:
return None
def get_user_by_employee_pk(self, employee_pk: int) -> Optional[LdapUser]:
"""
Получает пользователя из LDAP по Employee.pk.
Сначала пытается найти через LdapSyncState (более надежно),
затем через employeeNumber атрибут в LDAP.
"""
# Способ 1: через LdapSyncState (рекомендуется)
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=str(employee_pk)
)
if sync_state.ldap_dn:
return self.get_user_by_dn(sync_state.ldap_dn)
except LdapSyncState.DoesNotExist:
pass
# Способ 2: через employeeNumber в LDAP (fallback)
try:
return LdapUser.objects.get(employee_number=str(employee_pk))
except LdapUser.DoesNotExist:
return None
def get_user_dn_by_employee_pk(self, employee_pk: int) -> Optional[str]:
"""
Получает DN пользователя в LDAP по Employee.pk через LdapSyncState.
Это быстрый метод без запроса к LDAP серверу.
"""
try:
sync_state = LdapSyncState.objects.get(
model='employee',
object_pk=str(employee_pk)
)
return sync_state.ldap_dn or None
except LdapSyncState.DoesNotExist:
return None
class LdapOrmGroupService:
"""Сервис для управления группами в LDAP через ORM."""
def create_group(
self,
cn: str,
description: Optional[str] = None,
ou_dn: Optional[str] = None,
) -> LdapGroup:
"""
Создает группу в LDAP.
Args:
cn: Common Name группы
description: Описание группы
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapGroup объект
"""
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
cn_safe = esc_rdn(cn)
dn = f"CN={cn_safe},{ou_dn}"
group = LdapGroup()
group.dn = dn
group.cn = cn
group.sam_account_name = cn
if description:
group.description = description
group.save()
logger.info(f"Created LDAP group: {dn}")
return group
def add_member(self, group_dn: str, member_dn: str) -> None:
"""Добавляет члена в группу."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Добавляем DN члена в список member
if member_dn not in (group.member or []):
members = list(group.member or [])
members.append(member_dn)
group.member = members
group.save()
logger.info(f"Added {member_dn} to group {group_dn}")
def remove_member(self, group_dn: str, member_dn: str) -> None:
"""Удаляет члена из группы."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Удаляем DN члена из списка member
if member_dn in (group.member or []):
members = list(group.member or [])
members.remove(member_dn)
group.member = members
group.save()
logger.info(f"Removed {member_dn} from group {group_dn}")
def delete_group(self, group_dn: str) -> None:
"""Удаляет группу из LDAP."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.delete()
logger.info(f"Deleted LDAP group: {group_dn}")
except LdapGroup.DoesNotExist:
logger.warning(f"LDAP group not found for deletion: {group_dn}")
class LdapOrmDepartmentService:
"""Сервис для управления Organizational Units (отделами) в LDAP через ORM."""
def create_ou(
self,
ou_name: str,
description: Optional[str] = None,
parent_dn: Optional[str] = None,
) -> LdapOrganizationalUnit:
"""
Создает Organizational Unit в LDAP.
Args:
ou_name: Название OU
description: Описание
parent_dn: DN родительского контейнера
Returns:
Созданный LdapOrganizationalUnit объект
"""
if parent_dn is None:
parent_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
ou_safe = esc_rdn(ou_name)
dn = f"OU={ou_safe},{parent_dn}"
ou = LdapOrganizationalUnit()
ou.dn = dn
ou.ou = ou_name
if description:
ou.description = description
ou.save()
logger.info(f"Created LDAP OU: {dn}")
return ou
def delete_ou(self, ou_dn: str) -> None:
"""Удаляет OU из LDAP (должна быть пустой)."""
try:
ou = LdapOrganizationalUnit.objects.get(dn=ou_dn)
ou.delete()
logger.info(f"Deleted LDAP OU: {ou_dn}")
except LdapOrganizationalUnit.DoesNotExist:
logger.warning(f"LDAP OU not found for deletion: {ou_dn}")
__all__ = [
'LdapOrmUserService',
'LdapOrmGroupService',
'LdapOrmDepartmentService',
]