From e5561ba06ffc69d12089189d2cbddcc6208edbdf Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:15:59 -0700 Subject: [PATCH 1/3] Fix: correctly scope mail account enumeration (#12636) --- src/paperless_mail/serialisers.py | 14 +++++++++++++- src/paperless_mail/tests/test_api.py | 24 +++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index aff3e75da..d84041f45 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext as _ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied +from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import has_perms_owner_aware from documents.serialisers import CorrespondentField from documents.serialisers import DocumentTypeField @@ -59,7 +60,18 @@ class MailAccountSerializer(OwnedObjectSerializer): class AccountField(serializers.PrimaryKeyRelatedField): def get_queryset(self): - return MailAccount.objects.all().order_by("-id") + user = getattr(self.context.get("request"), "user", None) + if user is None: + user = getattr(self.root, "user", None) + + if user is None: + return MailAccount.objects.none() + + return get_objects_for_user_owner_aware( + user, + "change_mailaccount", + MailAccount, + ).order_by("-id") class MailRuleSerializer(OwnedObjectSerializer): diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index 905509ec1..23972da35 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -632,7 +632,7 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): self.assertEqual(returned_rule1.name, "Updated Name 1") self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE) - def test_create_mail_rule_forbidden_for_unpermitted_account(self): + def test_create_mail_rule_scopes_accounts(self): other_user = User.objects.create_user(username="mail-owner") foreign_account = MailAccount.objects.create( name="ForeignEmail", @@ -660,8 +660,26 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): "attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, }, ) + missing_response = self.client.post( + self.ENDPOINT, + data={ + "name": "Rule1", + "account": foreign_account.pk + 1000, + "folder": "INBOX", + "filter_from": "from@example.com", + "maximum_age": 30, + "action": MailRule.MailAction.MARK_READ, + "assign_title_from": MailRule.TitleSource.FROM_SUBJECT, + "assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING, + "order": 0, + "attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + }, + ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(missing_response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["account"][0].code, "does_not_exist") + self.assertEqual(missing_response.data["account"][0].code, "does_not_exist") self.assertEqual(MailRule.objects.count(), 0) def test_create_mail_rule_allowed_for_granted_account_change_permission(self): @@ -736,7 +754,7 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): data={"account": foreign_account.pk}, ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) rule1.refresh_from_db() self.assertEqual(rule1.account, own_account) From d574867abb67a9267bb1a6d9e7b760dbd9fb2fa3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:57:01 -0700 Subject: [PATCH 2/3] Fix: use only allauth login/logout endpoints (#12639) --- src/paperless/tests/test_api_auth.py | 48 ++++++++++++++++++++++++++++ src/paperless/urls.py | 16 +++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/paperless/tests/test_api_auth.py diff --git a/src/paperless/tests/test_api_auth.py b/src/paperless/tests/test_api_auth.py new file mode 100644 index 000000000..d55b4bdb2 --- /dev/null +++ b/src/paperless/tests/test_api_auth.py @@ -0,0 +1,48 @@ +import uuid + +from django.contrib.auth.models import User +from django.test import TestCase +from django.test import override_settings +from django.urls import resolve +from django.urls import reverse +from rest_framework import status + + +class TestApiAuthViews(TestCase): + def test_api_auth_login_uses_allauth_login_view(self): + response = self.client.get(reverse("rest_framework:login")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTemplateUsed(response, "account/login.html") + + def test_api_auth_login_uses_same_view_as_account_login(self): + api_match = resolve("/api/auth/login/") + account_match = resolve("/accounts/login/") + + self.assertIs(api_match.func.view_class, account_match.func.view_class) + + @override_settings(DISABLE_REGULAR_LOGIN=True) + def test_api_auth_login_respects_disable_regular_login(self): + username = f"testuser-{uuid.uuid4().hex}" + User.objects.create_user( + username=username, + password="testpassword", + ) + + response = self.client.post( + reverse("rest_framework:login"), + data={ + "login": username, + "password": "testpassword", + "next": "/api/documents/", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTemplateUsed(response, "account/login.html") + self.assertContains(response, "Regular login is disabled") + self.assertNotIn("_auth_user_id", self.client.session) + + def test_api_auth_logout_uses_named_route(self): + self.assertEqual(reverse("rest_framework:login"), "/api/auth/login/") + self.assertEqual(reverse("rest_framework:logout"), "/api/auth/logout/") diff --git a/src/paperless/urls.py b/src/paperless/urls.py index e24d1a459..e9635a0ed 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -89,7 +89,21 @@ urlpatterns = [ re_path( "^auth/", include( - ("rest_framework.urls", "rest_framework"), + ( + [ + path( + "login/", + allauth_account_views.login, + name="login", + ), + path( + "logout/", + allauth_account_views.logout, + name="logout", + ), + ], + "rest_framework", + ), namespace="rest_framework", ), ), From 552e5cf422ea9904654484de752bda45b53f9c5b Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:46:11 -0700 Subject: [PATCH 3/3] Merge commit from fork --- src/documents/tests/test_admin.py | 25 +++++++++++++++++++++++++ src/paperless/views.py | 10 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/documents/tests/test_admin.py b/src/documents/tests/test_admin.py index 61a579dc7..ef776269a 100644 --- a/src/documents/tests/test_admin.py +++ b/src/documents/tests/test_admin.py @@ -160,3 +160,28 @@ class TestPaperlessAdmin(DirectoriesMixin, TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) superuser.refresh_from_db() self.assertEqual(superuser.first_name, "Updated") + + def test_superuser_can_only_be_deleted_by_superuser(self): + superuser = User.objects.create_superuser(username="superuser", password="test") + user = User.objects.create( + username="test", + is_superuser=False, + is_staff=True, + ) + delete_user_perm = Permission.objects.get(codename="delete_user") + user.user_permissions.add(delete_user_perm) + + self.client.force_login(user) + response = self.client.delete(f"/api/users/{superuser.pk}/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.content.decode(), + "Superusers can only be deleted by other superusers", + ) + self.assertTrue(User.objects.filter(pk=superuser.pk).exists()) + + self.client.logout() + self.client.force_login(superuser) + response = self.client.delete(f"/api/users/{superuser.pk}/") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(User.objects.filter(pk=superuser.pk).exists()) diff --git a/src/paperless/views.py b/src/paperless/views.py index 2a2ee9518..1464a0e98 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -180,6 +180,16 @@ class UserViewSet(ModelViewSet): ) return super().update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): + user_to_delete: User = self.get_object() + + if not request.user.is_superuser and user_to_delete.is_superuser: + return HttpResponseForbidden( + "Superusers can only be deleted by other superusers", + ) + + return super().destroy(request, *args, **kwargs) + @extend_schema( request=None, responses={