fix(roles): enforce LDAP-first pattern for role operations

When LDAP is enabled:
- create_role: creates LDAP group before DB record
- update_role: renames LDAP group before DB update
- delete_role: deletes LDAP group before DB deletion
- assign_role: adds to LDAP group before creating RoleAssignment
- revoke_role: removes from LDAP group before deactivating assignment

On LDAP error: operation fails, no DB changes
On DB error after LDAP success: rollback LDAP changes

Fixes: roles could bypass LDAP when it was enabled
This commit is contained in:
Игорь Надеин 2025-12-30 11:04:39 +03:00
parent 689b291ca5
commit 2811ac6e21
2 changed files with 404 additions and 47 deletions

View File

@ -2701,6 +2701,156 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
qs = qs.order_by(*self.ordering)
return qs
def create(self, request, *args, **kwargs):
"""Создание роли: сначала группа в LDAP → затем запись в БД (если LDAP включен)."""
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
dept_id = ser.validated_data.get("department")
if isinstance(dept_id, Department):
dept = dept_id
else:
dept = get_object_or_404(Department, id=dept_id)
name = ser.validated_data["name"]
codes = ser.validated_data.pop("scoped_permission_codes", None)
perms = ser.validated_data.pop("scoped_permissions", None)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: создаём через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
# Подготавливаем scoped_permissions (codes имеют приоритет над ids)
scoped_permissions = None
if codes is not None:
scoped_permissions = list(
DepartmentPermission.objects.filter(code__in=codes)
)
elif perms is not None:
scoped_permissions = list(perms)
try:
role = dept_service.create_role(
department=dept,
name=name,
scoped_permissions=scoped_permissions,
)
return Response(
self.get_serializer(role).data, status=status.HTTP_201_CREATED
)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: создаём напрямую через сериализатор
role = DepartmentRole.objects.create(
department=dept,
name=name,
)
# codes имеют приоритет над ids (как в сериализаторе)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
role.scoped_permissions.set(list(qs))
elif perms is not None:
role.scoped_permissions.set(perms)
return Response(
self.get_serializer(role).data, status=status.HTTP_201_CREATED
)
def update(self, request, *args, **kwargs):
"""Обновление роли: сначала LDAP → затем БД (если LDAP включен)."""
partial = kwargs.pop("partial", False)
instance = self.get_object()
ser = self.get_serializer(instance, data=request.data, partial=partial)
ser.is_valid(raise_exception=True)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: обновляем через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
changes = {}
if "name" in ser.validated_data:
changes["name"] = ser.validated_data["name"]
if "scoped_permissions" in ser.validated_data:
changes["scoped_permissions"] = ser.validated_data["scoped_permissions"]
if "scoped_permission_codes" in ser.validated_data:
changes["scoped_permission_codes"] = ser.validated_data["scoped_permission_codes"]
try:
role = dept_service.update_role(instance, changes)
return Response(self.get_serializer(role).data)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: обновляем через сериализатор
self.perform_update(ser)
return Response(ser.data)
def partial_update(self, request, *args, **kwargs):
"""Частичное обновление роли."""
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Удаление роли: сначала группа из LDAP → затем запись из БД (если LDAP включен)."""
instance = self.get_object()
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: удаляем через DepartmentService
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
try:
dept_service.delete_role(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: удаляем напрямую
instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"])
def perm_choices(self, request):
"""
@ -2800,6 +2950,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
При включенном LDAP: сначала добавляет в группу LDAP, потом в БД.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
@ -2816,22 +2967,48 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
{"detail": "Сотрудник не найден."}, status=404
)
# Получаем assigned_by из текущего пользователя
# В этом проекте User == Employee (кастомная модель)
assigned_by = request.user if request.user.is_authenticated else None
# Используем DepartmentService для назначения с LDAP-синхронизацией
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
assignment = dept_service.assign_role(employee, role, assigned_by)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: через DepartmentService (сначала LDAP, потом БД)
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
assignment = dept_service.assign_role(employee, role, assigned_by)
return Response({
"id": assignment.id,
"employee_id": assignment.employee_id,
"role_id": assignment.role_id,
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"is_active": assignment.is_active,
}, status=201)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
except DirectoryDbError as e:
return Response(
{"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
else:
# Режим без LDAP: создаём RoleAssignment напрямую
assignment, created = RoleAssignment.objects.update_or_create(
employee=employee,
role=role,
defaults={
"is_active": True,
"assigned_by": assigned_by,
}
)
return Response({
"id": assignment.id,
"employee_id": assignment.employee_id,
@ -2839,8 +3016,6 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"is_active": assignment.is_active,
}, status=201)
except Exception as e:
return Response({"detail": str(e)}, status=400)
@action(detail=True, methods=["post"])
def revoke(self, request, pk=None):
@ -2851,6 +3026,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
- employee_id: int — ID сотрудника
Требуется право DeptPerm.ASSIGN_ROLE в отделе роли.
При включенном LDAP: сначала удаляет из группы LDAP, потом из БД.
"""
role = self.get_object()
employee_id = request.data.get("employee_id")
@ -2867,21 +3043,33 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
{"detail": "Сотрудник не найден."}, status=404
)
# Используем DepartmentService для отзыва с LDAP-синхронизацией
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.revoke_role(employee, role)
ldap_enabled = _is_ldap_enabled()
if ldap_enabled:
# Режим с LDAP: через DepartmentService (сначала LDAP, потом БД)
try:
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
dept_service.revoke_role(employee, role)
return Response(status=204)
except DirectoryLdapError as e:
return Response(
{"detail": f"LDAP error: {e}"}, status=status.HTTP_502_BAD_GATEWAY
)
else:
# Режим без LDAP: деактивируем RoleAssignment напрямую
RoleAssignment.objects.filter(
employee=employee,
role=role
).update(is_active=False)
return Response(status=204)
except Exception as e:
return Response({"detail": str(e)}, status=400)
class SkillViewSet(viewsets.ModelViewSet):

View File

@ -528,6 +528,165 @@ class DepartmentService:
return dept
# ==================== Role CRUD (LDAP → DB) ==================== #
def create_role(
self,
department: Department,
name: str,
description: str = "",
scoped_permissions: Optional[list] = None,
) -> DepartmentRole:
"""Создаёт роль: сначала группу в LDAP, затем запись в БД.
Аналогично create_department — сначала LDAP, потом БД.
При ошибке БД откатываем созданную группу в LDAP.
Args:
department: Отдел для роли.
name: Название роли.
description: Описание роли.
scoped_permissions: Список DepartmentPermission для назначения.
Returns:
DepartmentRole: Созданная роль.
Raises:
DirectoryLdapError: Ошибка создания группы в LDAP.
DirectoryDbError: Ошибка создания записи в БД.
"""
# 1. Создаём группу в LDAP
dept_dn = self._get_department_dn(department)
role_name = self._sanitize_name(name)
cn = f"ROLE_{role_name}"
group_dn = f"CN={esc_rdn(cn)},{dept_dn}"
with _ldap() as conn:
try:
attrs = {
"sAMAccountName": cn[:20],
"description": description or f"Role: {name} in {department.name}",
"groupType": group_type("global", security_enabled=True),
}
ok = conn.add(group_dn, ["top", "group"], attrs)
if not ok:
if "entryAlreadyExists" not in str(conn.result):
raise DirectoryLdapError(
f"LDAP create role group failed: {conn.result}"
)
except DirectoryLdapError:
raise
except Exception as e:
raise DirectoryLdapError(f"LDAP create role group failed: {e}") from e
# 2. Создаём запись в БД
try:
with transaction.atomic():
role = DepartmentRole.objects.create(
department=department,
name=name,
ldap_group_dn=group_dn,
)
if scoped_permissions:
role.scoped_permissions.set(scoped_permissions)
except Exception as e:
# Откатываем группу в LDAP
try:
self._group_service.delete(conn, group_dn)
except Exception:
pass
raise DirectoryDbError(str(e)) from e
return role
def update_role(
self,
role: DepartmentRole,
changes: Dict[str, Any],
) -> DepartmentRole:
"""Обновляет роль: сначала в LDAP, затем в БД.
Args:
role: Роль для обновления.
changes: Словарь изменений (name, scoped_permissions, scoped_permission_codes).
Returns:
DepartmentRole: Обновлённая роль.
Raises:
DirectoryLdapError: Ошибка обновления в LDAP.
DirectoryDbError: Ошибка сохранения в БД.
"""
new_name = changes.get("name")
with _ldap() as conn:
# Если меняется имя — переименовываем группу в LDAP
if new_name and new_name != role.name:
if role.ldap_group_dn:
try:
new_role_name = self._sanitize_name(new_name)
new_cn = f"ROLE_{new_role_name}"
new_dn = self._group_service.rename(
conn, role.ldap_group_dn, new_cn=new_cn
)
role.ldap_group_dn = new_dn
except Exception as e:
raise DirectoryLdapError(
f"LDAP rename role group failed: {e}"
) from e
else:
# Группы нет — создаём
role.name = new_name
self._ensure_role_group(role)
# Обновляем БД
try:
with transaction.atomic():
if new_name:
role.name = new_name
role.save()
# Обновляем права
perms = changes.get("scoped_permissions")
codes = changes.get("scoped_permission_codes")
if perms is not None:
role.scoped_permissions.set(perms)
elif codes is not None:
from employees.models import DepartmentPermission
qs = DepartmentPermission.objects.filter(code__in=codes)
role.scoped_permissions.set(list(qs))
except Exception as e:
raise DirectoryDbError(str(e)) from e
return role
def delete_role(self, role: DepartmentRole) -> None:
"""Удаляет роль: сначала группу из LDAP, затем запись из БД.
Args:
role: Роль для удаления.
Raises:
DirectoryLdapError: Ошибка удаления группы из LDAP.
DirectoryDbError: Ошибка удаления записи из БД.
"""
# 1. Удаляем группу из LDAP
if role.ldap_group_dn:
with _ldap() as conn:
try:
self._group_service.delete(conn, role.ldap_group_dn)
except Exception as e:
raise DirectoryLdapError(
f"LDAP delete role group failed: {e}"
) from e
# 2. Удаляем запись из БД
try:
role.delete()
except Exception as e:
raise DirectoryDbError(str(e)) from e
def set_member_role(
self, dept: Department, employee: Employee, role
) -> None:
@ -575,6 +734,8 @@ class DepartmentService:
) -> RoleAssignment:
"""Назначает роль сотруднику (не требует членства в отделе).
Логика: сначала LDAP, потом БД. При ошибке LDAP — операция отменяется.
Args:
employee: Сотрудник.
role: Роль для назначения.
@ -584,10 +745,16 @@ class DepartmentService:
RoleAssignment: Созданное/обновлённое назначение.
Raises:
DirectoryDbError: Ошибка БД.
DirectoryLdapError: Ошибка LDAP.
DirectoryDbError: Ошибка БД.
"""
# 1. Создать/обновить назначение в БД
# 1. Сначала синхронизируем LDAP-группу роли
try:
self._sync_role_membership(employee, role, add=True)
except Exception as e:
raise DirectoryLdapError(f"LDAP role sync failed: {e}") from e
# 2. Только при успехе LDAP — создаём/обновляем назначение в БД
try:
with transaction.atomic():
assignment, created = RoleAssignment.objects.update_or_create(
@ -599,16 +766,13 @@ class DepartmentService:
}
)
except Exception as e:
# Откатываем LDAP — удаляем из группы
try:
self._sync_role_membership(employee, role, add=False)
except Exception:
pass # Best effort rollback
raise DirectoryDbError(str(e)) from e
# 2. Синхронизировать LDAP-группу роли
try:
self._sync_role_membership(employee, role, add=True)
except DirectoryServiceError:
pass # Best effort — LDAP-ошибки не прерывают операцию
except Exception as e:
raise DirectoryLdapError(f"LDAP role sync failed: {e}") from e
return assignment
def revoke_role(
@ -618,21 +782,26 @@ class DepartmentService:
) -> None:
"""Отзывает роль у сотрудника.
Логика: сначала LDAP, потом БД. При ошибке LDAP — операция отменяется.
Args:
employee: Сотрудник.
role: Роль для отзыва.
Raises:
DirectoryLdapError: Ошибка LDAP.
"""
# 1. Деактивировать назначение
# 1. Сначала удаляем из LDAP-группы
try:
self._sync_role_membership(employee, role, add=False)
except Exception as e:
raise DirectoryLdapError(f"LDAP role revoke failed: {e}") from e
# 2. Только при успехе LDAP — деактивируем назначение
RoleAssignment.objects.filter(
employee=employee,
role=role
).update(is_active=False)
# 2. Удалить из LDAP-группы
try:
self._sync_role_membership(employee, role, add=False)
except Exception:
pass # Best effort
def _sync_role_membership(
self,