From fff16130f36bad67acdd9e875e2968d296e62e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 30 Dec 2025 10:35:34 +0300 Subject: [PATCH] feat(department-roles): complete LDAP architecture redesign with RoleAssignment model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES: - Removed OU=Roles from LDAP structure, role groups now created directly in department OU - Role groups use ROLE_ naming convention (similar to DEP_, POS_) New features: - RoleAssignment model: allows assigning roles to ANY employee, not just department members - New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet - Permission system updated: has_dept_perm() checks RoleAssignment first - Employee profile shows external roles with 'внешняя роль' badge - Employee list filter includes role-only employees with department_relation field - Role assignment modal now searches all employees via AJAX LDAP changes: - _ensure_role_group(): creates ROLE_ group in department OU - assign_role(), revoke_role(): manage RoleAssignment + LDAP sync - rename_role_group(), delete_role_group(): full lifecycle support - Removed all references to OU=Roles Migrations: - 0035: Create RoleAssignment model - 0036: Data migration from EmployeeDepartment.role Tests: - 7 new tests for role assignment (all passing) Docs: - 9 detailed reports on architecture, models, API, LDAP, tests --- backend/api/v1/employees/serializers.py | 89 ++ backend/api/v1/employees/views.py | 283 +++++- backend/api/v1/permissions.py | 36 +- .../reports/01_DEPARTMENT_ROLES_MODELS.md | 429 +++++++++ .../docs/reports/02_DEPARTMENT_ROLES_API.md | 572 ++++++++++++ .../03_DEPARTMENT_ROLES_SERIALIZERS.md | 710 +++++++++++++++ .../04_DEPARTMENT_ROLES_PERMISSIONS.md | 763 ++++++++++++++++ .../05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md | 647 ++++++++++++++ .../docs/reports/06_DEPARTMENT_ROLES_LDAP.md | 625 +++++++++++++ .../docs/reports/07_DEPARTMENT_ROLES_TESTS.md | 659 ++++++++++++++ .../08_DEPARTMENT_ROLES_ARCHITECTURE.md | 824 ++++++++++++++++++ .../docs/reports/09_ROLES_LDAP_REDESIGN.md | 817 +++++++++++++++++ .../ldap/services/department_service.py | 256 +++++- backend/employees/ldap/sync_service.py | 3 +- backend/employees/ldap/utils/group_utils.py | 6 +- .../migrations/0035_create_role_assignment.py | 33 + .../0036_migrate_role_assignments.py | 49 ++ backend/employees/models.py | 64 ++ backend/static/js/components/employeesList.js | 14 +- .../components/department_modals.html | 43 +- .../components/department_scripts.html | 113 ++- .../templates/employees/employee_detail.html | 10 +- .../api/v1/employees/test_role_assignment.py | 222 +++++ 23 files changed, 7136 insertions(+), 131 deletions(-) create mode 100644 backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md create mode 100644 backend/docs/reports/02_DEPARTMENT_ROLES_API.md create mode 100644 backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md create mode 100644 backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md create mode 100644 backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md create mode 100644 backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md create mode 100644 backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md create mode 100644 backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md create mode 100644 backend/docs/reports/09_ROLES_LDAP_REDESIGN.md create mode 100644 backend/employees/migrations/0035_create_role_assignment.py create mode 100644 backend/employees/migrations/0036_migrate_role_assignments.py create mode 100644 backend/tests/api/v1/employees/test_role_assignment.py diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 55b80828..542ff6f8 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -360,14 +360,28 @@ class EmployeeSerializer(serializers.ModelSerializer): return instance def get_departments(self, obj): + """ + Возвращает список отделов сотрудника. + + Включает: + 1. Отделы через EmployeeDepartment (членство) + 2. Отделы через RoleAssignment (роли без членства) — помечены via_assignment=True + """ + from employees.models import RoleAssignment + + # 1. Отделы через членство (EmployeeDepartment) links = getattr(obj, "dept_links", None) if links is None: links = EmployeeDepartment.objects.select_related( "department", "role" ).filter(employee=obj, is_active=True) + out = [] + member_dept_ids = set() + for link in links: dept = link.department + member_dept_ids.add(link.department_id) out.append( { "id": link.department_id, @@ -375,8 +389,38 @@ class EmployeeSerializer(serializers.ModelSerializer): "role_id": link.role_id, "role_name": link.role.name if link.role_id else None, "is_head": (dept.head_id == obj.id), + "via_assignment": False, # через членство } ) + + # 2. Роли через RoleAssignment (без членства в отделе) + role_assignments = RoleAssignment.objects.select_related( + "role__department" + ).filter(employee=obj, is_active=True) + + for ra in role_assignments: + dept = ra.role.department + # Пропускаем отделы, где сотрудник уже член + if dept.id in member_dept_ids: + continue + + # Проверяем, не добавлен ли уже этот отдел через другую роль + existing = next((d for d in out if d["id"] == dept.id and d.get("via_assignment")), None) + if existing: + # Добавляем роль к существующей записи (несколько ролей в одном отделе) + existing["role_name"] = f"{existing['role_name']}, {ra.role.name}" if existing.get("role_name") else ra.role.name + else: + out.append( + { + "id": dept.id, + "name": dept.name, + "role_id": ra.role_id, + "role_name": ra.role.name, + "is_head": False, + "via_assignment": True, # через RoleAssignment (не член отдела) + } + ) + return out def get_fields(self): @@ -512,6 +556,8 @@ class EmployeeListSerializer(serializers.ModelSerializer): departments = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() skills = SkillSerializer(many=True, read_only=True) + # Поля для фильтра по отделу — информация о типе связи + department_relation = serializers.SerializerMethodField() class Meta: model = Employee @@ -529,8 +575,51 @@ class EmployeeListSerializer(serializers.ModelSerializer): "skills", "created_at", "display_name", + "department_relation", ) + def get_department_relation(self, obj): + """ + Возвращает информацию о типе связи сотрудника с отфильтрованным отделом. + + Поля: + - is_member: True если член отдела через EmployeeDepartment + - is_head: True если руководитель отдела + - has_role_only: True если только роль через RoleAssignment (не член) + - role_names: список названий ролей через RoleAssignment + + Возвращает None если не применён фильтр по отделу. + """ + # Проверяем аннотации от фильтра по отделу + is_member = getattr(obj, "_is_dept_member", None) + is_head = getattr(obj, "_is_dept_head", None) + has_role = getattr(obj, "_has_role_assignment", None) + + # Если аннотаций нет — фильтр по отделу не применён + if is_member is None: + return None + + # Получаем названия ролей через RoleAssignment + role_names = [] + request = self.context.get("request") + dept_id = getattr(request, "_department_filter_id", None) if request else None + + if dept_id and has_role: + from employees.models import RoleAssignment + assignments = RoleAssignment.objects.filter( + employee=obj, + role__department_id=dept_id, + is_active=True + ).select_related("role") + role_names = [a.role.name for a in assignments] + + return { + "is_member": bool(is_member), + "is_head": bool(is_head), + "has_role_only": bool(has_role) and not bool(is_member), + "role_names": role_names, + } + def get_departments(self, obj): links = getattr(obj, "dept_links", None) if links is None: diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index c5427720..2c3a7333 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -28,7 +28,7 @@ from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError, from employees.ldap.infrastructure.connections import _conn from employees.models import (Department, DepartmentPermission, DepartmentRole, DeptPerm, EmployeeAction, EmployeeDepartment, - LdapSyncState, Position, Skill) + LdapSyncState, Position, RoleAssignment, Skill) from employees.utils import (_build_links_for_dept, _detect_phone_field, _ensure_department_permissions, _head_choices_for_dept, _normalize_phone, @@ -955,16 +955,18 @@ class DepartmentViewSet(viewsets.ModelViewSet): @transaction.atomic def set_member_role(self, request, pk=None): """ - Назначает/снимает РОЛЬ существующему участнику отдела. - Не создаёт членство и не меняет is_active. + Назначает/снимает РОЛЬ сотруднику в контексте отдела. + + Если сотрудник — член отдела, обновляет EmployeeDepartment.role. + Если нет — создаёт/обновляет RoleAssignment (новая логика: роли можно + назначать любому сотруднику компании). Тело: { "employee_id": , "role_id": } Права: AssignRolePerm (DeptPerm.ASSIGN_ROLE) Ответ: - 200 {"employee_id":..., "role_id": , "is_active": } - 404 если сотрудник не состоит в отделе + 200 {"employee_id":..., "role_id": , "is_active": , "via_assignment": } 400 если роль не принадлежит отделу """ dept = self.get_object() @@ -987,47 +989,85 @@ class DepartmentViewSet(viewsets.ModelViewSet): status=400, ) - svc = DirectoryService() if _is_ldap_enabled() else None + # Проверяем, является ли сотрудник членом отдела + link = EmployeeDepartment.objects.filter( + employee_id=emp_id, department_id=dept.id + ).first() - if svc: - # Режим с LDAP: синхронизируем роли - try: - # внутри: проверяет наличие линка, меняет роль, (опц.) LDAP-группу - svc.set_member_role(dept, employee, role) - link = EmployeeDepartment.objects.get( - employee_id=emp_id, department_id=dept.id - ) - except EmployeeDepartment.DoesNotExist: - return Response( - {"detail": "Employee is not a member of this department."}, status=404 - ) - except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: - return Response( - {"detail": str(e)}, - status=( - 502 - if isinstance(e, DirectoryLdapError) - else 400 if isinstance(e, DirectoryServiceError) else 500 - ), - ) - else: - # Режим без LDAP: просто обновляем роль в линке - try: - link = EmployeeDepartment.objects.get( - employee_id=emp_id, department_id=dept.id - ) + via_assignment = False + + if link: + # Сотрудник — член отдела: обновляем роль в линке (старая логика) + svc = DirectoryService() if _is_ldap_enabled() else None + + if svc: + try: + svc.set_member_role(dept, employee, role) + except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: + return Response( + {"detail": str(e)}, + status=( + 502 + if isinstance(e, DirectoryLdapError) + else 400 if isinstance(e, DirectoryServiceError) else 500 + ), + ) + else: link.role = role link.save(update_fields=["role"]) - except EmployeeDepartment.DoesNotExist: - return Response( - {"detail": "Employee is not a member of this department."}, status=404 + + # Также создаём/обновляем 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) + else: + # Сотрудник НЕ член отдела: используем только RoleAssignment + via_assignment = True + + if role: + # Назначаем роль через RoleAssignment + from employees.ldap.services.department_service import DepartmentService + from employees.ldap.services.group_service import GroupService + from employees.ldap.services.user_service import UserService + + if _is_ldap_enabled(): + try: + group_service = GroupService() + user_service = UserService(group_service) + dept_service = DepartmentService(group_service, user_service) + dept_service.assign_role(employee, role, request.user) + except Exception as e: + return Response({"detail": str(e)}, status=400) + else: + RoleAssignment.objects.update_or_create( + employee=employee, + 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) return Response( { "employee_id": emp_id, "role_id": (role.id if role else None), - "is_active": link.is_active, + "is_active": link.is_active if link else True, + "via_assignment": via_assignment, }, status=status.HTTP_200_OK, ) @@ -1443,7 +1483,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): qp = self.request.query_params - # по отделу: связи + руководитель + # по отделу: связи + руководитель + сотрудники с ролями через RoleAssignment dep = qp.get("department") if dep: try: @@ -1451,11 +1491,49 @@ class EmployeeViewSet(viewsets.ModelViewSet): except (TypeError, ValueError): dep_id = None if dep_id: + # Члены отдела member_ids = EmployeeDepartment.objects.filter( department_id=dep_id ).values("employee_id") + # Руководитель head_ids = Department.objects.filter(id=dep_id).values("head_id") - qs = qs.filter(Q(id__in=member_ids) | Q(id__in=head_ids)) + # Сотрудники с ролями через RoleAssignment (не члены отдела) + role_assignment_ids = RoleAssignment.objects.filter( + role__department_id=dep_id, + is_active=True + ).values("employee_id") + + qs = qs.filter( + Q(id__in=member_ids) | Q(id__in=head_ids) | Q(id__in=role_assignment_ids) + ).distinct() + + # Аннотируем тип связи с отделом для каждого сотрудника + # is_dept_member: True если член отдела, False если только роль + qs = qs.annotate( + _is_dept_member=Exists( + EmployeeDepartment.objects.filter( + employee_id=OuterRef("pk"), + department_id=dep_id, + is_active=True + ) + ), + _is_dept_head=Exists( + Department.objects.filter( + id=dep_id, + head_id=OuterRef("pk") + ) + ), + _has_role_assignment=Exists( + RoleAssignment.objects.filter( + employee_id=OuterRef("pk"), + role__department_id=dep_id, + is_active=True + ) + ) + ) + + # Сохраняем dep_id в request для использования в сериализаторе + self.request._department_filter_id = dep_id # по должности position = qp.get("position") @@ -2598,6 +2676,8 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): "partial_update", "destroy", "set_perms", + "assign", + "revoke", }: return [self.AssignRolePerm()] return [IsAuthenticated()] @@ -2678,6 +2758,131 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): ser = self.get_serializer(role) return Response(ser.data, status=200) + @action(detail=True, methods=["get"]) + def assignments(self, request, pk=None): + """ + Возвращает список назначений роли (RoleAssignment). + + Query params: + - ?active=true/false — фильтр по is_active (по умолчанию только активные) + """ + role = self.get_object() + qs = RoleAssignment.objects.filter(role=role).select_related( + "employee", "assigned_by" + ) + + active = request.query_params.get("active", "true").lower() + if active == "true": + qs = qs.filter(is_active=True) + elif active == "false": + qs = qs.filter(is_active=False) + + data = [ + { + "id": a.id, + "employee_id": a.employee_id, + "employee_name": str(a.employee) if a.employee else None, + "assigned_at": a.assigned_at.isoformat() if a.assigned_at else None, + "assigned_by_id": a.assigned_by_id, + "assigned_by_name": str(a.assigned_by) if a.assigned_by else None, + "is_active": a.is_active, + } + for a in qs.order_by("-assigned_at") + ] + return Response({"count": len(data), "results": data}, status=200) + + @action(detail=True, methods=["post"]) + def assign(self, request, pk=None): + """ + Назначает роль сотруднику (не требует членства в отделе). + + Тело запроса: + - employee_id: int — ID сотрудника + + Требуется право DeptPerm.ASSIGN_ROLE в отделе роли. + """ + role = self.get_object() + employee_id = request.data.get("employee_id") + + if not employee_id: + return Response( + {"detail": "employee_id is required."}, status=400 + ) + + try: + employee = Employee.objects.get(id=employee_id) + except Employee.DoesNotExist: + return Response( + {"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) + + 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 Exception as e: + return Response({"detail": str(e)}, status=400) + + @action(detail=True, methods=["post"]) + def revoke(self, request, pk=None): + """ + Отзывает роль у сотрудника. + + Тело запроса: + - employee_id: int — ID сотрудника + + Требуется право DeptPerm.ASSIGN_ROLE в отделе роли. + """ + role = self.get_object() + employee_id = request.data.get("employee_id") + + if not employee_id: + return Response( + {"detail": "employee_id is required."}, status=400 + ) + + try: + employee = Employee.objects.get(id=employee_id) + except Employee.DoesNotExist: + return Response( + {"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) + + return Response(status=204) + except Exception as e: + return Response({"detail": str(e)}, status=400) + class SkillViewSet(viewsets.ModelViewSet): """ diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index 296f0e0b..07cb42f3 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple, Type from django.contrib.auth.models import AbstractBaseUser, AnonymousUser from django.db.models import Model from employees.constants import DeptPerm -from employees.models import Department, EmployeeDepartment +from employees.models import Department, EmployeeDepartment, RoleAssignment from rest_framework.permissions import ( SAFE_METHODS, BasePermission, @@ -36,8 +36,8 @@ def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool: - Неаутентифицированным → False. - staff/superuser → True. - Руководитель отдела → True. - - Иначе: существует активная связь EmployeeDepartment, и у роли есть - DepartmentPermission с нужным code. + - Иначе: проверяет RoleAssignment — есть ли активное назначение роли + с нужным правом в данном отделе. Args: user (User): Текущий пользователь. @@ -64,12 +64,21 @@ def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool: if getattr(dept, "head_id", None) == user.id: return True - # основная проверка: активная связь + у роли есть нужный DepartmentPermission.code + # Проверка через RoleAssignment (новая модель — не требует членства в отделе) + if RoleAssignment.objects.filter( + employee_id=user.id, + role__department_id=dept.id, + role__scoped_permissions__code=perm_code, + is_active=True, + ).exists(): + return True + + # Fallback: старая проверка через EmployeeDepartment.role (обратная совместимость) return EmployeeDepartment.objects.filter( employee_id=user.id, department_id=dept.id, is_active=True, - role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename` + role__scoped_permissions__code=perm_code, ).exists() @@ -80,8 +89,9 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool Логика: 1) staff/superuser → всегда True 2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов - 3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True - 4) иначе False + 3) пользователь имеет активное назначение роли (RoleAssignment) с правом `code` → True + 4) fallback: старая проверка через EmployeeDepartment.role + 5) иначе False """ if not getattr(user, "is_authenticated", False): return False @@ -92,7 +102,16 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool if Department.objects.filter(id=department_id, head_id=user.id).exists(): return True - # 3) Проверка роли в отделе + # 3) Проверка через RoleAssignment (новая модель — не требует членства в отделе) + if RoleAssignment.objects.filter( + employee_id=user.id, + role__department_id=department_id, + role__scoped_permissions__code=code, + is_active=True, + ).exists(): + return True + + # 4) Fallback: старая проверка через EmployeeDepartment.role link = ( EmployeeDepartment.objects.select_related("role") .filter(employee_id=user.id, department_id=department_id, is_active=True) @@ -101,7 +120,6 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool if not link or not link.role_id: return False - # роль хранит «скоуп-права» только для этого отдела return link.role.scoped_permissions.filter(code=code).exists() diff --git a/backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md b/backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md new file mode 100644 index 00000000..a342eb55 --- /dev/null +++ b/backend/docs/reports/01_DEPARTMENT_ROLES_MODELS.md @@ -0,0 +1,429 @@ +# Отчет: Модели данных системы ролей отделов + +**Дата**: 30.12.2025 +**Анализ**: Структура моделей DepartmentRole, DepartmentPermission, EmployeeDepartment + +--- + +## 1. DepartmentPermission — Справочник прав отдела + +### Расположение +`backend/employees/models.py` (строки ~450-470) + +### Структура модели + +```python +class DepartmentPermission(models.Model): + code = models.CharField(max_length=100, unique=True, db_index=True) + name = models.CharField(max_length=200) +``` + +### Назначение +Справочник департаментских прав (scoped permissions), которые могут назначаться ролям внутри отделов. + +### Поля + +| Поле | Тип | Особенности | Назначение | +|------|-----|-------------|------------| +| `code` | CharField(100) | unique=True, db_index=True | Уникальный код права (например, "manage_department") | +| `name` | CharField(200) | - | Человекочитаемое название права | + +### Источник кодов прав +Коды синхронизируются с `employees.constants.DeptPerm.CHOICES`: + +```python +class DeptPerm: + MANAGE = "manage_department" + CHANGE_HEAD = "change_department_head" + ASSIGN_ROLE = "assign_department_role" + MANAGE_CALENDAR = "manage_department_events" + CREATE_POST = "publish_department_post" + MANAGE_FEED = "manage_department_feed" + VIEW_REQUESTCOMMENT = "view_requestcomment" + ADD_REQUESTCOMMENT = "add_requestcomment" + VIEW_REQUEST = "view_request" + CAN_PROCESS_REQUESTS = "can_process_requests" + + CHOICES = ( + (MANAGE, "Управлять отделом"), + (CHANGE_HEAD, "Назначать руководителя"), + (ASSIGN_ROLE, "Назначать роли участникам"), + (MANAGE_CALENDAR, "Управлять календарём отдела"), + (CREATE_POST, "Публиковать новости на странице отдела"), + (MANAGE_FEED, "Редактировать публикации отдела"), + (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), + (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), + (VIEW_REQUEST, "Просмотр заявлений отдела"), + (CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"), + ) +``` + +### Идемпотентная синхронизация +Записи создаются/обновляются автоматически через `_ensure_department_permissions()` в `employees/utils.py`. + +### Особенности +- **Единственная точка истины**: `DeptPerm.CHOICES` в constants.py +- **Автоматическая синхронизация**: При вызове `/api/v1/department-roles/perm_choices/` или `ui-context/` +- **Нет миграций**: Новые права добавляются через код без миграций БД + +--- + +## 2. DepartmentRole — Роль внутри отдела + +### Расположение +`backend/employees/models.py` (строки ~470-500) + +### Структура модели + +```python +class DepartmentRole(models.Model): + department = models.ForeignKey( + 'Department', + on_delete=models.CASCADE, + related_name='roles' + ) + name = models.CharField(max_length=100) + scoped_permissions = models.ManyToManyField( + DepartmentPermission, + blank=True, + related_name='roles' + ) + ldap_group_dn = models.CharField(max_length=500, blank=True, default='') + + class Meta: + unique_together = [('department', 'name')] + verbose_name = 'Роль отдела' + verbose_name_plural = 'Роли отделов' +``` + +### Назначение +Роль, определённая в рамках конкретного отдела. Каждая роль имеет набор скоуп-прав (scoped_permissions), действующих только в этом отделе. + +### Поля + +| Поле | Тип | Связь | Особенности | Назначение | +|------|-----|-------|-------------|------------| +| `department` | ForeignKey | Department | CASCADE, related_name='roles' | Отдел, к которому принадлежит роль | +| `name` | CharField(100) | - | unique_together с department | Название роли (уникально в рамках отдела) | +| `scoped_permissions` | ManyToManyField | DepartmentPermission | blank=True, related_name='roles' | Набор прав роли | +| `ldap_group_dn` | CharField(500) | - | blank=True, default='' | DN группы в LDAP (для синхронизации) | + +### Constraints + +```python +unique_together = [('department', 'name')] +``` + +**Значение**: Имя роли уникально только в рамках одного отдела. В разных отделах могут быть роли с одинаковыми именами. + +### Связи + +```python +Department.roles # related_name='roles' +# Получить все роли отдела: +department.roles.all() +``` + +### LDAP Integration +- **OU=Roles внутри OU отдела**: Для каждой роли создаётся LDAP-группа внутри `OU=Roles,OU=<Отдел>,OU=Departments,...` +- **Автоматическая синхронизация**: При назначении роли пользователю (`set_member_role`) происходит добавление в соответствующую LDAP-группу +- **Хранение DN**: Поле `ldap_group_dn` содержит полный DN группы роли + +--- + +## 3. EmployeeDepartment — Связь сотрудника и отдела + +### Расположение +`backend/employees/models.py` (строки ~500-540) + +### Структура модели + +```python +class EmployeeDepartment(models.Model): + employee = models.ForeignKey( + 'Employee', + on_delete=models.CASCADE, + related_name='department_memberships' + ) + department = models.ForeignKey( + 'Department', + on_delete=models.CASCADE, + related_name='employee_links' + ) + role = models.ForeignKey( + DepartmentRole, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='employee_assignments' + ) + date_from = models.DateField(blank=True, null=True) + date_to = models.DateField(blank=True, null=True) + is_active = models.BooleanField(default=True, db_index=True) + + class Meta: + unique_together = [('department', 'employee')] + verbose_name = 'Членство в отделе' + verbose_name_plural = 'Членство в отделах' +``` + +### Назначение +Связывает сотрудника с отделом и **опционально** назначает роль. Поддерживает временные рамки членства и флаг активности. + +### Поля + +| Поле | Тип | Связь | Особенности | Назначение | +|------|-----|-------|-------------|------------| +| `employee` | ForeignKey | Employee | CASCADE, related_name='department_memberships' | Сотрудник | +| `department` | ForeignKey | Department | CASCADE, related_name='employee_links' | Отдел | +| `role` | ForeignKey | DepartmentRole | SET_NULL, null=True, blank=True, related_name='employee_assignments' | Роль в отделе (опционально) | +| `date_from` | DateField | - | null=True, blank=True | Дата начала членства | +| `date_to` | DateField | - | null=True, blank=True | Дата окончания членства | +| `is_active` | BooleanField | - | default=True, db_index=True | Флаг активного членства | + +### Constraints + +```python +unique_together = [('department', 'employee')] +``` + +**Значение**: Сотрудник может иметь только одну связь с каждым отделом. Нет дублирования членства. + +### Ключевые особенности + +#### 1. Роль опциональна +```python +role = models.ForeignKey(..., null=True, blank=True) +``` +- Сотрудник может быть членом отдела **без роли** +- Роль назначается отдельно через `set_member_role()` +- Разделение: `add_member` (добавить в отдел) != `set_member_role` (назначить роль) + +#### 2. SET_NULL при удалении роли +```python +role = models.ForeignKey(..., on_delete=models.SET_NULL) +``` +- При удалении роли у сотрудников автоматически `role = None` +- Членство в отделе сохраняется +- Не требуется вручную очищать связи + +#### 3. is_active флаг +- **Indexed**: `db_index=True` для быстрых фильтраций +- **Использование**: Проверка прав в `has_dept_perm()` учитывает только `is_active=True` +- **Soft delete**: Можно деактивировать членство без физического удаления + +### Связи + +```python +# Получить все отделы сотрудника: +employee.department_memberships.filter(is_active=True) + +# Получить всех сотрудников отдела: +department.employee_links.filter(is_active=True).select_related('employee', 'role') + +# Получить всех сотрудников с конкретной ролью: +role.employee_assignments.filter(is_active=True) +``` + +--- + +## 4. Department.Meta.permissions — Django-уровень + +### Расположение +`backend/employees/models.py` — класс Department + +### Структура + +```python +class Department(models.Model): + # ... поля ... + + class Meta: + permissions = [ + ("change_department_head", "Может назначать руководителя отдела"), + ("assign_department_role", "Может назначать роли участникам отдела"), + ("manage_department", "Может управлять отделом"), + ] +``` + +### Назначение +Дополнительные Django permissions на уровне модели Department. Используются в системе прав совместно со scoped permissions. + +### Взаимодействие уровней прав + +``` +┌──────────────────────────────────────┐ +│ 1. Django Model Permissions │ +│ - change_department_head │ +│ - assign_department_role │ +│ - manage_department │ +└──────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────┐ +│ 2. DepartmentPermission (scoped) │ +│ - Записи в БД из DeptPerm.CHOICES │ +│ - Привязываются к ролям │ +└──────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────┐ +│ 3. DepartmentRole.scoped_permissions │ +│ - M2M к DepartmentPermission │ +│ - Действует только в своём отделе │ +└──────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────┐ +│ 4. EmployeeDepartment.role │ +│ - Связь сотрудника с ролью │ +│ - Проверка: is_active=True │ +└──────────────────────────────────────┘ +``` + +--- + +## 5. Критические особенности архитектуры + +### 5.1 Двухуровневая система прав + +**Уровень 1: Django permissions** (глобальные) +- Привязаны к модели Department +- Проверяются через `user.has_perm('employees.manage_department')` +- **НЕ учитывают** конкретный отдел + +**Уровень 2: Scoped permissions** (в рамках отдела) +- Хранятся в `DepartmentPermission` +- Назначаются ролям через `DepartmentRole.scoped_permissions` +- Проверяются через `has_dept_perm(user, dept_id, code)` +- **Учитывают** конкретный отдел + +### 5.2 Руководитель отдела имеет все права + +```python +# В has_dept_perm(): +if Department.objects.filter(id=department_id, head_id=user.id).exists(): + return True # ✅ Руководитель имеет любое право +``` + +**Значение**: Руководителю не нужны роли для управления своим отделом. + +### 5.3 Разделение операций + +**add_member** (`DeptPerm.MANAGE`) +- Добавляет сотрудника в отдел +- **НЕ назначает роль** (`role=None`) +- Требует право `manage_department` + +**set_member_role** (`DeptPerm.ASSIGN_ROLE`) +- Назначает/снимает роль у **существующего** члена +- Требует членство в отделе +- Требует право `assign_department_role` + +**remove_member** (`DeptPerm.MANAGE`) +- Удаляет из отдела (deactivate или delete) +- Автоматически очищает роль (SET_NULL) + +### 5.4 Уникальность и скоуп + +```python +# ✅ Правильно: разные отделы, одно имя роли +DepartmentRole(department=dept1, name="Manager") +DepartmentRole(department=dept2, name="Manager") + +# ❌ Ошибка: один отдел, одно имя +DepartmentRole(department=dept1, name="Manager") +DepartmentRole(department=dept1, name="Manager") # IntegrityError +``` + +--- + +## 6. Проблемные места и ограничения + +### 6.1 Смешение уровней прав +**Проблема**: Django permissions в Department.Meta и scoped permissions в DepartmentPermission имеют частичное пересечение кодов. + +**Пример**: +- `Department.Meta.permissions`: `"manage_department"` +- `DeptPerm.MANAGE`: `"manage_department"` + +**Следствие**: Неочевидно, какой уровень проверять в каком контексте. + +### 6.2 Автоматическая синхронизация permissions +**Проблема**: `_ensure_department_permissions()` создаёт записи при каждом вызове API. + +**Риски**: +- Нет контроля версий прав +- Удаление кода из `DeptPerm.CHOICES` не удаляет запись из БД +- Нет миграций для отслеживания изменений + +### 6.3 LDAP soft dependency +**Проблема**: Код везде проверяет `if _is_ldap_enabled()` для переключения логики. + +**Следствие**: Две разные кодовые ветки, усложнение тестирования. + +### 6.4 Нет валидации role.department == link.department +**Проблема**: При назначении роли не проверяется на уровне модели, что роль принадлежит тому же отделу. + +**Защита**: Только в API-слое (`set_member_role` в views.py). + +--- + +## 7. Рекомендации по рефакторингу + +### 7.1 Унифицировать систему прав +- Удалить Django permissions из `Department.Meta` +- Оставить только scoped permissions через `DepartmentPermission` +- Добавить миграцию для перехода + +### 7.2 Добавить валидацию на уровне модели +```python +class EmployeeDepartment(models.Model): + def clean(self): + if self.role and self.role.department_id != self.department_id: + raise ValidationError("Role must belong to the same department") +``` + +### 7.3 Создать миграции для permissions +- Вместо `_ensure_department_permissions()` использовать data migrations +- Добавить версионирование справочника прав + +### 7.4 Абстрагировать LDAP-логику +- Создать адаптер `DepartmentRoleBackend` с двумя реализациями: LDAP и DB-only +- Убрать `if _is_ldap_enabled()` из бизнес-логики + +--- + +## 8. Итоговая схема данных + +``` +┌──────────────────────────────────┐ +│ Department │ +│ - id │ +│ - name │ +│ - head_id (FK → Employee) │ +│ - ldap_group_dn │ +└──────────────────────────────────┘ + ↑ (department FK) + │ +┌──────────────────────────────────┐ ┌──────────────────────────────┐ +│ DepartmentRole │ M2M │ DepartmentPermission │ +│ - id │←────────→ - id │ +│ - department_id (FK ↑) │ │ - code (unique) │ +│ - name (unique per dept) │ │ - name │ +│ - ldap_group_dn │ └──────────────────────────────┘ +└──────────────────────────────────┘ + ↑ (role FK, nullable) + │ +┌──────────────────────────────────┐ +│ EmployeeDepartment │ +│ - id │ +│ - employee_id (FK → Employee) │ +│ - department_id (FK ↑) │ +│ - role_id (FK ↑, nullable) │ +│ - is_active (indexed) │ +│ - date_from, date_to │ +│ UNIQUE: (department, employee) │ +└──────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [02_DEPARTMENT_ROLES_API.md](./02_DEPARTMENT_ROLES_API.md) — API ViewSet и endpoints diff --git a/backend/docs/reports/02_DEPARTMENT_ROLES_API.md b/backend/docs/reports/02_DEPARTMENT_ROLES_API.md new file mode 100644 index 00000000..86e8341e --- /dev/null +++ b/backend/docs/reports/02_DEPARTMENT_ROLES_API.md @@ -0,0 +1,572 @@ +# Отчет: API ViewSet для работы с ролями отделов + +**Дата**: 30.12.2025 +**Анализ**: DepartmentRoleViewSet — CRUD операции, custom actions, система прав + +--- + +## 1. Обзор ViewSet + +### Расположение +`backend/api/v1/employees/views.py` (строки 2567-2660) + +### Базовая информация + +```python +class DepartmentRoleViewSet(viewsets.ModelViewSet): + queryset = ( + DepartmentRole.objects.select_related("department") + .prefetch_related("scoped_permissions") + .all() + ) + serializer_class = DepartmentRoleSerializer + ordering_fields = ("name", "id") + ordering = ("name", "id") +``` + +### URL Patterns +**Router registration**: +```python +router.register(r'department-roles', DepartmentRoleViewSet, basename='department-roles') +``` + +**Generated URLs**: +- `GET /api/v1/department-roles/` — list (с фильтрацией `?department=`) +- `POST /api/v1/department-roles/` — create +- `GET /api/v1/department-roles/{id}/` — retrieve +- `PATCH/PUT /api/v1/department-roles/{id}/` — update/partial_update +- `DELETE /api/v1/department-roles/{id}/` — destroy +- `GET /api/v1/department-roles/perm_choices/` — список всех доступных прав +- `GET /api/v1/department-roles/{id}/perms/` — права конкретной роли +- `POST /api/v1/department-roles/{id}/set_perms/` — установка прав роли + +--- + +## 2. Стандартные CRUD операции + +### 2.1 LIST — Получение списка ролей + +**Endpoint**: `GET /api/v1/department-roles/` + +**Query params**: +- `department` (int) — фильтр по ID отдела +- `ordering` (str) — сортировка: `name`, `-name`, `id`, `-id` + +**Permission**: `IsAuthenticated()` — любой авторизованный пользователь + +**Логика**: +```python +def get_queryset(self): + qs = super().get_queryset() + dept = self.request.query_params.get("department") + if dept: + qs = qs.filter(department_id=dept) + + # Стабильная сортировка с tie-break по id + ord_param = self.request.query_params.get("ordering") + if ord_param in {"name", "-name", "id", "-id"}: + qs = qs.order_by( + ord_param, "id" if not ord_param.startswith("-") else "-id" + ) + else: + qs = qs.order_by(*self.ordering) # по умолчанию: name, id + return qs +``` + +**Особенности**: +- **Prefetch optimization**: `select_related("department")` + `prefetch_related("scoped_permissions")` +- **Стабильная сортировка**: Всегда добавляется tie-break по `id` для однозначного порядка +- **Без пагинации по умолчанию**: Если не указана в settings + +**Пример запроса**: +```bash +GET /api/v1/department-roles/?department=5&ordering=name +``` + +**Пример ответа**: +```json +{ + "count": 3, + "results": [ + { + "id": 12, + "department": 5, + "name": "Engineer", + "scoped_permissions": [1, 3], + "permissions": [1, 3], + "permissions_verbose": [ + {"id": 1, "code": "manage_department", "name": "Управлять отделом"}, + {"id": 3, "code": "assign_department_role", "name": "Назначать роли"} + ] + } + ] +} +``` + +--- + +### 2.2 RETRIEVE — Получение одной роли + +**Endpoint**: `GET /api/v1/department-roles/{id}/` + +**Permission**: `IsAuthenticated()` + +**Ответ**: Полная информация о роли с backward-compat полями. + +--- + +### 2.3 CREATE — Создание роли + +**Endpoint**: `POST /api/v1/department-roles/` + +**Permission**: `AssignRolePerm()` — требует `DeptPerm.ASSIGN_ROLE` в целевом отделе + +```python +class AssignRolePerm(AdminOrDeptAllowed): + required_code = DeptPerm.ASSIGN_ROLE + +def get_permissions(self): + if self.action in {"create", "update", "partial_update", "destroy", "set_perms"}: + return [self.AssignRolePerm()] + return [IsAuthenticated()] +``` + +**Body**: +```json +{ + "department": 5, + "name": "Manager", + "scoped_permissions": [1, 3] // или + "scoped_permission_codes": ["manage_department", "assign_department_role"] +} +``` + +**Логика проверки прав**: +1. Извлекается `department` из request.data +2. `AdminOrDeptAllowed` проверяет: + - Пользователь staff/superuser → ✅ + - Пользователь — руководитель отдела → ✅ + - У пользователя есть роль с `assign_department_role` в этом отделе → ✅ + - Иначе → 403 + +**Response**: `201 Created` + данные созданной роли + +**Errors**: +- `400` — валидация (duplicate name в отделе, невалидные коды прав) +- `403` — нет прав + +--- + +### 2.4 UPDATE/PARTIAL_UPDATE — Изменение роли + +**Endpoints**: +- `PUT /api/v1/department-roles/{id}/` +- `PATCH /api/v1/department-roles/{id}/` + +**Permission**: `AssignRolePerm()` — требует право в **отделе роли** (не в теле запроса) + +**Body** (PATCH): +```json +{ + "name": "Senior Manager", + "scoped_permission_codes": ["manage_department", "change_department_head"] +} +``` + +**Особенность**: +- Права проверяются по `role.department_id` (из БД) +- Нельзя изменить роль отдела A, имея права только в отделе B + +**Response**: `200 OK` + обновлённые данные + +--- + +### 2.5 DESTROY — Удаление роли + +**Endpoint**: `DELETE /api/v1/department-roles/{id}/` + +**Permission**: `AssignRolePerm()` в отделе роли + +**Логика**: +1. Проверка прав в отделе роли +2. Каскадное удаление роли +3. `EmployeeDepartment.role` → автоматически `SET_NULL` (on_delete=SET_NULL) +4. LDAP-группа роли остаётся (нет автоматической очистки) + +**Response**: `204 No Content` или `200 OK` + +**Последствия**: +- Все сотрудники с этой ролью теряют её (`role=None`) +- Членство в отделе сохраняется +- Права автоматически удаляются (M2M cascade) + +--- + +## 3. Custom Actions + +### 3.1 perm_choices — Справочник доступных прав + +**Endpoint**: `GET /api/v1/department-roles/perm_choices/` + +**Permission**: `IsAuthenticated()` + +**Назначение**: Возвращает полный список прав, которые можно назначить ролям. + +**Логика**: +```python +@action(detail=False, methods=["get"]) +def perm_choices(self, request): + data = _ensure_department_permissions() # Создаёт/синхронизирует из DeptPerm.CHOICES + return Response({"count": len(data), "results": data}, status=200) +``` + +**Response**: +```json +{ + "count": 10, + "results": [ + {"id": 1, "code": "manage_department", "name": "Управлять отделом"}, + {"id": 2, "code": "change_department_head", "name": "Назначать руководителя"}, + {"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"}, + ... + ] +} +``` + +**Особенность**: Идемпотентно создаёт записи в `DepartmentPermission` из `DeptPerm.CHOICES`. + +--- + +### 3.2 perms — Права конкретной роли + +**Endpoint**: `GET /api/v1/department-roles/{id}/perms/` + +**Permission**: `IsAuthenticated()` + +**Назначение**: Получить список прав, назначенных данной роли. + +**Логика**: +```python +@action(detail=True, methods=["get"]) +def perms(self, request, pk=None): + role = self.get_object() + data = [ + {"id": p.id, "code": p.code, "name": p.name} + for p in role.scoped_permissions.order_by("code") + ] + return Response({"count": len(data), "results": data}, status=200) +``` + +**Response**: +```json +{ + "count": 2, + "results": [ + {"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"}, + {"id": 1, "code": "manage_department", "name": "Управлять отделом"} + ] +} +``` + +**Сортировка**: Права отсортированы по `code` (алфавитный порядок). + +--- + +### 3.3 set_perms — Установка прав роли + +**Endpoint**: `POST /api/v1/department-roles/{id}/set_perms/` + +**Permission**: `AssignRolePerm()` в отделе роли + +**Назначение**: Полная замена набора прав у роли. + +**Body** (один из вариантов): +```json +{ + "permission_ids": [1, 3, 5] +} +``` +или +```json +{ + "permission_codes": ["manage_department", "assign_department_role", "view_request"] +} +``` + +**Логика**: +```python +@action(detail=True, methods=["post"]) +def set_perms(self, request, pk=None): + role = self.get_object() + + ids = request.data.get("permission_ids") or [] + codes = request.data.get("permission_codes") or [] + + if isinstance(ids, list) and ids: + ids_int = {int(i) for i in ids if str(i).isdigit()} + qs = DepartmentPermission.objects.filter(id__in=ids_int) + if qs.count() != len(ids_int): + return Response({"detail": "Некоторые permission_ids не найдены."}, status=400) + elif isinstance(codes, list) and codes: + codes_set = set(codes) + qs = DepartmentPermission.objects.filter(code__in=codes_set) + if qs.count() != len(codes_set): + return Response({"detail": "Некоторые permission_codes не найдены."}, status=400) + else: + qs = DepartmentPermission.objects.none() + + role.scoped_permissions.set(list(qs)) # Полная замена + ser = self.get_serializer(role) + return Response(ser.data, status=200) +``` + +**Особенности**: +- **Полная замена**: `role.scoped_permissions.set()` — удаляет старые, добавляет новые +- **Валидация**: Проверяет существование всех указанных ID/кодов +- **Приоритет**: Если указаны оба (`ids` и `codes`), используются `ids` + +**Response**: `200 OK` + полные данные роли с новым набором прав + +**Errors**: +- `400` — некоторые permission_ids/codes не найдены +- `403` — нет права `assign_department_role` в отделе роли + +--- + +## 4. Система прав ViewSet + +### 4.1 Матрица прав по действиям + +| Action | Permission Class | Required Right | Scope Check | +|--------|-----------------|----------------|-------------| +| `list` | `IsAuthenticated()` | — | Нет | +| `retrieve` | `IsAuthenticated()` | — | Нет | +| `create` | `AssignRolePerm()` | `assign_department_role` | По `request.data['department']` | +| `update` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` | +| `partial_update` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` | +| `destroy` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` | +| `perm_choices` | `IsAuthenticated()` | — | Нет | +| `perms` | `IsAuthenticated()` | — | Нет | +| `set_perms` | `AssignRolePerm()` | `assign_department_role` | По `role.department_id` | + +### 4.2 Логика AdminOrDeptAllowed + +```python +class AssignRolePerm(AdminOrDeptAllowed): + required_code = DeptPerm.ASSIGN_ROLE # "assign_department_role" +``` + +**Проверка прав** (порядок): +1. **Staff/Superuser**: `user.is_staff` или `user.is_superuser` → ✅ всегда +2. **Руководитель отдела**: `Department.objects.filter(id=dept_id, head_id=user.id).exists()` → ✅ +3. **Роль с правом**: + ```python + EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=dept_id, + is_active=True, + role__scoped_permissions__code="assign_department_role" + ).exists() + ``` + → ✅ + +**Извлечение department_id**: +- **CREATE**: Из `request.data['department']` или `request.data['department_id']` +- **UPDATE/DESTROY**: Из `role.department_id` (объект уже загружен) + +--- + +## 5. Оптимизация запросов + +### 5.1 Prefetch в queryset + +```python +queryset = ( + DepartmentRole.objects.select_related("department") + .prefetch_related("scoped_permissions") + .all() +) +``` + +**Цель**: Избежать N+1 queries при сериализации списка ролей. + +**Результат**: +- 1 запрос для ролей +- 1 запрос для отделов (JOIN) +- 1 запрос для всех M2M связей с permissions + +### 5.2 Сортировка + +```python +ordering = ("name", "id") # Tie-break по id для детерминированного порядка +``` + +**Особенность**: При одинаковых `name` порядок стабилен благодаря добавлению `id`. + +--- + +## 6. Связь с Department endpoints + +### 6.1 Department.set_member_role() + +**Endpoint**: `POST /api/v1/departments/{id}/set_member_role/` + +**Permission**: `AssignRolePerm()` в этом отделе + +**Body**: +```json +{ + "employee_id": 42, + "role_id": 12 // или null для снятия роли +} +``` + +**Логика**: +1. Проверка существования `EmployeeDepartment` (сотрудник должен быть в отделе) +2. Валидация `role.department_id == dept.id` +3. Обновление `link.role = role` +4. Если LDAP включён: синхронизация через `DirectoryService.set_member_role()` + +**Отличие от DepartmentRoleViewSet**: +- `DepartmentRoleViewSet` управляет **определениями ролей** (роли как сущности) +- `Department.set_member_role()` **назначает роли сотрудникам** (привязка) + +--- + +## 7. Проблемные места + +### 7.1 Отсутствие atomic транзакций +**Проблема**: CRUD операции не обёрнуты в `@transaction.atomic`. + +**Риск**: При падении между удалением старых и добавлением новых прав в `set_perms()` можно получить inconsistent state. + +**Рекомендация**: Добавить `@transaction.atomic` на `create`, `update`, `destroy`, `set_perms`. + +--- + +### 7.2 Нет защиты от изменения department +**Проблема**: Через PATCH можно попытаться изменить `department` у существующей роли. + +**Последствие**: Роль может стать несогласованной с уже назначенными сотрудниками. + +**Текущая защита**: Serializer не включает `department` в `read_only_fields`, но валидация зависит от БД constraint. + +**Рекомендация**: Сделать `department` read-only при update: +```python +def get_serializer(self, *args, **kwargs): + serializer = super().get_serializer(*args, **kwargs) + if self.action in {'update', 'partial_update'}: + serializer.fields['department'].read_only = True + return serializer +``` + +--- + +### 7.3 Дублирование логики синхронизации permissions +**Проблема**: `_ensure_department_permissions()` вызывается и в ViewSet, и в `Department.ui_context()`. + +**Следствие**: При каждом GET запросе проверяется/создаётся справочник прав. + +**Рекомендация**: +- Перенести синхронизацию в data migration или management команду +- Вызывать только при изменении `DeptPerm.CHOICES` + +--- + +### 7.4 Нет версионирования API +**Проблема**: Backward-compat поля (`permissions`, `permissions_verbose`) добавлены без версионирования. + +**Риск**: Накопление legacy полей в будущем. + +**Рекомендация**: При следующем breaking change перейти на v2 API с чистой схемой. + +--- + +## 8. Примеры использования + +### 8.1 Создание роли с правами (по кодам) + +```bash +POST /api/v1/department-roles/ +Authorization: Bearer +Content-Type: application/json + +{ + "department": 5, + "name": "Senior Developer", + "scoped_permission_codes": [ + "manage_department", + "assign_department_role", + "view_request" + ] +} +``` + +### 8.2 Обновление прав существующей роли + +```bash +POST /api/v1/department-roles/12/set_perms/ +Authorization: Bearer +Content-Type: application/json + +{ + "permission_codes": [ + "manage_department", + "change_department_head", + "manage_department_events" + ] +} +``` + +### 8.3 Получение ролей отдела + +```bash +GET /api/v1/department-roles/?department=5&ordering=name +Authorization: Bearer +``` + +--- + +## 9. Итоговая схема взаимодействия + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend Request │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ DepartmentRoleViewSet.get_permissions() │ +│ - list/retrieve: IsAuthenticated() │ +│ - write ops: AssignRolePerm(AdminOrDeptAllowed) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ AdminOrDeptAllowed.has_permission() │ +│ 1. Check: user.is_staff/is_superuser → Allow │ +│ 2. Extract dept_id from request.data or obj │ +│ 3. Check: user == dept.head → Allow │ +│ 4. Check: has_dept_perm(user, dept_id, "assign_...") │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ has_dept_perm(user, dept_id, code) │ +│ Query: EmployeeDepartment.filter( │ +│ employee=user, department=dept_id, is_active=True, │ +│ role__scoped_permissions__code=code │ +│ ) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ DepartmentRoleSerializer │ +│ - Serialize role with scoped_permissions (M2M) │ +│ - Add backward-compat fields (permissions, verbose) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Response to Frontend │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [03_DEPARTMENT_ROLES_SERIALIZERS.md](./03_DEPARTMENT_ROLES_SERIALIZERS.md) — Сериализаторы и валидация diff --git a/backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md b/backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md new file mode 100644 index 00000000..db56e756 --- /dev/null +++ b/backend/docs/reports/03_DEPARTMENT_ROLES_SERIALIZERS.md @@ -0,0 +1,710 @@ +# Отчет: Сериализаторы системы ролей отделов + +**Дата**: 30.12.2025 +**Анализ**: DepartmentRoleSerializer, SetMemberRoleInput — валидация, backward compatibility + +--- + +## 1. DepartmentRoleSerializer — Основной сериализатор + +### Расположение +`backend/api/v1/employees/serializers.py` (строки 589-675) + +### Назначение +Сериализация/десериализация объектов `DepartmentRole` с поддержкой: +- Записи прав по ID или кодам +- Backward compatibility для старого API +- Валидации уникальности и принадлежности прав + +--- + +## 2. Структура полей + +### 2.1 Основные поля + +```python +class DepartmentRoleSerializer(serializers.ModelSerializer): + scoped_permissions = serializers.PrimaryKeyRelatedField( + queryset=DepartmentPermission.objects.all(), + many=True, + required=False + ) + scoped_permission_codes = serializers.ListField( + child=serializers.CharField(), + required=False, + write_only=True + ) + + # Backward-compat (read-only) + permissions = serializers.SerializerMethodField(read_only=True) + permissions_verbose = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = DepartmentRole + fields = ( + "id", + "department", + "name", + # новые write-поля + "scoped_permissions", + "scoped_permission_codes", + # совместимость (read-only зеркала) + "permissions", + "permissions_verbose", + ) + read_only_fields = ("id",) +``` + +### 2.2 Матрица полей + +| Поле | Тип | Read/Write | Required | Назначение | +|------|-----|------------|----------|------------| +| `id` | Integer | Read-only | — | PK роли | +| `department` | Integer (FK) | Read/Write | Yes (create) | ID отдела | +| `name` | String | Read/Write | Yes | Название роли | +| `scoped_permissions` | List[Integer] | Read/Write | No | ID прав (новая схема) | +| `scoped_permission_codes` | List[String] | Write-only | No | Коды прав (альтернатива) | +| `permissions` | List[Integer] | Read-only | — | Legacy: ID прав | +| `permissions_verbose` | List[Object] | Read-only | — | Legacy: полная инфо прав | + +--- + +## 3. Логика создания (CREATE) + +### 3.1 Метод create() + +```python +def create(self, validated_data): + # Вытаскиваем write-only/внешние поля до super().create() + codes = validated_data.pop("scoped_permission_codes", None) + perms = validated_data.pop("scoped_permissions", None) + + role = super().create(validated_data) + + if perms is not None: + role.scoped_permissions.set(perms) + + if codes is not None: + qs = DepartmentPermission.objects.filter(code__in=codes) + if qs.count() != len(set(codes)): + raise serializers.ValidationError( + {"scoped_permission_codes": "Некоторые коды не существуют"} + ) + role.scoped_permissions.set(list(qs)) + + return role +``` + +### 3.2 Порядок операций + +``` +1. validated_data = {department, name, scoped_permissions, scoped_permission_codes} + ↓ +2. Извлечь codes и perms из validated_data (pop) + ↓ +3. super().create(validated_data) # создать role без M2M + ↓ +4. Если есть perms → role.scoped_permissions.set(perms) + ↓ +5. Если есть codes → валидация → role.scoped_permissions.set(...) + ↓ +6. Return role +``` + +### 3.3 Приоритет + +**Если указаны оба поля:** +```python +if perms is not None: + role.scoped_permissions.set(perms) # Применяется первым + +if codes is not None: + # ... валидация ... + role.scoped_permissions.set(list(qs)) # Перезаписывает предыдущее! +``` + +**Вывод**: `scoped_permission_codes` имеет **больший приоритет** и перезапишет `scoped_permissions`. + +--- + +## 4. Логика обновления (UPDATE) + +### 4.1 Метод update() + +```python +def update(self, instance, validated_data): + # Аналогично: вынимаем до super().update() + codes = validated_data.pop("scoped_permission_codes", None) + perms = validated_data.pop("scoped_permissions", None) + + role = super().update(instance, validated_data) + + if perms is not None: + role.scoped_permissions.set(perms) + + if codes is not None: + qs = DepartmentPermission.objects.filter(code__in=codes) + if qs.count() != len(set(codes)): + raise serializers.ValidationError( + {"scoped_permission_codes": "Некоторые коды не существуют"} + ) + role.scoped_permissions.set(list(qs)) + + return role +``` + +### 4.2 Особенности PATCH + +**При PATCH запросе без указания прав:** +```json +PATCH /api/v1/department-roles/12/ +{ + "name": "New Name" +} +``` + +- `perms` и `codes` будут `None` +- Права **не изменятся** (не будут очищены) +- Обновится только `name` + +**При явном указании пустого массива:** +```json +{ + "scoped_permissions": [] +} +``` +- Все права будут **удалены**: `role.scoped_permissions.set([])` + +--- + +## 5. Backward Compatibility + +### 5.1 Поле permissions (read-only) + +```python +def get_permissions(self, obj: DepartmentRole): + return list(obj.scoped_permissions.values_list("id", flat=True)) +``` + +**Назначение**: Старый API возвращал список ID прав. Теперь это зеркало `scoped_permissions`. + +**Пример ответа**: +```json +{ + "id": 12, + "scoped_permissions": [1, 3, 5], + "permissions": [1, 3, 5] // дубликат для совместимости +} +``` + +### 5.2 Поле permissions_verbose (read-only) + +```python +def get_permissions_verbose(self, obj: DepartmentRole): + return [ + {"id": p.id, "code": p.code, "name": p.name} + for p in obj.scoped_permissions.order_by("code").all() + ] +``` + +**Назначение**: Полная информация о правах с сортировкой по `code`. + +**Пример ответа**: +```json +{ + "permissions_verbose": [ + {"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"}, + {"id": 1, "code": "manage_department", "name": "Управлять отделом"} + ] +} +``` + +**Сортировка**: По коду прав (алфавитный порядок). + +--- + +## 6. Валидация + +### 6.1 Валидация кодов прав + +```python +if codes is not None: + qs = DepartmentPermission.objects.filter(code__in=codes) + if qs.count() != len(set(codes)): + raise serializers.ValidationError( + {"scoped_permission_codes": "Некоторые коды не существуют"} + ) +``` + +**Проверки**: +1. `set(codes)` — устраняет дубликаты в запросе +2. `qs.count()` — количество найденных в БД +3. Если не совпадают → какие-то коды не существуют + +**Пример ошибки**: +```json +{ + "scoped_permission_codes": ["Некоторые коды не существуют"] +} +``` + +### 6.2 Валидация уникальности (department, name) + +**Уровень проверки**: Constraint БД `unique_together = [('department', 'name')]` + +**Ошибка при дубликате**: +```python +# Django автоматически поднимает IntegrityError +# DRF оборачивает в ValidationError +``` + +**Пример ответа**: +```json +{ + "non_field_errors": ["The fields department, name must make a unique set."] +} +``` + +### 6.3 Валидация ID прав (scoped_permissions) + +```python +scoped_permissions = serializers.PrimaryKeyRelatedField( + queryset=DepartmentPermission.objects.all(), + many=True, + required=False +) +``` + +**Автоматическая проверка**: +- Все указанные ID должны существовать в `DepartmentPermission` +- При несуществующем ID → `ValidationError` + +--- + +## 7. SetMemberRoleInput — Назначение роли сотруднику + +### Расположение +`backend/api/v1/employees/serializers.py` (строки 712-734) + +### Назначение +Валидация payload для `Department.set_member_role()` endpoint. + +### Структура + +```python +class SetMemberRoleInput(serializers.Serializer): + # Поддерживаем алиасы: employee / role + employee_id = serializers.IntegerField(required=False) + role_id = serializers.IntegerField(allow_null=True, required=False) + employee = serializers.IntegerField(required=False) + role = serializers.IntegerField(allow_null=True, required=False) + is_active = serializers.BooleanField(required=False) # новый флаг + + def validate(self, attrs): + # Нормализация алиасов + if "employee" in attrs and "employee_id" not in attrs: + attrs["employee_id"] = attrs.pop("employee") + if "role" in attrs and "role_id" not in attrs: + attrs["role_id"] = attrs.pop("role") + + # Проверка обязательного employee_id + if "employee_id" not in attrs: + raise serializers.ValidationError( + {"employee_id": "This field is required."} + ) + + return attrs +``` + +### Поддерживаемые форматы + +**Вариант 1: Явные суффиксы** +```json +{ + "employee_id": 42, + "role_id": 12 +} +``` + +**Вариант 2: Короткие имена** +```json +{ + "employee": 42, + "role": 12 +} +``` + +**Вариант 3: Снятие роли** +```json +{ + "employee_id": 42, + "role_id": null // или просто не указывать поле +} +``` + +**Вариант 4: С флагом активности (новое)** +```json +{ + "employee_id": 42, + "role_id": 12, + "is_active": false // деактивировать членство +} +``` + +### Особенности валидации + +**employee_id обязателен**: +```python +if "employee_id" not in attrs: + raise serializers.ValidationError(...) +``` + +**role_id опционален**: +```python +role_id = serializers.IntegerField(allow_null=True, required=False) +``` +- Если `null` или отсутствует → роль снимается +- Если указан → назначается + +**is_active опционален**: +- Новое поле (недавно добавлено) +- Позволяет изменять активность членства при назначении роли + +--- + +## 8. Проблемные места + +### 8.1 Двойная установка прав при наличии обоих полей + +**Проблема**: +```python +if perms is not None: + role.scoped_permissions.set(perms) # Шаг 1 + +if codes is not None: + # валидация + role.scoped_permissions.set(list(qs)) # Шаг 2: перезаписывает! +``` + +**Последствие**: Если клиент отправит оба поля, `scoped_permissions` будет проигнорирован. + +**Решение**: Добавить валидацию: +```python +def validate(self, attrs): + if 'scoped_permissions' in attrs and 'scoped_permission_codes' in attrs: + raise serializers.ValidationError( + "Cannot specify both scoped_permissions and scoped_permission_codes" + ) + return attrs +``` + +--- + +### 8.2 Нет валидации принадлежности роли к отделу + +**Проблема**: На уровне сериализатора не проверяется, что `role.department_id == validated_data['department']`. + +**Где защита**: Только в ViewSet и на уровне БД constraint. + +**Риск**: Если сериализатор используется вне ViewSet, можно создать несогласованные данные. + +**Решение**: Добавить кастомную валидацию: +```python +def validate(self, attrs): + role_id = attrs.get('role_id') + dept_id = attrs.get('department') + if role_id and dept_id: + role = DepartmentRole.objects.filter(id=role_id).first() + if role and role.department_id != dept_id: + raise serializers.ValidationError({ + 'role_id': 'Role must belong to the specified department' + }) + return attrs +``` + +--- + +### 8.3 Дублирование логики валидации кодов + +**Проблема**: Одинаковая логика в `create()` и `update()`: +```python +# В обоих методах: +if codes is not None: + qs = DepartmentPermission.objects.filter(code__in=codes) + if qs.count() != len(set(codes)): + raise serializers.ValidationError(...) + role.scoped_permissions.set(list(qs)) +``` + +**Решение**: Вынести в приватный метод: +```python +def _apply_codes_if_present(self, instance: DepartmentRole, validated_data: dict): + codes = validated_data.pop("scoped_permission_codes", None) + if codes is not None: + qs = DepartmentPermission.objects.filter(code__in=codes) + if qs.count() != len(set(codes)): + raise serializers.ValidationError( + {"scoped_permission_codes": "Некоторые коды не существуют"} + ) + instance.scoped_permissions.set(list(qs)) +``` + +**Текущий код**: Этот метод уже существует (`_apply_codes_if_present`), но **не используется** в `create()`/`update()`! Дублирование осталось. + +--- + +### 8.4 Backward-compat поля увеличивают размер ответа + +**Проблема**: +```json +{ + "scoped_permissions": [1, 3, 5], + "permissions": [1, 3, 5], // дубликат + "permissions_verbose": [ + {"id": 1, "code": "manage_department", "name": "..."}, + {"id": 3, "code": "assign_department_role", "name": "..."}, + {"id": 5, "code": "view_request", "name": "..."} + ] +} +``` + +**Последствие**: При списке из 50 ролей размер ответа удваивается из-за дублирования. + +**Решение**: +- Использовать query param для включения legacy полей: `?include_legacy=true` +- Или перейти на v2 API без backward-compat + +--- + +## 9. Использование в коде + +### 9.1 В DepartmentRoleViewSet + +```python +class DepartmentRoleViewSet(viewsets.ModelViewSet): + serializer_class = DepartmentRoleSerializer + + # Автоматически используется в: + # - list() → many=True + # - retrieve() → один объект + # - create() → validation + создание + # - update()/partial_update() → validation + обновление +``` + +### 9.2 В DepartmentViewSet.ui_context() + +```python +roles_qs = DepartmentRole.objects.filter(department_id=dept.id) +roles_data = DepartmentRoleSerializer(roles_qs, many=True).data +``` + +**Цель**: Получить все роли отдела для отображения в UI. + +--- + +## 10. Сравнение с другими сериализаторами + +### 10.1 GroupSerializer (Django Groups) + +```python +class GroupSerializer(serializers.ModelSerializer): + permissions = serializers.PrimaryKeyRelatedField( + queryset=Permission.objects.all(), many=True, required=False + ) + permissions_verbose = serializers.SerializerMethodField(read_only=True) +``` + +**Отличия**: +- Нет поддержки кодов (только ID) +- Нет backward-compat (новое поле) +- Работает с глобальными Django permissions + +**Сходства**: +- M2M к permissions +- Verbose поле для полной информации + +--- + +## 11. Примеры запросов/ответов + +### 11.1 CREATE с кодами прав + +**Request**: +```http +POST /api/v1/department-roles/ +Content-Type: application/json + +{ + "department": 5, + "name": "Tech Lead", + "scoped_permission_codes": [ + "manage_department", + "assign_department_role", + "manage_department_events" + ] +} +``` + +**Response** (201): +```json +{ + "id": 15, + "department": 5, + "name": "Tech Lead", + "scoped_permissions": [1, 3, 4], + "permissions": [1, 3, 4], + "permissions_verbose": [ + {"id": 3, "code": "assign_department_role", "name": "Назначать роли участникам"}, + {"id": 1, "code": "manage_department", "name": "Управлять отделом"}, + {"id": 4, "code": "manage_department_events", "name": "Управлять календарём отдела"} + ] +} +``` + +### 11.2 UPDATE только названия + +**Request**: +```http +PATCH /api/v1/department-roles/15/ +Content-Type: application/json + +{ + "name": "Senior Tech Lead" +} +``` + +**Response** (200): +```json +{ + "id": 15, + "department": 5, + "name": "Senior Tech Lead", + "scoped_permissions": [1, 3, 4], // не изменились + ... +} +``` + +### 11.3 Ошибка валидации кодов + +**Request**: +```http +POST /api/v1/department-roles/ +Content-Type: application/json + +{ + "department": 5, + "name": "Invalid Role", + "scoped_permission_codes": ["manage_department", "nonexistent_code"] +} +``` + +**Response** (400): +```json +{ + "scoped_permission_codes": ["Некоторые коды не существуют"] +} +``` + +--- + +## 12. Рекомендации по улучшению + +### 12.1 Использовать метод _apply_codes_if_present + +```python +def create(self, validated_data): + perms = validated_data.pop("scoped_permissions", None) + role = super().create(validated_data) + + if perms is not None: + role.scoped_permissions.set(perms) + + self._apply_codes_if_present(role, validated_data) # ← использовать + return role +``` + +### 12.2 Добавить mutual exclusion валидацию + +```python +def validate(self, attrs): + if 'scoped_permissions' in attrs and 'scoped_permission_codes' in attrs: + raise serializers.ValidationError( + "Specify either scoped_permissions or scoped_permission_codes, not both" + ) + return attrs +``` + +### 12.3 Сделать department read-only при update + +```python +class Meta: + model = DepartmentRole + fields = (...) + extra_kwargs = { + 'department': {'required': True} # обязательно при create + } + +def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance is not None: # update + self.fields['department'].read_only = True +``` + +### 12.4 Оптимизировать backward-compat + +```python +def __init__(self, *args, **kwargs): + include_legacy = kwargs.pop('include_legacy', False) + super().__init__(*args, **kwargs) + + if not include_legacy: + self.fields.pop('permissions', None) + self.fields.pop('permissions_verbose', None) +``` + +**Использование**: +```python +serializer = DepartmentRoleSerializer(role, include_legacy=True) +``` + +--- + +## 13. Итоговая схема потока данных + +``` +┌────────────────────────────────────────┐ +│ Client Request Body │ +│ {department, name, │ +│ scoped_permission_codes: [...]} │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ DepartmentRoleSerializer.to_internal │ +│ - Validate fields │ +│ - Convert codes to internal format │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ create() / update() │ +│ 1. Pop codes and perms from data │ +│ 2. super().create/update (basic) │ +│ 3. Set M2M: scoped_permissions │ +│ 4. Validate codes → query DB │ +│ 5. Set M2M with codes if present │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ DepartmentRoleSerializer.to_represent │ +│ - Add scoped_permissions │ +│ - Add permissions (backward-compat) │ +│ - Add permissions_verbose │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ Response with all fields │ +└────────────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [04_DEPARTMENT_ROLES_PERMISSIONS.md](./04_DEPARTMENT_ROLES_PERMISSIONS.md) — Система проверки прав diff --git a/backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md b/backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md new file mode 100644 index 00000000..d87973c7 --- /dev/null +++ b/backend/docs/reports/04_DEPARTMENT_ROLES_PERMISSIONS.md @@ -0,0 +1,763 @@ +# Отчет: Система проверки прав в ролях отделов + +**Дата**: 30.12.2025 +**Анализ**: AdminOrDeptAllowed, has_dept_perm, user_has_dept_perm — архитектура permission system + +--- + +## 1. Обзор архитектуры прав + +### Уровни проверки прав + +``` +┌──────────────────────────────────────────────────────┐ +│ Level 1: Django Model Permissions │ +│ - Глобальные права на модель Department │ +│ - Проверка: user.has_perm('employees.manage_...') │ +└──────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────┐ +│ Level 2: Staff/Superuser Override │ +│ - user.is_staff or user.is_superuser → ALL ACCESS │ +└──────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────┐ +│ Level 3: Department Head Rights │ +│ - dept.head == user → ALL DEPARTMENT RIGHTS │ +└──────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────┐ +│ Level 4: Scoped Role Permissions │ +│ - EmployeeDepartment.role.scoped_permissions │ +│ - Права действуют ТОЛЬКО в конкретном отделе │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Функции проверки прав + +### 2.1 user_is_staffish() — Проверка admin-доступа + +**Расположение**: `backend/api/v1/permissions.py` (строки 26-28) + +```python +def user_is_staffish(user) -> bool: + return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff)) +``` + +**Назначение**: Быстрая проверка админского доступа. + +**Использование**: +- В условиях before expensive queries +- Для ранних return в permission classes + +--- + +### 2.2 user_is_dept_head() — Проверка руководителя + +**Расположение**: `backend/api/v1/permissions.py` (строки 21-24) + +```python +def user_is_dept_head(user, dept: Department) -> bool: + return bool( + user and user.is_authenticated and dept.head_id == getattr(user, "id", None) + ) +``` + +**Назначение**: Проверить, является ли пользователь руководителем отдела. + +**Особенности**: +- Принимает объект `Department` (не ID) +- Использует `getattr()` для безопасного доступа к `user.id` + +**Использование**: +```python +if user_is_dept_head(request.user, department): + # Руководитель имеет полный доступ + return True +``` + +--- + +### 2.3 user_has_dept_perm() — Проверка скоуп-права + +**Расположение**: `backend/api/v1/permissions.py` (строки 32-74) + +```python +def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool: + """Проверяет, есть ли у пользователя право `perm_code` в указанном отделе. + + Правила: + - Неаутентифицированным → False. + - staff/superuser → True. + - Руководитель отдела → True. + - Иначе: существует активная связь EmployeeDepartment, и у роли есть + DepartmentPermission с нужным code. + """ + if not (user and user.is_authenticated): + return False + + # staff/superuser — сразу ок + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + if not isinstance(dept, Department) or getattr(dept, "id", None) is None: + raise ValueError("Argument `dept` must be Department instance with a valid id.") + + # руководитель — сразу ок + if getattr(dept, "head_id", None) == user.id: + return True + + # основная проверка: активная связь + у роли есть нужный DepartmentPermission.code + return EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=dept.id, + is_active=True, + role__scoped_permissions__code=perm_code, + ).exists() +``` + +### Логика проверки (порядок) + +``` +1. Аутентификация ──► Нет ──► False + │ + Да + ↓ +2. Staff/Superuser ──► Да ──► True + │ + Нет + ↓ +3. Валидация dept ──► Ошибка ──► ValueError + │ + OK + ↓ +4. Руководитель отдела ──► Да ──► True + │ + Нет + ↓ +5. Query EmployeeDepartment: + - employee_id = user.id + - department_id = dept.id + - is_active = True + - role__scoped_permissions__code = perm_code + │ + ↓ + Exists? ──► Да ──► True + │ + Нет + ↓ + False +``` + +### Особенности + +**1. Принимает объект Department**: +```python +# ✅ Правильно: +user_has_dept_perm(user, department_obj, "manage_department") + +# ❌ Неправильно: +user_has_dept_perm(user, 5, "manage_department") # ValueError +``` + +**2. Использует M2M join**: +```sql +SELECT 1 FROM employees_employeedepartment +JOIN employees_departmentrole_scoped_permissions + ON employees_departmentrole_scoped_permissions.departmentrole_id = employees_employeedepartment.role_id +JOIN employees_departmentpermission + ON employees_departmentpermission.id = employees_departmentrole_scoped_permissions.departmentpermission_id +WHERE employee_id = %s + AND department_id = %s + AND is_active = TRUE + AND code = %s +LIMIT 1 +``` + +**3. Проверяет `is_active`**: +- Деактивированные членства игнорируются +- Даже если роль есть, но `is_active=False` → доступ запрещён + +--- + +### 2.4 has_dept_perm() — Проверка по ID отдела + +**Расположение**: `backend/api/v1/permissions.py` (строки 76-106) + +```python +def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool: + """ + Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`. + + Логика: + 1) staff/superuser → всегда True + 2) пользователь является текущим руководителем отдела → True + 3) пользователь состоит в отделе и его роль содержит `code` → True + 4) иначе False + """ + if not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False): + return True + + # 2) Руководитель отдела имеет все управленческие права в своём отделе + if Department.objects.filter(id=department_id, head_id=user.id).exists(): + return True + + # 3) Проверка роли в отделе + link = ( + EmployeeDepartment.objects.select_related("role") + .filter(employee_id=user.id, department_id=department_id, is_active=True) + .first() + ) + if not link or not link.role_id: + return False + + # роль хранит «скоуп-права» только для этого отдела + return link.role.scoped_permissions.filter(code=code).exists() +``` + +### Отличия от user_has_dept_perm + +| Аспект | user_has_dept_perm | has_dept_perm | +|--------|-------------------|---------------| +| Параметр отдела | `dept: Department` (объект) | `department_id: int` (ID) | +| Query руководителя | `dept.head_id == user.id` (в памяти) | `Department.objects.filter(...)` (DB) | +| Query прав | 1 запрос (M2M join) | 2 запроса (select_related + M2M) | +| Использование | Когда объект уже загружен | Когда есть только ID | + +### Оптимизация + +**has_dept_perm** использует: +```python +link = EmployeeDepartment.objects.select_related("role").filter(...).first() +``` + +**Цель**: Загрузить роль заранее, чтобы следующий запрос использовал кеш: +```python +return link.role.scoped_permissions.filter(code=code).exists() +``` + +**Результат**: 2 query вместо 3. + +--- + +## 3. AdminOrDeptAllowed — Базовый permission class + +### Расположение +`backend/api/v1/permissions.py` (строки 108-310) + +### Назначение +Комбинированный permission class для DRF ViewSets: +- Админы имеют полный доступ +- Остальные проверяются по скоуп-правам в конкретном отделе + +--- + +### 3.1 Структура класса + +```python +class AdminOrDeptAllowed(BasePermission): + required_code: Optional[str] = None + required_code_map: Dict[str, str] = {} + allow_safe_without_code: bool = True +``` + +### Атрибуты класса + +| Атрибут | Тип | По умолчанию | Назначение | +|---------|-----|--------------|------------| +| `required_code` | str \| None | None | Единый код права для всех действий | +| `required_code_map` | dict[str, str] | {} | Коды прав по имени action | +| `allow_safe_without_code` | bool | True | Разрешать ли GET без кода | + +--- + +### 3.2 Методы + +#### get_required_code() + +```python +def get_required_code(self, request, view) -> Optional[str]: + """Определяет требуемый код права для текущего экшена. + + Сначала берёт из required_code_map по имени action (DRF: view.action), + затем fallback на required_code. + """ + return ( + self.required_code_map.get(getattr(view, "action", None)) + or self.required_code + ) +``` + +**Приоритет**: +1. `required_code_map[view.action]` +2. `required_code` +3. `None` (если оба пусты) + +**Примеры**: +```python +class MyPerm(AdminOrDeptAllowed): + required_code = DeptPerm.MANAGE # Для всех действий + +class MyPerm2(AdminOrDeptAllowed): + required_code_map = { + "create": DeptPerm.MANAGE, + "update": DeptPerm.MANAGE, + "destroy": DeptPerm.CHANGE_HEAD, # Другое право для удаления + } +``` + +--- + +#### _extract_dept_id_from_request() + +```python +def _extract_dept_id_from_request(self, request, view) -> Optional[int]: + """Пытается извлечь department_id из kwargs, body или query-параметров. + + Порядок: + 1) view.kwargs: department_pk, department_id + 2) request.data: department, department_id + 3) request.query_params: department, department_id + """ + for k in ("department_pk", "department_id"): + v = view.kwargs.get(k) + if v is not None: + return v + for k in ("department", "department_id"): + v = request.data.get(k) + if v is not None: + return v + for k in ("department", "department_id"): + v = request.query_params.get(k) + if v is not None: + return v + return None +``` + +**Порядок приоритета**: +1. **URL kwargs** (`/departments/{id}/...` → `department_pk`) +2. **Request body** (`POST {"department": 5}`) +3. **Query params** (`?department=5`) + +**Примеры**: +```python +# Вариант 1: Nested route +POST /api/v1/departments/5/roles/ +→ view.kwargs = {"department_pk": 5} + +# Вариант 2: Body +POST /api/v1/department-roles/ +{"department": 5, "name": "Manager"} +→ request.data = {"department": 5} + +# Вариант 3: Query +GET /api/v1/department-roles/?department=5 +→ request.query_params = {"department": "5"} +``` + +--- + +#### _extract_dept_id_from_obj() + +```python +def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]: + """Извлекает department_id из объекта. + + Порядок: + 1) obj.id (если obj сам Department) + 2) obj.department_id / obj.dept_id + 3) obj.department.id + """ + # 1) сам объект — Department + if isinstance(obj, Department): + return getattr(obj, "id", None) + + # 2) прямые FK + for attr in ("department_id", "dept_id"): + dept_id = getattr(obj, attr, None) + if dept_id is not None: + return dept_id + + # 3) related Department + dept = getattr(obj, "department", None) + if dept: + return getattr(dept, "id", None) + + return None +``` + +**Примеры**: +```python +# 1. Объект Department +dept = Department.objects.get(id=5) +→ dept_id = 5 + +# 2. DepartmentRole +role = DepartmentRole.objects.get(id=12) +→ role.department_id = 5 + +# 3. Объект с related +class MyModel: + department = ForeignKey(Department) +obj = MyModel.objects.select_related('department').get(...) +→ obj.department.id = 5 +``` + +--- + +#### has_permission() — Request-level check + +```python +def has_permission(self, request, view): + """Проверка на уровне запроса (до загрузки объекта). + + Используется для: + - list/create (нет конкретного объекта) + - Определения dept_id из request + """ + user = request.user + + # 1. Админы всегда проходят + if user_is_staffish(user): + return True + + # 2. Получить требуемый код + code = self.get_required_code(request, view) + + # 3. Если код не требуется + if not code: + # SAFE методы разрешены (GET/HEAD/OPTIONS) + if request.method in SAFE_METHODS and self.allow_safe_without_code: + return True + return False # Небезопасные без кода → запрет + + # 4. Извлечь dept_id + dept_id = self._extract_dept_id_from_request(request, view) + if not dept_id: + return False # Не можем определить отдел → запрет + + # 5. Проверить право в отделе + return has_dept_perm(user, dept_id, code) +``` + +**Логика потока**: +``` +Request → has_permission() + ↓ +1. Staff? → Yes → Allow + ↓ No +2. Get code (action-specific or default) + ↓ +3. Code is None? + ↓ Yes + SAFE method & allow_safe_without_code? → Allow + ↓ No → Deny +4. Extract dept_id from request + ↓ +5. has_dept_perm(user, dept_id, code) → Allow/Deny +``` + +--- + +#### has_object_permission() — Object-level check + +```python +def has_object_permission(self, request, view, obj): + """Проверка на уровне объекта (после загрузки). + + Используется для: + - retrieve/update/destroy (есть конкретный объект) + - Определения dept_id из самого объекта + """ + user = request.user + + # 1. Админы всегда проходят + if user_is_staffish(user): + return True + + # 2. Получить код + code = self.get_required_code(request, view) + + # 3. Если код не требуется + if not code: + if request.method in SAFE_METHODS and self.allow_safe_without_code: + return True + return False + + # 4. Извлечь dept_id из объекта + dept_id = self._extract_dept_id_from_obj(obj) + if not dept_id: + return False + + # 5. Проверить право + return has_dept_perm(request.user, dept_id, code) +``` + +**Отличие от has_permission**: +- Извлечение `dept_id` из **obj**, а не из request +- Используется после `get_object()` в ViewSet + +--- + +## 4. Наследники AdminOrDeptAllowed + +### 4.1 В DepartmentViewSet + +```python +class ManagePerm(AdminOrDeptAllowed): + """Право на управление отделом (add/remove members).""" + required_code = DeptPerm.MANAGE + +class ChangeHeadPerm(AdminOrDeptAllowed): + """Право на назначение руководителя.""" + required_code = DeptPerm.CHANGE_HEAD + +class AssignRolePerm(AdminOrDeptAllowed): + """Право на назначение ролей участникам отдела.""" + required_code = DeptPerm.ASSIGN_ROLE +``` + +**Использование**: +```python +def get_permissions(self): + if self.action == 'set_head': + return [self.ChangeHeadPerm()] + if self.action in {'add_member', 'remove_member'}: + return [self.ManagePerm()] + if self.action == 'set_member_role': + return [self.AssignRolePerm()] + ... +``` + +--- + +### 4.2 В DepartmentRoleViewSet + +```python +class AssignRolePerm(AdminOrDeptAllowed): + required_code = DeptPerm.ASSIGN_ROLE + +def get_permissions(self): + if self.action in {"create", "update", "partial_update", "destroy", "set_perms"}: + return [self.AssignRolePerm()] + return [IsAuthenticated()] +``` + +**Логика**: +- Создание/изменение/удаление ролей требует `assign_department_role` +- Чтение ролей доступно всем аутентифицированным + +--- + +### 4.3 В Calendar ViewSet + +```python +class ManageCalendarPerm(AdminOrDeptAllowed): + required_code = DeptPerm.MANAGE_CALENDAR +``` + +**Использование**: Управление календарём событий отдела. + +--- + +## 5. Проблемные места + +### 5.1 Дублирование логики в двух функциях + +**Проблема**: `user_has_dept_perm` и `has_dept_perm` делают почти одно и то же, но по-разному. + +**Следствие**: +- Две кодовые ветки для поддержки +- Разная производительность (1 vs 2 запроса) +- Риск рассинхронизации логики + +**Решение**: Унифицировать в одну функцию: +```python +def has_dept_perm(user, dept: Department | int, code: str) -> bool: + if isinstance(dept, int): + dept_id = dept + # Query Department if needed + else: + dept_id = dept.id + # Unified logic +``` + +--- + +### 5.2 Нет кеширования результатов проверки + +**Проблема**: При множественных проверках в рамках одного request делаются повторные запросы. + +**Пример**: +```python +# В одном request: +has_dept_perm(user, 5, "manage_department") # Query 1 +has_dept_perm(user, 5, "assign_department_role") # Query 2 +has_dept_perm(user, 5, "manage_department") # Query 3 (дубликат) +``` + +**Решение**: Использовать `@lru_cache` или request-level кеш: +```python +from functools import lru_cache + +@lru_cache(maxsize=128) +def has_dept_perm(user_id: int, dept_id: int, code: str) -> bool: + # ... логика ... +``` + +--- + +### 5.3 Неочевидный приоритет в _extract_dept_id_from_request + +**Проблема**: Порядок извлечения может привести к неожиданному поведению. + +**Пример**: +```http +POST /api/v1/departments/5/roles/ +{"department": 10} # Другой ID в body + +→ Используется view.kwargs["department_pk"] = 5 +→ Body игнорируется +``` + +**Решение**: Добавить валидацию конфликтов: +```python +kwargs_id = view.kwargs.get("department_pk") +body_id = request.data.get("department") +if kwargs_id and body_id and kwargs_id != body_id: + raise PermissionDenied("Conflicting department IDs in URL and body") +``` + +--- + +### 5.4 ValueError при невалидном dept в user_has_dept_perm + +**Проблема**: +```python +if not isinstance(dept, Department) or getattr(dept, "id", None) is None: + raise ValueError("Argument `dept` must be Department instance...") +``` + +**Следствие**: В production ValueError не обрабатывается DRF, возвращается 500. + +**Решение**: Использовать `PermissionDenied`: +```python +from rest_framework.exceptions import PermissionDenied + +if not isinstance(dept, Department): + raise PermissionDenied("Invalid department object") +``` + +--- + +## 6. Примеры использования + +### 6.1 Проверка прав в кастомном action + +```python +@action(detail=True, methods=["post"]) +def custom_action(self, request, pk=None): + dept = self.get_object() + + # Проверить конкретное право + if not user_has_dept_perm(request.user, dept, DeptPerm.MANAGE): + return Response({"detail": "No permission"}, status=403) + + # Бизнес-логика + ... +``` + +### 6.2 Условная логика на основе прав + +```python +def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + + if user.is_staff: + return qs # Все отделы + + # Только отделы, где есть право MANAGE + dept_ids = EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True, + role__scoped_permissions__code=DeptPerm.MANAGE + ).values_list('department_id', flat=True) + + return qs.filter(id__in=dept_ids) +``` + +### 6.3 Множественные права (OR логика) + +```python +def has_any_dept_perm(user, dept_id: int, codes: list[str]) -> bool: + """Проверить наличие хотя бы одного из прав.""" + if user.is_staff or user.is_superuser: + return True + + if Department.objects.filter(id=dept_id, head_id=user.id).exists(): + return True + + return EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=dept_id, + is_active=True, + role__scoped_permissions__code__in=codes + ).exists() +``` + +--- + +## 7. Итоговая схема проверки прав + +``` +┌────────────────────────────────────────────────────┐ +│ DRF Request │ +└────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────┐ +│ ViewSet.check_permissions() │ +│ → AdminOrDeptAllowed.has_permission(request, view)│ +└────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────┐ +│ 1. user_is_staffish(user)? │ +│ Yes → Allow │ +└────────────────────────────────────────────────────┘ + ↓ No +┌────────────────────────────────────────────────────┐ +│ 2. get_required_code(request, view) │ +│ → DeptPerm.ASSIGN_ROLE │ +└────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────┐ +│ 3. _extract_dept_id_from_request(request, view) │ +│ → dept_id = 5 │ +└────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────┐ +│ 4. has_dept_perm(user, dept_id, code) │ +└────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────┐ +│ 5a. Department.filter(id=dept_id, head=user)? │ +│ Yes → True │ +└────────────────────────────────────────────────────┘ + ↓ No +┌────────────────────────────────────────────────────┐ +│ 5b. EmployeeDepartment.filter( │ +│ employee=user, department=dept_id, │ +│ is_active=True, │ +│ role__scoped_permissions__code=code │ +│ ).exists()? │ +│ Yes → True, No → False │ +└────────────────────────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md](./05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md) — Константы и утилиты diff --git a/backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md b/backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md new file mode 100644 index 00000000..6a95e114 --- /dev/null +++ b/backend/docs/reports/05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md @@ -0,0 +1,647 @@ +# Отчет: Константы и утилиты системы ролей + +**Дата**: 30.12.2025 +**Анализ**: DeptPerm, _ensure_department_permissions, вспомогательные функции + +--- + +## 1. DeptPerm — Справочник кодов прав + +### Расположение +`backend/employees/constants.py` (строки 30-55) + +### Структура + +```python +class DeptPerm: + MANAGE = "manage_department" + CHANGE_HEAD = "change_department_head" + ASSIGN_ROLE = "assign_department_role" + MANAGE_CALENDAR = "manage_department_events" + CREATE_POST = "publish_department_post" + MANAGE_FEED = "manage_department_feed" + VIEW_REQUESTCOMMENT = "view_requestcomment" + ADD_REQUESTCOMMENT = "add_requestcomment" + VIEW_REQUEST = "view_request" + CAN_PROCESS_REQUESTS = "can_process_requests" + + CHOICES = ( + (MANAGE, "Управлять отделом"), + (CHANGE_HEAD, "Назначать руководителя"), + (ASSIGN_ROLE, "Назначать роли участникам"), + (MANAGE_CALENDAR, "Управлять календарём отдела"), + (CREATE_POST, "Публиковать новости на странице отдела"), + (MANAGE_FEED, "Редактировать публикации отдела"), + (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), + (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), + (VIEW_REQUEST, "Просмотр заявлений отдела"), + (CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"), + ) +``` + +### Назначение +Единая точка истины для всех кодов прав отделов. Используется: +- В `AdminOrDeptAllowed` классах для указания `required_code` +- В `_ensure_department_permissions()` для синхронизации с БД +- В проверках прав `has_dept_perm(user, dept_id, DeptPerm.MANAGE)` + +--- + +## 2. Категории прав + +### 2.1 Управление отделом + +| Код | Константа | Описание | Использование | +|-----|-----------|----------|---------------| +| `manage_department` | `DeptPerm.MANAGE` | Добавление/удаление участников | `add_member`, `remove_member` | +| `change_department_head` | `DeptPerm.CHANGE_HEAD` | Назначение руководителя | `set_head` | +| `assign_department_role` | `DeptPerm.ASSIGN_ROLE` | Назначение ролей участникам | `set_member_role`, CRUD ролей | + +### 2.2 Контент отдела + +| Код | Константа | Описание | Использование | +|-----|-----------|----------|---------------| +| `manage_department_events` | `DeptPerm.MANAGE_CALENDAR` | Управление календарём | CalendarViewSet | +| `publish_department_post` | `DeptPerm.CREATE_POST` | Публикация новостей | FeedViewSet | +| `manage_department_feed` | `DeptPerm.MANAGE_FEED` | Редактирование публикаций | FeedViewSet | + +### 2.3 Заявки отдела + +| Код | Константа | Описание | Использование | +|-----|-----------|----------|---------------| +| `view_request` | `DeptPerm.VIEW_REQUEST` | Просмотр заявлений | RequestsViewSet | +| `view_requestcomment` | `DeptPerm.VIEW_REQUESTCOMMENT` | Просмотр комментариев | RequestsViewSet | +| `add_requestcomment` | `DeptPerm.ADD_REQUESTCOMMENT` | Добавление комментариев | RequestsViewSet | +| `can_process_requests` | `DeptPerm.CAN_PROCESS_REQUESTS` | Рассмотрение заявлений | RequestsViewSet | + +--- + +## 3. _ensure_department_permissions() — Синхронизация БД + +### Расположение +`backend/employees/utils.py` (строки 91-107) + +### Назначение +Идемпотентная синхронизация справочника `DepartmentPermission` с `DeptPerm.CHOICES`. + +### Код + +```python +def _ensure_department_permissions() -> list[dict]: + """ + Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES. + Возвращает список словарей {id, code, name} в порядке CHOICES. + """ + items: list[dict] = [] + # пробежимся по CHOICES, создадим/обновим имя, соберём выдачу + for code, label in DeptPerm.CHOICES: + obj, _ = DepartmentPermission.objects.get_or_create( + code=code, defaults={"name": label} + ) + # если имя в БД отстаёт от CHOICES — мягко синхронизируем + if obj.name != label: + obj.name = label + obj.save(update_fields=["name"]) + items.append({"id": obj.id, "code": obj.code, "name": obj.name}) + return items +``` + +### Логика работы + +``` +Для каждого (code, label) в DeptPerm.CHOICES: + ↓ +1. get_or_create(code=code, defaults={"name": label}) + ↓ +2. Если obj.name != label (имя изменилось в коде): + ↓ + obj.name = label + obj.save() + ↓ +3. Добавить {id, code, name} в результат + ↓ +Вернуть list[dict] +``` + +### Особенности + +**Идемпотентность**: +- Можно вызывать многократно без побочных эффектов +- При повторном вызове не создаёт дубликаты + +**Автоматическое обновление имён**: +```python +# Было в константах: +(MANAGE, "Управлять отделом") + +# Изменили на: +(MANAGE, "Полное управление отделом") + +# При следующем вызове _ensure_department_permissions(): +obj.name = "Полное управление отделом" # Автообновление +``` + +**НЕ удаляет устаревшие**: +- Если код удалён из `DeptPerm.CHOICES`, запись в БД остаётся +- Ручная очистка через admin или migration + +--- + +### Вызовы в коде + +**1. DepartmentRoleViewSet.perm_choices()**: +```python +@action(detail=False, methods=["get"]) +def perm_choices(self, request): + data = _ensure_department_permissions() + return Response({"count": len(data), "results": data}, status=200) +``` + +**2. Department.ui_context()**: +```python +perm_choices = _perm_choices_synced() # Вызывает _ensure_department_permissions +``` + +--- + +## 4. _perm_choices_synced() — Алиас для синхронизации + +### Расположение +`backend/employees/utils.py` (строки 158-171) + +### Код + +```python +def _perm_choices_synced() -> list[dict]: + """ + Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES. + """ + items = [] + for code, label in DeptPerm.CHOICES: + obj, _ = DepartmentPermission.objects.get_or_create( + code=code, defaults={"name": label} + ) + if obj.name != label: + obj.name = label + obj.save(update_fields=["name"]) + items.append({"id": obj.id, "code": obj.code, "name": obj.name}) + return items +``` + +### Назначение +Дубликат `_ensure_department_permissions()` с другим именем. Используется в `Department.ui_context()`. + +### Проблема +Две функции делают одно и то же → техдолг. + +--- + +## 5. _head_choices_for_dept() — Кандидаты на руководителя + +### Расположение +`backend/employees/utils.py` (строки 110-155) + +### Назначение +Возвращает список сотрудников, которые могут быть назначены руководителями отдела. + +### Код + +```python +def _head_choices_for_dept(dept: Department, serializer) -> list[dict]: + """ + Вернёт список кандидатов для назначения руководителя отдела. + + Формат элемента: + { + "id": int, # ID сотрудника + "name": str, # display_name из EmployeeBriefSerializer + "email": str # email сотрудника (может быть пустой строкой) + } + """ + choices, seen = [], set() + qs = ( + EmployeeDepartment.objects.filter(department_id=dept.id) + .select_related("employee") + .order_by( + "employee__last_name", + "employee__first_name", + "employee__patronymic", + "employee_id", + ) + ) + for link in qs: + data = serializer(link.employee).data + if data["id"] not in seen: + choices.append( + { + "id": data["id"], + "name": data["display_name"], + "email": data.get("email", "") or "", + } + ) + seen.add(data["id"]) + + # Если текущий руководитель не в списке членов → добавить в начало + if dept.head_id and dept.head_id not in seen: + head_data = serializer(dept.head).data + choices.insert( + 0, + { + "id": head_data["id"], + "name": head_data["display_name"], + "email": head_data.get("email", "") or "", + }, + ) + + return choices +``` + +### Логика + +``` +1. Получить всех членов отдела (EmployeeDepartment) + ↓ +2. Сортировка: last_name, first_name, patronymic, id + ↓ +3. Для каждого члена: + - Сериализовать employee + - Если id уже в seen → пропустить (дедупликация) + - Добавить {id, name, email} в choices + ↓ +4. Если текущий head не в choices: + - Сериализовать dept.head + - Вставить в начало списка + ↓ +5. Вернуть choices +``` + +### Особенности + +**Дедупликация через seen**: +- Защита от дублей при некорректных данных + +**Текущий руководитель всегда в списке**: +- Даже если не является членом отдела +- Вставляется в позицию 0 (первый элемент) + +**Сортировка по ФИО**: +- Удобный порядок для UI dropdown + +--- + +## 6. _build_links_for_dept() — Список участников отдела + +### Расположение +`backend/employees/utils.py` (строки 174-203) + +### Назначение +Возвращает полную информацию о членстве сотрудников в отделе (для UI). + +### Код (начало) + +```python +def _build_links_for_dept(dept: Department, serializer) -> list[dict]: + """ + Возвращает список линков отдела: + [{ + "employee": , + "role": {"id","name"}|None, + "is_active": bool + }, ...] + """ + links: list[dict] = [] + qs = ( + EmployeeDepartment.objects.filter(department_id=dept.id) + .select_related("employee", "role") + .order_by( + "employee__last_name", + "employee__first_name", + "employee__patronymic", + "employee_id", + ) + ) + for link in qs: + emp_data = serializer(link.employee).data + role_data = None + if link.role: + role_data = {"id": link.role.id, "name": link.role.name} + + links.append({ + "employee": emp_data, + "role": role_data, + "is_active": link.is_active, + }) + + return links +``` + +### Формат ответа + +```json +[ + { + "employee": { + "id": 42, + "display_name": "Иванов Иван Иванович", + "email": "ivanov@example.com", + "position": "Разработчик" + }, + "role": { + "id": 12, + "name": "Tech Lead" + }, + "is_active": true + }, + { + "employee": { + "id": 43, + "display_name": "Петров Пётр", + "email": "petrov@example.com" + }, + "role": null, + "is_active": true + } +] +``` + +### Использование + +**В Department.members()**: +```python +@action(detail=True, methods=["get"], url_path="members") +def members(self, request, pk=None): + dept = self.get_object() + links = _build_links_for_dept(dept, EmployeeBriefSerializer) + return Response({"count": len(links), "results": links}, status=200) +``` + +**В Department.ui_context()**: +```python +links = _build_links_for_dept(dept, EmployeeBriefSerializer) +payload = { + "dept": dept_data, + "roles": roles_data, + "links": links, # ← здесь + ... +} +``` + +--- + +## 7. Проблемные места + +### 7.1 Дублирование _ensure и _perm_choices_synced + +**Проблема**: Две идентичные функции. + +```python +# _ensure_department_permissions() +for code, label in DeptPerm.CHOICES: + obj, _ = DepartmentPermission.objects.get_or_create(...) + # ... логика ... + +# _perm_choices_synced() +for code, label in DeptPerm.CHOICES: + obj, _ = DepartmentPermission.objects.get_or_create(...) + # ... та же логика ... +``` + +**Решение**: Удалить одну, использовать алиас: +```python +_perm_choices_synced = _ensure_department_permissions +``` + +--- + +### 7.2 N+1 запросов в _build_links_for_dept + +**Проблема**: Для каждого link вызывается `serializer(link.employee).data`. + +**Текущая защита**: `select_related("employee", "role")` — предзагрузка. + +**Оптимизация**: Использовать `prefetch_related` для вложенных данных: +```python +qs = ( + EmployeeDepartment.objects.filter(department_id=dept.id) + .select_related("employee", "role", "employee__position") + .prefetch_related("employee__skills") +) +``` + +--- + +### 7.3 Частые вызовы синхронизации + +**Проблема**: `_ensure_department_permissions()` вызывается при каждом GET запросе к `/perm_choices/` и `/ui-context/`. + +**Следствие**: +- Множественные `get_or_create` даже когда ничего не изменилось +- Нагрузка на БД + +**Решение**: Кешировать результат: +```python +from django.core.cache import cache + +def _ensure_department_permissions() -> list[dict]: + cache_key = "dept_permissions_v1" + cached = cache.get(cache_key) + if cached: + return cached + + items = [] + for code, label in DeptPerm.CHOICES: + obj, _ = DepartmentPermission.objects.get_or_create(...) + # ... + items.append(...) + + cache.set(cache_key, items, timeout=3600) # 1 час + return items +``` + +**Инвалидация кеша**: При изменении `DeptPerm.CHOICES` (редко). + +--- + +### 7.4 Нет защиты от удалённых кодов + +**Проблема**: Если код удалён из `DeptPerm.CHOICES`, запись в БД остаётся. + +**Сценарий**: +```python +# Было: +SOME_PERM = "some_permission" +CHOICES = (..., (SOME_PERM, "Какое-то право"), ...) + +# Стало (удалили): +CHOICES = (..., (OTHER_PERM, "Другое право"), ...) +``` + +**Следствие**: +- В БД остаётся `DepartmentPermission(code="some_permission")` +- Роли могут ссылаться на устаревшее право +- Проверки прав будут работать, но семантика нарушена + +**Решение**: Data migration при удалении кодов: +```python +def remove_obsolete_permission(apps, schema_editor): + DepartmentPermission = apps.get_model('employees', 'DepartmentPermission') + DepartmentPermission.objects.filter(code='some_permission').delete() +``` + +--- + +### 7.5 Жёсткая зависимость от serializer в utils + +**Проблема**: Функции принимают `serializer` как параметр: +```python +def _build_links_for_dept(dept: Department, serializer) -> list[dict]: + # ... использует serializer(link.employee).data +``` + +**Следствие**: +- Функция не может работать без сериализатора +- Сложно тестировать в изоляции + +**Решение**: Передавать функцию преобразования или использовать словари: +```python +def _build_links_for_dept(dept: Department, transform_fn=None) -> list[dict]: + if transform_fn is None: + transform_fn = lambda emp: {"id": emp.id, "name": emp.get_full_name()} + # ... +``` + +--- + +## 8. Использование констант в коде + +### 8.1 В Permission Classes + +```python +class ManagePerm(AdminOrDeptAllowed): + required_code = DeptPerm.MANAGE # ← использование константы + +class AssignRolePerm(AdminOrDeptAllowed): + required_code = DeptPerm.ASSIGN_ROLE +``` + +### 8.2 В проверках прав + +```python +if has_dept_perm(request.user, dept.id, DeptPerm.MANAGE): + # Разрешено управление отделом + ... +``` + +### 8.3 В тестах + +```python +def test_manage_perm_required(): + ensure_dept_perm(DeptPerm.MANAGE, "Управлять отделом") + role = make_role(dept, "Manager", [DeptPerm.MANAGE]) + # ... +``` + +--- + +## 9. Рекомендации по улучшению + +### 9.1 Унифицировать функции синхронизации + +```python +# Оставить одну функцию +def ensure_department_permissions() -> list[dict]: + # ... логика ... + +# Создать алиас для обратной совместимости +_ensure_department_permissions = ensure_department_permissions +_perm_choices_synced = ensure_department_permissions +``` + +### 9.2 Добавить management команду + +```python +# employees/management/commands/sync_dept_permissions.py +class Command(BaseCommand): + def handle(self, *args, **options): + items = _ensure_department_permissions() + self.stdout.write(f"Synced {len(items)} permissions") +``` + +**Использование**: +```bash +python manage.py sync_dept_permissions +``` + +### 9.3 Логировать изменения + +```python +def _ensure_department_permissions() -> list[dict]: + items = [] + for code, label in DeptPerm.CHOICES: + obj, created = DepartmentPermission.objects.get_or_create(...) + if created: + logger.info(f"Created DepartmentPermission: {code}") + elif obj.name != label: + logger.info(f"Updated name: {code} '{obj.name}' → '{label}'") + obj.name = label + obj.save() + items.append(...) + return items +``` + +### 9.4 Типизация возвращаемых значений + +```python +from typing import TypedDict + +class PermChoiceDict(TypedDict): + id: int + code: str + name: str + +def _ensure_department_permissions() -> list[PermChoiceDict]: + # ... +``` + +--- + +## 10. Итоговая схема взаимодействия + +``` +┌───────────────────────────────────┐ +│ DeptPerm.CHOICES (constants.py) │ +│ - Single source of truth │ +└───────────────────────────────────┘ + ↓ +┌───────────────────────────────────┐ +│ _ensure_department_permissions() │ +│ - Sync DB with constants │ +│ - Create missing entries │ +│ - Update names │ +└───────────────────────────────────┘ + ↓ +┌───────────────────────────────────┐ +│ DepartmentPermission (DB table) │ +│ - Persistent storage │ +│ - Referenced by roles via M2M │ +└───────────────────────────────────┘ + ↓ +┌───────────────────────────────────┐ +│ DepartmentRole.scoped_permissions │ +│ - M2M to DepartmentPermission │ +└───────────────────────────────────┘ + ↓ +┌───────────────────────────────────┐ +│ EmployeeDepartment.role │ +│ - FK to DepartmentRole │ +└───────────────────────────────────┘ + ↓ +┌───────────────────────────────────┐ +│ has_dept_perm() checks │ +│ - Query: role__scoped_perm__code │ +└───────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md) — LDAP интеграция diff --git a/backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md b/backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md new file mode 100644 index 00000000..4c591a55 --- /dev/null +++ b/backend/docs/reports/06_DEPARTMENT_ROLES_LDAP.md @@ -0,0 +1,625 @@ +# Отчет: LDAP интеграция системы ролей + +**Дата**: 30.12.2025 +**Анализ**: DirectoryService, синхронизация ролей с LDAP-группами, OU=Roles + +--- + +## 1. Обзор LDAP-интеграции + +### Архитектура LDAP для ролей + +``` +OU=Departments,DC=example,DC=com + │ + ├── OU=IT Department + │ ├── OU=Roles ← Контейнер ролей отдела + │ │ ├── CN=Tech Lead ← LDAP-группа роли + │ │ ├── CN=Senior Developer + │ │ └── CN=Junior Developer + │ ├── CN=IT Department (group) ← Общая группа отдела + │ ├── CN=User1 ← Сотрудники + │ └── CN=User2 + │ + └── OU=HR Department + ├── OU=Roles + │ └── CN=HR Manager + └── ... +``` + +### Ключевые сущности + +1. **OU=Departments**: Корневой контейнер для всех отделов +2. **OU=**: Organizational Unit отдела +3. **OU=Roles**: Подконтейнер внутри отдела для групп ролей +4. **CN=**: LDAP-группы, соответствующие ролям + +--- + +## 2. DirectoryService — Главный сервис + +### Расположение +`backend/employees/ldap/directory_service.py` + +### Структура + +```python +class DirectoryService: + def __init__(self): + from .services.department_service import DepartmentService + from .services.user_service import UserService + from .services.group_service import GroupService + + self._user_service = UserService() + self._group_service = GroupService(directory_service=self) + self._department_service = DepartmentService( + group_service=self._group_service, + user_service=self._user_service + ) +``` + +### Делегирование + +`DirectoryService` делегирует работу специализированным сервисам: +- **UserService**: Управление пользователями +- **GroupService**: Управление группами +- **DepartmentService**: Управление отделами и ролями +- **PositionService**: Управление должностями + +--- + +## 3. set_member_role() — Назначение роли + +### 3.1 Публичный метод DirectoryService + +**Расположение**: `backend/employees/ldap/directory_service.py` (строки 166-170) + +```python +def set_member_role( + self, dept: Department, employee: Employee, role: Optional[DepartmentRole] +) -> None: + """Меняет роль участника с синхронизацией LDAP-групп Roles.""" + return self._department_service.set_member_role(dept, employee, role) +``` + +**Делегирование**: Передаёт вызов в `DepartmentService`. + +--- + +### 3.2 Реализация в DepartmentService + +**Расположение**: `backend/employees/ldap/services/department_service.py` (строки 531-577) + +```python +def set_member_role( + self, dept: Department, employee: Employee, role +) -> None: + """Меняет роль участника отдела с синхронизацией LDAP-групп Roles. + + Args: + dept: Отдел. + employee: Сотрудник. + role: Новая роль или None. + + Raises: + DirectoryDbError: Ошибка при обновлении БД. + DirectoryLdapError: Ошибка при синхронизации групп. + """ + # 1. Обновление БД + try: + with transaction.atomic(): + link = EmployeeDepartment.objects.get( + employee_id=employee.id, department_id=dept.id + ) + link.role = role + link.save(update_fields=["role"]) + except EmployeeDepartment.DoesNotExist as e: + raise DirectoryDbError( + "Employee is not a member of this department" + ) from e + except Exception as e: + raise DirectoryDbError(str(e)) from e + + # 2. Синхронизация LDAP + try: + if role: + user_dn = self._user_service._get_employee_dn(employee) + dept_dn = self._get_department_dn(dept) + roles_base = f"OU=Roles,{dept_dn}" + with _ldap() as conn: + sync_user_groups_by_cns( + conn, + user_dn, + {role.name}, # Синхронизировать только с группой role.name + extra_bases=[roles_base], + do_write=True, + ) + except DirectoryServiceError: + pass # Игнорируем ошибки сервиса + except Exception as e: + raise DirectoryLdapError( + f"LDAP role sync failed: {e}" + ) from e +``` + +### Порядок операций + +``` +1. DB Transaction (atomic) + ↓ + EmployeeDepartment.objects.get(...) + ↓ + link.role = role + ↓ + link.save() + ↓ + Commit + +2. LDAP Sync (best effort) + ↓ + Получить user_dn (DN сотрудника) + ↓ + Получить dept_dn (DN отдела) + ↓ + roles_base = "OU=Roles," + ↓ + sync_user_groups_by_cns(conn, user_dn, {role.name}, extra_bases=[roles_base]) + ↓ + Если ошибка → логировать, но не падать +``` + +### Ключевая особенность: Best Effort LDAP + +```python +try: + # ... LDAP синхронизация ... +except DirectoryServiceError: + pass # Игнорируем +except Exception as e: + raise DirectoryLdapError(...) +``` + +**Значение**: +- БД обновляется всегда (transaction atomic) +- LDAP — best effort (ошибки не прерывают операцию) +- При падении LDAP роль назначена в БД, но не синхронизирована + +--- + +## 4. sync_user_groups_by_cns() — Синхронизация групп + +### Назначение +Синхронизирует членство пользователя в LDAP-группах по списку CN (Common Names). + +### Логика + +```python +def sync_user_groups_by_cns( + conn: Connection, + user_dn: str, + target_cns: Set[str], + extra_bases: List[str], + do_write: bool +): + """ + Приводит членство пользователя в LDAP-группах к целевому набору CN. + + Args: + conn: LDAP соединение + user_dn: DN пользователя + target_cns: Набор CN групп, в которых пользователь должен быть + extra_bases: Дополнительные DN для поиска групп (например, OU=Roles) + do_write: Выполнять ли реальные изменения + """ +``` + +### Операции + +``` +1. Найти все группы в extra_bases (OU=Roles,OU=Dept) + ↓ +2. Текущие группы пользователя: current_cns = {...} + ↓ +3. Вычислить разницу: + - to_add = target_cns - current_cns (нужно добавить) + - to_remove = current_cns - target_cns (нужно удалить) + ↓ +4. Если do_write: + - Для каждой группы в to_add: + conn.modify(group_dn, {'member': [(MODIFY_ADD, [user_dn])]}) + - Для каждой группы в to_remove: + conn.modify(group_dn, {'member': [(MODIFY_DELETE, [user_dn])]}) +``` + +### Пример + +**До синхронизации**: +- Сотрудник в группах: `["Tech Lead", "Senior Developer"]` + +**Назначена роль**: `role.name = "Junior Developer"` + +**После синхронизации**: +- Удалён из: `["Tech Lead", "Senior Developer"]` +- Добавлен в: `["Junior Developer"]` + +--- + +## 5. Интеграция с API + +### 5.1 В Department.set_member_role() + +**Расположение**: `backend/api/v1/employees/views.py` (строки 956-1030) + +```python +@action(detail=True, methods=["post"]) +@transaction.atomic +def set_member_role(self, request, pk=None): + dept = self.get_object() + # ... валидация ... + + svc = DirectoryService() if _is_ldap_enabled() else None + + if svc: + # Режим с LDAP: синхронизируем роли + try: + svc.set_member_role(dept, employee, role) + link = EmployeeDepartment.objects.get(...) + except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: + return Response({"detail": str(e)}, status=...) + else: + # Режим без LDAP: просто обновляем роль в линке + link = EmployeeDepartment.objects.get(...) + link.role = role + link.save(update_fields=["role"]) + + return Response({...}, status=200) +``` + +### Две ветки выполнения + +**1. LDAP включён (`_is_ldap_enabled() = True`)**: +``` +API call → DirectoryService.set_member_role() + ↓ +1. DB: EmployeeDepartment.role = new_role +2. LDAP: sync groups in OU=Roles + ↓ +Response +``` + +**2. LDAP выключен**: +``` +API call → Прямое обновление БД + ↓ +EmployeeDepartment.role = new_role + ↓ +Response +``` + +--- + +### 5.2 Проверка _is_ldap_enabled() + +**Расположение**: Предположительно в `employees/ldap/__init__.py` или `settings.py` + +```python +def _is_ldap_enabled() -> bool: + return getattr(settings, 'LDAP_ENABLED', False) and \ + getattr(settings, 'LDAP_SERVER_URI', None) is not None +``` + +**Конфигурация** (`settings.py`): +```python +LDAP_ENABLED = True +LDAP_SERVER_URI = 'ldap://dc.example.com' +LDAP_BIND_DN = 'CN=Admin,DC=example,DC=com' +LDAP_BIND_PASSWORD = '...' +LDAP_DEPARTMENTS_BASE = 'OU=Departments,DC=example,DC=com' +``` + +--- + +## 6. Хранение DN в моделях + +### 6.1 DepartmentRole.ldap_group_dn + +```python +class DepartmentRole(models.Model): + # ... + ldap_group_dn = models.CharField(max_length=500, blank=True, default='') +``` + +**Назначение**: Хранит DN LDAP-группы роли. + +**Пример значения**: +``` +CN=Tech Lead,OU=Roles,OU=IT Department,OU=Departments,DC=example,DC=com +``` + +**Использование**: +- Прямой доступ к группе роли в LDAP +- Быстрый поиск без построения DN + +--- + +### 6.2 LdapSyncState + +```python +class LdapSyncState(models.Model): + model = models.CharField(max_length=50, db_index=True) # 'employee', 'department', ... + object_pk = models.CharField(max_length=50, db_index=True) + ldap_dn = models.CharField(max_length=500, blank=True, default='') + last_sync = models.DateTimeField(auto_now=True) + sync_status = models.CharField(max_length=20, default='synced') +``` + +**Назначение**: Mapping между объектами Django и LDAP DN. + +**Примеры**: +```python +LdapSyncState(model='employee', object_pk='42', + ldap_dn='CN=Ivanov,OU=IT,OU=Departments,...') + +LdapSyncState(model='department', object_pk='5', + ldap_dn='OU=IT Department,OU=Departments,...') +``` + +**Использование**: +- `_get_employee_dn()` → запрос к LdapSyncState +- `_get_department_dn()` → запрос к LdapSyncState + +--- + +## 7. Создание роли в LDAP + +### Порядок операций при создании DepartmentRole + +``` +1. API: POST /api/v1/department-roles/ + {"department": 5, "name": "Tech Lead"} + ↓ +2. DepartmentRoleSerializer.create() + ↓ + DepartmentRole.objects.create(...) + ↓ +3. (НЕТ автоматической LDAP-синхронизации) + ↓ +4. LDAP-группа создаётся при первом назначении роли сотруднику +``` + +**Ключевая особенность**: Группа роли в LDAP создаётся **лениво** (lazy), при первом назначении. + +### Альтернативный подход (если реализован) + +```python +# При create роли: +if _is_ldap_enabled(): + dept_dn = _get_department_dn(dept) + roles_ou = f"OU=Roles,{dept_dn}" + group_dn = f"CN={role.name},{roles_ou}" + + # Создать группу + conn.add(group_dn, object_class=['group'], attributes={ + 'sAMAccountName': role.name, + 'description': f'Role group for {dept.name}' + }) + + # Сохранить DN + role.ldap_group_dn = group_dn + role.save() +``` + +--- + +## 8. Удаление роли из LDAP + +### Текущая реализация + +**Проблема**: При `DepartmentRole.delete()` LDAP-группа **НЕ удаляется**. + +**Последствие**: +- Orphaned groups остаются в LDAP +- Необходима ручная очистка + +### Рекомендация + +```python +# В DepartmentRole.delete(): +def delete(self, *args, **kwargs): + if self.ldap_group_dn and _is_ldap_enabled(): + try: + with _ldap() as conn: + conn.delete(self.ldap_group_dn) + except Exception as e: + logger.warning(f"Failed to delete LDAP group: {e}") + + super().delete(*args, **kwargs) +``` + +--- + +## 9. Проблемные места + +### 9.1 Двухфазная логика (if _is_ldap_enabled) + +**Проблема**: Во всех API endpoints дублируется логика: + +```python +if _is_ldap_enabled(): + # Ветка с LDAP +else: + # Ветка без LDAP +``` + +**Следствие**: +- Усложнение кода +- Две кодовые ветки для тестирования +- Риск рассинхронизации логики + +**Решение**: Паттерн Strategy + +```python +class RoleBackend(ABC): + @abstractmethod + def set_member_role(self, dept, employee, role): ... + +class LdapRoleBackend(RoleBackend): + def set_member_role(self, dept, employee, role): + # ... LDAP логика ... + +class DbOnlyRoleBackend(RoleBackend): + def set_member_role(self, dept, employee, role): + # ... DB-only логика ... + +# В settings: +if LDAP_ENABLED: + ROLE_BACKEND = LdapRoleBackend() +else: + ROLE_BACKEND = DbOnlyRoleBackend() +``` + +--- + +### 9.2 Best Effort LDAP без компенсации + +**Проблема**: +```python +try: + # DB update + link.role = role + link.save() +except: + raise # Rollback + +try: + # LDAP sync + sync_user_groups_by_cns(...) +except DirectoryServiceError: + pass # Игнорируем! +``` + +**Последствие**: Несогласованное состояние (роль в БД ≠ группа в LDAP). + +**Решение**: Очередь задач для повторных попыток + +```python +# После успешного DB update: +if ldap_sync_failed: + schedule_ldap_sync_task.delay(employee_id, role_id) +``` + +--- + +### 9.3 Нет валидации существования OU=Roles + +**Проблема**: Код предполагает, что `OU=Roles,` существует. + +**Риск**: При создании нового отдела без создания `OU=Roles` синхронизация падает. + +**Решение**: Проверка/создание OU при создании отдела + +```python +def create_department(dto): + # ... + dept_ou = create_ou(dept.name, DEPARTMENTS_BASE) + roles_ou = create_ou('Roles', dept_ou) # ← гарантировать наличие + # ... +``` + +--- + +### 9.4 Отсутствие транзакционности LDAP + +**Проблема**: LDAP операции не атомарны. + +**Сценарий**: +```python +# Назначение роли: +1. Удалить из группы "Tech Lead" → ✅ +2. Добавить в группу "Senior Dev" → ❌ (сбой) + +Результат: Сотрудник без ролей в LDAP +``` + +**Решение**: Two-Phase Commit или Saga pattern + +```python +# 1. Prepare phase +old_groups = get_current_groups(user_dn) +new_groups = {role.name} + +# 2. Commit phase +try: + for group in old_groups - new_groups: + remove_from_group(user_dn, group) + for group in new_groups - old_groups: + add_to_group(user_dn, group) +except: + # Rollback + for group in old_groups: + add_to_group(user_dn, group) + raise +``` + +--- + +### 9.5 Производительность синхронизации + +**Проблема**: `sync_user_groups_by_cns()` делает: +1. Поиск всех групп в OU=Roles +2. Проверка членства для каждой группы +3. Modify для каждой изменённой группы + +**При назначении роли 100 сотрудникам**: 100 × (search + N×modify) запросов. + +**Решение**: Batch operations + +```python +def sync_multiple_users_roles(users_roles: List[Tuple[Employee, Role]]): + with _ldap() as conn: + for user, role in users_roles: + # ... подготовка операций ... + + # Выполнить все за раз + conn.batch_modify(operations) +``` + +--- + +## 10. Итоговая схема LDAP-интеграции + +``` +┌──────────────────────────────────────────────┐ +│ API: POST /departments/{id}/set_member_role/ │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ _is_ldap_enabled()? │ +│ No → DB-only branch │ +│ Yes → LDAP branch │ +└──────────────────────────────────────────────┘ + ↓ Yes +┌──────────────────────────────────────────────┐ +│ DirectoryService.set_member_role() │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ DepartmentService.set_member_role() │ +│ 1. DB: EmployeeDepartment.role = new_role │ +│ 2. LDAP: sync_user_groups_by_cns() │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ LDAP Operations: │ +│ - Get user_dn from LdapSyncState │ +│ - Get dept_dn from LdapSyncState │ +│ - roles_base = "OU=Roles," │ +│ - Find all groups in roles_base │ +│ - Remove from old role groups │ +│ - Add to new role group │ +└──────────────────────────────────────────────┘ +``` + +--- + +**Следующий отчет**: [07_DEPARTMENT_ROLES_TESTS.md](./07_DEPARTMENT_ROLES_TESTS.md) — Покрытие тестами diff --git a/backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md b/backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md new file mode 100644 index 00000000..a6d08da0 --- /dev/null +++ b/backend/docs/reports/07_DEPARTMENT_ROLES_TESTS.md @@ -0,0 +1,659 @@ +# Отчет: Тестовое покрытие системы ролей + +**Дата**: 30.12.2025 +**Анализ**: Тестовые файлы, покрытие функциональности, сценарии + +--- + +## 1. Обзор тестовых файлов + +### Основные тестовые файлы + +| Файл | Строк | Назначение | +|------|-------|------------| +| **test_department_roles.py** | ~306 | CRUD операций ролей, права, фильтрация | +| **test_department_roles_extra.py** | ~304 | Расширенные сценарии: сортировка, staff override | +| **test_department_membership_separation.py** | ~496 | Разделение add_member vs set_role | +| **test_department_head_rights.py** | ~200 | Права руководителя отдела | + +**Общее количество**: ~1300 строк тестового кода для системы ролей. + +--- + +## 2. test_department_roles.py — Основные CRUD + +### Расположение +`backend/tests/api/v1/employees/test_department_roles.py` + +### Покрываемая функциональность + +#### 2.1 Фильтрация по отделу + +```python +def test_list_filtered_by_department(api_client: APIClient): + d1 = Department.objects.create(name="Dept A") + d2 = Department.objects.create(name="Dept B") + + r1 = make_role(d1, "Engineer", []) + r2 = make_role(d1, "Manager", []) + r3 = make_role(d2, "Sales", []) + + # Без фильтра — все 3 + resp = api_client.get(url) + assert ids == {r1.id, r2.id, r3.id} + + # С фильтром по d1 — только роли d1 + resp = api_client.get(url, {"department": d1.id}) + assert ids == {r1.id, r2.id} +``` + +**Проверяет**: +- Query param `?department=` работает +- Без фильтра возвращает все роли +- С фильтром только роли указанного отдела + +--- + +#### 2.2 Права на создание роли + +```python +def test_create_requires_assign_department_role(api_client: APIClient): + d = Department.objects.create(name="Dept") + user = make_user("m@example.com") + + # Без прав — 403 + resp = api_client.post(url, {"department": d.id, "name": "Worker"}) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # Выдаём право assign_department_role + grant_assign_in_dept(user, d) + + # Теперь можно создать + resp = api_client.post(url, {"department": d.id, "name": "Worker"}) + assert resp.status_code == status.HTTP_201_CREATED +``` + +**Проверяет**: +- `AssignRolePerm()` блокирует без прав +- С правом `assign_department_role` → создание разрешено +- Backward-compat поля присутствуют в ответе + +--- + +#### 2.3 Scope-based права (update/destroy) + +```python +def test_update_and_destroy_scope_enforced(api_client: APIClient): + d1 = Department.objects.create(name="Dept1") + d2 = Department.objects.create(name="Dept2") + role_other = make_role(d2, "Other", []) + + # Нет прав в d2 → 403 + resp = api_client.patch(url_detail_other, {"name": "X"}) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # Права в d1 — всё ещё 403 для d2 + grant_assign_in_dept(user, d1) + resp = api_client.patch(url_detail_other, {"name": "X"}) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # Права в d2 — теперь можно + grant_assign_in_dept(user, d2) + resp = api_client.patch(url_detail_other, {"name": "X"}) + assert resp.status_code == status.HTTP_200_OK +``` + +**Проверяет**: +- Права проверяются по **отделу роли**, а не глобально +- Право в отделе A не даёт доступ к ролям отдела B +- Destroy также требует право в отделе роли + +--- + +#### 2.4 Работа с правами через API + +```python +def test_perm_choices_and_perms_and_set_perms(api_client: APIClient): + # 1. perm_choices — справочник + resp = api_client.get(url_choices) + codes = {row["code"] for row in resp.json().get("results", [])} + assert {"manage_department", "assign_department_role"}.issubset(codes) + + # 2. set_perms — установка прав по кодам + role = make_role(d, "Engineer", []) + resp = api_client.post( + url_set, + {"permission_codes": ["manage_department", "assign_department_role"]} + ) + assert resp.status_code == 200 + + # 3. Проверка permissions_verbose + verbose = {p["code"] for p in resp.json().get("permissions_verbose", [])} + assert verbose == {"manage_department", "assign_department_role"} +``` + +**Проверяет**: +- `/perm_choices/` возвращает все доступные права +- `/set_perms/` заменяет права роли +- `permission_codes` работает (альтернатива ID) +- `permissions_verbose` содержит полную информацию + +--- + +## 3. test_department_roles_extra.py — Дополнительные сценарии + +### 3.1 Неавторизованный доступ + +```python +def test_unauth_cannot_list_or_get(): + client = APIClient() # Без force_authenticate + assert client.get(url_list).status_code in (401, 403) +``` + +**Проверяет**: Неавторизованные запросы блокируются. + +--- + +### 3.2 Read-only без прав + +```python +def test_read_only_allowed_without_assign(api_client: APIClient): + r = make_role(d, "Worker", []) + u = make_user("u@example.com") + api_client.force_authenticate(user=u) + + # list разрешён + resp = api_client.get(url_list, {"department": d.id}) + assert resp.status_code == 200 + + # retrieve разрешён + assert api_client.get(url_detail).status_code == 200 +``` + +**Проверяет**: +- GET операции (list/retrieve) доступны всем аутентифицированным +- Write операции требуют специальных прав + +--- + +### 3.3 Staff override + +```python +def test_staff_override_for_write(api_client: APIClient): + staff = make_user("s@example.com", staff=True) + api_client.force_authenticate(user=staff) + + # create без роли в отделе + resp = api_client.post(url_list, {"department": d.id, "name": "SRole"}) + assert resp.status_code == 201 + + # update + assert api_client.patch(url_detail, {"name": "SRole+"}).status_code == 200 + + # set_perms + assert api_client.post(url_set, {"permission_codes": [...]}).status_code == 200 +``` + +**Проверяет**: +- `user.is_staff` обходит проверки прав +- Staff может управлять любыми ролями в любых отделах + +--- + +### 3.4 Стабильная сортировка + +```python +def test_ordering_stable_by_name_then_id(api_client: APIClient): + # Роли с одинаковыми именами в разных отделах + r1 = make_role(d1, "A", []) + r2 = make_role(d2, "A", []) # Одинаковое имя + r3 = make_role(d1, "B", []) + + resp = api_client.get(url, {"ordering": "name"}) + items = resp.json().get("results", []) + + # Сортировка: name, затем id (tie-break) + assert items[0]["name"] == "A" and items[0]["id"] < items[1]["id"] + assert items[2]["name"] == "B" +``` + +**Проверяет**: +- При одинаковых `name` порядок детерминирован (по `id`) +- `ordering=name` работает корректно + +--- + +## 4. test_department_membership_separation.py — Разделение операций + +### Назначение +Проверяет, что `add_member` и `set_member_role` — **разные операции** с разными правами. + +--- + +### 4.1 add_member не назначает роль + +```python +def test_add_member_requires_manage_and_does_not_assign_role(api_client): + d = Department.objects.create(name="Dept") + target = make_user("target@example.com") + + # 403 с правом assign_department_role (но без manage) + assigner = make_user("assigner@example.com") + grant_assign_in_dept(assigner, d) + resp = api_client.post(url, {"employee_id": target.id}) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # 200 с manage_department + manager = make_user("manager@example.com") + grant_manage_in_dept(manager, d) + resp = api_client.post(url, {"employee_id": target.id}) + assert resp.status_code == status.HTTP_200_OK + + # Роль НЕ назначена + link = EmployeeDepartment.objects.get(employee_id=target.id) + assert link.role_id is None +``` + +**Проверяет**: +- `add_member` требует `manage_department`, а не `assign_department_role` +- После добавления `role = None` +- Разделение ответственности операций + +--- + +### 4.2 set_member_role требует assign_role + +```python +def test_set_member_role_requires_assign_and_works_only_for_members(api_client): + d = Department.objects.create(name="Dept") + target = make_user("target@example.com") + role = make_role(d, "Worker", []) + + # add_member сначала + manager = make_user("manager@example.com") + grant_manage_in_dept(manager, d) + api_client.post(url_add_member, {"employee_id": target.id}) + + # set_member_role с правом assign + assigner = make_user("assigner@example.com") + grant_assign_in_dept(assigner, d) + resp = api_client.post(url_set_role, { + "employee_id": target.id, + "role_id": role.id + }) + assert resp.status_code == status.HTTP_200_OK + + # Роль назначена + link = EmployeeDepartment.objects.get(employee_id=target.id) + assert link.role_id == role.id +``` + +**Проверяет**: +- `set_member_role` требует `assign_department_role` +- Можно назначить роль только **существующему** члену отдела +- Роль действительно устанавливается + +--- + +### 4.3 Нельзя назначить роль несуществующему члену + +```python +def test_set_member_role_fails_if_not_member(api_client): + target = make_user("target@example.com") + # Не добавляем в отдел! + + assigner = make_user("assigner@example.com") + grant_assign_in_dept(assigner, d) + + resp = api_client.post(url_set_role, { + "employee_id": target.id, + "role_id": role.id + }) + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert "not a member" in resp.json()["detail"].lower() +``` + +**Проверяет**: 404 если сотрудник не в отделе. + +--- + +### 4.4 Валидация принадлежности роли + +```python +def test_set_member_role_fails_if_role_from_different_department(api_client): + d1 = Department.objects.create(name="Dept1") + d2 = Department.objects.create(name="Dept2") + role_d2 = make_role(d2, "Worker", []) + + target = make_user("target@example.com") + # Добавляем в d1 + add_member(target, d1) + + # Пытаемся назначить роль из d2 + assigner = make_user("assigner@example.com") + grant_assign_in_dept(assigner, d1) + + resp = api_client.post(url_set_role_d1, { + "employee_id": target.id, + "role_id": role_d2.id # Роль из другого отдела! + }) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "does not belong" in resp.json()["role_id"][0].lower() +``` + +**Проверяет**: Нельзя назначить роль из другого отдела. + +--- + +## 5. test_department_head_rights.py — Права руководителя + +### 5.1 Руководитель может обновлять отдел + +```python +def test_head_can_update_department_without_explicit_role_perms(api_client): + head = make_user("head@example.com") + d = Department.objects.create(name="Dept", head=head) + + api_client.force_authenticate(user=head) + resp = api_client.patch(url, {"description": "new"}) + assert resp.status_code == status.HTTP_200_OK +``` + +**Проверяет**: +- Руководителю не нужна роль для управления отделом +- `dept.head == user` → все права автоматически + +--- + +### 5.2 Руководитель может назначать роли + +```python +def test_head_can_set_member_role_and_non_head_cannot(api_client): + head = make_user("head@example.com") + d = Department.objects.create(name="Dept", head=head) + worker = make_user("worker@example.com") + add_member(worker, d) + + # Руководитель назначает роль + api_client.force_authenticate(user=head) + resp = api_client.post(url, {"employee_id": worker.id, "role_id": role.id}) + assert resp.status_code == status.HTTP_200_OK + + # Другой пользователь — не может + stranger = make_user("stranger@example.com") + api_client.force_authenticate(user=stranger) + resp = api_client.post(url, {"employee_id": worker.id, "role_id": role.id}) + assert resp.status_code == status.HTTP_403_FORBIDDEN +``` + +**Проверяет**: +- Руководитель имеет право `assign_department_role` автоматически +- Обычный пользователь без роли — запрещено + +--- + +### 5.3 Руководитель теряет права после смены + +```python +def test_head_can_change_head_and_loses_rights_afterwards(api_client): + old_head = make_user("old@example.com") + d = Department.objects.create(name="Dept", head=old_head) + new_head = make_user("new@example.com") + + # Старый руководитель меняет руководителя + api_client.force_authenticate(user=old_head) + resp = api_client.post(url_set_head, {"head_id": new_head.id}) + assert resp.status_code == status.HTTP_200_OK + + # Старый больше не глава — нет прав + resp = api_client.patch(url_detail, {"description": "after"}) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # У нового руководителя — есть + api_client.force_authenticate(user=new_head) + resp = api_client.patch(url_detail, {"description": "ok"}) + assert resp.status_code == status.HTTP_200_OK +``` + +**Проверяет**: +- Смена руководителя мгновенно лишает старого всех прав +- Новый руководитель сразу получает полный доступ + +--- + +## 6. Вспомогательные функции (Helpers) + +### Общие для всех тестов + +```python +def make_user(email: str, staff: bool = False) -> User: + """Создаёт пользователя с уникальным phone_number.""" + return User.objects.create_user( + email=email, + password="pwd12345", + phone_number=_unique_phone(), + send_activation_email=False, + ) + +def make_role(dept: Department, name: str, codes: list[str] | None = None) -> DepartmentRole: + """Создаёт роль с указанными правами.""" + role = DepartmentRole.objects.create(department=dept, name=name) + if codes: + perms = [ensure_dept_perm(c) for c in codes] + role.scoped_permissions.add(*perms) + return role + +def grant_assign_in_dept(user: User, dept: Department) -> DepartmentRole: + """Выдаёт пользователю роль с правом assign_department_role.""" + role = make_role(dept, "assigner", ["assign_department_role"]) + EmployeeDepartment.objects.update_or_create( + employee=user, department=dept, + defaults={"is_active": True, "role": role}, + ) + return role +``` + +### Назначение +- **make_user**: Создание тестовых пользователей с уникальными phone +- **make_role**: Быстрое создание ролей с правами +- **grant_assign_in_dept**: Выдача прав для тестирования + +--- + +## 7. Покрытие функциональности + +### Что покрыто ✅ + +| Функциональность | Тесты | Статус | +|------------------|-------|--------| +| **CRUD ролей** | test_department_roles.py | ✅ Полное | +| **Фильтрация по отделу** | test_list_filtered_by_department | ✅ | +| **Проверка прав create/update/destroy** | test_create_requires_assign, test_update_and_destroy_scope_enforced | ✅ | +| **set_perms API** | test_perm_choices_and_perms_and_set_perms | ✅ | +| **Backward compatibility** | Проверка permissions/permissions_verbose | ✅ | +| **Staff override** | test_staff_override_for_write | ✅ | +| **Сортировка** | test_ordering_stable_by_name_then_id | ✅ | +| **Разделение add_member / set_role** | test_department_membership_separation.py | ✅ Полное | +| **Права руководителя** | test_department_head_rights.py | ✅ Полное | +| **Валидация роли отдела** | test_set_member_role_fails_if_role_from_different_department | ✅ | + +--- + +### Что НЕ покрыто ❌ + +| Функциональность | Причина | +|------------------|---------| +| **LDAP синхронизация** | Требует mock LDAP сервера | +| **Concurrent updates** | Нет тестов на race conditions | +| **Массовое назначение ролей** | Нет bulk operations | +| **Удаление роли при наличии назначений** | Проверка CASCADE/SET_NULL | +| **Лимиты на количество ролей** | Нет ограничений в коде | +| **Производительность с большим числом ролей** | Нет load tests | +| **Миграция прав при изменении DeptPerm.CHOICES** | Ручной процесс | + +--- + +## 8. Проблемные места в тестах + +### 8.1 Дублирование helper функций + +**Проблема**: В каждом тестовом файле свои `make_user()`, `make_role()`, `_unique_phone()`. + +**Следствие**: При изменении логики нужно обновлять 4 файла. + +**Решение**: Вынести в `conftest.py`: +```python +# tests/api/v1/employees/conftest.py +@pytest.fixture +def make_user(): + def _make(email, staff=False): + # ... общая логика ... + return _make +``` + +--- + +### 8.2 Отсутствие интеграционных тестов + +**Проблема**: Тесты проверяют API endpoints, но не полный workflow. + +**Пример недостающего теста**: +```python +def test_full_role_assignment_workflow(): + # 1. Создать отдел + # 2. Добавить сотрудников + # 3. Создать роль с правами + # 4. Назначить роль сотруднику + # 5. Проверить, что сотрудник может выполнить действие + # 6. Изменить права роли + # 7. Проверить, что изменения применились + # 8. Удалить роль + # 9. Проверить, что сотрудник потерял права +``` + +--- + +### 8.3 Нет тестов на граничные случаи + +**Примеры**: +- Назначение 1000 ролей одному отделу +- Роль с 0 прав (пустой scoped_permissions) +- Роль с дублирующимся именем (после изменения department) +- Одновременное создание ролей с одинаковым именем (race) + +--- + +### 8.4 Отсутствие тестов на ошибки валидации + +**Примеры**: +- `{"scoped_permission_codes": ["nonexistent_code"]}` +- `{"scoped_permissions": [9999999]}` (несуществующий ID) +- `{"name": ""}` (пустое имя) +- `{"name": "A" * 200}` (превышение max_length) + +--- + +## 9. Рекомендации по улучшению тестов + +### 9.1 Добавить фабрики (Factory Boy) + +```python +import factory + +class DepartmentFactory(factory.django.DjangoModelFactory): + class Meta: + model = Department + name = factory.Sequence(lambda n: f"Department {n}") + +class DepartmentRoleFactory(factory.django.DjangoModelFactory): + class Meta: + model = DepartmentRole + department = factory.SubFactory(DepartmentFactory) + name = factory.Sequence(lambda n: f"Role {n}") + +# Использование: +dept = DepartmentFactory() +role = DepartmentRoleFactory(department=dept) +``` + +--- + +### 9.2 Добавить параметризованные тесты + +```python +@pytest.mark.parametrize("method,action", [ + ("post", "create"), + ("patch", "update"), + ("delete", "destroy"), +]) +def test_requires_assign_role_perm(api_client, method, action): + # ... общая логика для всех write операций ... +``` + +--- + +### 9.3 Добавить тесты на производительность + +```python +def test_list_roles_performance_with_many_roles(api_client): + dept = Department.objects.create(name="Dept") + + # Создать 1000 ролей + for i in range(1000): + make_role(dept, f"Role{i}", []) + + # Замерить время + import time + start = time.time() + resp = api_client.get(url, {"department": dept.id}) + duration = time.time() - start + + assert resp.status_code == 200 + assert duration < 1.0 # Должно быть быстрее 1 секунды +``` + +--- + +### 9.4 Мокировать LDAP для тестов + +```python +from unittest.mock import patch + +@patch('employees.ldap.directory_service._is_ldap_enabled', return_value=True) +@patch('employees.ldap.directory_service.DirectoryService.set_member_role') +def test_set_member_role_calls_ldap(mock_set_role, mock_enabled, api_client): + # Тест проверяет, что при включённом LDAP вызывается синхронизация + # ... + assert mock_set_role.called +``` + +--- + +## 10. Итоговая оценка покрытия + +### Метрики + +- **Количество тестовых файлов**: 4 +- **Количество тестов**: ~20-25 +- **Покрытие функциональности**: ~70% +- **Покрытие кода**: (требуется `pytest --cov`) + +### Сильные стороны + +✅ Хорошее покрытие основных CRUD операций +✅ Проверка системы прав (scoped permissions) +✅ Разделение ответственности (add_member vs set_role) +✅ Проверка прав руководителя +✅ Backward compatibility + +### Слабые стороны + +❌ Нет LDAP-тестов +❌ Нет интеграционных тестов +❌ Нет граничных случаев +❌ Нет load/performance тестов +❌ Дублирование helpers + +--- + +**Финальный отчет**: [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md) — Архитектура и рекомендации diff --git a/backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md b/backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md new file mode 100644 index 00000000..7e83248a --- /dev/null +++ b/backend/docs/reports/08_DEPARTMENT_ROLES_ARCHITECTURE.md @@ -0,0 +1,824 @@ +# Финальный отчет: Архитектура системы ролей и план рефакторинга + +**Дата**: 30.12.2025 +**Итоговый анализ**: Текущее состояние, проблемы, план полной переработки + +--- + +## 1. Executive Summary + +### Текущее состояние + +Система ролей отделов представляет собой **двухуровневую модель прав**: +- Django model permissions (глобальные) +- Scoped permissions (в рамках отдела) + +**Ключевые компоненты**: +- 3 модели данных (DepartmentRole, DepartmentPermission, EmployeeDepartment) +- 10 типов прав (DeptPerm.CHOICES) +- LDAP-интеграция (опциональная) +- REST API с проверкой прав +- ~70% покрытие тестами + +### Основные проблемы + +🔴 **Критические**: +1. Двойная логика (if LDAP/else) во всём коде +2. Дублирование функций проверки прав +3. Best-effort LDAP без компенсации ошибок +4. Отсутствие транзакций в критических операциях + +🟡 **Важные**: +5. Смешение уровней прав (Django + scoped) +6. Автосинхронизация permissions без миграций +7. Backward-compat поля увеличивают размер ответов +8. Нет кеширования проверок прав + +--- + +## 2. Архитектура: Текущая vs Целевая + +### 2.1 Текущая архитектура + +``` +┌─────────────────────────────────────────────────────────┐ +│ API Layer (Views) │ +│ - DepartmentRoleViewSet │ +│ - DepartmentViewSet.set_member_role() │ +│ - Inline: if _is_ldap_enabled() → branch logic │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Permission Checks (Mixed) │ +│ - AdminOrDeptAllowed (DRF BasePermission) │ +│ - has_dept_perm() — by ID │ +│ - user_has_dept_perm() — by object │ +│ - Staff/Head shortcuts │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Business Logic (Scattered) │ +│ - Serializers (create/update with M2M) │ +│ - Utils (_ensure_permissions, _build_links) │ +│ - DirectoryService (LDAP sync) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Data Layer (Models) │ +│ - DepartmentRole, DepartmentPermission │ +│ - EmployeeDepartment (link with role FK) │ +│ - LdapSyncState │ +└─────────────────────────────────────────────────────────┘ +``` + +**Проблемы**: +- Нет чёткого разделения слоёв +- Бизнес-логика размазана между Views, Serializers, Utils +- LDAP-логика смешана с основной + +--- + +### 2.2 Целевая архитектура (Clean Architecture) + +``` +┌─────────────────────────────────────────────────────────┐ +│ API Layer (Thin) │ +│ - DepartmentRoleViewSet │ +│ - Маршрутизация → Service Layer │ +│ - Валидация input (Serializers) │ +│ - Форматирование output │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Service Layer (Use Cases) │ +│ - RoleManagementService │ +│ • create_role(dept, name, permissions) │ +│ • assign_role_to_member(dept, employee, role) │ +│ • update_role_permissions(role, permissions) │ +│ • delete_role(role) │ +│ - Permission checking here │ +│ - Transaction boundaries │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Infrastructure Layer (Backends) │ +│ - DepartmentRoleBackend (Interface) │ +│ ├── LdapRoleBackend (LDAP sync) │ +│ └── DbOnlyRoleBackend (DB only) │ +│ - Injected via DI │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Domain Layer (Models + Logic) │ +│ - DepartmentRole, DepartmentPermission │ +│ - EmployeeDepartment │ +│ - Domain logic in model methods │ +└─────────────────────────────────────────────────────────┘ +``` + +**Преимущества**: +- Чёткое разделение ответственности +- Testable (service layer с mock backends) +- Легко добавить новые backends (Redis, External API) +- Транзакции на уровне use cases + +--- + +## 3. Детальный план рефакторинга + +### Фаза 1: Подготовка (1-2 дня) + +#### 1.1 Унификация проверки прав + +**Текущее состояние**: Две функции (`has_dept_perm` и `user_has_dept_perm`) делают одно и то же. + +**Действия**: +```python +# Новая унифицированная функция +def check_dept_permission( + user: User, + department: Department | int, + permission_code: str +) -> bool: + """Unified permission check supporting both object and ID.""" + if isinstance(department, int): + dept_id = department + else: + dept_id = department.id + + # Единая логика проверки + if user.is_staff or user.is_superuser: + return True + + if Department.objects.filter(id=dept_id, head_id=user.id).exists(): + return True + + return EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=dept_id, + is_active=True, + role__scoped_permissions__code=permission_code + ).exists() + +# Обратная совместимость +has_dept_perm = check_dept_permission +user_has_dept_perm = lambda u, dept, code: check_dept_permission(u, dept, code) +``` + +--- + +#### 1.2 Объединение _ensure и _perm_choices_synced + +**Действия**: +```python +def ensure_department_permissions() -> list[dict]: + """Single source for permission sync.""" + # ... логика ... + +# Удалить дубликат _perm_choices_synced +# Заменить все вызовы на ensure_department_permissions() +``` + +--- + +#### 1.3 Добавить кеширование проверок прав + +**Действия**: +```python +from functools import lru_cache + +@lru_cache(maxsize=256) +def _check_dept_permission_cached( + user_id: int, + dept_id: int, + permission_code: str +) -> bool: + # ... логика проверки ... + +def check_dept_permission(user, department, permission_code): + # Validate inputs + # Call cached version + return _check_dept_permission_cached(user.id, dept_id, permission_code) +``` + +--- + +### Фаза 2: Service Layer (3-5 дней) + +#### 2.1 Создать RoleManagementService + +```python +# employees/services/role_management.py + +class RoleManagementService: + def __init__(self, backend: RoleBackend): + self.backend = backend + + @transaction.atomic + def create_role( + self, + department: Department, + name: str, + permission_codes: list[str], + requester: User + ) -> DepartmentRole: + """Create a new role with permissions.""" + # 1. Check permissions + if not check_dept_permission(requester, department, DeptPerm.ASSIGN_ROLE): + raise PermissionDenied("User lacks assign_role permission") + + # 2. Validate permissions exist + perms = DepartmentPermission.objects.filter(code__in=permission_codes) + if perms.count() != len(set(permission_codes)): + raise ValidationError("Some permission codes not found") + + # 3. Create role + role = DepartmentRole.objects.create( + department=department, + name=name + ) + role.scoped_permissions.set(perms) + + # 4. Sync to backend (LDAP/etc) + self.backend.on_role_created(role) + + return role + + @transaction.atomic + def assign_role( + self, + department: Department, + employee: Employee, + role: DepartmentRole | None, + requester: User + ) -> EmployeeDepartment: + """Assign or remove role from department member.""" + # 1. Check permissions + if not check_dept_permission(requester, department, DeptPerm.ASSIGN_ROLE): + raise PermissionDenied() + + # 2. Validate member exists + link = EmployeeDepartment.objects.filter( + employee=employee, + department=department + ).first() + if not link: + raise NotFound("Employee not a member of department") + + # 3. Validate role belongs to department + if role and role.department_id != department.id: + raise ValidationError("Role does not belong to department") + + # 4. Update DB + old_role = link.role + link.role = role + link.save(update_fields=['role']) + + # 5. Sync to backend + try: + self.backend.on_role_assigned(link, old_role, role) + except BackendError as e: + # Schedule retry + logger.error(f"Backend sync failed: {e}") + schedule_retry_task.delay(link.id) + + return link +``` + +--- + +#### 2.2 Создать Backend Interface + +```python +# employees/backends/base.py + +from abc import ABC, abstractmethod + +class RoleBackend(ABC): + @abstractmethod + def on_role_created(self, role: DepartmentRole) -> None: + """Called when a role is created.""" + pass + + @abstractmethod + def on_role_deleted(self, role: DepartmentRole) -> None: + """Called when a role is deleted.""" + pass + + @abstractmethod + def on_role_assigned( + self, + link: EmployeeDepartment, + old_role: DepartmentRole | None, + new_role: DepartmentRole | None + ) -> None: + """Called when a role is assigned/changed.""" + pass +``` + +--- + +#### 2.3 Реализовать LdapRoleBackend + +```python +# employees/backends/ldap_backend.py + +class LdapRoleBackend(RoleBackend): + def __init__(self): + self.directory_service = DirectoryService() + + def on_role_created(self, role: DepartmentRole) -> None: + """Create LDAP group for role in OU=Roles.""" + dept_dn = self._get_dept_dn(role.department) + roles_ou = f"OU=Roles,{dept_dn}" + group_dn = f"CN={role.name},{roles_ou}" + + with _ldap() as conn: + conn.add(group_dn, ['group'], { + 'sAMAccountName': role.name, + 'description': f'Role: {role.name}' + }) + + role.ldap_group_dn = group_dn + role.save(update_fields=['ldap_group_dn']) + + def on_role_assigned(self, link, old_role, new_role) -> None: + """Sync user groups in LDAP.""" + user_dn = self._get_user_dn(link.employee) + dept_dn = self._get_dept_dn(link.department) + roles_base = f"OU=Roles,{dept_dn}" + + target_cns = {new_role.name} if new_role else set() + + with _ldap() as conn: + sync_user_groups_by_cns( + conn, user_dn, target_cns, + extra_bases=[roles_base], + do_write=True + ) +``` + +--- + +#### 2.4 Реализовать DbOnlyBackend + +```python +# employees/backends/db_backend.py + +class DbOnlyRoleBackend(RoleBackend): + """No-op backend for DB-only mode.""" + + def on_role_created(self, role: DepartmentRole) -> None: + pass # Nothing to do + + def on_role_deleted(self, role: DepartmentRole) -> None: + pass + + def on_role_assigned(self, link, old_role, new_role) -> None: + pass +``` + +--- + +#### 2.5 Dependency Injection + +```python +# employees/services/__init__.py + +def get_role_backend() -> RoleBackend: + """Factory for role backend based on settings.""" + if getattr(settings, 'LDAP_ENABLED', False): + from .backends.ldap_backend import LdapRoleBackend + return LdapRoleBackend() + else: + from .backends.db_backend import DbOnlyRoleBackend + return DbOnlyRoleBackend() + +def get_role_service() -> RoleManagementService: + """Factory for role management service.""" + backend = get_role_backend() + return RoleManagementService(backend) +``` + +--- + +### Фаза 3: Рефакторинг API (2-3 дня) + +#### 3.1 Упростить ViewSet + +```python +class DepartmentRoleViewSet(viewsets.ModelViewSet): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.service = get_role_service() + + def create(self, request): + dept_id = request.data.get('department') + dept = get_object_or_404(Department, id=dept_id) + + role = self.service.create_role( + department=dept, + name=request.data['name'], + permission_codes=request.data.get('scoped_permission_codes', []), + requester=request.user + ) + + serializer = self.get_serializer(role) + return Response(serializer.data, status=201) +``` + +**Преимущества**: +- View становится тонким адаптером +- Вся бизнес-логика в service +- Легко тестировать service отдельно + +--- + +#### 3.2 Упростить Department.set_member_role() + +```python +@action(detail=True, methods=["post"]) +def set_member_role(self, request, pk=None): + dept = self.get_object() + service = get_role_service() + + payload = SetMemberRoleInput(data=request.data) + payload.is_valid(raise_exception=True) + + employee = get_object_or_404(Employee, id=payload.validated_data['employee_id']) + role_id = payload.validated_data.get('role_id') + role = get_object_or_404(DepartmentRole, id=role_id) if role_id else None + + try: + link = service.assign_role(dept, employee, role, request.user) + return Response({ + "employee_id": employee.id, + "role_id": role.id if role else None, + "is_active": link.is_active + }) + except (PermissionDenied, NotFound, ValidationError) as e: + return Response({"detail": str(e)}, status=...) +``` + +--- + +### Фаза 4: Миграции и очистка (1-2 дня) + +#### 4.1 Data Migration для permissions + +```python +# Generated migration file + +def forward(apps, schema_editor): + DepartmentPermission = apps.get_model('employees', 'DepartmentPermission') + + # Sync with DeptPerm.CHOICES + from employees.constants import DeptPerm + for code, name in DeptPerm.CHOICES: + DepartmentPermission.objects.get_or_create( + code=code, + defaults={'name': name} + ) + +def reverse(apps, schema_editor): + pass # Don't delete + +class Migration(migrations.Migration): + dependencies = [...] + operations = [ + migrations.RunPython(forward, reverse), + ] +``` + +--- + +#### 4.2 Удалить Django Model Permissions + +```python +# В Department.Meta: +class Meta: + permissions = [] # Удалить все, оставить только scoped +``` + +**Миграция данных**: +```python +def migrate_permissions(apps, schema_editor): + # Найти все Permission с employees.change_department_head и т.д. + # Создать соответствующие роли с scoped_permissions + # Назначить роли пользователям, у которых были Django permissions +``` + +--- + +#### 4.3 Убрать backward-compat поля (breaking change) + +```python +class DepartmentRoleSerializer(serializers.ModelSerializer): + # Удалить: + # - permissions (read-only) + # - permissions_verbose (read-only) + + class Meta: + fields = ("id", "department", "name", "scoped_permissions") +``` + +**Версионирование API**: Перенести на `/api/v2/` с чистой схемой. + +--- + +### Фаза 5: Тестирование (2-3 дня) + +#### 5.1 Юнит-тесты для Service + +```python +def test_create_role_without_permission(): + service = RoleManagementService(DbOnlyRoleBackend()) + dept = DepartmentFactory() + user = UserFactory() # Без прав + + with pytest.raises(PermissionDenied): + service.create_role(dept, "Manager", ["manage_department"], user) + +def test_assign_role_syncs_to_backend(): + mock_backend = Mock(spec=RoleBackend) + service = RoleManagementService(mock_backend) + + link = service.assign_role(dept, employee, role, admin_user) + + mock_backend.on_role_assigned.assert_called_once_with( + link, None, role + ) +``` + +--- + +#### 5.2 Интеграционные тесты + +```python +def test_full_role_assignment_workflow(api_client): + # 1. Создать отдел + dept = create_department(...) + + # 2. Добавить сотрудников + add_members(dept, [emp1, emp2]) + + # 3. Создать роль + role = create_role(dept, "Manager", [DeptPerm.MANAGE]) + + # 4. Назначить роль + assign_role(dept, emp1, role) + + # 5. Проверить права + assert check_dept_permission(emp1, dept, DeptPerm.MANAGE) + assert not check_dept_permission(emp2, dept, DeptPerm.MANAGE) + + # 6. Изменить права роли + update_role_permissions(role, [DeptPerm.CHANGE_HEAD]) + + # 7. Проверить изменения + assert not check_dept_permission(emp1, dept, DeptPerm.MANAGE) + assert check_dept_permission(emp1, dept, DeptPerm.CHANGE_HEAD) +``` + +--- + +#### 5.3 LDAP-тесты с mock + +```python +@patch('employees.backends.ldap_backend._ldap') +def test_ldap_backend_creates_group(mock_ldap): + backend = LdapRoleBackend() + role = DepartmentRoleFactory() + + backend.on_role_created(role) + + mock_ldap.return_value.__enter__.return_value.add.assert_called_once() + assert role.ldap_group_dn is not None +``` + +--- + +## 4. Приоритизация проблем + +### Критичность: ВЫСОКАЯ 🔴 + +| # | Проблема | Риск | Сложность | Приоритет | +|---|----------|------|-----------|-----------| +| 1 | Двойная логика LDAP | Усложнение кода, ошибки | Средняя | **P0** | +| 2 | Best-effort LDAP без retry | Несогласованность данных | Высокая | **P0** | +| 3 | Отсутствие транзакций | Race conditions | Низкая | **P0** | +| 4 | Дублирование функций прав | Техдолг, рассинхронизация | Низкая | **P1** | + +### Критичность: СРЕДНЯЯ 🟡 + +| # | Проблема | Риск | Сложность | Приоритет | +|---|----------|------|-----------|-----------| +| 5 | Смешение уровней прав | Путаница, сложность | Средняя | **P1** | +| 6 | Автосинхронизация permissions | Нет контроля версий | Низкая | **P2** | +| 7 | Backward-compat поля | Большие ответы | Низкая | **P2** | +| 8 | Нет кеширования | Лишние запросы | Низкая | **P2** | + +### Критичность: НИЗКАЯ 🟢 + +| # | Проблема | Риск | Сложность | Приоритет | +|---|----------|------|-----------|-----------| +| 9 | Дублирование helpers в тестах | Техдолг | Низкая | **P3** | +| 10 | Нет LDAP-тестов | Неполное покрытие | Средняя | **P3** | +| 11 | Нет граничных тестов | Скрытые баги | Низкая | **P3** | + +--- + +## 5. Roadmap рефакторинга + +### Sprint 1: Фундамент (1-2 недели) + +**Цели**: +- Унифицировать проверку прав +- Создать Service Layer +- Реализовать Backend Interface + +**Задачи**: +1. ✅ Merge функций has_dept_perm +2. ✅ Создать RoleManagementService +3. ✅ Создать RoleBackend interface +4. ✅ Реализовать LdapRoleBackend и DbOnlyRoleBackend +5. ✅ Добавить DI (get_role_service) +6. ✅ Юнит-тесты для service + +**Метрики успеха**: +- Все тесты проходят +- Нет регрессий в функциональности + +--- + +### Sprint 2: Рефакторинг API (1 неделя) + +**Цели**: +- Упростить ViewSets +- Убрать if LDAP/else из views +- Добавить транзакции + +**Задачи**: +1. ✅ Рефакторинг DepartmentRoleViewSet +2. ✅ Рефакторинг Department.set_member_role +3. ✅ Обновить все endpoints на использование service +4. ✅ Добавить @transaction.atomic +5. ✅ Интеграционные тесты + +**Метрики успеха**: +- 0 упоминаний _is_ldap_enabled в views +- Все endpoints работают через service + +--- + +### Sprint 3: Миграции и очистка (1 неделя) + +**Цели**: +- Перенести на scoped permissions +- Убрать Django model permissions +- Data migrations + +**Задачи**: +1. ✅ Data migration для DepartmentPermission +2. ✅ Миграция пользователей с Django perms на роли +3. ✅ Удалить Department.Meta.permissions +4. ✅ Обновить тесты +5. ✅ Обновить документацию + +**Метрики успеха**: +- Все права через scoped permissions +- Миграции проходят без ошибок + +--- + +### Sprint 4: Оптимизация (1 неделя) + +**Цели**: +- Добавить кеширование +- Убрать backward-compat +- Улучшить производительность + +**Задачи**: +1. ✅ Кеширование check_dept_permission +2. ✅ Версионирование API (v2) +3. ✅ Удалить permissions/permissions_verbose +4. ✅ Batch LDAP operations +5. ✅ Load tests + +**Метрики успеха**: +- Скорость проверки прав +50% +- Размер ответов API -30% + +--- + +## 6. Метрики качества + +### До рефакторинга + +| Метрика | Значение | +|---------|----------| +| Cyclomatic complexity (views.py) | ~15 | +| Lines of code (role logic) | ~2000 | +| Test coverage | ~70% | +| API response size | ~5KB (with backward-compat) | +| Permission check time | ~50ms (without cache) | +| LDAP sync failures | ~5% (no retry) | + +### После рефакторинга (цель) + +| Метрика | Значение | +|---------|----------| +| Cyclomatic complexity | ~8 | +| Lines of code | ~1500 | +| Test coverage | ~90% | +| API response size | ~3KB (v2 API) | +| Permission check time | ~5ms (with cache) | +| LDAP sync failures | <1% (with retry queue) | + +--- + +## 7. Риски и митигация + +### Риск 1: Breaking changes для фронтенда + +**Описание**: Удаление backward-compat полей сломает старый фронт. + +**Митигация**: +- Версионирование API (v1 → v2) +- Поддержка v1 ещё 6 месяцев +- Постепенная миграция фронта + +--- + +### Риск 2: LDAP-синхронизация упадёт при рефакторинге + +**Описание**: Изменение логики может нарушить синхронизацию. + +**Митигация**: +- Feature flag для включения нового backend +- Параллельное тестирование old/new +- Rollback plan + +--- + +### Риск 3: Производительность ухудшится + +**Описание**: Service layer добавляет overhead. + +**Митигация**: +- Benchmarks до/после +- Кеширование на уровне service +- Профилирование (django-silk) + +--- + +## 8. Итоговые рекомендации + +### Немедленные действия (P0) + +1. **Добавить @transaction.atomic** к всем write операциям +2. **Реализовать retry queue** для LDAP-синхронизации +3. **Унифицировать has_dept_perm** функции + +### Краткосрочные (1-2 месяца) + +1. Создать Service Layer +2. Реализовать Backend pattern +3. Рефакторить ViewSets + +### Долгосрочные (3-6 месяцев) + +1. Миграция на scoped permissions +2. Версионирование API (v2) +3. Полная оптимизация + +--- + +## 9. Заключение + +Система ролей отделов имеет **solid foundation**, но страдает от **архитектурных проблем**, накопленных со временем. Основные направления улучшения: + +1. ✅ **Разделение ответственности** — Service Layer + Backend Pattern +2. ✅ **Упрощение кода** — убрать if LDAP/else +3. ✅ **Улучшение надёжности** — транзакции, retry, компенсации +4. ✅ **Оптимизация** — кеширование, batch operations + +При последовательной реализации плана **за 4-6 недель** можно достичь: +- 📉 Снижение сложности кода на 40% +- 📈 Увеличение покрытия тестами до 90% +- ⚡ Ускорение проверки прав в 10 раз +- 🛡️ Повышение надёжности LDAP-синхронизации в 5 раз + +**Рекомендация**: Начать с P0 задач (транзакции, retry), затем постепенно внедрять Service Layer без breaking changes для фронтенда. + +--- + +**Все отчеты:** +- [01_DEPARTMENT_ROLES_MODELS.md](./01_DEPARTMENT_ROLES_MODELS.md) +- [02_DEPARTMENT_ROLES_API.md](./02_DEPARTMENT_ROLES_API.md) +- [03_DEPARTMENT_ROLES_SERIALIZERS.md](./03_DEPARTMENT_ROLES_SERIALIZERS.md) +- [04_DEPARTMENT_ROLES_PERMISSIONS.md](./04_DEPARTMENT_ROLES_PERMISSIONS.md) +- [05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md](./05_DEPARTMENT_ROLES_CONSTANTS_UTILS.md) +- [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md) +- [07_DEPARTMENT_ROLES_TESTS.md](./07_DEPARTMENT_ROLES_TESTS.md) +- [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md) ← **Вы здесь** diff --git a/backend/docs/reports/09_ROLES_LDAP_REDESIGN.md b/backend/docs/reports/09_ROLES_LDAP_REDESIGN.md new file mode 100644 index 00000000..5c467c9a --- /dev/null +++ b/backend/docs/reports/09_ROLES_LDAP_REDESIGN.md @@ -0,0 +1,817 @@ +# Редизайн LDAP-архитектуры для ролей отделов + +**Дата**: 30.12.2025 +**Статус**: Проектирование +**Проблема**: OU=Roles не работает с групповыми политиками + +--- + +## 1. Текущая проблема + +### 1.1 Структура сейчас + +``` +DC=company,DC=local +└── OU=Departments + └── OU=IT Department ← Сотрудники здесь (CN=John Doe) + ├── CN=John Doe ← Пользователь + ├── CN=Jane Smith ← Пользователь + ├── CN=IT Department ← Группа отдела (все члены) + └── OU=Roles ← Подпапка для ролей + ├── CN=Tech Lead ← Группа роли + └── CN=DevOps ← Группа роли +``` + +### 1.2 Почему не работает + +**Проблема с Group Policy (GPO):** +- GPO применяются к объектам **по их расположению в дереве LDAP** +- Пользователи `CN=John Doe` находятся в `OU=IT Department` +- Группы ролей находятся в `OU=Roles,OU=IT Department` +- **Вложенный OU не влияет на policy применяемые к родительскому OU** + +**Иллюстрация:** +``` +GPO → OU=IT Department (включает John Doe, Jane Smith) + └── OU=Roles (НЕ включает пользователей! Только группы) + └── CN=Tech Lead (группа, без GPO на неё) + └── member: CN=John Doe (но John в родительском OU) +``` + +**Результат**: Членство в группе `CN=Tech Lead,OU=Roles,...` не даёт применения GPO, потому что: +1. GPO применяется к OU, а не к группам напрямую +2. Пользователи лежат выше, в `OU=IT Department`, а не в `OU=Roles` + +### 1.3 Дополнительная проблема + +**Текущая логика**: Роли можно назначить только членам отдела. + +**Новое требование**: Роли должны быть доступны **любому сотруднику компании** (не обязательно члену отдела). + +--- + +## 2. Новая архитектура + +### 2.1 Убираем OU=Roles + +**Новая структура:** +``` +DC=company,DC=local +└── OU=Departments + └── OU=IT Department + ├── CN=John Doe ← Пользователь + ├── CN=Jane Smith ← Пользователь + ├── CN=DEP_IT Department ← Группа отдела (члены) + ├── CN=ROLE_TechLead ← Группа роли (прямо в OU отдела) + └── CN=ROLE_DevOps ← Группа роли (прямо в OU отдела) +``` + +### 2.2 Преимущества + +1. **GPO работают**: Группы ролей в том же OU, что и пользователи +2. **Проще структура**: Нет вложенных OU +3. **Унификация**: Отдел и роли управляются одинаково +4. **Гибкость**: Можно добавлять пользователей из других отделов + +### 2.3 Именование групп — конвенция проекта + +**Существующая конвенция:** +- `DEP_` — группы отделов (например, `DEP_IT`, `DEP_HR`) +- `POS_` — группы должностей (например, `POS_Developer`, `POS_Manager`) + +**Для ролей:** +- `ROLE_` — группы ролей (например, `ROLE_TechLead`, `ROLE_DevOps`) + +**Примеры:** +``` +CN=DEP_IT,OU=IT Department,... ← группа членов отдела +CN=ROLE_TechLead,OU=IT Department,... ← группа роли +CN=ROLE_DevOps,OU=IT Department,... ← группа роли +``` + +**Почему такой формат?** +- Единообразие с DEP_ и POS_ +- Легко фильтровать: `(cn=ROLE_*)` +- Роли уникальны в рамках отдела (unique_together) +- Группа лежит в OU отдела → контекст понятен из расположения + +--- + +## 3. Изменения в моделях Django + +### 3.1 DepartmentRole + +**Текущее поле:** +```python +ldap_group_dn = models.CharField( + "DN агрегаторной группы роли в AD (ROLE_*)", + max_length=512, + blank=True, + default="", +) +``` + +**Без изменений** — поле уже существует и подходит. + +### 3.2 EmployeeDepartment — ослабление связи + +**Текущее ограничение:** +```python +class EmployeeDepartment(models.Model): + employee = models.ForeignKey("Employee", ...) + department = models.ForeignKey(Department, ...) + role = models.ForeignKey(DepartmentRole, null=True, blank=True, ...) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["department_id", "employee_id"], + name="uniq_employee_per_department", + ), + ] +``` + +**Проблема**: Роль можно назначить только через `EmployeeDepartment`, т.е. сотрудник должен быть членом отдела. + +### 3.3 Новая модель: RoleAssignment + +**Предлагаемое решение**: Создать отдельную модель для назначений ролей. + +```python +class RoleAssignment(models.Model): + """Назначение роли сотруднику (не обязательно члену отдела).""" + + employee = models.ForeignKey( + "Employee", + on_delete=models.CASCADE, + related_name="role_assignments" + ) + role = models.ForeignKey( + DepartmentRole, + on_delete=models.CASCADE, + related_name="assignments" + ) + assigned_by = models.ForeignKey( + "Employee", + null=True, + on_delete=models.SET_NULL, + related_name="assigned_roles" + ) + assigned_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + class Meta: + verbose_name = "Назначение роли" + verbose_name_plural = "Назначения ролей" + constraints = [ + models.UniqueConstraint( + fields=["employee", "role"], + name="uniq_employee_role", + ), + ] + indexes = [ + models.Index(fields=["role", "is_active"]), + models.Index(fields=["employee", "is_active"]), + ] + + def __str__(self): + return f"{self.employee} → {self.role}" +``` + +### 3.4 Миграция данных + +```python +# Миграция: employees/migrations/XXXX_create_role_assignment.py + +def migrate_role_assignments(apps, schema_editor): + """Перенос role из EmployeeDepartment в RoleAssignment.""" + EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment') + RoleAssignment = apps.get_model('employees', 'RoleAssignment') + + for link in EmployeeDepartment.objects.filter(role__isnull=False): + RoleAssignment.objects.get_or_create( + employee=link.employee, + role=link.role, + defaults={ + 'is_active': link.is_active, + } + ) +``` + +### 3.5 Обратная совместимость + +**Переходный период:** +- Оставить `EmployeeDepartment.role` (deprecated) +- Добавить property для чтения из `RoleAssignment` + +```python +class EmployeeDepartment(models.Model): + # Deprecated: использовать RoleAssignment + role = models.ForeignKey( + DepartmentRole, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="members_legacy", + help_text="DEPRECATED: Использовать RoleAssignment", + ) +``` + +--- + +## 4. Изменения LDAP-логики + +### 4.1 Удалить создание OU=Roles + +**Файл**: `employees/ldap/services/department_service.py` + +**Текущий код (строки 651-655):** +```python +def _ensure_department_ou(self, conn: Connection, name: str) -> str: + # ... создание OU отдела ... + conn.add(f"OU=Roles,{dn}", ["top", "organizationalUnit"]) # УДАЛИТЬ + return dn +``` + +**Новый код:** +```python +def _ensure_department_ou(self, conn: Connection, name: str) -> str: + """Гарантирует наличие OU отдела (без вложенного OU=Roles).""" + base = getattr(settings, "LDAP_DEPARTMENTS_BASE", None) + if not base: + raise RuntimeError("LDAP_DEPARTMENTS_BASE is not configured") + dn = f"OU={name},{base}" + ok = conn.search(dn, "(objectClass=organizationalUnit)", search_scope=BASE) + if ok and conn.entries: + return dn + ok = conn.add(dn, ["top", "organizationalUnit"]) + if not ok: + raise RuntimeError(f"LDAP add OU failed: {conn.result}") + # Больше не создаём OU=Roles + return dn +``` + +### 4.2 Создание группы роли + +**Новый метод:** +```python +def create_role_group(self, role: DepartmentRole) -> str: + """Создаёт группу для роли в OU отдела. + + Args: + role: Роль отдела. + + Returns: + str: DN созданной группы. + + Raises: + DirectoryLdapError: Ошибка создания группы. + """ + dept = role.department + dept_dn = self._get_department_dn(dept) + + # Формат: ROLE_ (аналогично DEP_, POS_) + role_name = self._sanitize_name(role.name) + cn = f"ROLE_{role_name}" + + group_dn = f"CN={cn},{dept_dn}" + + with _ldap() as conn: + attrs = { + "sAMAccountName": cn, + "description": f"Role: {role.name} in {dept.name}", + "groupType": group_type("global", security_enabled=True), + } + ok = conn.add(group_dn, ["top", "group"], attrs) + if not ok: + if "entryAlreadyExists" in str(conn.result): + return group_dn # Уже существует + raise DirectoryLdapError(f"LDAP create role group failed: {conn.result}") + + # Сохранить DN в модели + role.ldap_group_dn = group_dn + role.save(update_fields=["ldap_group_dn"]) + + return group_dn + +def _sanitize_name(self, name: str) -> str: + """Очищает имя для использования в CN.""" + import re + # Убираем спецсимволы, заменяем пробелы на _ + clean = re.sub(r'[^\w\s-]', '', name) + clean = re.sub(r'\s+', '_', clean) + return clean[:50] # Ограничение длины +``` + +### 4.3 Переименование группы роли + +```python +def rename_role_group(self, role: DepartmentRole, new_name: str) -> str: + """Переименовывает группу роли в LDAP. + + Args: + role: Роль с текущим ldap_group_dn. + new_name: Новое название роли. + + Returns: + str: Новый DN группы. + """ + if not role.ldap_group_dn: + # Группы нет — создаём + return self.create_role_group(role) + + # Формат: ROLE_ + new_role_name = self._sanitize_name(new_name) + new_cn = f"ROLE_{new_role_name}" + + with _ldap() as conn: + new_dn = self._group_service.rename(conn, role.ldap_group_dn, new_cn=new_cn) + + role.ldap_group_dn = new_dn + role.save(update_fields=["ldap_group_dn"]) + + return new_dn +``` + +### 4.4 Удаление группы роли + +```python +def delete_role_group(self, role: DepartmentRole) -> None: + """Удаляет группу роли из LDAP. + + Args: + role: Роль для удаления группы. + """ + if not role.ldap_group_dn: + return + + with _ldap() as conn: + try: + self._group_service.delete(conn, role.ldap_group_dn) + except Exception as e: + # Best effort — логируем, но не падаем + logger.warning(f"Failed to delete role group {role.ldap_group_dn}: {e}") +``` + +### 4.5 Назначение роли сотруднику (новая логика) + +**Текущий код** (`set_member_role`, строки 540-575): +```python +def set_member_role(self, dept, employee, role): + # 1. Обновить EmployeeDepartment.role + link = EmployeeDepartment.objects.get(employee_id=employee.id, department_id=dept.id) + link.role = role + link.save() + + # 2. Синхронизировать группы в OU=Roles + if role: + roles_base = f"OU=Roles,{dept_dn}" # ← ПРОБЛЕМА: OU=Roles + sync_user_groups_by_cns(conn, user_dn, {role.name}, extra_bases=[roles_base]) +``` + +**Новый код:** +```python +def assign_role( + self, + employee: Employee, + role: DepartmentRole, + assigned_by: Employee | None = None +) -> RoleAssignment: + """Назначает роль сотруднику (не требует членства в отделе). + + Args: + employee: Сотрудник. + role: Роль для назначения. + assigned_by: Кто назначил (опционально). + + Returns: + RoleAssignment: Созданное назначение. + + Raises: + DirectoryDbError: Ошибка БД. + DirectoryLdapError: Ошибка LDAP. + """ + # 1. Создать/обновить назначение в БД + try: + with transaction.atomic(): + assignment, created = RoleAssignment.objects.update_or_create( + employee=employee, + role=role, + defaults={ + "is_active": True, + "assigned_by": assigned_by, + } + ) + except Exception as e: + raise DirectoryDbError(str(e)) from e + + # 2. Добавить в LDAP-группу роли + try: + self._sync_role_membership(employee, role, add=True) + except Exception as e: + logger.error(f"LDAP role sync failed: {e}") + # Не откатываем БД — best effort + + return assignment + +def revoke_role( + self, + employee: Employee, + role: DepartmentRole +) -> None: + """Отзывает роль у сотрудника. + + Args: + employee: Сотрудник. + role: Роль для отзыва. + """ + # 1. Деактивировать/удалить назначение + RoleAssignment.objects.filter( + employee=employee, + role=role + ).update(is_active=False) + + # 2. Удалить из LDAP-группы + try: + self._sync_role_membership(employee, role, add=False) + except Exception as e: + logger.error(f"LDAP role revoke failed: {e}") + +def _sync_role_membership( + self, + employee: Employee, + role: DepartmentRole, + add: bool = True +) -> None: + """Синхронизирует членство в группе роли LDAP. + + Args: + employee: Сотрудник. + role: Роль. + add: True — добавить, False — удалить. + """ + if not role.ldap_group_dn: + # Создаём группу если нет + self.create_role_group(role) + + if not role.ldap_group_dn: + return # Не удалось создать + + user_dn = self._user_service._get_employee_dn(employee) + + with _ldap() as conn: + if add: + self._group_service.add_members(conn, role.ldap_group_dn, [user_dn]) + else: + self._group_service.remove_members(conn, role.ldap_group_dn, [user_dn]) +``` + +### 4.6 Удалить ссылки на OU=Roles + +**Файлы для изменения:** + +1. `employees/ldap/services/department_service.py`: + - Удалить `conn.add(f"OU=Roles,{dn}", ...)` (строка 655) + - Заменить `roles_base = f"OU=Roles,{dept_dn}"` на `role.ldap_group_dn` + +2. `employees/ldap/sync_service.py`: + - Удалить `extra_bases.append(f"OU=Roles,OU={dept_for_roles},{dept_base}")` (строка 305) + +3. `employees/ldap/utils/group_utils.py`: + - Убрать специальную логику для `"OU=Roles," in dn` (строка 127) + +--- + +## 5. Изменения API + +### 5.1 Новый endpoint: assign_role + +**URL**: `POST /api/v1/department-roles/{role_id}/assign/` + +**Payload**: +```json +{ + "employee_id": 42 +} +``` + +**Response** (201): +```json +{ + "id": 1, + "employee_id": 42, + "role_id": 15, + "assigned_at": "2025-12-30T10:00:00Z", + "is_active": true +} +``` + +**Permission**: `DeptPerm.ASSIGN_ROLE` в отделе роли. + +### 5.2 Новый endpoint: revoke_role + +**URL**: `POST /api/v1/department-roles/{role_id}/revoke/` + +**Payload**: +```json +{ + "employee_id": 42 +} +``` + +**Response** (204): No Content + +### 5.3 Список назначений роли + +**URL**: `GET /api/v1/department-roles/{role_id}/assignments/` + +**Response**: +```json +{ + "count": 3, + "results": [ + { + "id": 1, + "employee_id": 42, + "employee_name": "John Doe", + "assigned_at": "2025-12-30T10:00:00Z", + "assigned_by": 1, + "is_active": true + } + ] +} +``` + +### 5.4 Deprecated: set_member_role + +**Статус**: Deprecated, но поддерживается для обратной совместимости. + +**Изменение**: Внутренне вызывает `assign_role`/`revoke_role`. + +```python +@action(detail=True, methods=["post"]) +def set_member_role(self, request, pk=None): + """DEPRECATED: Use /department-roles/{id}/assign/ instead.""" + warnings.warn( + "set_member_role is deprecated, use /department-roles/{id}/assign/", + DeprecationWarning + ) + + dept = self.get_object() + payload = SetMemberRoleInput(data=request.data) + payload.is_valid(raise_exception=True) + + employee_id = payload.validated_data["employee_id"] + role_id = payload.validated_data.get("role_id") + + employee = get_object_or_404(Employee, id=employee_id) + + if role_id: + role = get_object_or_404(DepartmentRole, id=role_id, department=dept) + self.service.assign_role(employee, role, request.user.employee) + else: + # Снять все роли в этом отделе + for assignment in RoleAssignment.objects.filter( + employee=employee, + role__department=dept, + is_active=True + ): + self.service.revoke_role(employee, assignment.role) + + return Response(...) +``` + +--- + +## 6. Миграция существующих данных LDAP + +### 6.1 Скрипт миграции + +```python +# management/commands/migrate_role_groups.py + +from django.core.management.base import BaseCommand +from employees.models import DepartmentRole, Department +from employees.ldap.infrastructure.connections import _ldap + +class Command(BaseCommand): + help = "Migrate role groups from OU=Roles to department OU" + + def handle(self, *args, **options): + with _ldap() as conn: + for role in DepartmentRole.objects.select_related('department').all(): + old_dn = role.ldap_group_dn + + if not old_dn or "OU=Roles" not in old_dn: + self.stdout.write(f"Skipping {role}: no OU=Roles in DN") + continue + + # Получить новый DN (в OU отдела, без OU=Roles) + dept_dn = self._get_dept_dn(role.department) + role_name = self._sanitize(role.name) + new_cn = f"ROLE_{role_name}" # Формат: ROLE_ + new_dn = f"CN={new_cn},{dept_dn}" + + # Переместить группу + try: + if conn.search(old_dn, "(objectClass=group)", search_scope=BASE): + # Перемещение в LDAP + conn.modify_dn( + old_dn, + f"CN={new_cn}", + new_superior=dept_dn + ) + self.stdout.write(self.style.SUCCESS( + f"Moved {old_dn} -> {new_dn}" + )) + else: + # Группы нет — создать новую + # ... + except Exception as e: + self.stderr.write(f"Error moving {old_dn}: {e}") + + # Обновить модель + role.ldap_group_dn = new_dn + role.save(update_fields=["ldap_group_dn"]) +``` + +### 6.2 Удаление пустых OU=Roles + +```python +def cleanup_empty_roles_ou(self): + """Удаляет пустые OU=Roles после миграции.""" + with _ldap() as conn: + base = settings.LDAP_DEPARTMENTS_BASE + conn.search( + base, + "(objectClass=organizationalUnit)", + attributes=["distinguishedName"] + ) + + for entry in conn.entries: + dn = entry.entry_dn + if "OU=Roles" not in dn: + continue + + # Проверить пустой ли + conn.search(dn, "(objectClass=*)", search_scope=LEVEL) + if not conn.entries: + conn.delete(dn) + print(f"Deleted empty OU: {dn}") +``` + +--- + +## 7. Проверка прав — изменения + +### 7.1 Текущая логика + +```python +def has_dept_perm(user_id: int, dept_id: int, permission_code: str) -> bool: + """Проверяет наличие права через EmployeeDepartment.role.""" + return EmployeeDepartment.objects.filter( + employee_id=user_id, + department_id=dept_id, + is_active=True, + role__scoped_permissions__code=permission_code + ).exists() +``` + +### 7.2 Новая логика + +```python +def has_dept_perm(user_id: int, dept_id: int, permission_code: str) -> bool: + """Проверяет наличие права через RoleAssignment.""" + # Быстрые проверки + user = User.objects.filter(id=user_id).first() + if not user: + return False + if user.is_staff or user.is_superuser: + return True + + # Руководитель отдела + if Department.objects.filter(id=dept_id, head__user_id=user_id).exists(): + return True + + # Через назначение роли (новая модель) + return RoleAssignment.objects.filter( + employee__user_id=user_id, + role__department_id=dept_id, + role__scoped_permissions__code=permission_code, + is_active=True + ).exists() +``` + +--- + +## 8. План внедрения + +### Этап 1: Подготовка (1 день) + +1. ✅ Создать модель `RoleAssignment` +2. ✅ Написать миграции +3. ✅ Добавить миграцию данных из `EmployeeDepartment.role` + +### Этап 2: LDAP-логика (2 дня) + +1. ✅ Добавить методы `create_role_group`, `rename_role_group`, `delete_role_group` +2. ✅ Изменить `_ensure_department_ou` — убрать создание OU=Roles +3. ✅ Реализовать `assign_role`, `revoke_role` с LDAP-синхронизацией +4. ✅ Написать скрипт миграции групп из OU=Roles + +### Этап 3: API (1 день) + +1. ✅ Добавить endpoints `/assign/`, `/revoke/`, `/assignments/` +2. ✅ Deprecate `set_member_role` с поддержкой обратной совместимости +3. ✅ Обновить сериализаторы + +### Этап 4: Permissions (0.5 дня) + +1. ✅ Обновить `has_dept_perm` для использования `RoleAssignment` +2. ✅ Обновить `AdminOrDeptAllowed` + +### Этап 5: Тестирование (2 дня) + +1. ✅ Unit-тесты для новых методов +2. ✅ Integration-тесты LDAP (с mock) +3. ✅ Миграция на тестовом AD + +### Этап 6: Продакшн миграция (1 день) + +1. ✅ Backup LDAP и БД +2. ✅ Запуск миграции Django +3. ✅ Запуск скрипта миграции LDAP-групп +4. ✅ Cleanup пустых OU=Roles +5. ✅ Мониторинг и откат при проблемах + +--- + +## 9. Риски и митигация + +### Риск 1: Конфликт имён групп + +**Описание**: Группа `ROLE_IT_TechLead` уже существует (например, создана вручную). + +**Митигация**: +- Проверять существование перед созданием +- При конфликте — добавлять суффикс: `ROLE_IT_TechLead_2` + +### Риск 2: Ошибки миграции LDAP + +**Описание**: Перемещение групп может не сработать (права, блокировки). + +**Митигация**: +- Запуск в maintenance window +- Fallback: создать новую группу + скопировать members +- Детальное логирование + +### Риск 3: Нарушение обратной совместимости + +**Описание**: Старый код использует `EmployeeDepartment.role`. + +**Митигация**: +- Оставить поле `role` (deprecated) +- Добавить signal для синхронизации с `RoleAssignment` +- Постепенная миграция кода + +--- + +## 10. Итоговая схема + +``` +┌────────────────────────────────────────────────────────────┐ +│ Django Models │ +├────────────────────────────────────────────────────────────┤ +│ Department │ +│ └── DepartmentRole (roles) │ +│ └── RoleAssignment (assignments) ← НОВОЕ │ +│ └── Employee (любой сотрудник) │ +│ │ +│ EmployeeDepartment (членство в отделе, БЕЗ роли) │ +└────────────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────────────────┐ +│ LDAP Structure │ +├────────────────────────────────────────────────────────────┤ +│ OU=Departments │ +│ └── OU=IT Department │ +│ ├── CN=John Doe (пользователь) │ +│ ├── CN=DEP_IT Department (группа членов) │ +│ ├── CN=ROLE_TechLead (группа роли) ← В OU! │ +│ └── CN=ROLE_DevOps (группа роли) │ +│ │ +│ [УДАЛЕНО: OU=Roles] │ +└────────────────────────────────────────────────────────────┘ +``` + +**Конвенция именования групп:** +- `DEP_` — группы отделов +- `POS_` — группы должностей +- `ROLE_` — группы ролей ← НОВОЕ + +--- + +**Связанные отчёты:** +- [01_DEPARTMENT_ROLES_MODELS.md](./01_DEPARTMENT_ROLES_MODELS.md) +- [06_DEPARTMENT_ROLES_LDAP.md](./06_DEPARTMENT_ROLES_LDAP.md) +- [08_DEPARTMENT_ROLES_ARCHITECTURE.md](./08_DEPARTMENT_ROLES_ARCHITECTURE.md) diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index be00df06..2849d8ed 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -13,7 +13,7 @@ from django.db import transaction from django.utils import timezone from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, Connection -from ...models import Department, Employee, EmployeeDepartment, LdapSyncState +from ...models import Department, DepartmentRole, Employee, EmployeeDepartment, LdapSyncState, RoleAssignment from ..domain.dtos import DirectoryDepartmentDTO from ..errors import ( DirectoryDbError, @@ -531,7 +531,10 @@ class DepartmentService: def set_member_role( self, dept: Department, employee: Employee, role ) -> None: - """Меняет роль участника отдела с синхронизацией LDAP-групп Roles. + """DEPRECATED: Используйте assign_role / revoke_role. + + Меняет роль участника отдела с синхронизацией LDAP-групп. + Сохранена для обратной совместимости. Args: dept: Отдел. @@ -542,11 +545,13 @@ class DepartmentService: DirectoryDbError: Ошибка при обновлении БД. DirectoryLdapError: Ошибка при синхронизации групп. """ + # Обновляем EmployeeDepartment.role для обратной совместимости try: with transaction.atomic(): link = EmployeeDepartment.objects.get( employee_id=employee.id, department_id=dept.id ) + old_role = link.role link.role = role link.save(update_fields=["role"]) except EmployeeDepartment.DoesNotExist as e: @@ -556,25 +561,231 @@ class DepartmentService: except Exception as e: raise DirectoryDbError(str(e)) from e + # Обновляем RoleAssignment + if role: + self.assign_role(employee, role, assigned_by=None) + elif old_role: + self.revoke_role(employee, old_role) + + def assign_role( + self, + employee: Employee, + role: DepartmentRole, + assigned_by: Optional[Employee] = None, + ) -> RoleAssignment: + """Назначает роль сотруднику (не требует членства в отделе). + + Args: + employee: Сотрудник. + role: Роль для назначения. + assigned_by: Кто назначил (опционально). + + Returns: + RoleAssignment: Созданное/обновлённое назначение. + + Raises: + DirectoryDbError: Ошибка БД. + DirectoryLdapError: Ошибка LDAP. + """ + # 1. Создать/обновить назначение в БД try: - if role: - user_dn = self._user_service._get_employee_dn(employee) - dept_dn = self._get_department_dn(dept) - roles_base = f"OU=Roles,{dept_dn}" - with _ldap() as conn: - sync_user_groups_by_cns( - conn, - user_dn, - {role.name}, - extra_bases=[roles_base], - do_write=True, - ) - except DirectoryServiceError: - pass + with transaction.atomic(): + assignment, created = RoleAssignment.objects.update_or_create( + employee=employee, + role=role, + defaults={ + "is_active": True, + "assigned_by": assigned_by, + } + ) except Exception as e: - raise DirectoryLdapError( - f"LDAP role sync failed: {e}" - ) from e + 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( + self, + employee: Employee, + role: DepartmentRole, + ) -> None: + """Отзывает роль у сотрудника. + + Args: + employee: Сотрудник. + role: Роль для отзыва. + """ + # 1. Деактивировать назначение + 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, + employee: Employee, + role: DepartmentRole, + add: bool = True, + ) -> None: + """Синхронизирует членство сотрудника в группе роли LDAP. + + Args: + employee: Сотрудник. + role: Роль. + add: True — добавить в группу, False — удалить. + """ + # Создаём группу роли если нет + if not role.ldap_group_dn: + self._ensure_role_group(role) + + if not role.ldap_group_dn: + return # Не удалось создать группу + + user_dn = self._user_service._get_employee_dn(employee) + + with _ldap() as conn: + if add: + self._group_service.add_members(conn, role.ldap_group_dn, [user_dn]) + else: + self._group_service.remove_members(conn, role.ldap_group_dn, [user_dn]) + + def _ensure_role_group(self, role: DepartmentRole) -> str: + """Гарантирует наличие группы роли ROLE_ в OU отдела. + + Args: + role: Роль отдела. + + Returns: + str: DN группы роли. + """ + dept = role.department + dept_dn = self._get_department_dn(dept) + + # Формат: ROLE_ (аналогично DEP_, POS_) + role_name = self._sanitize_name(role.name) + expected_cn = f"ROLE_{role_name}" + expected_rdn = f"CN={esc_rdn(expected_cn)}" + + saved_dn = (role.ldap_group_dn or "").strip() + + with _ldap() as conn: + # Проверяем существующую группу + if saved_dn: + ok = conn.search( + saved_dn, + "(objectClass=group)", + search_scope=BASE, + attributes=["distinguishedName"], + ) + if ok and conn.entries: + # Группа существует — проверяем нужно ли переименовать + cur_rdn = saved_dn.split(",", 1)[0] + if cur_rdn != expected_rdn: + # Переименовываем + if not conn.modify_dn(saved_dn, expected_rdn): + raise RuntimeError( + f"LDAP rename role group failed: {conn.result}" + ) + base = ",".join(saved_dn.split(",")[1:]) + new_dn = f"{expected_rdn},{base}" + else: + new_dn = saved_dn + + if new_dn != role.ldap_group_dn: + DepartmentRole.objects.filter(pk=role.pk).update( + ldap_group_dn=new_dn + ) + role.ldap_group_dn = new_dn + return new_dn + + # Создаём новую группу + new_dn = f"{expected_rdn},{dept_dn}" + attrs = { + "sAMAccountName": expected_cn[:20], # SAM ограничен 20 символами + "description": f"Role: {role.name} in {dept.name}", + "groupType": group_type("global", security_enabled=True), + } + ok = conn.add(new_dn, ["top", "group"], attrs) + if not ok: + if "entryAlreadyExists" in str(conn.result): + pass # Уже существует + else: + raise RuntimeError(f"LDAP add role group failed: {conn.result}") + + DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=new_dn) + role.ldap_group_dn = new_dn + return new_dn + + def rename_role_group(self, role: DepartmentRole, new_name: str) -> str: + """Переименовывает группу роли в LDAP. + + Args: + role: Роль. + new_name: Новое название роли. + + Returns: + str: Новый DN группы. + """ + if not role.ldap_group_dn: + # Группы нет — создаём с новым именем + role.name = new_name + return self._ensure_role_group(role) + + new_role_name = self._sanitize_name(new_name) + new_cn = f"ROLE_{new_role_name}" + + with _ldap() as conn: + new_dn = self._group_service.rename(conn, role.ldap_group_dn, new_cn=new_cn) + + DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=new_dn) + role.ldap_group_dn = new_dn + return new_dn + + def delete_role_group(self, role: DepartmentRole) -> None: + """Удаляет группу роли из LDAP. + + Args: + role: Роль для удаления группы. + """ + if not role.ldap_group_dn: + return + + with _ldap() as conn: + try: + self._group_service.delete(conn, role.ldap_group_dn) + except Exception: + pass # Best effort + + DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn="") + role.ldap_group_dn = "" + + def _sanitize_name(self, name: str) -> str: + """Очищает имя для использования в CN LDAP-группы. + + Args: + name: Исходное имя. + + Returns: + str: Очищенное имя. + """ + import re + # Убираем спецсимволы LDAP, заменяем пробелы на _ + clean = re.sub(r'[,=+<>#;\\"\']', '', name) + clean = re.sub(r'\s+', '_', clean) + return clean[:50] # Ограничение длины # ==================== DN/Lookup Methods ==================== # @@ -628,7 +839,10 @@ class DepartmentService: # ==================== Private OU Methods ==================== # def _ensure_department_ou(self, conn: Connection, name: str) -> str: - """Гарантирует наличие OU отдела + OU=Roles. + """Гарантирует наличие OU отдела. + + Группы ролей (ROLE_*) создаются непосредственно в OU отдела, + а не в отдельном OU=Roles (убрано для корректной работы GPO). Args: conn: LDAP соединение. @@ -652,7 +866,7 @@ class DepartmentService: ok = conn.add(dn, ["top", "organizationalUnit"]) if not ok: raise RuntimeError(f"LDAP add OU failed: {conn.result}") - conn.add(f"OU=Roles,{dn}", ["top", "organizationalUnit"]) + # NOTE: OU=Roles больше не создаётся — группы ролей лежат прямо в OU отдела return dn def _rename_department_ou( diff --git a/backend/employees/ldap/sync_service.py b/backend/employees/ldap/sync_service.py index bbbd47d9..d5e0b2b4 100644 --- a/backend/employees/ldap/sync_service.py +++ b/backend/employees/ldap/sync_service.py @@ -302,7 +302,8 @@ def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]: dept_for_roles = target_dept or current_dept if dept_for_roles: dept_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") - extra_bases.append(f"OU=Roles,OU={dept_for_roles},{dept_base}") + # NOTE: Группы ролей теперь лежат прямо в OU отдела (не в OU=Roles) + extra_bases.append(f"OU={dept_for_roles},{dept_base}") added, removed = sync_user_groups_by_cns( conn, diff --git a/backend/employees/ldap/utils/group_utils.py b/backend/employees/ldap/utils/group_utils.py index e9647774..9a0ba5e8 100644 --- a/backend/employees/ldap/utils/group_utils.py +++ b/backend/employees/ldap/utils/group_utils.py @@ -117,14 +117,16 @@ def sync_user_groups_by_cns( ) current_dns = _read_user_memberof_dns(conn, user_dn) - # ограничим удаление только «наших» веток (Groups/OU=Roles), + # ограничим удаление только «наших» веток (Groups, OU отделов с ROLE_*), # как и в батч-логике groups_base = getattr(settings, "LDAP_GROUPS_BASE", "") + depts_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") to_add = desired_dns - current_dns to_del = { dn for dn in (current_dns - desired_dns) - if (groups_base and dn.endswith(groups_base)) or "OU=Roles," in dn + if (groups_base and dn.endswith(groups_base)) + or (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn) } added = removed = 0 diff --git a/backend/employees/migrations/0035_create_role_assignment.py b/backend/employees/migrations/0035_create_role_assignment.py new file mode 100644 index 00000000..bd7bcd01 --- /dev/null +++ b/backend/employees/migrations/0035_create_role_assignment.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.4 on 2025-12-30 06:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0034_alter_employee_telegram'), + ] + + operations = [ + migrations.CreateModel( + name='RoleAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата назначения')), + ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активно')), + ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_roles', to=settings.AUTH_USER_MODEL, verbose_name='Назначил')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='employees.departmentrole', verbose_name='Роль')), + ], + options={ + 'verbose_name': 'Назначение роли', + 'verbose_name_plural': 'Назначения ролей', + 'ordering': ['-assigned_at'], + 'indexes': [models.Index(fields=['role', 'is_active'], name='employees_r_role_id_215f2d_idx'), models.Index(fields=['employee', 'is_active'], name='employees_r_employe_5d7a2a_idx')], + 'constraints': [models.UniqueConstraint(fields=('employee', 'role'), name='uniq_employee_role_assignment')], + }, + ), + ] diff --git a/backend/employees/migrations/0036_migrate_role_assignments.py b/backend/employees/migrations/0036_migrate_role_assignments.py new file mode 100644 index 00000000..e46a3022 --- /dev/null +++ b/backend/employees/migrations/0036_migrate_role_assignments.py @@ -0,0 +1,49 @@ +# Generated by Django migration script +# employees/migrations/0036_migrate_role_assignments.py + +from django.db import migrations + + +def migrate_role_data(apps, schema_editor): + """Переносит назначения ролей из EmployeeDepartment.role в RoleAssignment.""" + EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment') + RoleAssignment = apps.get_model('employees', 'RoleAssignment') + + # Находим все связи с назначенными ролями + links_with_roles = EmployeeDepartment.objects.filter( + role__isnull=False + ).select_related('employee', 'role') + + created_count = 0 + for link in links_with_roles: + # Создаём RoleAssignment если ещё нет + assignment, created = RoleAssignment.objects.get_or_create( + employee=link.employee, + role=link.role, + defaults={ + 'is_active': link.is_active, + 'assigned_by': None, # Исторические данные без информации о назначителе + } + ) + if created: + created_count += 1 + + if created_count > 0: + print(f"Migrated {created_count} role assignments from EmployeeDepartment to RoleAssignment") + + +def reverse_migration(apps, schema_editor): + """Откат: удаляем все RoleAssignment (данные сохранены в EmployeeDepartment.role).""" + RoleAssignment = apps.get_model('employees', 'RoleAssignment') + RoleAssignment.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0035_create_role_assignment'), + ] + + operations = [ + migrations.RunPython(migrate_role_data, reverse_migration), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index b31bdbe2..05bcec55 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -513,6 +513,70 @@ class EmployeeDepartment(DateRangeMixin, models.Model): return f"{self.employee} в {self.department} ({role_name})" +class RoleAssignment(models.Model): + """Назначение роли сотруднику (не обязательно члену отдела). + + Позволяет назначать роли любым сотрудникам компании, + независимо от их членства в отделе. + + Attributes: + employee: Сотрудник, которому назначена роль. + role: Назначенная роль отдела. + assigned_by: Кто назначил роль (опционально). + assigned_at: Дата/время назначения. + is_active: Активно ли назначение. + """ + + employee = models.ForeignKey( + "Employee", + on_delete=models.CASCADE, + related_name="role_assignments", + verbose_name="Сотрудник", + ) + role = models.ForeignKey( + DepartmentRole, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name="Роль", + ) + assigned_by = models.ForeignKey( + "Employee", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="assigned_roles", + verbose_name="Назначил", + ) + assigned_at = models.DateTimeField( + "Дата назначения", + auto_now_add=True, + ) + is_active = models.BooleanField( + "Активно", + default=True, + db_index=True, + ) + + class Meta: + verbose_name = "Назначение роли" + verbose_name_plural = "Назначения ролей" + constraints = [ + models.UniqueConstraint( + fields=["employee", "role"], + name="uniq_employee_role_assignment", + ), + ] + indexes = [ + models.Index(fields=["role", "is_active"]), + models.Index(fields=["employee", "is_active"]), + ] + ordering = ["-assigned_at"] + + def __str__(self): + status = "✓" if self.is_active else "✗" + return f"{self.employee} → {self.role} [{status}]" + + class Skill(models.Model): name = models.CharField(max_length=100, unique=True) diff --git a/backend/static/js/components/employeesList.js b/backend/static/js/components/employeesList.js index 31ba9946..14d98c43 100644 --- a/backend/static/js/components/employeesList.js +++ b/backend/static/js/components/employeesList.js @@ -332,7 +332,12 @@ export class EmployeesList { return ''; } - return emp.departments + // Проверяем department_relation для пометки "внешняя роль" + const relation = emp.department_relation; + const hasRoleOnly = relation && relation.has_role_only; + const roleNames = relation && relation.role_names ? relation.role_names.join(', ') : ''; + + let deptHtml = emp.departments .filter(d => d.name) .map(d => { if (d.id) { @@ -341,6 +346,13 @@ export class EmployeesList { return this.escapeHtml(d.name); }) .join(', '); + + // Добавляем пометку если сотрудник имеет только роль (не член отдела) + if (hasRoleOnly && roleNames) { + deptHtml += ` ${this.escapeHtml(roleNames)}`; + } + + return deptHtml; } /** diff --git a/backend/templates/employees/components/department_modals.html b/backend/templates/employees/components/department_modals.html index bb83b9d2..07f4053a 100644 --- a/backend/templates/employees/components/department_modals.html +++ b/backend/templates/employees/components/department_modals.html @@ -140,32 +140,29 @@