423 lines
17 KiB
Python
423 lines
17 KiB
Python
"""
|
||
Сервис для работы с должностями (Position) в Active Directory.
|
||
|
||
Обрабатывает создание, обновление, удаление POS-групп
|
||
(агрегаторных групп должностей), управление участниками
|
||
(сотрудники с должностью), вложение POS-групп в целевые группы.
|
||
|
||
Рефакторенная версия с улучшениями:
|
||
- Наследуется от BaseService (логирование, _touch_state)
|
||
- Использует константы вместо магических строк
|
||
- Логирует все критические операции
|
||
"""
|
||
|
||
from typing import Set
|
||
|
||
from django.conf import settings
|
||
from ldap3 import BASE, SUBTREE, Connection
|
||
|
||
from employees.models import Employee, LdapSyncState, Position
|
||
|
||
from ..errors import DirectoryServiceError
|
||
from ..repositories.ldap_repository import ensure_container_exists
|
||
from ..utils.ldap_utils import group_type
|
||
from ..utils.text_utils import esc_filter, esc_rdn
|
||
from .base_service import BaseService
|
||
|
||
|
||
class PositionService(BaseService):
|
||
"""
|
||
Сервис для работы с должностями (Position) в Active Directory.
|
||
|
||
Отвечает за:
|
||
- Создание и управление POS-группами (CN=POS_<name>, OU=Positions)
|
||
- Назначение/снятие должностей для сотрудников
|
||
- Вложение POS-групп в целевые группы (из Position.groups)
|
||
- Синхронизацию состава участников POS-группы
|
||
|
||
Зависимости (через Dependency Injection):
|
||
- GroupService: для операций с группами
|
||
(add_members, remove_members, replace_members)
|
||
- UserService: для получения DN сотрудника
|
||
"""
|
||
|
||
def __init__(self, group_service=None, user_service=None):
|
||
"""
|
||
Инициализация PositionService.
|
||
|
||
Args:
|
||
group_service: Сервис для работы с группами
|
||
(для управления членством)
|
||
user_service: Сервис для работы с пользователями (для получения DN)
|
||
"""
|
||
super().__init__()
|
||
self._group_service = group_service
|
||
self._user_service = user_service
|
||
|
||
def _ensure_runtime_services(self) -> None:
|
||
"""Ленивая инициализация зависимостей для sync/retry путей."""
|
||
if self._group_service is None:
|
||
from .group_service import GroupService
|
||
|
||
self._group_service = GroupService()
|
||
if self._user_service is None:
|
||
from .user_service import UserService
|
||
|
||
self._user_service = UserService(self._group_service)
|
||
|
||
# ======================== PUBLIC API ======================== #
|
||
|
||
def reconcile_position(self, pos: Position) -> str:
|
||
"""
|
||
Публичный метод — открыть соединение и привести POS к консистентности.
|
||
|
||
Приводит должность к консистентному состоянию:
|
||
1) Создаёт/обновляет POS-группу (CN=POS_<name>)
|
||
2) Вкладывает POS-группу в целевые группы (из pos.groups)
|
||
3) Синхронизирует участников POS-группы (сотрудники с этой должностью)
|
||
|
||
Args:
|
||
pos: Должность для синхронизации
|
||
|
||
Returns:
|
||
DN POS-группы
|
||
|
||
Raises:
|
||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||
ValueError: Если имя должности пустое
|
||
"""
|
||
self._ensure_runtime_services()
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
with _ldap() as conn:
|
||
return self._reconcile_position(conn, pos)
|
||
|
||
def assign_position(self, employee: Employee, position: Position) -> None:
|
||
"""
|
||
Добавляет сотрудника в POS-группу должности
|
||
и убеждается, что вложения POS в целевые группы актуальны.
|
||
|
||
Args:
|
||
employee: Сотрудник
|
||
position: Должность
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если DN сотрудника не найден
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
self._ensure_runtime_services()
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
with _ldap() as conn:
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
self._group_service.add_members(pos_dn, [emp_dn])
|
||
self._log_operation(
|
||
"assign_position",
|
||
model="position",
|
||
object_id=position.id,
|
||
dn=pos_dn,
|
||
success=True,
|
||
extra={"employee_id": employee.id, "employee_dn": emp_dn},
|
||
)
|
||
# вложения можно обновлять best-effort, не обязательно каждый раз
|
||
try:
|
||
self._reconcile_position_nesting(conn, position)
|
||
except Exception as error:
|
||
self._logger.warning(
|
||
"LDAP position nesting reconciliation failed",
|
||
extra={
|
||
"event": "ldap.position.nesting_reconcile_failed",
|
||
"employee_id": employee.pk,
|
||
"position_id": position.pk,
|
||
"error_type": type(error).__name__,
|
||
},
|
||
exc_info=True,
|
||
)
|
||
|
||
def unassign_position(self, employee: Employee, position: Position) -> None:
|
||
"""
|
||
Удаляет сотрудника из POS-группы должности.
|
||
|
||
Args:
|
||
employee: Сотрудник
|
||
position: Должность
|
||
"""
|
||
self._ensure_runtime_services()
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
try:
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
except DirectoryServiceError:
|
||
emp_dn = None
|
||
if not emp_dn:
|
||
return
|
||
with _ldap() as conn:
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
self._group_service.remove_members(pos_dn, [emp_dn])
|
||
self._log_operation(
|
||
"unassign_position",
|
||
model="position",
|
||
object_id=position.id,
|
||
dn=pos_dn,
|
||
success=True,
|
||
extra={"employee_id": employee.id, "employee_dn": emp_dn},
|
||
)
|
||
|
||
def delete_position_group(self, position: Position) -> None:
|
||
"""
|
||
Best-effort: снять POS-группу из всех правовых групп и удалить её.
|
||
|
||
Args:
|
||
position: Должность с POS-группой для удаления
|
||
"""
|
||
self._ensure_runtime_services()
|
||
from ..infrastructure.connections import _ldap
|
||
from ..orm_models import LdapGroup
|
||
|
||
with _ldap():
|
||
dn = (position.ldap_group_dn or "").strip()
|
||
if not dn:
|
||
return
|
||
# снять вложения через ORM (groups_with_member)
|
||
parent_groups = self._group_service.groups_with_member(dn)
|
||
for parent_dn in parent_groups:
|
||
self._group_service.remove_members(parent_dn, [dn])
|
||
# удалить саму POS-группу (ORM)
|
||
try:
|
||
group = LdapGroup.objects.get(dn=dn)
|
||
group.delete()
|
||
self._log_operation(
|
||
"delete_position_group",
|
||
model="position",
|
||
object_id=position.id,
|
||
dn=dn,
|
||
success=True,
|
||
)
|
||
except LdapGroup.DoesNotExist:
|
||
self._logger.warning(
|
||
"LDAP position group already deleted",
|
||
extra={"event": "ldap.position_group.already_deleted"},
|
||
)
|
||
|
||
# ======================== PRIVATE HELPERS ======================== #
|
||
|
||
def _positions_base(self) -> str:
|
||
"""
|
||
Получить базовый DN для контейнера должностей (OU=Positions).
|
||
|
||
Returns:
|
||
DN контейнера должностей из настроек LDAP_POSITIONS_BASE
|
||
|
||
Raises:
|
||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||
"""
|
||
base = getattr(settings, "LDAP_POSITIONS_BASE", None)
|
||
if not base:
|
||
raise RuntimeError("LDAP_POSITIONS_BASE is not configured")
|
||
return base
|
||
|
||
def _ensure_positions_base(self, conn: Connection) -> str:
|
||
"""
|
||
Гарантировать наличие контейнера OU=Positions в AD.
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
|
||
Returns:
|
||
DN контейнера должностей
|
||
|
||
Raises:
|
||
RuntimeError: Если создание контейнера не удалось
|
||
"""
|
||
base = self._positions_base()
|
||
ensure_container_exists(conn, base)
|
||
return base
|
||
|
||
def _ensure_position_group(self, conn: Connection, pos: Position) -> str:
|
||
"""
|
||
Гарантирует наличие агрегаторной группы должности:
|
||
CN=POS_<name>,OU=Positions,...
|
||
Обновляет pos.ldap_group_dn при необходимости и возвращает DN.
|
||
|
||
Логика:
|
||
1. Если pos.ldap_group_dn существует - проверить существование
|
||
и актуальность CN
|
||
2. Если нет - искать по CN=POS_<name> в OU=Positions
|
||
3. Если не найдено - создать новую POS-группу
|
||
4. Обновить pos.ldap_group_dn в БД при изменениях
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
pos: Должность
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
ValueError: Если имя должности пустое
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
ensure_container_exists(conn, self._positions_base())
|
||
name = (pos.name or "").strip()
|
||
if not name:
|
||
raise ValueError("Position.name is empty")
|
||
|
||
expected_cn = f"POS_{name}"
|
||
expected_rdn = f"CN={esc_rdn(expected_cn)}"
|
||
|
||
saved_dn = (pos.ldap_group_dn or "").strip()
|
||
if saved_dn:
|
||
ok = conn.search(
|
||
saved_dn,
|
||
"(objectClass=group)",
|
||
search_scope=BASE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
cur_rdn = saved_dn.split(",", 1)[0]
|
||
if cur_rdn != expected_rdn:
|
||
if not conn.modify_dn(saved_dn, expected_rdn):
|
||
raise RuntimeError(
|
||
f"LDAP rename POS group failed: {conn.result}"
|
||
)
|
||
base = ",".join(saved_dn.split(",")[1:])
|
||
new_dn = f"{expected_rdn},{base}"
|
||
else:
|
||
new_dn = saved_dn
|
||
if new_dn != pos.ldap_group_dn:
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=new_dn)
|
||
pos.ldap_group_dn = new_dn
|
||
return new_dn
|
||
|
||
# поиск по CN в OU=Positions
|
||
ok = conn.search(
|
||
self._positions_base(),
|
||
f"(&(objectClass=group)(cn={esc_filter(expected_cn)}))",
|
||
search_scope=SUBTREE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
dn = str(conn.entries[0].entry_dn)
|
||
if dn != saved_dn:
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||
pos.ldap_group_dn = dn
|
||
return dn
|
||
|
||
# создаём
|
||
dn = f"{expected_rdn},{self._positions_base()}"
|
||
attrs = {
|
||
"cn": expected_cn,
|
||
"sAMAccountName": expected_cn,
|
||
"groupType": group_type("global", True),
|
||
"description": f"{name} position members",
|
||
}
|
||
ok = conn.add(dn, ["top", "group"], attrs)
|
||
if not ok:
|
||
raise RuntimeError(f"LDAP add POS group failed: {conn.result}")
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||
pos.ldap_group_dn = dn
|
||
return dn
|
||
|
||
def _reconcile_position_nesting(self, conn: Connection, position: Position) -> str:
|
||
"""
|
||
Делает так, чтобы POS-группа должности была участником
|
||
ровно тех AD-групп,
|
||
что привязаны к Position.groups.
|
||
|
||
Логика:
|
||
1. Получить список целевых групп из position.groups
|
||
(Django Group -> DN в AD)
|
||
2. Получить список текущих групп, где POS-группа уже состоит
|
||
3. Добавить POS-группу в недостающие целевые группы
|
||
4. Удалить POS-группу из лишних групп
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
position: Должность
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
|
||
# желаемые группы (по именам Django Group -> CN в AD)
|
||
desired_dns: Set[str] = set()
|
||
for g in position.groups.all():
|
||
dn = self._group_service.find_dn(
|
||
g.name,
|
||
bases=[
|
||
getattr(settings, "LDAP_GROUPS_BASE", None)
|
||
or getattr(settings, "LDAP_BASE_DN", "")
|
||
],
|
||
)
|
||
if dn:
|
||
desired_dns.add(dn)
|
||
|
||
# текущие группы, где POS уже состоит (ORM)
|
||
current_dns = self._group_service.groups_with_member(pos_dn)
|
||
|
||
# добавить недостающее
|
||
for add_dn in desired_dns - current_dns:
|
||
self._group_service.add_members(add_dn, [pos_dn])
|
||
|
||
# снять лишнее
|
||
for rem_dn in current_dns - desired_dns:
|
||
self._group_service.remove_members(rem_dn, [pos_dn])
|
||
|
||
return pos_dn
|
||
|
||
def _reconcile_position(self, conn: Connection, pos: Position) -> str:
|
||
"""
|
||
Приводит к консистентности:
|
||
1) наличие POS_* группы,
|
||
2) вложение POS_* в группы из pos.groups,
|
||
3) состав участников POS_* = сотрудники с этой должностью.
|
||
Возвращает DN POS_* группы.
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
pos: Должность для синхронизации
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
pos_dn = self._ensure_position_group(conn, pos)
|
||
|
||
# 2) вложение POS_* в target-группы
|
||
expected_container_dns: Set[str] = set()
|
||
for g in pos.groups.all():
|
||
dn = self._group_service.find_dn(g.name) # ищем по CN группы
|
||
if dn:
|
||
expected_container_dns.add(dn)
|
||
|
||
current_container_dns = self._group_service.groups_with_member(pos_dn)
|
||
to_add = expected_container_dns - current_container_dns
|
||
to_del = current_container_dns - expected_container_dns
|
||
|
||
for dn in to_add:
|
||
self._group_service.add_members(dn, [pos_dn])
|
||
for dn in to_del:
|
||
self._group_service.remove_members(dn, [pos_dn])
|
||
|
||
# 3) участники POS_* = сотрудники с этой позицией
|
||
emp_ids = list(
|
||
Employee.objects.filter(position_id=pos.id, is_active=True).values_list(
|
||
"id", flat=True
|
||
)
|
||
)
|
||
dn_map = dict(
|
||
LdapSyncState.objects.filter(
|
||
model="employee", object_pk__in=[str(i) for i in emp_ids]
|
||
).values_list("object_pk", "ldap_dn")
|
||
)
|
||
member_dns = [dn for dn in dn_map.values() if dn]
|
||
self._group_service.replace_members(pos_dn, member_dns)
|
||
return pos_dn
|