feat(ldap): интеграция django-ldapdb ORM в employees/ldap/

Добавлена поддержка django-ldapdb для упрощения работы с LDAP через Django ORM.

Ключевые изменения:
- Установлен django-ldapdb==1.5.1 в requirements.txt
- Созданы LDAP ORM модели (LdapUser, LdapGroup, LdapOrganizationalUnit) в employees/ldap/orm_models.py
- Реализованы ORM сервисы для CRUD операций в employees/ldap/orm_services.py
- Настроен database router в eusrr_backend/db_routers.py
- Добавлена LDAP database конфигурация в settings.py
- Создана документация ORM_INTEGRATION.md

Архитектура:
✅ GET → Employee (PostgreSQL) - быстро
✅ POST/PUT/DELETE → LdapUser (django-ldapdb ORM) - просто

Преимущества:
- Django ORM вместо низкоуровневого ldap3
- Автоматическая валидация через Django fields
- Унифицированный CRUD API
- Меньше кода, больше удобства

Файлы:
+ employees/ldap/orm_models.py - LDAP ORM модели (LdapUser, LdapGroup, LdapOrganizationalUnit)
+ employees/ldap/orm_services.py - ORM сервисы (LdapOrmUserService, LdapOrmGroupService, LdapOrmDepartmentService)
+ employees/ldap/ORM_INTEGRATION.md - документация по интеграции
+ eusrr_backend/db_routers.py - database router для LDAP моделей
~ requirements.txt - добавлен django-ldapdb==1.5.1
~ eusrr_backend/settings.py - настроена LDAP database и router
This commit is contained in:
lizerk 2026-03-18 13:42:32 +03:00
parent 45a634de92
commit 2eb2da4c5a
6 changed files with 809 additions and 0 deletions

View File

