fix(ldap): nest department role groups into department group

This commit is contained in:
lizerk 2026-04-14 01:07:13 +03:00
parent 274e5767c9
commit 2d00699831
11 changed files with 535 additions and 39 deletions

View File

@ -381,37 +381,50 @@ class DepartmentViewSet(viewsets.ModelViewSet):
via_assignment = False
if link:
# Сотрудник — член отдела: обновляем роль в линке
link.role = role
link.save(update_fields=["role"])
active_assignments = list(
RoleAssignment.objects.filter(
employee_id=emp_id,
role__department=dept,
is_active=True,
).select_related("role")
)
def deactivate_assignment(assignment: RoleAssignment) -> None:
assignment.is_active = False
assignment.save(update_fields=["is_active"])
if link:
# Сотрудник — член отдела: сперва приводим RoleAssignment
# к единственному актуальному значению, затем обновляем link.role.
for assignment in active_assignments:
if role is None or assignment.role_id != role.id:
deactivate_assignment(assignment)
# Также создаём/обновляем RoleAssignment для консистентности
if role:
RoleAssignment.objects.update_or_create(
employee_id=emp_id,
role=role,
defaults={"is_active": True, "assigned_by": request.user},
)
else:
RoleAssignment.objects.filter(
employee_id=emp_id, role__department=dept, is_active=True
).update(is_active=False)
link.role = role
link.save(update_fields=["role"])
else:
# Сотрудник НЕ член отдела: используем только RoleAssignment
via_assignment = True
if role:
for assignment in active_assignments:
if assignment.role_id != role.id:
deactivate_assignment(assignment)
RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={"is_active": True, "assigned_by": request.user},
)
else:
active_assignments = RoleAssignment.objects.filter(
employee_id=emp_id, role__department=dept, is_active=True
)
active_assignments.update(is_active=False)
for assignment in active_assignments:
deactivate_assignment(assignment)
return Response(
{

View File

@ -75,7 +75,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
return qs
def create(self, request, *args, **kwargs):
"""Создание роли. Синхронизация LDAP требует реализации сигналов."""
"""Создание роли."""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
@ -104,7 +104,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
)
def update(self, request, *args, **kwargs):
"""Обновление роли. Синхронизация LDAP требует реализации сигналов."""
"""Обновление роли."""
partial = kwargs.pop("partial", False)
instance = self.get_object()
ser = self.get_serializer(instance, data=request.data, partial=partial)
@ -118,7 +118,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
return self.update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Удаление роли. Синхронизация LDAP требует реализации сигналов."""
"""Удаление роли."""
instance = self.get_object()
instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -204,10 +204,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def assign(self, request, pk=None):
"""Назначает роль сотруднику.
Синхронизация LDAP требует реализации сигналов.
"""
"""Назначает роль сотруднику."""
role = self.get_object()
employee_id = request.data.get("employee_id")
@ -244,10 +241,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def revoke(self, request, pk=None):
"""Отзывает роль у сотрудника.
Синхронизация LDAP требует реализации сигналов.
"""
"""Отзывает роль у сотрудника."""
role = self.get_object()
employee_id = request.data.get("employee_id")
@ -259,7 +253,11 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден."}, status=404)
RoleAssignment.objects.filter(employee=employee, role=role).update(
is_active=False
)
for assignment in RoleAssignment.objects.filter(
employee=employee,
role=role,
is_active=True,
):
assignment.is_active = False
assignment.save(update_fields=["is_active"])
return Response(status=204)

View File

@ -890,6 +890,46 @@ class DepartmentService(BaseService):
except Exception as e:
raise DirectoryDbError(str(e)) from e
def sync_role_state(self, role: DepartmentRole) -> None:
"""Приводит LDAP-состояние роли к текущему состоянию БД."""
self._ensure_runtime_services()
self._ensure_role_group(role)
try:
dept_dn = self._get_department_dn(role.department)
except DirectoryServiceError:
dept_dn = None
self._reconcile_department_group(role.department, dept_dn)
def sync_role_delete(
self, department: Department, role_group_dn: str | None = None
) -> None:
"""Удаляет LDAP-группу роли и пересобирает группу отдела."""
self._ensure_runtime_services()
if role_group_dn:
try:
self._load_ou_group(role_group_dn).delete()
except Exception:
pass
try:
dept_dn = self._get_department_dn(department)
except DirectoryServiceError:
dept_dn = None
self._reconcile_department_group(department, dept_dn)
def sync_role_assignment_state(
self,
employee: Employee,
role: DepartmentRole,
*,
is_active: bool,
) -> None:
"""Синхронизирует LDAP-членство сотрудника в группе роли."""
self._ensure_runtime_services()
self._sync_role_membership(employee, role, add=is_active)
def set_member_role(
self, dept: Department, employee: Employee, role
) -> None:
@ -1520,20 +1560,33 @@ class DepartmentService(BaseService):
department_id=dept.id, is_active=True
).values_list("employee_id", flat=True)
)
if not active_emp_ids:
role_group_dns = [
item
for item in DepartmentRole.objects.filter(
department_id=dept.id,
)
.exclude(ldap_group_dn="")
.values_list("ldap_group_dn", flat=True)
if item
]
if not active_emp_ids and not role_group_dns:
group.member = []
group.save()
return group_dn
dn_map = dict(
LdapSyncState.objects.filter(
model="employee",
object_pk__in=[str(i) for i in active_emp_ids],
).values_list("object_pk", "ldap_dn")
)
member_dns = [dn for dn in dn_map.values() if dn]
member_dns: List[str] = []
if active_emp_ids:
dn_map = dict(
LdapSyncState.objects.filter(
model="employee",
object_pk__in=[str(i) for i in active_emp_ids],
).values_list("object_pk", "ldap_dn")
)
member_dns.extend(dn for dn in dn_map.values() if dn)
member_dns.extend(role_group_dns)
group.member = member_dns
deduped_member_dns = list(dict.fromkeys(member_dns))
group.member = deduped_member_dns
group.save()
return group_dn

View File

@ -20,6 +20,7 @@ if getattr(settings, 'LDAP_ENABLED', False):
import_module('employees.signals.ldap.department')
import_module('employees.signals.ldap.group')
import_module('employees.signals.ldap.position')
import_module('employees.signals.ldap.role')
__all__ = ['common', 'birthday']
else:
__all__ = ['common', 'birthday']

View File

@ -1,5 +1,5 @@
"""LDAP signals для автоматической синхронизации моделей с Active Directory."""
from . import employee, department, group, position
from . import employee, department, group, position, role
__all__ = ['employee', 'department', 'group', 'position']
__all__ = ['employee', 'department', 'group', 'position', 'role']

View File

@ -0,0 +1,179 @@
"""LDAP signals для DepartmentRole и RoleAssignment."""
import logging
from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from employees.ldap import DepartmentService
from employees.ldap.errors import (
DirectoryDbError,
DirectoryLdapError,
DirectoryServiceError,
)
from employees.signals.ldap._queue import _enqueue
logger = logging.getLogger(__name__)
def _is_ldap_enabled():
return getattr(settings, "LDAP_ENABLED", False)
@receiver(post_save, sender="employees.DepartmentRole")
def sync_department_role_to_ldap_on_save(sender, instance, created, **kwargs):
del sender, created, kwargs
if not _is_ldap_enabled():
return
if getattr(instance, "_skip_ldap_sync", False):
return
try:
DepartmentService().sync_role_state(instance)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
"LDAP sync failed for DepartmentRole %s: %s",
instance.pk,
e,
exc_info=True,
)
_enqueue(
"department_role_save",
"department_role",
instance.pk,
{"role_pk": str(instance.pk)},
)
except Exception as e:
logger.error(
"Unexpected LDAP sync error for DepartmentRole %s: %s",
instance.pk,
e,
exc_info=True,
)
finally:
if hasattr(instance, "_skip_ldap_sync"):
delattr(instance, "_skip_ldap_sync")
@receiver(post_delete, sender="employees.DepartmentRole")
def sync_department_role_to_ldap_on_delete(sender, instance, **kwargs):
del sender, kwargs
if not _is_ldap_enabled():
return
try:
DepartmentService().sync_role_delete(
instance.department,
role_group_dn=(instance.ldap_group_dn or "").strip() or None,
)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
"LDAP delete failed for DepartmentRole %s: %s",
instance.pk,
e,
exc_info=True,
)
_enqueue(
"department_role_delete",
"department_role",
instance.pk,
{
"department_pk": str(instance.department_id),
"role_group_dn": (instance.ldap_group_dn or "").strip(),
},
)
except Exception as e:
logger.error(
"Unexpected LDAP delete error for DepartmentRole %s: %s",
instance.pk,
e,
exc_info=True,
)
@receiver(post_save, sender="employees.RoleAssignment")
def sync_role_assignment_to_ldap_on_save(sender, instance, created, **kwargs):
del sender, created, kwargs
if not _is_ldap_enabled():
return
if getattr(instance, "_skip_ldap_sync", False):
return
try:
DepartmentService().sync_role_assignment_state(
instance.employee,
instance.role,
is_active=instance.is_active,
)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
"LDAP sync failed for RoleAssignment %s: %s",
instance.pk,
e,
exc_info=True,
)
_enqueue(
"role_assignment",
"role_assignment",
instance.pk,
{
"employee_pk": str(instance.employee_id),
"role_pk": str(instance.role_id),
"is_active": bool(instance.is_active),
},
)
except Exception as e:
logger.error(
"Unexpected LDAP sync error for RoleAssignment %s: %s",
instance.pk,
e,
exc_info=True,
)
finally:
if hasattr(instance, "_skip_ldap_sync"):
delattr(instance, "_skip_ldap_sync")
@receiver(post_delete, sender="employees.RoleAssignment")
def sync_role_assignment_to_ldap_on_delete(sender, instance, **kwargs):
del sender, kwargs
if not _is_ldap_enabled():
return
try:
DepartmentService().sync_role_assignment_state(
instance.employee,
instance.role,
is_active=False,
)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
logger.error(
"LDAP delete sync failed for RoleAssignment %s: %s",
instance.pk,
e,
exc_info=True,
)
_enqueue(
"role_assignment",
"role_assignment",
instance.pk,
{
"employee_pk": str(instance.employee_id),
"role_pk": str(instance.role_id),
"is_active": False,
},
)
except Exception as e:
logger.error(
"Unexpected LDAP delete sync error for RoleAssignment %s: %s",
instance.pk,
e,
exc_info=True,
)

View File

@ -255,6 +255,76 @@ def _execute_position_delete(payload: dict) -> None:
svc.delete_position_group(pos_stub)
def _execute_department_role_save(payload: dict) -> None:
"""Повторяет синхронизацию роли отдела → LDAP."""
from employees.ldap import DepartmentService
from employees.models import DepartmentRole
role_pk = _require_payload_value(payload, "role_pk")
role = DepartmentRole.objects.filter(pk=role_pk).select_related(
"department"
).first()
if role is None:
logger.info(
"DepartmentRole %s no longer exists, skipping queued role sync",
role_pk,
)
return
DepartmentService().sync_role_state(role)
def _execute_department_role_delete(payload: dict) -> None:
"""Повторяет удаление LDAP-следов роли отдела."""
from employees.ldap import DepartmentService
from employees.models import Department
department_pk = _require_payload_value(payload, "department_pk")
dept = Department.objects.filter(pk=department_pk).first()
if dept is None:
logger.info(
"Department %s no longer exists, skipping queued role delete sync",
department_pk,
)
return
DepartmentService().sync_role_delete(
dept,
role_group_dn=payload.get("role_group_dn"),
)
def _execute_role_assignment(payload: dict) -> None:
"""Повторяет синхронизацию назначения роли → LDAP."""
from employees.ldap import DepartmentService
from employees.models import DepartmentRole, Employee
employee_pk = _require_payload_value(payload, "employee_pk")
role_pk = _require_payload_value(payload, "role_pk")
employee = Employee.objects.filter(pk=employee_pk).first()
if employee is None:
logger.info(
"Employee %s no longer exists, skipping queued role assignment sync",
employee_pk,
)
return
role = DepartmentRole.objects.filter(pk=role_pk).first()
if role is None:
logger.info(
"DepartmentRole %s no longer exists, skipping queued role assignment sync",
role_pk,
)
return
DepartmentService().sync_role_assignment_state(
employee,
role,
is_active=bool(payload.get("is_active")),
)
# Реестр: operation → callable
_EXECUTORS = {
"employee_save": _execute_employee_save,
@ -267,6 +337,9 @@ _EXECUTORS = {
"group_members": _execute_group_members,
"position_save": _execute_position_save,
"position_delete": _execute_position_delete,
"department_role_save": _execute_department_role_save,
"department_role_delete": _execute_department_role_delete,
"role_assignment": _execute_role_assignment,
}

View File

@ -2,8 +2,10 @@
import pytest
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.test import override_settings
from rest_framework.test import APIClient
from rest_framework import status
from unittest.mock import patch
from employees.models import (
Department,
@ -258,6 +260,29 @@ def test_create_with_scoped_permission_codes_and_update_name(api_client: APIClie
format="json",
)
assert resp.status_code == 201
@pytest.mark.django_db
@override_settings(LDAP_ENABLED=True)
def test_create_role_triggers_ldap_sync(api_client: APIClient):
with override_settings(LDAP_ENABLED=False):
d = Department.objects.create(name="Dept LDAP")
staff = make_user("staff-ldap@example.com", staff=True)
api_client.force_authenticate(user=staff)
url = reverse("api:v1:department-roles-list")
with patch(
"employees.signals.ldap.role.DepartmentService.sync_role_state"
) as mock_sync:
resp = api_client.post(
url,
{"department": d.id, "name": "Worker LDAP"},
format="json",
)
assert resp.status_code == status.HTTP_201_CREATED
mock_sync.assert_called_once()
rid = resp.json()["id"]
# update имени (PATCH)

View File

@ -2,8 +2,10 @@
Тесты для новых endpoints назначения ролей: assign, revoke, assignments.
"""
import pytest
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APIClient
from unittest.mock import patch
from django.contrib.auth import get_user_model
from employees.models import (
@ -165,9 +167,35 @@ class TestRoleAssignmentEndpoints:
api_client.force_authenticate(user)
url = f"/api/v1/department-roles/{role.id}/assign/"
resp = api_client.post(url, {"employee_id": other_user.id})
assert resp.status_code == status.HTTP_201_CREATED
@override_settings(LDAP_ENABLED=True)
def test_assign_and_revoke_trigger_ldap_sync(self, api_client: APIClient):
with override_settings(LDAP_ENABLED=False):
admin = make_user("admin-ldap@test.com", is_staff=True)
dept = make_dept("IT LDAP")
role = make_role(dept, "Developer LDAP", [])
user = make_user("worker-ldap@test.com")
api_client.force_authenticate(admin)
url_assign = f"/api/v1/department-roles/{role.id}/assign/"
url_revoke = f"/api/v1/department-roles/{role.id}/revoke/"
with patch(
"employees.signals.ldap.role.DepartmentService.sync_role_assignment_state"
) as mock_sync:
assign_resp = api_client.post(url_assign, {"employee_id": user.id})
revoke_resp = api_client.post(url_revoke, {"employee_id": user.id})
assert assign_resp.status_code == status.HTTP_201_CREATED
assert revoke_resp.status_code == status.HTTP_204_NO_CONTENT
assert mock_sync.call_count >= 2
first_call = mock_sync.call_args_list[0]
second_call = mock_sync.call_args_list[1]
assert first_call.kwargs["is_active"] is True
assert second_call.kwargs["is_active"] is False
def test_department_members_include_role_only_employees(self, api_client: APIClient):
"""members endpoint показывает сотрудников с ролью в отделе даже без membership."""
admin = make_user("admin-members@test.com", is_staff=True)

View File

@ -0,0 +1,44 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from employees.ldap.services.department_service import DepartmentService
from employees.models import Department, DepartmentRole
@pytest.mark.django_db
def test_sync_role_state_nests_role_group_into_department_group():
dept = Department.objects.create(name="LDAP Dept")
role = DepartmentRole.objects.create(department=dept, name="Reviewer")
dept_dn = "OU=LDAP Dept,OU=Departments,DC=example,DC=local"
role_group_dn = "CN=ROLE_Reviewer,OU=LDAP Dept,OU=Departments,DC=example,DC=local"
fake_group = SimpleNamespace(member=[], save=MagicMock())
def ensure_role_group_side_effect(role_obj):
DepartmentRole.objects.filter(pk=role_obj.pk).update(
ldap_group_dn=role_group_dn
)
role_obj.ldap_group_dn = role_group_dn
return role_group_dn
with patch.object(
DepartmentService,
"_get_department_dn",
return_value=dept_dn,
), patch.object(
DepartmentService,
"_ensure_role_group",
side_effect=ensure_role_group_side_effect,
), patch.object(
DepartmentService,
"_ensure_department_group",
return_value=f"CN=DEP_{dept.name},{dept_dn}",
), patch(
"employees.ldap.services.department_service.LdapOrganizationalUnitGroup.objects.get",
return_value=fake_group,
):
DepartmentService().sync_role_state(role)
assert fake_group.member == [role_group_dn]
fake_group.save.assert_called_once()

View File

@ -377,6 +377,88 @@ class TestDepartmentQueueExecutors:
)
class TestDepartmentRoleQueueExecutors:
@override_settings(LDAP_ENABLED=True)
def test_department_role_retry_reconciles_role_group(
self, db
):
from employees.models import Department, DepartmentRole, LdapSyncQueue
from employees.tasks import process_ldap_queue_item
with override_settings(LDAP_ENABLED=False):
department = Department.objects.create(name="Dept Role Queue")
role = DepartmentRole.objects.create(
department=department,
name="Reviewer",
)
item = LdapSyncQueue.objects.create(
operation="department_role_save",
model_name="department_role",
object_pk=str(role.pk),
payload={"role_pk": str(role.pk)},
)
with patch(
"employees.ldap.services.department_service.DepartmentService.sync_role_state"
) as mock_sync:
process_ldap_queue_item(item.pk)
item.refresh_from_db()
assert item.status == LdapSyncQueue.Status.COMPLETED
mock_sync.assert_called_once_with(role)
@override_settings(LDAP_ENABLED=True)
def test_role_assignment_retry_reconciles_ldap_membership(
self, db, user_factory
):
from employees.models import (
Department,
DepartmentRole,
LdapSyncQueue,
RoleAssignment,
)
from employees.tasks import process_ldap_queue_item
with override_settings(LDAP_ENABLED=False):
employee = user_factory(email="role-sync@example.com")
department = Department.objects.create(name="Dept Role Assignment")
role = DepartmentRole.objects.create(
department=department,
name="Operator",
)
assignment = RoleAssignment.objects.create(
employee=employee,
role=role,
is_active=True,
)
item = LdapSyncQueue.objects.create(
operation="role_assignment",
model_name="role_assignment",
object_pk=str(assignment.pk),
payload={
"employee_pk": str(employee.pk),
"role_pk": str(role.pk),
"is_active": True,
},
)
with patch(
"employees.ldap.services.department_service.DepartmentService.sync_role_assignment_state"
) as mock_sync:
process_ldap_queue_item(item.pk)
item.refresh_from_db()
assert item.status == LdapSyncQueue.Status.COMPLETED
mock_sync.assert_called_once_with(
employee,
role,
is_active=True,
)
# ---------------------------------------------------------------------------
# Сигналы: проверка enqueue при ошибке LDAP
# ---------------------------------------------------------------------------