(api) add endpoint to manage ThreadAccess

Allows creating, updating, and deleting access to threads based on
user role.
This commit is contained in:
Sabrina Demagny
2025-05-16 12:18:14 +02:00
parent bbb8f8cb4a
commit eca469e666
7 changed files with 653 additions and 11 deletions
+67
View File
@@ -276,3 +276,70 @@ class IsAllowedToCreateMessage(IsAuthenticated):
# return True
#
# return False
class IsAllowedToManageThreadAccess(IsAuthenticated):
"""Permission class for access to create, update, delete and list thread accesses."""
def has_permission(self, request, view):
# Get thread_id from URL kwargs instead of query params/data
thread_id = view.kwargs.get("thread_id")
if not thread_id:
return False
# if create action, check if user has admin/editor access to the mailbox and the thread access role is editor
if view.action == "create":
# authenticated user wants to create a thread access for a specific thread
# check if user has admin/editor access to the mailbox and the thread access role is editor already exists for them
return (
models.ThreadAccess.objects.select_related("mailbox")
.filter(
thread_id=thread_id,
mailbox__accesses__user=request.user,
mailbox__accesses__role__in=[
enums.MailboxRoleChoices.ADMIN,
enums.MailboxRoleChoices.EDITOR,
],
role=enums.ThreadAccessRoleChoices.EDITOR,
)
.exists()
)
elif view.action == "list":
# list is only allowed for a user with access to the thread
return (
models.ThreadAccess.objects.select_related("mailbox")
.filter(
thread_id=thread_id,
mailbox__accesses__user=request.user,
mailbox__accesses__role__in=[
enums.MailboxRoleChoices.ADMIN,
enums.MailboxRoleChoices.EDITOR,
],
role=enums.ThreadAccessRoleChoices.EDITOR,
)
.exists()
)
else:
return True # to proceed to object-level checks
def has_object_permission(self, request, view, obj):
"""Check if user has permission to access the specific object (ThreadAccess).
Manage retrieve, update, destroy actions here.
"""
# Verify the thread access belongs to the thread in the URL
if str(obj.thread.id) != view.kwargs.get("thread_id"):
return False
return (
models.ThreadAccess.objects.select_related("mailbox")
.filter(
thread=obj.thread,
mailbox__accesses__user=request.user,
mailbox__accesses__role__in=[
enums.MailboxRoleChoices.ADMIN,
enums.MailboxRoleChoices.EDITOR,
],
role=enums.ThreadAccessRoleChoices.EDITOR,
)
.exists()
)
+9
View File
@@ -279,3 +279,12 @@ class MessageSerializer(serializers.ModelSerializer):
"is_trashed",
]
read_only_fields = fields # Mark all as read-only
class ThreadAccessSerializer(serializers.ModelSerializer):
"""Serialize thread access information."""
class Meta:
model = models.ThreadAccess
fields = ["id", "thread", "mailbox", "role", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
@@ -0,0 +1,68 @@
"""API ViewSet for ThreadAccess model."""
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
extend_schema,
)
from rest_framework import mixins, viewsets
from core import models
from .. import permissions, serializers
@extend_schema(
tags=["thread-access"],
parameters=[
OpenApiParameter(
name="mailbox_id",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Filter thread accesses by mailbox ID.",
required=False,
),
],
)
class ThreadAccessViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
"""ViewSet for ThreadAccess model."""
serializer_class = serializers.ThreadAccessSerializer
permission_classes = [
permissions.IsAuthenticated,
permissions.IsAllowedToManageThreadAccess,
]
lookup_field = "id"
lookup_url_kwarg = "id"
queryset = models.ThreadAccess.objects.all()
def get_queryset(self):
"""Restrict results to thread accesses for the specified thread."""
# Get thread_id from URL kwargs (provided by nested router)
thread_id = self.kwargs.get("thread_id")
if not thread_id:
return models.ThreadAccess.objects.none()
# Filter by thread_id from URL
queryset = self.queryset.filter(thread_id=thread_id)
# Optional mailbox filter
mailbox_id = self.request.GET.get("mailbox_id")
if mailbox_id:
queryset = queryset.filter(mailbox_id=mailbox_id)
return queryset.distinct()
def create(self, request, *args, **kwargs):
"""Create a new thread access."""
request.data["thread"] = self.kwargs.get("thread_id")
return super().create(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
"""List thread accesses for the specified thread."""
return super().list(request, *args, **kwargs)
+7 -7
View File
@@ -21,6 +21,13 @@ class MailboxRoleChoices(models.TextChoices):
ADMIN = "admin", _("Admin")
class ThreadAccessRoleChoices(models.TextChoices):
"""Defines the possible roles a mailbox can have to access to a thread."""
VIEWER = "viewer", _("Viewer")
EDITOR = "editor", _("Editor")
class MessageRecipientTypeChoices(models.TextChoices):
"""Defines the possible types of message recipients."""
@@ -29,13 +36,6 @@ class MessageRecipientTypeChoices(models.TextChoices):
BCC = "bcc", _("Bcc")
class ThreadAccessRoleChoices(models.TextChoices):
"""Defines the possible roles a mailbox can have to access to a thread."""
VIEWER = "viewer", _("Viewer")
EDITOR = "editor", _("Editor")
THREAD_STATS_FIELDS_MAP = {
"unread": "count_unread",
"trashed": "count_trashed",
@@ -187,7 +187,6 @@ class TestApiDraftAndSendMessage:
assert sent_message.thread.sender_names == [sent_message.sender.name]
assert sent_message.thread.messaged_at is not None
@patch("core.mda.outbound.send_outbound_message")
def test_draft_and_send_message_success_delegated_access(
self, mock_send_outbound_message, mailbox, authenticated_user, send_url
@@ -0,0 +1,479 @@
"""Tests for the ThreadAccess API endpoints."""
import uuid
from django.urls import reverse
import pytest
from rest_framework import status
from core import enums, factories, models
pytestmark = pytest.mark.django_db
def get_thread_access_url(thread_id, access_id=None):
"""Helper function to get the thread access URL."""
if access_id:
return reverse("thread-access-detail", args=[thread_id, access_id])
return reverse("thread-access-list", args=[thread_id])
@pytest.fixture
def mailbox_with_access():
"""Create a mailbox with access for a user."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
return user, mailbox
@pytest.fixture
def thread_with_editor_access(mailbox_with_access):
"""Create a thread with access for a mailbox."""
user, mailbox = mailbox_with_access
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
return user, mailbox, thread, thread_access
class TestThreadAccessList:
"""Test the GET /threads/{thread_id}/accesses/ endpoint."""
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
],
)
def test_list_thread_access_success(
self,
api_client,
thread_access_role,
mailbox_access_role,
django_assert_num_queries,
):
"""Test listing thread accesses of a thread."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
api_client.force_authenticate(user=user)
# Create other accesses for thread
factories.ThreadAccessFactory.create_batch(10, thread=thread)
# Create others thread accesses for different threads
other_thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=other_thread,
)
factories.ThreadAccessFactory.create_batch(5, thread=other_thread)
with django_assert_num_queries(3):
response = api_client.get(get_thread_access_url(thread.id))
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 11
assert response.data["results"][0]["thread"] == thread.id
def test_list_thread_access_filter_by_mailbox(
self, api_client, thread_with_editor_access, django_assert_num_queries
):
"""Test listing thread accesses filtered by mailbox."""
user, mailbox, thread, thread_access = thread_with_editor_access
api_client.force_authenticate(user=user)
# Create another thread access for a different mailbox
other_mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=other_mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
factories.ThreadAccessFactory(
mailbox=other_mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
with django_assert_num_queries(3):
response = api_client.get(
f"{get_thread_access_url(thread.id)}?mailbox_id={mailbox.id}"
)
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["mailbox"] == mailbox.id
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_list_thread_access_forbidden(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test listing thread accesses without permission."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
# Create a mailbox and thread access that the user doesn't have access to manage
mailbox = factories.MailboxFactory()
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
# Test that user cannot access thread accesses for a thread they don't have proper access to
response = api_client.get(get_thread_access_url(thread.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
# Test that user cannot access thread accesses for a non-existent thread
response = api_client.get(get_thread_access_url(uuid.uuid4()))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_thread_access_unauthorized(self, api_client):
"""Test listing thread accesses without authentication."""
thread = factories.ThreadFactory()
response = api_client.get(get_thread_access_url(thread.id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestThreadAccessCreate:
"""Test the POST /threads/{thread_id}/accesses/ endpoint."""
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
],
)
def test_create_thread_access_success(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test creating a thread access successfully."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
api_client.force_authenticate(user=user)
delegated_mailbox = factories.MailboxFactory()
data = {
"mailbox": str(delegated_mailbox.id),
"role": enums.ThreadAccessRoleChoices.VIEWER,
}
response = api_client.post(get_thread_access_url(thread.id), data)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["thread"] == thread.id
assert response.data["mailbox"] == delegated_mailbox.id
assert response.data["role"] == enums.ThreadAccessRoleChoices.VIEWER
def test_create_thread_access_duplicate(
self, api_client, thread_with_editor_access
):
"""Test creating a duplicate thread access."""
user, mailbox, thread, _ = thread_with_editor_access
api_client.force_authenticate(user=user)
data = {
"mailbox": str(mailbox.id),
"role": enums.ThreadAccessRoleChoices.EDITOR,
}
response = api_client.post(get_thread_access_url(thread.id), data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_create_thread_access_forbidden(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test creating a thread access without permission."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
api_client.force_authenticate(user=user)
delegated_mailbox = factories.MailboxFactory()
data = {
"mailbox": str(delegated_mailbox.id),
"role": enums.ThreadAccessRoleChoices.VIEWER,
}
response = api_client.post(get_thread_access_url(thread.id), data)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_thread_access_invalid_data(
self, api_client, thread_with_editor_access
):
"""Test creating a thread access with invalid data."""
user, mailbox, thread, _ = thread_with_editor_access
api_client.force_authenticate(user=user)
data = {
"mailbox": str(mailbox.id),
"role": "invalid_role", # Invalid role
}
response = api_client.post(get_thread_access_url(thread.id), data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_create_thread_access_unauthorized(self, api_client):
"""Test creating a thread access without authentication."""
thread = factories.ThreadFactory()
response = api_client.post(get_thread_access_url(thread.id), {})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestThreadAccessUpdate:
"""Test the PUT/PATCH /threads/{thread_id}/accesses/{id}/ endpoint."""
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
],
)
def test_update_thread_access_success(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test updating a thread access successfully."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
api_client.force_authenticate(user=user)
thread_access = factories.ThreadAccessFactory(
thread=thread, role=enums.ThreadAccessRoleChoices.VIEWER
)
url = get_thread_access_url(thread.id, thread_access.id)
data = {"role": enums.ThreadAccessRoleChoices.EDITOR}
response = api_client.patch(url, data)
assert response.status_code == status.HTTP_200_OK
assert response.data["role"] == enums.ThreadAccessRoleChoices.EDITOR
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_update_thread_access_forbidden(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test updating a thread access without permission."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
# Create a thread access that the user doesn't have right role to modify
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
url = get_thread_access_url(thread.id, thread_access.id)
data = {"role": enums.ThreadAccessRoleChoices.EDITOR}
response = api_client.patch(url, data)
assert response.status_code == status.HTTP_403_FORBIDDEN
# Create a thread access that the user doesn't have any role to modify
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory()
url = get_thread_access_url(thread.id, thread_access.id)
data = {"role": enums.ThreadAccessRoleChoices.EDITOR}
response = api_client.patch(url, data)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_thread_access_not_found(self, api_client, mailbox_with_access):
"""Test updating a non-existent thread access."""
user, _ = mailbox_with_access
api_client.force_authenticate(user=user)
thread = factories.ThreadFactory()
url = get_thread_access_url(thread.id, uuid.uuid4())
data = {"role": enums.ThreadAccessRoleChoices.EDITOR}
response = api_client.patch(url, data)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_thread_access_unauthorized(self, api_client):
"""Test updating a thread access without authentication."""
thread = factories.ThreadFactory()
url = get_thread_access_url(thread.id, uuid.uuid4())
response = api_client.patch(url, {})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestThreadAccessDelete:
"""Test the DELETE /threads/{thread_id}/accesses/{id}/ endpoint."""
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
],
)
def test_delete_thread_access_success(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test deleting a thread access successfully."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
api_client.force_authenticate(user=user)
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify the thread access was deleted
assert not models.ThreadAccess.objects.filter(id=thread_access.id).exists()
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_delete_thread_access_forbidden(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test deleting a thread access without permission."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
# Create a thread access that the user doesn't have any role to delete
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
# Create a thread access that the user doesn't have any role to delete
thread_access = factories.ThreadAccessFactory()
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_thread_access_not_found(self, api_client, mailbox_with_access):
"""Test deleting a non-existent thread access."""
user, _ = mailbox_with_access
api_client.force_authenticate(user=user)
thread = factories.ThreadFactory()
url = get_thread_access_url(thread.id, uuid.uuid4())
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_thread_access_unauthorized(self, api_client):
"""Test deleting a thread access without authentication."""
thread = factories.ThreadFactory()
url = get_thread_access_url(thread.id, uuid.uuid4())
response = api_client.delete(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+23 -3
View File
@@ -1,7 +1,7 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
@@ -15,6 +15,7 @@ from core.api.viewsets.mta import MTAViewSet
from core.api.viewsets.send import SendMessageView
from core.api.viewsets.task import TaskDetailView
from core.api.viewsets.thread import ThreadViewSet
from core.api.viewsets.thread_access import ThreadAccessViewSet
from core.api.viewsets.user import UserViewSet
from core.authentication.urls import urlpatterns as oidc_urls
@@ -23,14 +24,33 @@ router = DefaultRouter()
router.register("mta", MTAViewSet, basename="mta")
router.register("users", UserViewSet, basename="users")
router.register("mailboxes", MailboxViewSet, basename="mailboxes")
router.register("threads", ThreadViewSet, basename="threads")
router.register("messages", MessageViewSet, basename="messages")
router.register("blob", BlobViewSet, basename="blob")
router.register("thread-access", ThreadAccessViewSet, basename="thread-access")
# Add nested router for thread accesses
thread_router = DefaultRouter()
thread_router.register("threads", ThreadViewSet, basename="threads")
thread_related_router = DefaultRouter()
thread_related_router.register(
"accesses", ThreadAccessViewSet, basename="thread-access"
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
include([*router.urls, *oidc_urls]),
include(
[
*router.urls,
*thread_router.urls,
re_path(
r"^threads/(?P<thread_id>[\w-]+)/",
include(thread_related_router.urls),
),
*oidc_urls,
]
),
),
path(f"api/{settings.API_VERSION}/config/", ConfigView.as_view()),
path(