refactor(ldap): углубить замену ldap3 → ORM

- 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 из сервисов.
This commit is contained in:
lizerk 2026-03-18 14:48:27 +03:00
parent 95ff0dc0af
commit ea0a7e1f29
4 changed files with 110 additions and 204 deletions

View File

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

View File

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

View File

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

View File

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