Files
ACORE/apps/ad_connector/ad_service.py
T
lokses 81dee5cba2 Интеграции AD/Mattermost: OU-управление, импорт, шифрование токенов
- ad_connector: страницы управления OU и импорта из AD, поле ad_ou_dn
  в Directory, ADSyncLog; расширение sync-сервиса и клиента
- mattermost_connector: personal access token шифруется (Fernet,
  token_service), бот-сервис, команды создания бота и синхронизации;
  публикация постов в каналы. Тесты (19)
- core: ConnectorConfig, Directory (ad_ou_dn), обновления админки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:32:26 +03:00

301 lines
13 KiB
Python
Raw 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.
# services/ad_service.py
import logging
from typing import Dict, List, Optional
from ldap3 import Connection, MODIFY_REPLACE, NTLM, SIMPLE, Server
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn, parse_dn, safe_rdn
logger = logging.getLogger(__name__)
# Биты userAccountControl
UF_ACCOUNTDISABLE = 0x0002
UF_NORMAL_ACCOUNT = 0x0200
class ADConfigError(Exception):
pass
class ADService:
"""LDAP-клиент для Active Directory на базе ldap3"""
USER_ATTRIBUTES = [
'distinguishedName', 'cn', 'sAMAccountName', 'userPrincipalName',
'givenName', 'sn', 'middleName', 'displayName', 'mail',
'title', 'department', 'company', 'telephoneNumber',
'userAccountControl',
]
def __init__(self):
from core.models import ConnectorConfig
cfg = ConnectorConfig.objects.filter(connector_type='ad').first()
if not cfg:
raise ADConfigError(
'Настройки коннектора Active Directory не найдены. '
'Добавьте запись в Core → Настройки коннекторов.'
)
if not cfg.is_enabled:
raise ADConfigError(
'Коннектор Active Directory отключён. '
'Включите его в Core → Настройки коннекторов.'
)
extra = cfg.extra_config or {}
self.base_dn = extra.get('base_dn', '')
self.users_ou = extra.get('users_ou') or self.base_dn
self.domain = extra.get('domain', '')
if not self.base_dn:
raise ADConfigError(
'В extra_config коннектора AD не задан base_dn. '
'Пример: {"base_dn": "DC=company,DC=local", '
'"users_ou": "OU=Users,DC=company,DC=local", "domain": "company.local"}'
)
self.use_ssl = cfg.use_ssl
port = cfg.port or (636 if cfg.use_ssl else 389)
server = Server(cfg.host, port=port, use_ssl=cfg.use_ssl, connect_timeout=15)
# NTLM для DOMAIN\user, иначе simple bind (DN или UPN)
auth = NTLM if '\\' in cfg.username else SIMPLE
try:
self.conn = Connection(
server,
user=cfg.username,
password=cfg.password,
authentication=auth,
auto_bind=True,
raise_exceptions=True,
receive_timeout=30,
)
except LDAPException as e:
raise ADConfigError(f'Не удалось подключиться к AD ({cfg.host}:{port}): {e}')
# --- Поиск ---
def _search_one(self, ldap_filter: str) -> Optional[Dict]:
"""Поиск одного пользователя по LDAP-фильтру"""
try:
self.conn.search(self.base_dn, ldap_filter, attributes=self.USER_ATTRIBUTES)
except LDAPException as e:
logger.error(f"AD search failed: {ldap_filter} — {type(e).__name__}: {e}")
raise
if not self.conn.entries:
return None
return self._entry_to_dict(self.conn.entries[0])
@classmethod
def _entry_to_dict(cls, entry) -> Dict:
data = {attr: entry[attr].value for attr in entry.entry_attributes}
# Сервер может вернуть имена атрибутов в другом регистре — нормализуем
lower_map = {k.lower(): k for k in data}
for attr in cls.USER_ATTRIBUTES:
if attr not in data and attr.lower() in lower_map:
data[attr] = data[lower_map[attr.lower()]]
data.setdefault('cn', data.get(lower_map.get('cn', ''), None))
data['dn'] = entry.entry_dn
return data
def get_user_by_dn(self, dn: str) -> Optional[Dict]:
"""Получение пользователя по DN"""
try:
self.conn.search(dn, '(objectClass=user)', search_scope='BASE',
attributes=self.USER_ATTRIBUTES)
except LDAPException:
return None
if not self.conn.entries:
return None
return self._entry_to_dict(self.conn.entries[0])
def get_user_by_sam_account_name(self, sam: str) -> Optional[Dict]:
"""Поиск пользователя по sAMAccountName"""
return self._search_one(
f'(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))'
)
def get_user_by_email(self, email: str) -> Optional[Dict]:
"""Поиск пользователя по email (mail)"""
return self._search_one(
f'(&(objectClass=user)(mail={escape_filter_chars(email)}))'
)
def search_users(self, term: str, limit: int = 50) -> List[Dict]:
"""Поиск пользователей по логину/ФИО/email — для ручного сопоставления"""
escaped = escape_filter_chars(term)
ldap_filter = (
f'(&(objectClass=user)(!(objectClass=computer))(|'
f'(sAMAccountName=*{escaped}*)(cn=*{escaped}*)(mail=*{escaped}*)))'
)
try:
self.conn.search(self.base_dn, ldap_filter, attributes=self.USER_ATTRIBUTES)
except LDAPException as e:
logger.error(f"AD: ошибка поиска '{term}' — {type(e).__name__}: {e}")
raise
return [self._entry_to_dict(entry) for entry in self.conn.entries[:limit]]
def get_all_users(self) -> List[Dict]:
"""Список всех учётных записей пользователей в каталоге"""
try:
self.conn.search(
self.base_dn,
'(&(objectClass=user)(!(objectClass=computer)))',
attributes=self.USER_ATTRIBUTES,
)
except LDAPException as e:
logger.error(f"AD: ошибка получения списка пользователей — {type(e).__name__}: {e}")
raise
return [self._entry_to_dict(entry) for entry in self.conn.entries]
# --- Организационные юниты (OU) ---
def list_ous(self) -> List[Dict]:
"""OU в поддереве base_dn: [{'dn': ..., 'name': ...}, ...]"""
try:
self.conn.search(
self.base_dn, '(objectClass=organizationalUnit)',
attributes=['distinguishedName', 'ou'],
)
except LDAPException as e:
logger.error(f"AD: ошибка получения списка OU — {type(e).__name__}: {e}")
raise
ous = [
{'dn': entry.entry_dn, 'name': str(entry.ou) if 'ou' in entry else entry.entry_dn}
for entry in self.conn.entries
]
return sorted(ous, key=lambda o: o['dn'])
def create_ou(self, name: str, parent_dn: Optional[str] = None) -> str:
"""Создание OU. Возвращает DN созданного юнита."""
dn = f'OU={escape_rdn(name)},{parent_dn or self.users_ou}'
logger.info(f"AD: создание OU {dn}")
try:
self.conn.add(dn, ['top', 'organizationalUnit'], {'ou': name})
except LDAPException as e:
logger.error(f"AD: ошибка создания OU {dn} — {type(e).__name__}: {e}")
raise
return dn
def rename_ou(self, dn: str, new_name: str) -> str:
"""Переименование OU (смена RDN). Возвращает новый DN."""
relative_dn = f'OU={escape_rdn(new_name)}'
parent = ','.join(
f'{attr}={escape_rdn(value)}'
for attr, value, _ in parse_dn(dn, escape=False)[1:]
)
logger.info(f"AD: переименование OU {dn} -> {relative_dn}")
try:
self.conn.modify_dn(dn, relative_dn)
except LDAPException as e:
logger.error(f"AD: ошибка переименования OU {dn} — {type(e).__name__}: {e}")
raise
return f'{relative_dn},{parent}'
def delete_ou(self, dn: str) -> None:
"""Удаление OU. AD не позволит удалить непустую OU."""
logger.info(f"AD: удаление OU {dn}")
try:
self.conn.delete(dn)
except LDAPException as e:
logger.error(f"AD: ошибка удаления OU {dn} — {type(e).__name__}: {e}")
raise
# --- Создание и обновление ---
def create_user(self, dn: str, attributes: Dict) -> None:
"""
Создание пользователя. Учётная запись создаётся отключённой —
включение возможно после установки пароля (см. set_password / enable_user).
"""
attrs = {k: v for k, v in attributes.items() if v}
attrs['userAccountControl'] = UF_NORMAL_ACCOUNT | UF_ACCOUNTDISABLE
logger.info(f"AD: создание пользователя {dn}")
try:
self.conn.add(dn, ['top', 'person', 'organizationalPerson', 'user'], attrs)
except LDAPException as e:
logger.error(f"AD: ошибка создания {dn} — {type(e).__name__}: {e}")
raise
def update_user(self, dn: str, attributes: Dict) -> None:
"""Обновление атрибутов пользователя. Пустые значения удаляют атрибут."""
changes = {
attr: [(MODIFY_REPLACE, [value] if value else [])]
for attr, value in attributes.items()
}
logger.info(f"AD: обновление пользователя {dn}")
try:
self.conn.modify(dn, changes)
except LDAPException as e:
logger.error(f"AD: ошибка обновления {dn} — {type(e).__name__}: {e}")
raise
def rename_user(self, dn: str, new_cn: str) -> str:
"""Переименование учётной записи (смена CN). Возвращает новый DN."""
relative_dn = f'CN={escape_rdn(new_cn)}'
parent = ','.join(
f'{attr}={escape_rdn(value)}'
for attr, value, _ in parse_dn(dn, escape=False)[1:]
)
logger.info(f"AD: переименование {dn} -> {relative_dn}")
try:
self.conn.modify_dn(dn, relative_dn)
except LDAPException as e:
logger.error(f"AD: ошибка переименования {dn} — {type(e).__name__}: {e}")
raise
return f'{relative_dn},{parent}'
def move_user(self, dn: str, new_parent_dn: str) -> str:
"""Перенос учётной записи в другую OU без изменения CN. Возвращает новый DN."""
relative_dn = safe_rdn(dn)[0]
logger.info(f"AD: перенос {dn} -> {new_parent_dn}")
try:
self.conn.modify_dn(dn, relative_dn, new_superior=new_parent_dn)
except LDAPException as e:
logger.error(f"AD: ошибка переноса {dn} — {type(e).__name__}: {e}")
raise
return f'{relative_dn},{new_parent_dn}'
def set_password(self, dn: str, new_password: str) -> None:
"""Установка пароля. Требует подключения по LDAPS."""
if not self.use_ssl:
raise ADConfigError(
'Установка пароля в AD возможна только по LDAPS (use_ssl=True).'
)
logger.info(f"AD: установка пароля для {dn}")
try:
self.conn.extend.microsoft.modify_password(dn, new_password)
except LDAPException as e:
logger.error(f"AD: ошибка установки пароля {dn} — {type(e).__name__}: {e}")
raise
# --- Активация / деактивация ---
def _set_uac_flag(self, dn: str, disable: bool) -> None:
user = self.get_user_by_dn(dn)
if not user:
raise ADConfigError(f'Пользователь {dn} не найден в AD')
uac = int(user.get('userAccountControl') or UF_NORMAL_ACCOUNT)
uac = (uac | UF_ACCOUNTDISABLE) if disable else (uac & ~UF_ACCOUNTDISABLE)
self.update_user(dn, {'userAccountControl': uac})
def enable_user(self, dn: str) -> None:
"""Включение учётной записи"""
self._set_uac_flag(dn, disable=False)
def disable_user(self, dn: str) -> None:
"""Отключение учётной записи"""
self._set_uac_flag(dn, disable=True)
@staticmethod
def is_disabled(user_data: Dict) -> bool:
"""Проверка, отключена ли учётная запись"""
return bool(int(user_data.get('userAccountControl') or 0) & UF_ACCOUNTDISABLE)
def unbind(self) -> None:
"""Закрытие соединения"""
try:
self.conn.unbind()
except LDAPException:
pass