From 5fd9d9bf943c9526e970397fbdd0d18b15d5d993 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 5 Mar 2026 15:04:22 +0300 Subject: [PATCH] WIP: separation of duties implementation (incomplete) Added: - is_not_document_uploader predicate - Specific FSM action permissions (approve_document, publish_document, etc.) - Tests for author cannot approve/publish own documents Note: This is work in progress and will be reviewed/simplified after comparing with develop branch --- backend/api/v1/documents/permissions.py | 18 +++++- backend/documents/rules.py | 43 +++++++++++++ .../api/v1/documents/test_fsm_workflow.py | 63 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index 6f1dba54..b9a06c58 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -80,8 +80,22 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): if user.is_staff or _has_any_model_perm(user, "documents", "document"): return True - # FSM actions → проверяем django-rules change_document - if action in self.FSM_ACTIONS: + # FSM actions → проверяем специфичные django-rules права (разделение обязанностей!) + if action == "submit_for_review": + return user.has_perm("documents.submit_for_review_document", obj) + elif action == "approve": + return user.has_perm("documents.approve_document", obj) + elif action == "publish": + return user.has_perm("documents.publish_document", obj) + elif action == "reject": + return user.has_perm("documents.reject_document", obj) + elif action in ("archive", "unarchive"): + return user.has_perm("documents.archive_document", obj) + elif action == "return_to_draft": + # Вернуть в draft может автор или менеджер + return user.has_perm("documents.change_document", obj) + elif action == "revert": + # Откатить версию может автор или менеджер return user.has_perm("documents.change_document", obj) # add_related/remove_related → проверяем django-rules change_document diff --git a/backend/documents/rules.py b/backend/documents/rules.py index c0ad1c82..28191c28 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -78,6 +78,14 @@ def is_same_department(user, document): return document.departments.filter(id=user.department.id).exists() +@rules.predicate +def is_not_document_uploader(user, document): + """Пользователь НЕ загрузил документ (для разделения обязанностей)""" + if document is None: + return True + return document.uploaded_by != user + + @rules.predicate def has_acknowledged_document(user, document): """Пользователь уже ознакомился с документом""" @@ -150,6 +158,41 @@ rules.add_rule( ) +# ----------------------------------------------------------------------------- +# FSM TRANSITIONS PERMISSIONS (разделение обязанностей) +# ----------------------------------------------------------------------------- + +# Submit for review: автор может отправить СВОЙ документ на рассмотрение +rules.add_rule( + 'documents.submit_for_review_document', + is_superuser | is_document_uploader | can_manage_documents +) + +# Approve: ТОЛЬКО менеджеры/superuser, НЕ автор (разделение обязанностей!) +rules.add_rule( + 'documents.approve_document', + is_superuser | (can_manage_documents & is_not_document_uploader) +) + +# Publish: ТОЛЬКО менеджеры/superuser, НЕ автор (разделение обязанностей!) +rules.add_rule( + 'documents.publish_document', + is_superuser | (can_manage_documents & is_not_document_uploader) +) + +# Reject: ТОЛЬКО менеджеры/superuser +rules.add_rule( + 'documents.reject_document', + is_superuser | can_manage_documents +) + +# Archive/Unarchive: менеджеры или автор может архивировать свой документ +rules.add_rule( + 'documents.archive_document', + is_superuser | is_document_uploader | can_manage_documents +) + + # ----------------------------------------------------------------------------- # PERMISSIONS ДЛЯ МОДЕЛЕЙ # ----------------------------------------------------------------------------- diff --git a/backend/tests/api/v1/documents/test_fsm_workflow.py b/backend/tests/api/v1/documents/test_fsm_workflow.py index 176d48cc..b00e3c50 100644 --- a/backend/tests/api/v1/documents/test_fsm_workflow.py +++ b/backend/tests/api/v1/documents/test_fsm_workflow.py @@ -453,3 +453,66 @@ class TestAuthorCanSubmitOwnDocument: # Перечитываем документ из БД doc = Document.objects.get(pk=doc.id) assert doc.title == 'Updated Title' + + def test_author_cannot_approve_own_document( + self, api_client, make_user + ): + """КРИТИЧНО: Автор НЕ может одобрить свой документ (разделение обязанностей!)""" + from documents.models import Document + + author = make_user("author@example.com", staff=False) + + # Создаем документ и отправляем на рассмотрение + doc = make_document(uploaded_by=author, status=Document.Status.DRAFT) + + # Автор отправляет на рассмотрение - это ОК + api_client.force_authenticate(user=author) + url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + # Проверяем что статус изменился + doc = Document.objects.get(pk=doc.id) + assert doc.status == Document.Status.IN_REVIEW + + # Теперь автор пытается ОДОБРИТЬ свой документ - должно быть запрещено! + url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) + response = api_client.post(url) + + # Должен получить 403 ИЛИ 400 (FSM не разрешает или нет прав) + assert response.status_code in [ + status.HTTP_403_FORBIDDEN, + status.HTTP_400_BAD_REQUEST + ], f"Author should NOT be able to approve own document! Got {response.status_code}" + + # Статус НЕ должен измениться + doc = Document.objects.get(pk=doc.id) + assert doc.status == Document.Status.IN_REVIEW, "Status should remain IN_REVIEW" + + def test_author_cannot_publish_own_document( + self, api_client, make_user + ): + """КРИТИЧНО: Автор НЕ может опубликовать свой документ (разделение обязанностей!)""" + from documents.models import Document + + author = make_user("author@example.com", staff=False) + + # Создаем документ в статусе APPROVED (как будто менеджер одобрил) + doc = make_document_with_status(uploaded_by=author, status='approved') + doc = Document.objects.get(pk=doc.id) + assert doc.status == Document.Status.APPROVED + + # Автор пытается ОПУБЛИКОВАТЬ - должно быть запрещено! + api_client.force_authenticate(user=author) + url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) + response = api_client.post(url) + + # Должен получить 403 ИЛИ 400 + assert response.status_code in [ + status.HTTP_403_FORBIDDEN, + status.HTTP_400_BAD_REQUEST + ], f"Author should NOT be able to publish own document! Got {response.status_code}" + + # Статус НЕ должен измениться + doc = Document.objects.get(pk=doc.id) + assert doc.status == Document.Status.APPROVED, "Status should remain APPROVED"