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
This commit is contained in:
lizerk 2026-03-05 15:04:22 +03:00
parent ad2e5ab76d
commit 5fd9d9bf94
3 changed files with 122 additions and 2 deletions

View File

@ -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

View File

@ -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 ДЛЯ МОДЕЛЕЙ
# -----------------------------------------------------------------------------

View File

@ -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"