@ -0,0 +1,206 @@
# LDAP ORM Integration
Интеграция django-ldapdb для упрощения работы с Active Directory через Django ORM.
## Концепция
**LDAP модели используются ТОЛЬКО для записи (POST/PUT/DELETE)!**
```
GET запросы → Employee (PostgreSQL) - быстро ⚡
POST/PUT/DELETE → LdapUser (django-ldapdb ORM) - просто ✨
```
## Компоненты
### 1. LDAP ORM Модели
Файл: `employees/ldap/orm_models.py`
```python
from employees.ldap.orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
# Создание пользователя через ORM
user = LdapUser()
user.cn = "John Doe"
user.sam_account_name = "jdoe"
user.given_name = "John"
user.sn = "Doe"
user.save() # → пишет в LDAP!
```
**Модели:**
- `LdapUser` - пользователи AD (objectClass=user)
- `LdapGroup` - группы (objectClass=group)
- `LdapOrganizationalUnit` - отделы (objectClass=organizationalUnit)
### 2. ORM Сервисы
Файл: `employees/ldap/orm_services.py`
```python
from employees.ldap.orm_services import LdapOrmUserService
svc = LdapOrmUserService()
# Создание
user = svc.create_user(
sam_account_name="jdoe",
first_name="John",
last_name="Doe",
email="jdoe@example.com",
employee_pk=123, # ID из Employee модели
is_active=True,
)
# Обновление
svc.update_user(
user_dn="CN=John Doe,OU=Users,DC=example,DC=com",
first_name="Johnny",
email="johnny@example.com",
)
# Удаление
svc.delete_user(user_dn, soft=True) # деактивация
```
**Сервисы:**
- `LdapOrmUserService` - пользователи
- `LdapOrmGroupService` - группы
- `LdapOrmDepartmentService` - отделы
### 3. Database Router
Файл: `eusrr_backend/db_routers.py`
Автоматически направляет:
- LDAP модели → `ldap` database
- Django модели → `default` database (PostgreSQL)
### 4. Settings
```python
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
# ...
},
'ldap': {
'ENGINE': 'ldapdb.backends.ldap',
'NAME': 'ldaps://dc.example.com:636',
'USER': 'CN=admin,DC=example,DC=com',
'PASSWORD': 'password',
},
}
DATABASE_ROUTERS = ['eusrr_backend.db_routers.LdapRouter']
```
## Преимущества
✅ **Django ORM вместо ldap3** - чистый код, меньше костылей
✅ **Автоматическая валидация** - через Django fields
✅ **Удобство** - унифицированный CRUD API
✅ **Производительность** - чтение из PostgreSQL (быстро), запись через ORM (удобно)
## Примеры использования
### ViewSet с ORM
```python
from employees.ldap.orm_services import LdapOrmUserService
class EmployeeViewSet(viewsets.ModelViewSet):
def create(self, request):
# 1. Создаем в Django
employee = Employee.objects.create(
first_name=request.data['first_name'],
last_name=request.data['last_name'],
email=request.data['email'],
)
# 2. Создаем в LDAP через ORM
svc = LdapOrmUserService()
ldap_user = svc.create_user(
sam_account_name=employee.email.split('@')[0],
first_name=employee.first_name,
last_name=employee.last_name,
email=employee.email,
employee_pk=employee.pk,
)
return Response(EmployeeSerializer(employee).data)
```
### Работа с группами
```python
from employees.ldap.orm_services import LdapOrmGroupService
svc = LdapOrmGroupService()
# Создание группы
group = svc.create_group(
cn="Developers",
description="Development team",
)
# Добавление члена
svc.add_member(
group_dn=group.dn,
member_dn="CN=John Doe,OU=Users,DC=example,DC=com",
)
```
## Миграция со старого подхода
### Было (ldap3):
```python
from ldap3 import Connection, MODIFY_REPLACE
with ldap_connection() as conn:
conn.modify(
user_dn,
{"givenName": [(MODIFY_REPLACE, ["John"])]},
)
```
### Стало (django-ldapdb):
```python
from employees.ldap.orm_services import LdapOrmUserService
svc = LdapOrmUserService()
svc.update_user(user_dn, first_name="John")
```
**Результат:**
- ❌ Было: 15-20 строк низкоуровневого кода
- ✅ Стало: 2 строки чистого ORM
## Установка
```bash
# 1. Системные зависимости (Linux)
sudo apt-get install libldap2-dev libsasl2-dev
# 2. Python пакет
pip install django-ldapdb==1.5.1
# 3. Django check
python manage.py check
```
## Следующие шаги
1. Создать сигналы для автоматической синхронизации Employee ↔ LDAP
2. Интегрировать в существующие ViewSets
3. Постепенно мигрировать старый код с ldap3 на ORM
## Заметки
- LDAP модели не мигрируют (managed=False)
- Для связи используется employeeNumber атрибут (хранит Employee.pk)
- Soft delete по умолчанию (деактивация вместо удаления)

View File

