From 5e5f6a88a314a22247ae6bf07c0fa2914d3d32ca Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:25:06 -0700 Subject: [PATCH 1/3] Fix: deny deactivated users in permission filtering and auto-login (#13623) * Fix: Hardening sweep, ensure a user is active, not just authenticated * Missed this test --- src/documents/filters.py | 6 +++ src/documents/permissions.py | 21 ++++++-- .../test_permission_filtering_security.py | 22 ++++++++ .../tests/test_permitted_objects_filter.py | 41 ++++++++++++++ src/paperless/auth.py | 5 +- src/paperless/tests/test_auth_middleware.py | 53 +++++++++++++++++++ 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 src/paperless/tests/test_auth_middleware.py diff --git a/src/documents/filters.py b/src/documents/filters.py index 4dac785ee..40ae8978b 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -1047,6 +1047,12 @@ class PermittedObjectsFilter(BaseFilterBackend): perm_codename: str | None = None def filter_queryset(self, request, queryset, view): + # Before the superuser and owner-only paths, neither of which consults + # permitted_object_ids. Scoped to authenticated users so anonymous + # access (AnonymousUser.is_active is False) keeps its existing + # unowned-only behaviour. + if request.user.is_authenticated and not request.user.is_active: + return queryset.none() if request.user.is_superuser: return queryset if not self.include_granted: diff --git a/src/documents/permissions.py b/src/documents/permissions.py index 82b5d9fe8..ef1bc281c 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -54,11 +54,15 @@ class PaperlessObjectPermissions(DjangoObjectPermissions): class PaperlessAdminPermissions(BasePermission): def has_permission(self, request, view): - return request.user.is_staff + return request.user.is_active and request.user.is_staff def has_global_statistics_permission(user: User | None) -> bool: - if user is None or not getattr(user, "is_authenticated", False): + if ( + user is None + or not getattr(user, "is_active", False) + or not getattr(user, "is_authenticated", False) + ): return False return getattr(user, "is_superuser", False) or user.has_perm( @@ -67,7 +71,11 @@ def has_global_statistics_permission(user: User | None) -> bool: def has_system_status_permission(user: User | None) -> bool: - if user is None or not getattr(user, "is_authenticated", False): + if ( + user is None + or not getattr(user, "is_active", False) + or not getattr(user, "is_authenticated", False) + ): return False return ( @@ -188,6 +196,13 @@ def permitted_object_ids( if user is None or not getattr(user, "is_authenticated", False): return base_qs.filter(owner__isnull=True).values_list("id", flat=True) + # Deactivated users get nothing, deactivated superusers included, so this + # has to come before the superuser shortcut. guardian's + # ObjectPermissionChecker denies inactive users, but get_objects_for_user + # (the pattern this replaces) does not, so it would not be inherited. + if not getattr(user, "is_active", False): + return base_qs.none().values_list("id", flat=True) + if getattr(user, "is_superuser", False): return base_qs.values_list("id", flat=True) diff --git a/src/documents/tests/test_permission_filtering_security.py b/src/documents/tests/test_permission_filtering_security.py index b0bd31b9c..84daaebbf 100644 --- a/src/documents/tests/test_permission_filtering_security.py +++ b/src/documents/tests/test_permission_filtering_security.py @@ -496,6 +496,28 @@ class TestPermittedObjectIdsGenericModels: expected_hidden=[strangers.pk], ) + @pytest.mark.parametrize("is_superuser", [False, True]) + def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser): + suffix = f"{model.__name__}_{is_superuser}" + user = User.objects.create_user( + username=f"inactive_{suffix}", + is_active=False, + is_superuser=is_superuser, + ) + other = User.objects.create_user(username=f"other_{suffix}") + granted = factory(owner=other) + assign_perm(perm, user, granted) + + assert_visible_document_ids( + permitted_object_ids(user, model, perm), + expected_visible=[], + expected_hidden=[ + factory(owner=None).pk, + factory(owner=user).pk, + granted.pk, + ], + ) + def test_unowned_object_visible_to_everyone(self, model, factory, perm): user = User.objects.create_user(username=f"user_{model.__name__}") unowned = factory(owner=None) diff --git a/src/documents/tests/test_permitted_objects_filter.py b/src/documents/tests/test_permitted_objects_filter.py index d3df22805..b087f091b 100644 --- a/src/documents/tests/test_permitted_objects_filter.py +++ b/src/documents/tests/test_permitted_objects_filter.py @@ -68,3 +68,44 @@ class TestPermittedObjectsFilter: visible_ids = set(result.values_list("id", flat=True)) assert visible_ids == {owned.pk} assert granted.pk not in visible_ids + + @pytest.mark.parametrize( + ("username", "is_superuser"), + [("inactive", False), ("inactive_super", True)], + ) + def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool): + user = User.objects.create_user( + username=username, + is_active=False, + is_superuser=is_superuser, + ) + TagFactory(owner=None) + TagFactory(owner=user) + granted = TagFactory(owner=User.objects.create_user(username=f"o_{username}")) + assign_perm("view_tag", user, granted) + request = APIRequestFactory().get("/") + request.user = user + + result = PermittedObjectsFilter().filter_queryset( + request, + Tag.objects.all(), + _DummyView(), + ) + assert result.count() == 0 + + def test_inactive_user_sees_nothing_with_include_granted_false(self): + user = User.objects.create_user(username="inactive_owner", is_active=False) + TagFactory(owner=user) + TagFactory(owner=None) + request = APIRequestFactory().get("/") + request.user = user + + class _OwnerOnlyFilter(PermittedObjectsFilter): + include_granted = False + + result = _OwnerOnlyFilter().filter_queryset( + request, + Tag.objects.all(), + _DummyView(), + ) + assert result.count() == 0 diff --git a/src/paperless/auth.py b/src/paperless/auth.py index 2e7f00bf2..e9a49b7b7 100644 --- a/src/paperless/auth.py +++ b/src/paperless/auth.py @@ -19,7 +19,10 @@ class AutoLoginMiddleware(MiddlewareMixin): if request.path.startswith("/api/token/") and request.method == "POST": return None try: - request.user = User.objects.get(username=settings.AUTO_LOGIN_USERNAME) + request.user = User.objects.get( + username=settings.AUTO_LOGIN_USERNAME, + is_active=True, + ) auth.login( request=request, user=request.user, diff --git a/src/paperless/tests/test_auth_middleware.py b/src/paperless/tests/test_auth_middleware.py new file mode 100644 index 000000000..fec355d7d --- /dev/null +++ b/src/paperless/tests/test_auth_middleware.py @@ -0,0 +1,53 @@ +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import User +from django.test import RequestFactory +from django.test import TestCase +from django.test import override_settings + +from paperless.auth import AutoLoginMiddleware + + +@override_settings(AUTO_LOGIN_USERNAME="autologin") +class TestAutoLoginMiddleware(TestCase): + def setUp(self) -> None: + super().setUp() + self.factory = RequestFactory() + self.middleware = AutoLoginMiddleware(lambda request: None) + + def _process(self, request): + # login() needs a session to write to + request.session = self.client.session + self.middleware.process_request(request) + return request + + def test_active_user_is_logged_in(self) -> None: + """ + GIVEN: + - AUTO_LOGIN_USERNAME names an active user + WHEN: + - A request is processed by the middleware + THEN: + - That user is attached to the request + """ + user = User.objects.create_user(username="autologin") + + request = self._process(self.factory.get("/")) + + self.assertEqual(request.user, user) + + def test_deactivated_user_is_not_logged_in(self) -> None: + """ + GIVEN: + - AUTO_LOGIN_USERNAME names a user who has been deactivated + WHEN: + - A request is processed by the middleware + THEN: + - The request is left anonymous rather than authenticated as them + """ + User.objects.create_user(username="autologin", is_active=False) + + request = self.factory.get("/") + request.user = AnonymousUser() + self._process(request) + + self.assertFalse(request.user.is_authenticated) From 62089df2d8b126bae69a2993ff8cdd76e71537df Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:26:58 +0000 Subject: [PATCH 2/3] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7307652bb..6aa600cce 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-08 14:28+0000\n" +"POT-Creation-Date: 2026-08-10 02:25+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -53,7 +53,7 @@ msgstr "" msgid "Maximum nesting depth exceeded." msgstr "" -#: documents/filters.py:1073 +#: documents/filters.py:1079 msgid "Custom field not found" msgstr "" From 855669ddf93e25fe5c45420781e191a7df63884d Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:38:07 -0700 Subject: [PATCH 3/3] Fix: fixes for workflow assign custom field values (#13630) --- docs/usage.md | 4 +- .../custom-fields-values.component.spec.ts | 8 +-- .../custom-fields-values.component.ts | 2 +- src/documents/serialisers.py | 7 +++ src/documents/tests/test_api_workflows.py | 9 ++++ src/documents/tests/test_workflows.py | 49 +++++++++++++++++++ src/documents/workflows/mutations.py | 3 +- 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index bd03c9a51..7fc245f22 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -576,7 +576,9 @@ The following workflow action types are available: - Tags, correspondent, document type and storage path - Document owner - View and / or edit permissions to users or groups -- Custom fields. Note that no value for the field will be set +- Custom fields, optionally with a value. If no value is set, the field is only added to the + document and any value it may already have is left untouched. If a value is set, it will + overwrite an existing value of that field on the document. ##### Removal {#workflow-action-removal} diff --git a/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.spec.ts b/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.spec.ts index 82a065452..a50511f55 100644 --- a/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.spec.ts +++ b/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.spec.ts @@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => { }) it('should set selectedFields and map values correctly', () => { - component.value = { 1: 'value1' } - component.selectedFields = [1, 2] - expect(component.selectedFields).toEqual([1, 2]) - expect(component.value).toEqual({ 1: 'value1', 2: null }) + component.value = { 1: 'value1', 3: 0, 4: false } + component.selectedFields = [1, 2, 3, 4] + expect(component.selectedFields).toEqual([1, 2, 3, 4]) + expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false }) }) it('should return the correct custom field by id', () => { diff --git a/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.ts b/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.ts index 837f43026..65a2bea8a 100644 --- a/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.ts +++ b/src-ui/src/app/components/common/input/custom-fields-values/custom-fields-values.component.ts @@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent this._selectedFields = newFields // map the selected fields to an object with field_id as key and value as value this.value = newFields.reduce((acc, fieldId) => { - acc[fieldId] = this.value?.[fieldId] || null + acc[fieldId] = this.value?.[fieldId] ?? null return acc }, {}) this.onChange(this.value) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1a491c5a4..e47f42e09 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -3213,6 +3213,13 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]): {"assign_title": f'Invalid f-string detected: "{e.args[0]}"'}, ) + if attrs.get("assign_custom_fields_values"): + # Empty strings treated as None to avoid unexpected behavior + attrs["assign_custom_fields_values"] = { + field_id: (None if value == "" else value) + for field_id, value in attrs["assign_custom_fields_values"].items() + } + if ( "type" in attrs and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index cdd3a5f40..e2689001a 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -422,6 +422,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): json.dumps( { "assign_title": "", + "assign_custom_fields": [self.cf1.id, self.cf2.id], + "assign_custom_fields_values": { + str(self.cf1.id): "", + str(self.cf2.id): 0, + }, }, ), content_type="application/json", @@ -429,6 +434,10 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) action = WorkflowAction.objects.get(id=response.data["id"]) self.assertIsNone(action.assign_title) + self.assertEqual( + action.assign_custom_fields_values, + {str(self.cf1.id): None, str(self.cf2.id): 0}, + ) response = self.client.post( self.ENDPOINT_TRIGGERS, diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index f3dace48f..574cecf4f 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -2000,6 +2000,55 @@ class TestWorkflows( r"Doc added in \w{3,}", ) # Match any 3-letter month name + def test_document_updated_workflow_existing_custom_field_empty_value(self) -> None: + """ + GIVEN: + - Existing workflow with UPDATED trigger and action that assigns a custom field + with an empty value + WHEN: + - Document is updated that already contains the field with a value + THEN: + - The existing value is left untouched, see GH #13627 + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + filter_has_document_type=self.dt, + ) + action = WorkflowAction.objects.create() + action.assign_custom_fields.add(self.cf1) + action.assign_custom_fields_values = {self.cf1.pk: ""} + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + CustomFieldInstance.objects.create( + document=doc, + field=self.cf1, + value_text="existing value", + ) + + superuser = User.objects.create_superuser("superuser") + self.client.force_authenticate(user=superuser) + + self.client.patch( + f"/api/documents/{doc.id}/", + {"document_type": self.dt.id}, + format="json", + ) + + doc.refresh_from_db() + self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "existing value") + def test_document_updated_workflow_existing_custom_field(self) -> None: """ GIVEN: diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py index 7d33bc545..869ea240f 100644 --- a/src/documents/workflows/mutations.py +++ b/src/documents/workflows/mutations.py @@ -105,7 +105,8 @@ def apply_assignment_to_document( field=field, document=document, ).first() - if instance and args[value_field_name] is not None: + # empty string is indistinguishable from no value in the UI + if instance and args[value_field_name] not in (None, ""): setattr(instance, value_field_name, args[value_field_name]) instance.save() elif not instance: