diff --git a/pyproject.toml b/pyproject.toml index 19dfe3fdc..0fca9ca46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.14" +version = "2.20.15" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.11" diff --git a/src-ui/package.json b/src-ui/package.json index f40da8f1b..60c245aee 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.14", + "version": "2.20.15", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 0db350bc2..d92d7d7d9 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '10', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.14', + version: '2.20.15', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/documents/tests/test_admin.py b/src/documents/tests/test_admin.py index 533319c2f..c7f259b22 100644 --- a/src/documents/tests/test_admin.py +++ b/src/documents/tests/test_admin.py @@ -179,3 +179,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/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 88e1a3178..d20df98e8 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -98,7 +98,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", ), ), diff --git a/src/paperless/version.py b/src/paperless/version.py index e10dae825..bb4d12607 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 14) +__version__: Final[tuple[int, int, int]] = (2, 20, 15) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/src/paperless/views.py b/src/paperless/views.py index 4d056ba68..022d7f217 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -192,6 +192,16 @@ class UserViewSet(ModelViewSet[User]): ) 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={ diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index a4c3c5830..8a0772732 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[MailAccount]): 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 f00a8059e..088ee0eb6 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -493,7 +493,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) -> None: + def test_create_mail_rule_scopes_accounts(self) -> None: other_user = User.objects.create_user(username="mail-owner") foreign_account = MailAccountFactory(name="ForeignEmail", owner=other_user) @@ -512,8 +512,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( @@ -553,7 +571,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) diff --git a/zensical.toml b/zensical.toml index d78ed4b39..30a898168 100644 --- a/zensical.toml +++ b/zensical.toml @@ -129,3 +129,5 @@ custom_fences = [ [project.markdown_extensions.pymdownx.emoji] emoji_index = "zensical.extensions.emoji.twemoji" emoji_generator = "zensical.extensions.emoji.to_svg" + +[project.markdown_extensions.zensical.extensions.glightbox]