@ -0,0 +1,177 @@
"""LDAP ORM модели для работы с Active Directory через django-ldapdb.
Эти модели используются ТОЛЬКО для записи (POST/PUT/DELETE) в LDAP.
Для чтения (GET) используются обычные Django модели (Employee, Department).
Преимущества ORM подхода:
- Замена низкоуровневых ldap3 операций на Django ORM
- Автоматическая валидация и типизация
- Унифицированный API для CRUD операций
- Меньше кода, больше удобства
"""
from django.conf import settings
from ldapdb.models import Model as LdapModel
from ldapdb.models.fields import (
CharField,
DateTimeField,
ImageField,
IntegerField,
ListField,
)
def get_users_base():
"""Получает base DN для пользователей из settings."""
return getattr(settings, 'LDAP_USERS_BASE',
getattr(settings, 'LDAP_USER_BASE',
'OU=company,DC=robotail,DC=local'))
def get_base_dn():
"""Получает корневой base DN из settings."""
return getattr(settings, 'LDAP_BASE_DN',
getattr(settings, 'LDAP_USER_BASE',
'DC=robotail,DC=local'))
class LdapUser(LdapModel):
"""LDAP модель для пользователя Active Directory.
Использует objectClass: top, person, organizationalPerson, user.
Только для WRITE операций (POST/PUT/DELETE).
"""
# Базовая конфигурация
base_dn = get_users_base()
object_classes = ['top', 'person', 'organizationalPerson', 'user']
# Основные атрибуты
cn = CharField(db_column='cn', primary_key=True)
distinguished_name = CharField(db_column='distinguishedName')
# Идентификация
object_guid = CharField(db_column='objectGUID')
sam_account_name = CharField(db_column='sAMAccountName')
user_principal_name = CharField(db_column='userPrincipalName')
# Персональные данные
given_name = CharField(db_column='givenName')
sn = CharField(db_column='sn') # surname (фамилия)
display_name = CharField(db_column='displayName')
mail = CharField(db_column='mail')
# Контакты
telephone_number = CharField(db_column='telephoneNumber', blank=True)
mobile = CharField(db_column='mobile', blank=True)
# Управление учетной записью
user_account_control = IntegerField(db_column='userAccountControl')
# Дополнительная информация
description = CharField(db_column='description', blank=True)
thumbnail_photo = ImageField(db_column='thumbnailPhoto', blank=True)
# ID сотрудника (для связи с Django Employee.pk)
employee_number = CharField(db_column='employeeNumber', blank=True)
# Членство в группах
member_of = ListField(db_column='memberOf', blank=True)
# Временные метки
when_created = DateTimeField(db_column='whenCreated')
when_changed = DateTimeField(db_column='whenChanged')
class Meta:
managed = False # Django не управляет схемой LDAP
def __str__(self):
return f"{self.display_name} ({self.sam_account_name})"
def __repr__(self):
return f"<LdapUser: {self.sam_account_name}>"
class LdapGroup(LdapModel):
"""LDAP модель для группы Active Directory.
Использует objectClass: top, group.
Только для WRITE операций (POST/PUT/DELETE).
"""
# Базовая конфигурация
base_dn = get_base_dn()
object_classes = ['top', 'group']
# Основные атрибуты
cn = CharField(db_column='cn', primary_key=True)
distinguished_name = CharField(db_column='distinguishedName')
# Идентификация
object_guid = CharField(db_column='objectGUID')
sam_account_name = CharField(db_column='sAMAccountName')
# Описание
description = CharField(db_column='description', blank=True)
# Члены группы
member = ListField(db_column='member', blank=True)
member_of = ListField(db_column='memberOf', blank=True)
# Временные метки
when_created = DateTimeField(db_column='whenCreated')
when_changed = DateTimeField(db_column='whenChanged')
class Meta:
managed = False
def __str__(self):
return f"Group: {self.cn}"
def __repr__(self):
return f"<LdapGroup: {self.cn}>"
class LdapOrganizationalUnit(LdapModel):
"""LDAP модель для Organizational Unit (отдел).
Использует objectClass: top, organizationalUnit.
Только для WRITE операций (POST/PUT/DELETE).
"""
# Базовая конфигурация
base_dn = get_base_dn()
object_classes = ['top', 'organizationalUnit']
# Основные атрибуты
ou = CharField(db_column='ou', primary_key=True)
distinguished_name = CharField(db_column='distinguishedName')
# Идентификация
object_guid = CharField(db_column='objectGUID')
# Описание
description = CharField(db_column='description', blank=True)
# Управление (managedBy - DN руководителя)
managed_by = CharField(db_column='managedBy', blank=True)
# Временные метки
when_created = DateTimeField(db_column='whenCreated')
when_changed = DateTimeField(db_column='whenChanged')
class Meta:
managed = False
def __str__(self):
return f"OU: {self.ou}"
def __repr__(self):
return f"<LdapOrganizationalUnit: {self.ou}>"
__all__ = [
'LdapUser',
'LdapGroup',
'LdapOrganizationalUnit',
]

View File

