564 lines
20 KiB
Python
564 lines
20 KiB
Python
"""Рефакторенный сервис для управления группами 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"]
|