81dee5cba2
- 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>
301 lines
13 KiB
Python
301 lines
13 KiB
Python
# 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
|