@ -0,0 +1,347 @@
"""ORM сервисы для работы с LDAP через django-ldapdb.
Упрощенная альтернатива низкоуровневым ldap3 операциям.
Использует LDAP модели (LdapUser, LdapGroup, LdapOrganizationalUnit) для CRUD.
Преимущества:
- Чистый Django ORM вместо ldap3 костылей
- Автоматическая валидация
- Унифицированный API
- Меньше кода
Использование:
- Только для WRITE операций (POST/PUT/DELETE)
- Чтение идет из обычных Django моделей (Employee, Department)
"""
import logging
from typing import Optional
from django.conf import settings
from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit
from .utils.text_utils import esc_rdn
logger = logging.getLogger(__name__)
# User Account Control values для Active Directory
UAC_ENABLED = 512
UAC_DISABLED = 514
class LdapOrmUserService:
"""Сервис для управления пользователями в LDAP через ORM."""
def create_user(
self,
*,
sam_account_name: str,
first_name: str,
last_name: str,
email: str,
employee_pk: Optional[int] = None,
phone: Optional[str] = None,
is_active: bool = True,
ou_dn: Optional[str] = None,
) -> LdapUser:
"""
Создает пользователя в LDAP используя django-ldapdb ORM.
Args:
sam_account_name: sAMAccountName (логин без домена)
first_name: Имя
last_name: Фамилия
email: Email адрес
employee_pk: ID сотрудника в Django (для employeeNumber)
phone: Телефон (опционально)
is_active: Активен ли пользователь
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapUser объект
"""
# Генерация CN и UPN
cn = f"{first_name} {last_name}".strip() or sam_account_name
cn_safe = esc_rdn(cn)
upn_suffix = getattr(settings, 'LDAP_UPN_SUFFIX', 'robotail.local')
upn = f"{sam_account_name}@{upn_suffix}"
# Определение OU для размещения
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_USERS_BASE',
getattr(settings, 'LDAP_USER_BASE',
'OU=company,DC=robotail,DC=local'))
# DN нового пользователя
dn = f"CN={cn_safe},{ou_dn}"
# Создание через ORM
user = LdapUser()
user.dn = dn
user.cn = cn
user.sam_account_name = sam_account_name
user.user_principal_name = upn
user.given_name = first_name
user.sn = last_name or "." # AD требует sn
user.display_name = cn
user.mail = email
if phone:
user.mobile = phone
if employee_pk:
user.employee_number = str(employee_pk)
# UserAccountControl
user.user_account_control = UAC_ENABLED if is_active else UAC_DISABLED
# Сохранение в LDAP
user.save()
logger.info(f"Created LDAP user: {dn} (sAMAccountName={sam_account_name})")
return user
def update_user(
self,
user_dn: str,
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
phone: Optional[str] = None,
is_active: Optional[bool] = None,
) -> LdapUser:
"""
Обновляет пользователя в LDAP через ORM.
Args:
user_dn: DN пользователя для обновления
first_name: Новое имя (если нужно изменить)
last_name: Новая фамилия
email: Новый email
phone: Новый телефон
is_active: Новый статус активности
Returns:
Обновленный LdapUser объект
"""
# Получаем пользователя по DN
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
raise ValueError(f"LDAP user not found: {user_dn}")
# Обновляем поля
updated = False
if first_name is not None and user.given_name != first_name:
user.given_name = first_name
updated = True
if last_name is not None and user.sn != last_name:
user.sn = last_name or "."
updated = True
if email is not None and user.mail != email:
user.mail = email
updated = True
if phone is not None and user.mobile != phone:
user.mobile = phone
updated = True
if is_active is not None:
new_uac = UAC_ENABLED if is_active else UAC_DISABLED
if user.user_account_control != new_uac:
user.user_account_control = new_uac
updated = True
# Обновление displayName если изменились имя/фамилия
if first_name is not None or last_name is not None:
new_display = f"{user.given_name} {user.sn}".strip()
if new_display and user.display_name != new_display:
user.display_name = new_display
updated = True
if updated:
user.save()
logger.info(f"Updated LDAP user: {user_dn}")
return user
def delete_user(self, user_dn: str, *, soft: bool = True) -> None:
"""
Удаляет пользователя из LDAP.
Args:
user_dn: DN пользователя для удаления
soft: Если True, только деактивирует (userAccountControl=DISABLED)
Если False, полностью удаляет из LDAP
"""
try:
user = LdapUser.objects.get(dn=user_dn)
except LdapUser.DoesNotExist:
logger.warning(f"LDAP user not found for deletion: {user_dn}")
return
if soft:
# Soft delete - деактивация
user.user_account_control = UAC_DISABLED
user.save()
logger.info(f"Soft deleted (disabled) LDAP user: {user_dn}")
else:
# Hard delete - удаление из LDAP
user.delete()
logger.info(f"Deleted LDAP user: {user_dn}")
def get_user_by_dn(self, dn: str) -> Optional[LdapUser]:
"""Получает пользователя из LDAP по DN."""
try:
return LdapUser.objects.get(dn=dn)
except LdapUser.DoesNotExist:
return None
def get_user_by_employee_pk(self, employee_pk: int) -> Optional[LdapUser]:
"""Получает пользователя из LDAP по Employee.pk."""
try:
return LdapUser.objects.get(employee_number=str(employee_pk))
except LdapUser.DoesNotExist:
return None
class LdapOrmGroupService:
"""Сервис для управления группами в LDAP через ORM."""
def create_group(
self,
cn: str,
description: Optional[str] = None,
ou_dn: Optional[str] = None,
) -> LdapGroup:
"""
Создает группу в LDAP.
Args:
cn: Common Name группы
description: Описание группы
ou_dn: DN organizational unit для размещения
Returns:
Созданный LdapGroup объект
"""
if ou_dn is None:
ou_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
cn_safe = esc_rdn(cn)
dn = f"CN={cn_safe},{ou_dn}"
group = LdapGroup()
group.dn = dn
group.cn = cn
group.sam_account_name = cn
if description:
group.description = description
group.save()
logger.info(f"Created LDAP group: {dn}")
return group
def add_member(self, group_dn: str, member_dn: str) -> None:
"""Добавляет члена в группу."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Добавляем DN члена в список member
if member_dn not in (group.member or []):
members = list(group.member or [])
members.append(member_dn)
group.member = members
group.save()
logger.info(f"Added {member_dn} to group {group_dn}")
def remove_member(self, group_dn: str, member_dn: str) -> None:
"""Удаляет члена из группы."""
try:
group = LdapGroup.objects.get(dn=group_dn)
except LdapGroup.DoesNotExist:
raise ValueError(f"LDAP group not found: {group_dn}")
# Удаляем DN члена из списка member
if member_dn in (group.member or []):
members = list(group.member or [])
members.remove(member_dn)
group.member = members
group.save()
logger.info(f"Removed {member_dn} from group {group_dn}")
def delete_group(self, group_dn: str) -> None:
"""Удаляет группу из LDAP."""
try:
group = LdapGroup.objects.get(dn=group_dn)
group.delete()
logger.info(f"Deleted LDAP group: {group_dn}")
except LdapGroup.DoesNotExist:
logger.warning(f"LDAP group not found for deletion: {group_dn}")
class LdapOrmDepartmentService:
"""Сервис для управления Organizational Units (отделами) в LDAP через ORM."""
def create_ou(
self,
ou_name: str,
description: Optional[str] = None,
parent_dn: Optional[str] = None,
) -> LdapOrganizationalUnit:
"""
Создает Organizational Unit в LDAP.
Args:
ou_name: Название OU
description: Описание
parent_dn: DN родительского контейнера
Returns:
Созданный LdapOrganizationalUnit объект
"""
if parent_dn is None:
parent_dn = getattr(settings, 'LDAP_BASE_DN', 'DC=robotail,DC=local')
ou_safe = esc_rdn(ou_name)
dn = f"OU={ou_safe},{parent_dn}"
ou = LdapOrganizationalUnit()
ou.dn = dn
ou.ou = ou_name
if description:
ou.description = description
ou.save()
logger.info(f"Created LDAP OU: {dn}")
return ou
def delete_ou(self, ou_dn: str) -> None:
"""Удаляет OU из LDAP (должна быть пустой)."""
try:
ou = LdapOrganizationalUnit.objects.get(dn=ou_dn)
ou.delete()
logger.info(f"Deleted LDAP OU: {ou_dn}")
except LdapOrganizationalUnit.DoesNotExist:
logger.warning(f"LDAP OU not found for deletion: {ou_dn}")
__all__ = [
'LdapOrmUserService',
'LdapOrmGroupService',
'LdapOrmDepartmentService',
]

View File

@ -0,0 +1,65 @@
"""Database routers для направления запросов к нужным базам данных.
LdapRouter направляет все операции с LDAP моделями (из employees.ldap.orm_models)
в LDAP database, а все остальные модели - в default PostgreSQL database.
"""
class LdapRouter:
"""
Database router для направления LDAP моделей в LDAP подключение.
LDAP модели (LdapUser, LdapGroup, LdapOrganizationalUnit) используются
ТОЛЬКО для записи (POST/PUT/DELETE) в LDAP через ORM.
Чтение (GET) происходит из обычных Django моделей (Employee, Department и т.д.)
которые хранятся в PostgreSQL.
"""
ldap_models = {
'ldapuser',
'ldapgroup',
'ldaporganizationalunit',
}
def db_for_read(self, model, **hints):
"""
Направляет READ операции для LDAP моделей в 'ldap' database.
"""
if model._meta.model_name.lower() in self.ldap_models:
return 'ldap'
return None # Используем default database
def db_for_write(self, model, **hints):
"""
Направляет WRITE операции для LDAP моделей в 'ldap' database.
"""
if model._meta.model_name.lower() in self.ldap_models:
return 'ldap'
return None # Используем default database
def allow_relation(self, obj1, obj2, **hints):
"""
Разрешает отношения между моделями только если они в одной базе.
LDAP модели не могут иметь ForeignKey к Django моделям.
"""
db_obj1 = obj1._meta.model_name.lower() in self.ldap_models
db_obj2 = obj2._meta.model_name.lower() in self.ldap_models
# Оба LDAP или оба Django - разрешаем
if db_obj1 == db_obj2:
return True
# Один LDAP, другой Django - запрещаем
return False
def allow_migrate(self, db, app_label, model_name=None, **hints):
"""
Запрещает миграции для LDAP моделей (схема управляется AD/LDAP сервером).
"""
if model_name and model_name.lower() in self.ldap_models:
# LDAP модели не мигрируют никуда
return False
# Обычные Django модели мигрируют только в default
return db == 'default'

View File

@ -130,6 +130,19 @@ else:
"PORT": os.getenv("DB_PORT", "5432"),
}
# LDAP database для django-ldapdb (используется только для WRITE операций)
# Конфигурация берется из переменных окружения LDAP_URI, LDAP_BIND_DN и т.д.
# которые определены ниже в разделе LDAP
DATABASES["ldap"] = {
"ENGINE": "ldapdb.backends.ldap",
"NAME": os.getenv("LDAP_URI", "ldaps://dcii.robotail.local:636"),
"USER": os.getenv("LDAP_BIND_DN", ""),
"PASSWORD": os.getenv("LDAP_BIND_PASSWORD", ""),
}
# Database router для направления LDAP моделей в LDAP database
DATABASE_ROUTERS = ["eusrr_backend.db_routers.LdapRouter"]
# -----------------------------------------------------------------------------
# АВТОРИЗАЦИЯ / ПАРОЛИ
# -----------------------------------------------------------------------------

View File

@ -38,6 +38,7 @@ django-bootstrap5==25.1
django-cors-headers==4.7.0
django-extensions==4.1
django-filter==25.1
django-ldapdb==1.5.1
django-phonenumber-field==8.1.0
django-registration==5.2.1
django-scheduler==0.12.0