diff --git a/backend/employees/ldap/ORM_INTEGRATION.md b/backend/employees/ldap/ORM_INTEGRATION.md new file mode 100644 index 00000000..95a58954 --- /dev/null +++ b/backend/employees/ldap/ORM_INTEGRATION.md @@ -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 по умолчанию (деактивация вместо удаления) diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py new file mode 100644 index 00000000..fb83e0b3 --- /dev/null +++ b/backend/employees/ldap/orm_models.py @@ -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"" + + +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"" + + +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"" + + +__all__ = [ + 'LdapUser', + 'LdapGroup', + 'LdapOrganizationalUnit', +] diff --git a/backend/employees/ldap/orm_services.py b/backend/employees/ldap/orm_services.py new file mode 100644 index 00000000..7239a5f5 --- /dev/null +++ b/backend/employees/ldap/orm_services.py @@ -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', +] diff --git a/backend/eusrr_backend/db_routers.py b/backend/eusrr_backend/db_routers.py new file mode 100644 index 00000000..fe4006ff --- /dev/null +++ b/backend/eusrr_backend/db_routers.py @@ -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' diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 63908ac2..21fda67f 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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"] + # ----------------------------------------------------------------------------- # АВТОРИЗАЦИЯ / ПАРОЛИ # ----------------------------------------------------------------------------- diff --git a/backend/requirements.txt b/backend/requirements.txt index e2e28e86..81080332 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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