From ea0a7e1f29ed7d91947608281a8ae817e5b5d872 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 18 Mar 2026 14:48:27 +0300 Subject: [PATCH] =?UTF-8?q?refactor(ldap):=20=D1=83=D0=B3=D0=BB=D1=83?= =?UTF-8?q?=D0=B1=D0=B8=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=83?= =?UTF-8?q?=20ldap3=20=E2=86=92=20ORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user_service: _enable_user через LdapUser ORM (убран ldap_modify_or_ignore) - user_service: _unique_logins через LdapUser.objects.filter (убран is_taken) - user_service: удалены импорты MODIFY_REPLACE, is_taken, ldap_modify_or_ignore - group_service: set_description, list_members, add/remove/replace_members, find_dn, groups_with_member — все через LdapGroup ORM - group_service: удалены ldap_modify_or_ignore, MODIFY_*, BASE, esc_filter - department_service: _set_ou_managed_by через LdapOrganizationalUnit ORM (заменены ~60 строк ADD/REPLACE/DELETE логики на 4 строки ORM) - department_service: удалены MODIFY_ADD/DELETE/REPLACE - position_service: delete_position_group и _reconcile_position_nesting через groups_with_member (ORM) вместо conn.search Итого: -94 строки, убраны 3 импорта ldap_repository из сервисов. --- .../ldap/services/department_service.py | 63 ++----- .../employees/ldap/services/group_service.py | 162 ++++++++---------- .../ldap/services/position_service.py | 54 ++---- .../employees/ldap/services/user_service.py | 35 ++-- 4 files changed, 110 insertions(+), 204 deletions(-) diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index 8d67c12a..637fe5fe 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple from django.conf import settings from django.db import transaction from django.utils import timezone -from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, Connection +from ldap3 import BASE, Connection from ...models import Department, DepartmentRole, Employee, EmployeeDepartment, LdapSyncState, RoleAssignment from ..domain.dtos import DirectoryDepartmentDTO @@ -1066,70 +1066,29 @@ class DepartmentService: def _set_ou_managed_by( self, conn: Connection, dept_dn: str, head_dn: Optional[str] ) -> None: - """Устанавливает/очищает managedBy у OU в AD (идемпотентно). - - TODO(ldap-orm): Можно заменить на ORM (ou.managed_by = head_dn; save()), - но нужна проверка существования head_dn и обработка ADD/REPLACE/DELETE. + """Устанавливает/очищает managedBy у OU в AD через ORM. Args: - conn: LDAP соединение. + conn: LDAP соединение (не используется, ORM). dept_dn: DN OU отдела. head_dn: DN руководителя или None. - - Raises: - DirectoryServiceError: Если OU или head DN не найдены. - RuntimeError: Если операция modify не удалась. """ if not dept_dn: raise DirectoryServiceError("dept_dn пуст") - if not conn.search( - search_base=dept_dn, - search_filter="(objectClass=organizationalUnit)", - search_scope=BASE, - attributes=["managedBy", "allowedAttributes"], - ): + try: + ou = LdapOrganizationalUnit.objects.get(dn=dept_dn) + except LdapOrganizationalUnit.DoesNotExist: raise DirectoryServiceError(f"OU not found: {dept_dn}") - ou_entry = conn.entries[0] - current_vals = list( - map(str, getattr(ou_entry, "managedBy", []) or []) - ) + current = (ou.managed_by or "").strip().lower() + target = (head_dn or "").strip().lower() - if head_dn: - if not conn.search( - search_base=head_dn, - search_filter="(objectClass=*)", - search_scope=BASE, - attributes=["distinguishedName"], - ): - raise DirectoryServiceError(f"Head DN not found: {head_dn}") - - def _norm(x: Optional[str]) -> str: - return (x or "").strip().lower() - - target = _norm(head_dn) - current = _norm(current_vals[0] if current_vals else None) - if target == current: + if current == target: return - if head_dn is None: - if not current_vals: - return - op, values = MODIFY_DELETE, [] - else: - op, values = ( - (MODIFY_ADD, [head_dn]) - if not current_vals - else (MODIFY_REPLACE, [head_dn]) - ) - - ok = conn.modify(dept_dn, {"managedBy": [(op, values)]}) - if not ok: - res = dict(conn.result) - if res.get("result") == 16 and op == MODIFY_DELETE: - return - raise RuntimeError(f"LDAP modify managedBy failed: {res}") + ou.managed_by = head_dn or "" + ou.save() def _set_ou_description( self, conn: Connection, dept_dn: str, description: Optional[str] diff --git a/backend/employees/ldap/services/group_service.py b/backend/employees/ldap/services/group_service.py index a4edc9f0..374ca704 100644 --- a/backend/employees/ldap/services/group_service.py +++ b/backend/employees/ldap/services/group_service.py @@ -14,12 +14,11 @@ from django.contrib.auth.models import Group from django.core.cache import cache from django.utils import timezone from employees.models import LdapSyncState -from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, SUBTREE, Connection +from ldap3 import SUBTREE, Connection from ..infrastructure.connections import _ldap from ..orm_models import LdapGroup -from ..repositories.ldap_repository import ldap_modify_or_ignore -from ..utils.text_utils import esc_filter, esc_rdn +from ..utils.text_utils import esc_rdn from ..utils.ldap_utils import get_guid_str, group_type @@ -145,119 +144,102 @@ class GroupService: def set_description( self, conn: Connection, group_dn: str, description: Optional[str] ) -> None: - """Ставит/очищает description.""" - changes = ( - {"description": [(MODIFY_REPLACE, [description])]} - if description - else {"description": [(MODIFY_DELETE, [])]} - ) - ldap_modify_or_ignore(conn, group_dn, changes, {"noSuchAttribute"}) + """Ставит/очищает description (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + group.description = description or "" + group.save() + except LdapGroup.DoesNotExist: + pass # ======================== Member Management ======================== # - def _modify_members( - self, conn: Connection, group_dn: str, op: int, members: List[str] - ) -> None: - """Применяет операцию к членству группы (DRY-хелпер). - - Args: - conn: Подключение LDAP. - group_dn: DN группы. - op: MODIFY_ADD | MODIFY_DELETE | MODIFY_REPLACE. - members: Список DN участников. - - Raises: - RuntimeError: При ошибке modify вне допустимых кейсов. - """ - if not members and op in (MODIFY_ADD, MODIFY_DELETE): - return - ignore = ( - {"typeOrValueExists", "entryAlreadyExists"} - if op == MODIFY_ADD - else ( - {"noSuchAttribute", "noSuchObject"} - if op == MODIFY_DELETE - else {"noSuchAttribute"} - ) - ) - ldap_modify_or_ignore(conn, group_dn, {"member": [(op, members or [])]}, ignore) - def add_members( self, conn: Connection, group_dn: str, member_dns: List[str] ) -> None: - """Добавляет участников (member ADD).""" - self._modify_members(conn, group_dn, MODIFY_ADD, member_dns) + """Добавляет участников (ORM).""" + if not member_dns: + return + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + members = list(group.member or []) + changed = False + for dn in member_dns: + if dn not in members: + members.append(dn) + changed = True + if changed: + group.member = members + group.save() def remove_members( self, conn: Connection, group_dn: str, member_dns: List[str] ) -> None: - """Удаляет участников (member DELETE).""" - self._modify_members(conn, group_dn, MODIFY_DELETE, member_dns) + """Удаляет участников (ORM).""" + if not member_dns: + return + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + members = list(group.member or []) + changed = False + for dn in member_dns: + if dn in members: + members.remove(dn) + changed = True + if changed: + group.member = members + group.save() def replace_members( self, conn: Connection, group_dn: str, exact_member_dns: List[str] ) -> None: - """Полная замена состава группы.""" - self._modify_members(conn, group_dn, MODIFY_REPLACE, exact_member_dns) + """Полная замена состава группы (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + group.member = exact_member_dns + group.save() def list_members(self, conn: Connection, group_dn: str) -> List[str]: - """Возвращает DN всех участников группы.""" - ok = conn.search( - search_base=group_dn, - search_filter="(objectClass=group)", - search_scope=BASE, - attributes=["member"], - ) - if not ok or not conn.entries: + """Возвращает DN всех участников группы (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + return list(group.member or []) + except LdapGroup.DoesNotExist: return [] - entry = conn.entries[0] - vals = getattr(getattr(entry, "member", None), "values", None) - return list(vals) if vals else [] # ======================== Finding and Search ======================== # def find_dn( self, conn: Connection, cn: str, bases: Optional[List[str]] = None ) -> Optional[str]: - """Ищет DN группы по CN в заданных базах.""" - bases = list(bases or []) - if not bases: - gb = getattr(settings, "LDAP_GROUPS_BASE", None) - if gb: - bases.append(gb) - for base in bases: - ok = conn.search( - search_base=base, - search_filter=f"(cn={esc_filter(cn)})", - search_scope=SUBTREE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - return str(conn.entries[0].entry_dn) - return None + """Ищет DN группы по CN (ORM).""" + try: + group = LdapGroup.objects.get(cn=cn) + return group.dn + except LdapGroup.DoesNotExist: + return None def groups_with_member(self, conn: Connection, member_dn: str) -> Set[str]: - """Находит все группы, в которых состоит указанный DN. - - Args: - conn: Подключение LDAP. - member_dn: DN участника. - - Returns: - Множество DN групп. - """ - base = getattr(settings, "LDAP_GROUPS_BASE", None) or getattr( - settings, "LDAP_BASE_DN", None - ) - if not base: + """Находит все группы, в которых состоит DN (ORM через memberOf).""" + from ..orm_models import LdapUser + # Пробуем получить memberOf пользователя (быстрее чем поиск по всем группам) + try: + user = LdapUser.objects.get(dn=member_dn) + return set(user.member_of or []) + except LdapUser.DoesNotExist: + pass + # Fallback: может быть группа вложена в группу + try: + group = LdapGroup.objects.get(dn=member_dn) + return set(group.member_of or []) + except LdapGroup.DoesNotExist: return set() - - ok = conn.search( - search_base=base, - search_filter=f"(&(objectClass=group)(member={esc_filter(member_dn)}))", - search_scope=SUBTREE, - attributes=["distinguishedName"], - ) if not ok: return set() return {str(e.entry_dn) for e in conn.entries} diff --git a/backend/employees/ldap/services/position_service.py b/backend/employees/ldap/services/position_service.py index 1c506898..4bcf7e16 100644 --- a/backend/employees/ldap/services/position_service.py +++ b/backend/employees/ldap/services/position_service.py @@ -123,34 +123,21 @@ class PositionService: position: Должность с POS-группой для удаления """ from ..infrastructure.connections import _ldap - + from ..orm_models import LdapGroup + with _ldap() as conn: dn = (position.ldap_group_dn or "").strip() if not dn: return - # снять вложения - bases = [ - b - for b in [ - getattr(settings, "LDAP_GROUPS_BASE", None), - getattr(settings, "LDAP_BASE_DN", None), - ] - if b - ] - for base in bases: - ok = conn.search( - search_base=base, - search_filter=f"(&(objectClass=group)(member={esc_filter(dn)}))", - search_scope=SUBTREE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - for e in conn.entries: - self._group_service.remove_members(conn, str(e.entry_dn), [dn]) - # удалить саму POS-группу + # снять вложения через ORM (groups_with_member) + parent_groups = self._group_service.groups_with_member(conn, dn) + for parent_dn in parent_groups: + self._group_service.remove_members(conn, parent_dn, [dn]) + # удалить саму POS-группу (ORM) try: - self._group_service.delete(conn, dn) - except Exception: + group = LdapGroup.objects.get(dn=dn) + group.delete() + except LdapGroup.DoesNotExist: pass # ======================== PRIVATE HELPERS ======================== # @@ -307,25 +294,8 @@ class PositionService: if dn: desired_dns.add(dn) - # текущие группы, где POS уже состоит - current_dns: Set[str] = set() - search_bases = [ - b - for b in [ - getattr(settings, "LDAP_GROUPS_BASE", None), - getattr(settings, "LDAP_BASE_DN", None), - ] - if b - ] - for base in search_bases: - ok = conn.search( - search_base=base, - search_filter=f"(&(objectClass=group)(member={esc_filter(pos_dn)}))", - search_scope=SUBTREE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - current_dns.update(str(e.entry_dn) for e in conn.entries) + # текущие группы, где POS уже состоит (ORM) + current_dns = self._group_service.groups_with_member(conn, pos_dn) # добавить недостающее for add_dn in desired_dns - current_dns: diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index 43fff8d5..a5a734ab 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -11,7 +11,7 @@ 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 MODIFY_REPLACE, Connection +from ldap3 import Connection from ...models import Employee, LdapSyncState, Position from ..domain.dtos import DirectoryUserDTO @@ -25,8 +25,6 @@ from ..utils.group_utils_orm import sync_user_groups_by_cns_orm from ..infrastructure.connections import _ldap from ..repositories.ldap_repository import ( ensure_container_exists, - is_taken, - ldap_modify_or_ignore, ) from ..utils.image_utils import normalize_avatar_to_jpeg from ..utils.ldap_utils import ( @@ -477,35 +475,32 @@ class UserService: raise RuntimeError(f"LDAP set password failed: {msg}") def _enable_user(self, conn: Connection, dn: str) -> None: - """Включает учётку (UAC=512). - - TODO(ldap-orm): Можно заменить на ORM (ldap_user.user_account_control = 512; save()). - Но используется в связке с _create_user_in_ldap → conn уже в контексте. - """ - ldap_modify_or_ignore( - conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [UAC_ENABLED])]}, set() - ) + """Включает учётку (UAC=512) через ORM.""" + ldap_user = LdapUser.objects.get(dn=dn) + ldap_user.user_account_control = UAC_ENABLED + ldap_user.save() def _unique_logins( self, conn: Connection, dto: DirectoryUserDTO, upn_suffix: str ) -> tuple[str, str]: - """Генерирует уникальные sAMAccountName и UPN. - - TODO(ldap-orm): is_taken() использует ldap3 raw search с OR фильтром. - Можно заменить на LdapUser.objects.filter(sam_account_name=s).exists(), - но нужен фильтр по нескольким атрибутам одновременно. - """ + """Генерирует уникальные sAMAccountName и UPN через ORM.""" sam, upn = build_logins_for_user( first_name=dto.first_name, last_name=dto.last_name, email=dto.email, upn_suffix=upn_suffix, - is_taken_sam=lambda s: is_taken(conn, attributes={"sAMAccountName": s}), - is_taken_upn=lambda u: is_taken(conn, attributes={"userPrincipalName": u}), + is_taken_sam=lambda s: LdapUser.objects.filter( + sam_account_name=s + ).exists(), + is_taken_upn=lambda u: LdapUser.objects.filter( + user_principal_name=u + ).exists(), guid=getattr(dto, "ldap_guid", None), ) if not sam or not upn: - raise ValueError("Не удалось сгенерировать уникальные sAMAccountName/UPN") + raise ValueError( + "Не удалось сгенерировать уникальные sAMAccountName/UPN" + ) return sam, upn def _phone_write_attr(self) -> str: