From 1ffcee9d8adc104a9dabb74554100d6df27bd35b Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 18 Mar 2026 18:53:07 +0300 Subject: [PATCH] =?UTF-8?q?refactor(ldap):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D1=83=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D1=8B=20=D0=B8=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=20employees/ldap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалён orm_services.py (424 строки) — не использовался нигде - Удалён group_utils.py (142 строки) — заменён group_utils_orm.py - Перенесена _desired_group_cns_for_employee в group_utils_orm.py - DirectoryService: удалены ~200 строк неиспользуемых методов (position_*, department member helpers, conn-based group ops, employee_ids_to_dns, dns_to_employee_ids, etc.) - Очищены __init__.py и utils/__init__.py от устаревших импортов --- backend/employees/ldap/__init__.py | 17 +- backend/employees/ldap/directory_service.py | 215 +-------- backend/employees/ldap/orm_services.py | 424 ------------------ backend/employees/ldap/utils/__init__.py | 12 +- backend/employees/ldap/utils/group_utils.py | 142 ------ .../employees/ldap/utils/group_utils_orm.py | 18 +- 6 files changed, 40 insertions(+), 788 deletions(-) delete mode 100644 backend/employees/ldap/orm_services.py delete mode 100644 backend/employees/ldap/utils/group_utils.py diff --git a/backend/employees/ldap/__init__.py b/backend/employees/ldap/__init__.py index 26ea6ad1..ecdae668 100644 --- a/backend/employees/ldap/__init__.py +++ b/backend/employees/ldap/__init__.py @@ -65,30 +65,15 @@ from .domain.dtos import ( ) # Утилиты работы с группами -from .utils.group_utils import sync_user_groups_by_cns from .utils.group_utils_orm import sync_user_groups_by_cns_orm -# ORM модели и сервисы +# ORM модели from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit -from .orm_services import LdapOrmUserService, LdapOrmGroupService, LdapOrmDepartmentService # Подключения from .infrastructure.connections import _ldap -# Обратная совместимость: обёртки над SyncService -def import_departments(cfg=None): - """Обёртка для SyncService().import_departments(cfg).""" - svc = SyncService() - return svc.import_departments(cfg or SyncConfig()) - - -def import_users(cfg=None): - """Обёртка для SyncService().import_users(cfg).""" - svc = SyncService() - return svc.import_users(cfg or SyncConfig()) - - def export_users(cfg=None): """Обёртка для SyncService().export_users(cfg).""" svc = SyncService() diff --git a/backend/employees/ldap/directory_service.py b/backend/employees/ldap/directory_service.py index 91462c50..b3317857 100644 --- a/backend/employees/ldap/directory_service.py +++ b/backend/employees/ldap/directory_service.py @@ -1,11 +1,10 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional from employees.models import LdapSyncState -from ldap3 import Connection -from ..models import Department, DepartmentRole, Employee, Position +from ..models import Department, Employee from .domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO # ------------------------- Сервис LDAP → DB ------------------------- # @@ -20,9 +19,6 @@ class DirectoryService: 3) Post-actions (best effort) В случае падения БД выполняется best-effort компенсация LDAP, где разумно. - - NOTE: Этот класс постепенно рефакторится. Методы работы с пользователями - делегируются к UserService (см. services/user_service.py). """ def __init__(self): @@ -30,16 +26,12 @@ class DirectoryService: from .services.department_service import DepartmentService from .services.user_service import UserService from .services.group_service import GroupService - from .services.position_service import PositionService self._user_service = UserService() self._group_service = GroupService(directory_service=self) self._department_service = DepartmentService( group_service=self._group_service, user_service=self._user_service ) - self._position_service = PositionService( - group_service=self._group_service, user_service=self._user_service - ) # ====================== ВСПОМОГАТЕЛЬНЫЕ ХЕЛПЕРЫ ====================== # @@ -151,175 +143,29 @@ class DirectoryService: """Удаляет отдел: исключает сотрудников → удаляет группу → OU → БД.""" return self._department_service.delete_department(dept) - def add_member(self, dept: Department, employee: Employee) -> None: - """Добавляет сотрудника в OU отдела и в одноименную группу отдела.""" - return self._department_service.add_member(dept, employee) + # ======================== GROUPS ======================== # - def remove_member(self, dept: Department, employee: Employee) -> None: - """Удаляет сотрудника из отдела: из группы → MOVE → удаление линка.""" - return self._department_service.remove_member(dept, employee) + # --- Conn-based методы (используются внутри services) --- - def set_head(self, dept: Department, head: Optional[Employee]) -> Department: - """Назначает/снимает руководителя отдела: LDAP managedBy → DB.head.""" - return self._department_service.set_head(dept, head) - - def set_member_role( - self, dept: Department, employee: Employee, role: Optional[DepartmentRole] - ) -> None: - """Меняет роль участника с синхронизацией LDAP-групп Roles.""" - return self._department_service.set_member_role(dept, employee, role) - - # =========================== POSITIONS =========================== # - - def reconcile_position(self, pos: Position) -> str: - """Публичный метод — открыть соединение и привести POS к консистентности.""" - return self._position_service.reconcile_position(pos) - - def assign_position(self, employee: Employee, position: Position) -> None: - """ - Добавляет сотрудника в POS-группу должности и убеждается, что вложения POS в целевые группы актуальны. - """ - self._position_service.assign_position(employee, position) - - def unassign_position(self, employee: Employee, position: Position) -> None: - """ - Удаляет сотрудника из POS-группы должности. - """ - self._position_service.unassign_position(employee, position) - - def delete_position_group(self, position: Position) -> None: - """ - Best-effort: снять POS-группу из всех правовых групп и удалить её. - """ - self._position_service.delete_position_group(position) - - # ======================== GROUPS (DRY-версия) ======================== # - - def create_group( - self, - conn: Connection, - cn: str, - parent_dn: Optional[str] = None, - *, - description: Optional[str] = None, - scope: str = "global", - security_enabled: bool = True, - ) -> str: - """Создаёт группу в LDAP под указанным контейнером.""" - return self._group_service.create( - conn, - cn, - parent_dn, - description=description, - scope=scope, - security_enabled=security_enabled, - ) - - def delete_group(self, conn: Connection, group_dn: str) -> None: - """Удаляет группу по DN (игнорирует noSuchObject).""" - self._group_service.delete(conn, group_dn) - - def rename_group(self, conn: Connection, group_dn: str, new_cn: str) -> str: - """Переименовывает группу (modify_dn).""" - return self._group_service.rename(conn, group_dn, new_cn) - - def set_group_description( - self, conn: Connection, group_dn: str, description: Optional[str] - ) -> None: - """Ставит/очищает description.""" - self._group_service.set_description(conn, group_dn, description) - - def sync_groups_catalog( - self, *, throttle_seconds: int = 60, delete_absent: bool = False - ) -> int: - """ - Тянет группы из AD под LDAP_GROUPS_BASE и гарантирует их наличие в Django Group. - Возвращает количество созданных групп. Обновляет LdapSyncState(model='group'). - Троттлит запросы к AD не чаще, чем раз в throttle_seconds. - """ - return self._group_service.sync_catalog( - throttle_seconds=throttle_seconds, delete_absent=delete_absent - ) - - def _modify_group_members( - self, conn: Connection, group_dn: str, op: int, members: List[str] - ) -> None: - """Применяет операцию к членству группы (DRY-хелпер).""" - self._group_service._modify_members(conn, group_dn, op, members) + def find_group_dn( + self, conn, cn: str, bases: Optional[List[str]] = None + ) -> Optional[str]: + """Ищет DN группы по CN в заданных базах.""" + return self._group_service.find_dn(conn, cn, bases) def add_group_members( - self, conn: Connection, group_dn: str, member_dns: List[str] + self, conn, group_dn: str, member_dns: List[str] ) -> None: """Добавляет участников (member ADD).""" self._group_service.add_members(conn, group_dn, member_dns) def remove_group_members( - self, conn: Connection, group_dn: str, member_dns: List[str] + self, conn, group_dn: str, member_dns: List[str] ) -> None: """Удаляет участников (member DELETE).""" self._group_service.remove_members(conn, group_dn, member_dns) - def replace_group_members( - self, conn: Connection, group_dn: str, exact_member_dns: List[str] - ) -> None: - """Полная замена состава группы.""" - self._group_service.replace_members(conn, group_dn, exact_member_dns) - - def list_group_members(self, conn: Connection, group_dn: str) -> List[str]: - """Возвращает DN всех участников группы.""" - return self._group_service.list_members(conn, group_dn) - - def find_group_dn( - self, conn: Connection, cn: str, bases: Optional[List[str]] = None - ) -> Optional[str]: - """Ищет DN группы по CN в заданных базах.""" - return self._group_service.find_dn(conn, cn, bases) - - # ============ LDAP HELPER METHODS (DEPARTMENT) ============ # - - def _ensure_department_ou(self, conn: Connection, name: str) -> str: - """Гарантирует наличие OU отдела + OU=Roles.""" - return self._department_service._ensure_department_ou(conn, name) - - def _rename_department_ou( - self, conn: Connection, dept_dn: str, new_name: str - ) -> str: - """Переименовывает OU отдела и возвращает новый DN.""" - return self._department_service._rename_department_ou(conn, dept_dn, new_name) - - def _set_ou_managed_by( - self, conn: Connection, dept_dn: str, head_dn: Optional[str] - ) -> None: - """Устанавливает/очищает managedBy у OU в AD (идемпотентно и с no-op при пустом состоянии). - - Args: - conn (Connection): LDAP соединение. - dept_dn (str): DN OU отдела. - head_dn (Optional[str]): DN руководителя или None. - """ - return self._department_service._set_ou_managed_by(conn, dept_dn, head_dn) - - def _set_ou_description( - self, conn: Connection, dept_dn: str, description: Optional[str] - ) -> None: - """Ставит/очищает description у OU.""" - return self._department_service._set_ou_description(conn, dept_dn, description) - - def _delete_department_ou(self, conn: Connection, dept_dn: str) -> None: - """Удаляет OU (ignore noSuchObject).""" - return self._department_service._delete_department_ou(conn, dept_dn) - - def _evict_all_users_from_department_ou( - self, conn: Connection, dept_dn: str - ) -> None: - """Перемещает всех пользователей из OU отдела в Users base.""" - return self._department_service._evict_all_users_from_department_ou( - conn, dept_dn - ) - - def _groups_with_member(self, conn: Connection, member_dn: str) -> Set[str]: - """Возвращает множество DN групп, где member=member_dn.""" - return self._group_service.groups_with_member(conn, member_dn) + # --- Высокоуровневые методы (используются в сигналах) --- def group_find_dn( self, cn: str, bases: Optional[list[str]] = None @@ -341,7 +187,7 @@ class DirectoryService: from .infrastructure.connections import _ldap with _ldap() as conn: - return self.create_group( + return self._group_service.create( conn, cn, parent_dn, @@ -354,61 +200,40 @@ class DirectoryService: from .infrastructure.connections import _ldap with _ldap() as conn: - self.delete_group(conn, group_dn) + self._group_service.delete(conn, group_dn) def group_rename(self, group_dn: str, new_cn: str) -> str: from .infrastructure.connections import _ldap with _ldap() as conn: - return self.rename_group(conn, group_dn, new_cn) + return self._group_service.rename(conn, group_dn, new_cn) def group_set_description(self, group_dn: str, description: Optional[str]) -> None: from .infrastructure.connections import _ldap with _ldap() as conn: - self.set_group_description(conn, group_dn, description) + self._group_service.set_description(conn, group_dn, description) def group_list_members(self, group_dn: str) -> list[str]: from .infrastructure.connections import _ldap with _ldap() as conn: - return self.list_group_members(conn, group_dn) + return self._group_service.list_members(conn, group_dn) def group_add_members(self, group_dn: str, member_dns: list[str]) -> None: from .infrastructure.connections import _ldap with _ldap() as conn: - self.add_group_members(conn, group_dn, member_dns) + self._group_service.add_members(conn, group_dn, member_dns) def group_remove_members(self, group_dn: str, member_dns: list[str]) -> None: from .infrastructure.connections import _ldap with _ldap() as conn: - self.remove_group_members(conn, group_dn, member_dns) + self._group_service.remove_members(conn, group_dn, member_dns) def group_replace_members(self, group_dn: str, exact_member_dns: list[str]) -> None: from .infrastructure.connections import _ldap with _ldap() as conn: - self.replace_group_members(conn, group_dn, exact_member_dns) - - def employee_ids_to_dns(self, ids: list[int]) -> list[str]: - """Конвертирует ID сотрудников в их Distinguished Names. - - NOTE: Метод делегирует к UserService. - """ - return self._user_service.employee_ids_to_dns(ids) - - def dns_to_employee_ids(self, dns: list[str]) -> list[int]: - """Конвертирует Distinguished Names в ID сотрудников. - - NOTE: Метод делегирует к UserService. - """ - return self._user_service.dns_to_employee_ids(dns) - - def employees_brief_by_dns(self, dns: list[str]) -> list[dict]: - """Получает краткую информацию о сотрудниках по их DN. - - NOTE: Метод делегирует к UserService. - """ - return self._user_service.employees_brief_by_dns(dns) + self._group_service.replace_members(conn, group_dn, exact_member_dns) diff --git a/backend/employees/ldap/orm_services.py b/backend/employees/ldap/orm_services.py deleted file mode 100644 index 38a97f37..00000000 --- a/backend/employees/ldap/orm_services.py +++ /dev/null @@ -1,424 +0,0 @@ -"""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', -] diff --git a/backend/employees/ldap/utils/__init__.py b/backend/employees/ldap/utils/__init__.py index 061ffa5d..b3b9c40c 100644 --- a/backend/employees/ldap/utils/__init__.py +++ b/backend/employees/ldap/utils/__init__.py @@ -34,11 +34,8 @@ from .dn_utils import ( # Утилиты изображений from .image_utils import normalize_avatar_to_jpeg -# Утилиты групп -from .group_utils import ( - _desired_group_cns_for_employee, - sync_user_groups_by_cns, -) +# Утилиты групп (ORM) +from .group_utils_orm import sync_user_groups_by_cns_orm __all__ = [ # Текстовые @@ -66,7 +63,6 @@ __all__ = [ "_move_to_department", # Изображения "normalize_avatar_to_jpeg", - # Группы - "_desired_group_cns_for_employee", - "sync_user_groups_by_cns", + # Группы (ORM) + "sync_user_groups_by_cns_orm", ] diff --git a/backend/employees/ldap/utils/group_utils.py b/backend/employees/ldap/utils/group_utils.py deleted file mode 100644 index 9a0ba5e8..00000000 --- a/backend/employees/ldap/utils/group_utils.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, Optional - -from django.conf import settings -from ldap3 import BASE, SUBTREE, Connection - -from employees.models import Employee -from .text_utils import esc_filter - - -def _read_user_memberof_dns(conn: Connection, user_dn: str) -> set[str]: - """Возвращает текущее множество DN групп (memberOf) пользователя.""" - conn.search(user_dn, "(objectClass=*)", BASE, attributes=["memberOf"]) - if not conn.entries: - return set() - raw = getattr(conn.entries[0], "memberOf", []) - vals = raw.values if hasattr(raw, "values") else raw - return {str(x) for x in (vals or [])} - - -def _group_add_member( - conn: Connection, group_dn: str, user_dn: str, *, do_write: bool -) -> None: - """Добавляет пользователя в группу (или пропускает в dry-run).""" - from ldap3 import MODIFY_ADD - - if not do_write: - return - ok = conn.modify(group_dn, {"member": [(MODIFY_ADD, [user_dn])]}) - if not ok and conn.result.get("description") not in {"typeOrValueExists"}: - raise RuntimeError(f"Add member failed: {group_dn}: {conn.result}") - - -def _group_remove_member( - conn: Connection, group_dn: str, user_dn: str, *, do_write: bool -) -> None: - """Удаляет пользователя из группы (или пропускает в dry-run).""" - from ldap3 import MODIFY_DELETE - - if not do_write: - return - ok = conn.modify(group_dn, {"member": [(MODIFY_DELETE, [user_dn])]}) - if not ok and conn.result.get("description") not in {"noSuchAttribute"}: - raise RuntimeError(f"Remove member failed: {group_dn}: {conn.result}") - - -def _resolve_group_dns_by_cn( - conn: Connection, cns: set[str], extra_bases: Optional[list[str]] = None -) -> dict[str, str]: - """Ищет DN групп по их CN в OU=Groups и дополнительных базах (например, Roles отдела).""" - if not cns: - return {} - bases = [getattr(settings, "LDAP_GROUPS_BASE", "")] - if extra_bases: - bases.extend(extra_bases) - found: dict[str, str] = {} - for base in bases: - if not base: - continue - for cn in sorted(cns - set(found.keys())): - conn.search( - base, - f"(&(objectClass=group)(cn={esc_filter(cn)}))", - SUBTREE, - attributes=["distinguishedName", "cn"], - ) - if conn.entries: - found[cn] = str(conn.entries[0].entry_dn) - return found - - -def _desired_group_cns_for_employee(emp: "Employee") -> set[str]: - """Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct).""" - cns: set[str] = set() - pos = getattr(emp, "position", None) - if pos is not None and hasattr(pos, "groups"): - cns |= {g.name for g in pos.groups.all()} - user_obj = getattr(emp, "user", None) or emp - if hasattr(user_obj, "groups"): - cns |= {g.name for g in user_obj.groups.all()} - dept_roles = getattr(emp, "department_roles", None) - if dept_roles is not None and hasattr(dept_roles, "all"): - for dr in dept_roles.all(): - if hasattr(dr, "groups"): - cns |= {g.name for g in dr.groups.all()} - return cns - - -def sync_user_groups_by_cns( - conn: Connection, - user_dn: str, - target_cns: Iterable[str], - *, - extra_bases: list[str] | None = None, - do_write: bool = True, -) -> tuple[int, int]: - """Приводит memberOf пользователя к точному набору CN (добавляет/удаляет). - - Args: - conn: соединение LDAP. - user_dn: DN пользователя. - target_cns: итоговый набор CN групп. - extra_bases: доп. базы поиска групп (например, OU=Roles,). - do_write: False => только расчёт. - - Returns: - (added, removed). - - Raises: - RuntimeError: если операции LDAP завершаются ошибкой. - """ - desired_dns = set( - _resolve_group_dns_by_cn( - conn, set(target_cns), extra_bases=extra_bases - ).values() - ) - current_dns = _read_user_memberof_dns(conn, user_dn) - - # ограничим удаление только «наших» веток (Groups, OU отделов с ROLE_*), - # как и в батч-логике - groups_base = getattr(settings, "LDAP_GROUPS_BASE", "") - depts_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") - to_add = desired_dns - current_dns - to_del = { - dn - for dn in (current_dns - desired_dns) - if (groups_base and dn.endswith(groups_base)) - or (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn) - } - - added = removed = 0 - if do_write: - for gdn in sorted(to_add): - _group_add_member(conn, gdn, user_dn, do_write=True) - added += 1 - for gdn in sorted(to_del): - _group_remove_member(conn, gdn, user_dn, do_write=True) - removed += 1 - else: - added, removed = len(to_add), len(to_del) - return added, removed diff --git a/backend/employees/ldap/utils/group_utils_orm.py b/backend/employees/ldap/utils/group_utils_orm.py index 97e1b4c8..8594cc72 100644 --- a/backend/employees/ldap/utils/group_utils_orm.py +++ b/backend/employees/ldap/utils/group_utils_orm.py @@ -111,9 +111,21 @@ def sync_user_groups_by_cns_orm( return added, removed -# _desired_group_cns_for_employee — чистая Django логика, нет ldap3 -# Реэкспортируем из оригинала -from .group_utils import _desired_group_cns_for_employee +def _desired_group_cns_for_employee(emp: "Employee") -> set[str]: + """Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct).""" + cns: set[str] = set() + pos = getattr(emp, "position", None) + if pos is not None and hasattr(pos, "groups"): + cns |= {g.name for g in pos.groups.all()} + user_obj = getattr(emp, "user", None) or emp + if hasattr(user_obj, "groups"): + cns |= {g.name for g in user_obj.groups.all()} + dept_roles = getattr(emp, "department_roles", None) + if dept_roles is not None and hasattr(dept_roles, "all"): + for dr in dept_roles.all(): + if hasattr(dr, "groups"): + cns |= {g.name for g in dr.groups.all()} + return cns __all__ = [