Corp/backend/employees/ldap/services/group_service.py
2026-09-07 19:43:48 +03:00

564 lines
20 KiB
Python
Raw Permalink 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.

"""Рефакторенный сервис для управления группами Active Directory.
Улучшения:
- Наследуется от BaseService (общая логика _touch_state, логирование)
- Использует константы из constants.py
- Унифицированный API (без дублирования _wrapped методов)
- Добавлено логирование критических операций
- Упрощённый интерфейс (сервис сам управляет LDAP соединением)
"""
from __future__ import annotations
import time
from typing import List, Optional, Set
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.utils import timezone
from ldap3 import SUBTREE, Connection
from ..infrastructure.connections import _ldap
from ..orm_models import LdapGroup, LdapUser
from ..utils.ldap_utils import get_guid_str
from ..utils.text_utils import esc_rdn
from .base_service import BaseService
from .constants import (
LdapAttribute,
LdapFilter,
)
class GroupService(BaseService):
"""Сервис для управления группами Active Directory.
Рефакторенная версия с улучшенной архитектурой:
- Использует BaseService для общей логики
- Константы вместо магических чисел
- Логирование всех операций
- Упрощённый API
"""
# ======================== Core CRUD Operations ======================== #
def create(
self,
cn: str,
*,
parent_dn: Optional[str] = None,
description: Optional[str] = None,
scope: str = "global",
security_enabled: bool = True,
) -> str:
"""Создаёт группу в LDAP под указанным контейнером
(с управлением соединением).
Args:
cn: Имя группы (CN).
parent_dn: Контейнер для группы;
если None — settings.LDAP_GROUPS_BASE.
description: Описание.
scope: 'global' | 'domain_local' | 'universal'.
security_enabled: Флаг безопасности.
Returns:
DN созданной (или существующей) группы.
Raises:
RuntimeError: Если базовый контейнер не задан
или операция add завершилась ошибкой.
"""
with _ldap() as conn:
return self._create_internal(
conn,
cn,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
def _create_internal(
self,
conn: Connection,
cn: str,
*,
parent_dn: Optional[str] = None,
description: Optional[str] = None,
scope: str = "global",
security_enabled: bool = True,
) -> str:
"""Внутренний метод создания группы через ORM.
Args:
conn: LDAP соединение
(сохраняется в сигнатуре для обратной совместимости).
cn: Имя группы.
parent_dn: Контейнер.
description: Описание.
scope: Тип группы.
security_enabled: Флаг безопасности.
"""
base = parent_dn or getattr(settings, "LDAP_GROUPS_BASE", None)
if not base:
raise RuntimeError(
"LDAP_GROUPS_BASE is not configured and parent_dn is not provided"
)
dn = f"CN={esc_rdn(cn)},{base}"
try:
LdapGroup.objects.create(
dn=dn,
cn=cn,
sam_account_name=cn,
description=description or "",
)
self._log_operation("create", model="group", dn=dn, success=True)
except Exception as e:
err_str = str(e).lower()
if "already exists" in err_str or "entryalreadyexists" in err_str:
self._logger.info(
"LDAP group already exists",
extra={
"event": "ldap.group.already_exists",
"has_ldap_dn": bool(dn),
},
)
else:
self._log_operation(
"create",
model="group",
dn=dn,
success=False,
error=e,
)
raise RuntimeError(f"LDAP add group failed: {e}") from e
return dn
def delete(self, group_dn: str) -> None:
"""Удаляет группу по DN через ORM (игнорирует DoesNotExist)."""
self._delete_internal(group_dn)
def _delete_internal(self, group_dn: str) -> None:
"""Внутренний метод удаления группы через ORM."""
try:
ldap_group = LdapGroup.objects.get(dn=group_dn)
ldap_group.delete()
self._log_operation("delete", model="group", dn=group_dn, success=True)
except LdapGroup.DoesNotExist:
self._logger.info(
"LDAP group already deleted or missing",
extra={
"event": "ldap.group.already_deleted",
"has_ldap_dn": bool(group_dn),
},
)
def rename(self, group_dn: str, new_cn: str) -> str:
"""Переименовывает группу (modify_dn)."""
with _ldap() as conn:
return self._rename_internal(conn, group_dn, new_cn)
def _rename_internal(self, conn: Connection, group_dn: str, new_cn: str) -> str:
"""Внутренний метод переименования группы."""
new_rdn = f"CN={esc_rdn(new_cn)}"
ok = conn.modify_dn(group_dn, new_rdn)
if not ok:
self._log_operation(
"rename",
model="group",
dn=group_dn,
success=False,
error=RuntimeError(f"LDAP rename group failed: {conn.result}"),
extra={"new_cn": new_cn},
)
raise RuntimeError(f"LDAP rename group failed: {conn.result}")
base = ",".join(group_dn.split(",")[1:])
new_dn = f"{new_rdn},{base}"
self._log_operation(
"rename",
model="group",
dn=group_dn,
success=True,
extra={"new_dn": new_dn},
)
return new_dn
def set_description(self, group_dn: str, description: Optional[str]) -> None:
"""Устанавливает или очищает description группы (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.description = description or ""
group.save()
self._log_operation(
"set_description",
model="group",
dn=group_dn,
success=True,
)
except LdapGroup.DoesNotExist:
self._logger.warning(
"LDAP group description update skipped",
extra={
"event": "ldap.group.description_update_skipped",
"reason": "group_not_found",
},
)
# ======================== Member Management ======================== #
def add_members(self, group_dn: str, member_dns: List[str]) -> None:
"""Добавляет участников в группу через high-level LDAP модель."""
if not member_dns:
return
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
self._logger.warning(
"LDAP group member update skipped",
extra={
"event": "ldap.group.member_update_skipped",
"reason": "group_not_found",
},
)
return
added_count = 0
for dn in member_dns:
before = set(group.list_members())
group.add_member(dn)
after = set(LdapGroup.objects.get(dn=group_dn).list_members())
if dn in after and dn not in before:
added_count += 1
if added_count:
total = len(LdapGroup.objects.get(dn=group_dn).list_members())
self._log_operation(
"add_members",
model="group",
dn=group_dn,
success=True,
extra={"added": added_count, "total": total},
)
def remove_members(self, group_dn: str, member_dns: List[str]) -> None:
"""Удаляет участников из группы через high-level LDAP модель."""
if not member_dns:
return
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
self._logger.warning(
"LDAP group member update skipped",
extra={
"event": "ldap.group.member_update_skipped",
"reason": "group_not_found",
},
)
return
removed_count = 0
for dn in member_dns:
before = set(group.list_members())
group.remove_member(dn)
after = set(LdapGroup.objects.get(dn=group_dn).list_members())
if dn not in after and dn in before:
removed_count += 1
if removed_count:
total = len(LdapGroup.objects.get(dn=group_dn).list_members())
self._log_operation(
"remove_members",
model="group",
dn=group_dn,
success=True,
extra={"removed": removed_count, "total": total},
)
def replace_members(self, group_dn: str, exact_member_dns: List[str]) -> None:
"""Полная замена состава группы через high-level LDAP модель."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
self._logger.warning(
"LDAP group member replacement skipped",
extra={
"event": "ldap.group.member_replacement_skipped",
"reason": "group_not_found",
},
)
return
old_count = len(group.list_members())
diff = group.sync_members(exact_member_dns)
self._log_operation(
"replace_members",
model="group",
dn=group_dn,
success=True,
extra={
"old_count": old_count,
"new_count": len(exact_member_dns),
**diff,
},
)
def list_members(self, group_dn: str) -> List[str]:
"""Возвращает DN всех участников группы (ORM)."""
try:
group = LdapGroup.objects.get(dn=group_dn)
return list(group.member or [])
except LdapGroup.DoesNotExist:
return []
# ======================== Finding and Search ======================== #
def find_dn(self, cn: str, bases: Optional[List[str]] = None) -> Optional[str]:
"""Ищет DN группы по CN (ORM).
Warning: Если есть несколько групп с одинаковым CN,
вернёт первую найденную.
"""
group = LdapGroup.objects.filter(cn=cn).first()
return group.dn if group else None
def groups_with_member(self, member_dn: str) -> Set[str]:
"""Находит все группы, в которых состоит DN (ORM через memberOf)."""
# Пробуем получить 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()
# ======================== Catalog Sync ======================== #
def sync_catalog(
self, *, throttle_seconds: int = 60, delete_absent: bool = False
) -> int:
"""Синхронизирует группы из AD в Django Group.
Тянет группы из AD под LDAP_GROUPS_BASE
и гарантирует их наличие в Django Group.
Возвращает количество созданных групп.
Обновляет LdapSyncState(model='group').
Троттлит запросы к AD не чаще, чем раз в throttle_seconds.
Args:
throttle_seconds: Минимальный интервал между синхронизациями
delete_absent: Удалять ли группы, отсутствующие в AD
Returns:
Количество созданных групп
"""
base = getattr(settings, "LDAP_GROUPS_BASE", None) or getattr(
settings, "LDAP_BASE_DN", None
)
if not base:
return 0
# Простая защита от шторминга
now = int(time.time())
last = cache.get("ad_groups_sync_last_ts") or 0
if throttle_seconds and now - last < throttle_seconds:
self._logger.debug(
"LDAP group sync skipped by throttle",
extra={
"event": "ldap.group.sync_skipped",
"reason": "throttled",
"throttle_seconds": throttle_seconds,
},
)
return 0
if not cache.add("ad_groups_sync_lock", "1", timeout=throttle_seconds or 30):
self._logger.debug(
"LDAP group sync skipped because another run is active",
extra={
"event": "ldap.group.sync_skipped",
"reason": "already_in_progress",
},
)
return 0
created = 0
try:
with _ldap() as conn:
ok = conn.search(
search_base=base,
search_filter=LdapFilter.ALL_GROUPS,
search_scope=SUBTREE,
attributes=[LdapAttribute.CN, LdapAttribute.OBJECT_GUID],
)
if not ok:
return 0
# AD → {name_lower: (CN, DN, GUID)}
ad_index = {}
for e in conn.entries:
cn = str(getattr(e, LdapAttribute.CN, "")) or ""
if not cn:
continue
dn = str(e.entry_dn)
guid = get_guid_str(
{
LdapAttribute.OBJECT_GUID: getattr(
e, LdapAttribute.OBJECT_GUID, None
)
}
)
ad_index[cn.lower()] = (cn, dn, guid)
# Существующие группы в Django
existing = {g.name.lower(): g for g in Group.objects.all()}
for key, (cn, dn, guid) in ad_index.items():
g = existing.get(key)
if not g:
g = Group.objects.create(name=cn)
created += 1
existing[key] = g
self._logger.info(
"Django group created from LDAP",
extra={
"event": "ldap.group.local_created",
"group_id": g.pk,
},
)
# Фиксируем DN/GUID в sync-state
self._touch_state(
model="group",
object_pk=g.pk,
ldap_dn=dn,
ldap_guid=guid,
sync_dir="ldap",
last_django_modify_ts=timezone.now(),
)
if delete_absent:
for key, g in list(existing.items()):
if key not in ad_index:
self._logger.info(
"Django group is absent from LDAP",
extra={
"event": "ldap.group.absent",
"group_id": g.pk,
},
)
# Можно удалить или только пометить
cache.set("ad_groups_sync_last_ts", now, timeout=24 * 3600)
self._logger.info(
"LDAP group catalog synchronized",
extra={
"event": "ldap.group_catalog.synchronized",
"created_count": created,
"ldap_group_count": len(ad_index),
},
)
return created
finally:
cache.delete("ad_groups_sync_lock")
# ======================== Low-Level Methods
# for Internal Use ======================== #
def _create_with_conn(
self,
conn: Connection,
cn: str,
parent_dn: Optional[str] = None,
description: Optional[str] = None,
scope: str = "global",
security_enabled: bool = True,
) -> str:
"""Создаёт группу с существующим соединением.
Для внутреннего использования.
Используется другими сервисами, которые уже управляют conn.
"""
return self._create_internal(
conn,
cn,
parent_dn=parent_dn,
description=description,
scope=scope,
security_enabled=security_enabled,
)
def _delete_with_conn(self, conn: Connection, group_dn: str) -> None:
"""Удаляет группу.
Для совместимости с Legacy API, conn игнорируется.
"""
self._delete_internal(group_dn)
def _rename_with_conn(self, conn: Connection, group_dn: str, new_cn: str) -> str:
"""Переименовывает группу с существующим соединением."""
return self._rename_internal(conn, group_dn, new_cn)
def _add_members_with_conn(
self, conn: Connection, group_dn: str, member_dns: List[str]
) -> None:
"""Добавляет участников с существующим соединением.
Просто вызывает ORM.
"""
self.add_members(group_dn, member_dns)
def _remove_members_with_conn(
self, conn: Connection, group_dn: str, member_dns: List[str]
) -> None:
"""Удаляет участников с существующим соединением.
Просто вызывает ORM.
"""
self.remove_members(group_dn, member_dns)
def _replace_members_with_conn(
self, conn: Connection, group_dn: str, exact_member_dns: List[str]
) -> None:
"""Заменяет участников с существующим соединением.
Просто вызывает ORM.
"""
self.replace_members(group_dn, exact_member_dns)
def _find_dn_with_conn(
self, conn: Connection, cn: str, bases: Optional[List[str]] = None
) -> Optional[str]:
"""Ищет DN с существующим соединением.
Просто вызывает ORM.
"""
return self.find_dn(cn, bases)
def _groups_with_member_with_conn(
self, conn: Connection, member_dn: str
) -> Set[str]:
"""Находит группы с участником с существующим соединением."""
return self.groups_with_member(member_dn)
__all__ = ["GroupService"]