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:
parent
689b291ca5
commit
2811ac6e21
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user