Compare commits

..
Author SHA1 Message Date
shamoon 1d75457e3d Also fix this setting false/0 from the frontend 2026-08-09 20:22:53 -07:00
shamoon f71ed307f9 Docs are wrong here too 2026-08-09 20:22:53 -07:00
shamoon 4badeb70d7 Also make empty string None in serializer 2026-08-09 20:22:53 -07:00
shamoon 9ffa237021 Fix: dont allow empty string in wf assign cf value to overwrite 2026-08-09 19:32:59 -07:00
GitHub Actions 62089df2d8 Auto translate strings 2026-08-10 02:26:58 +00:00
Trenton HandGitHub 5e5f6a88a3 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
2026-08-10 02:25:06 +00:00
18 changed files with 236 additions and 167 deletions
+4 -5
View File
@@ -948,11 +948,10 @@ for display in the web interface.
!!! note
The **remote OCR parser** (Azure AI) also honors this setting: when
no archive is requested (`never`, or `auto` with a born-digital PDF),
the remote engine is skipped entirely and locally-extracted text is
used instead, avoiding an unnecessary API call and a duplicate text
layer.
The **remote OCR parser** (Azure AI) always produces a searchable
PDF and stores it as the archive copy, regardless of this setting.
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
parser handles a document.
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
+4 -5
View File
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
### Remote OCR parser
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
honored the same way as for the local engine: when no archive is requested
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
entirely and locally-extracted text is used instead, avoiding an unnecessary
API call and a duplicate text layer.
If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced
unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy)
+3 -1
View File
@@ -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}
@@ -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', () => {
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
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)
+6
View File
@@ -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:
+18 -3
View File
@@ -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)
+7
View File
@@ -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
@@ -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,
@@ -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)
@@ -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
+49
View File
@@ -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:
+2 -1
View File
@@ -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:
+2 -2
View File
@@ -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 ""
+4 -1
View File
@@ -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,
+7 -33
View File
@@ -3,9 +3,7 @@ Built-in remote-OCR document parser.
Handles documents by sending them to a configured remote OCR engine
(currently Azure AI Vision / Document Intelligence) and retrieving both
the extracted text and a searchable PDF with an embedded text layer. For
born-digital PDFs that need no archive copy, the remote call is skipped
entirely in favor of locally-extracted text (see ``RemoteDocumentParser.parse``).
the extracted text and a searchable PDF with an embedded text layer.
When no engine is configured, ``score()`` returns ``None`` so the parser
is effectively invisible to the registry the tesseract parser handles
@@ -24,8 +22,6 @@ from typing import Self
from django.conf import settings
from documents.parsers import ParseError
from paperless.parsers.utils import extract_pdf_text
from paperless.parsers.utils import post_process_text
from paperless.version import __full_version_str__
if TYPE_CHECKING:
@@ -74,11 +70,8 @@ class RemoteDocumentParser:
"""Parse documents via a remote OCR API (currently Azure AI Vision).
This parser sends documents to a remote engine that returns both
extracted text and a searchable PDF with an embedded text layer,
except when ``parse()`` is called with ``produce_archive=False`` for
a PDF, in which case the remote call is skipped and only locally
extracted text is returned (no archive). It does not depend on
Tesseract or ocrmypdf.
extracted text and a searchable PDF with an embedded text layer.
It does not depend on Tesseract or ocrmypdf.
Class attributes
----------------
@@ -167,11 +160,8 @@ class RemoteDocumentParser:
Returns
-------
bool
Always True the remote engine is capable of returning a PDF
with an embedded text layer to serve as the archive copy.
Whether it actually does so for a given document depends on
``produce_archive`` passed to :meth:`parse` (see there for when
the remote engine call, and thus archive generation, is skipped).
Always True the remote engine always returns a PDF with an
embedded text layer that serves as the archive copy.
"""
return True
@@ -228,12 +218,6 @@ class RemoteDocumentParser:
) -> None:
"""Send the document to the remote engine and store results.
When *produce_archive* is False for a PDF, the caller (via
``documents.consumer.should_produce_archive``) has already determined
that the document is born-digital and needs no archive skip the
remote engine entirely rather than re-OCRing it and creating a
duplicate text layer.
Parameters
----------
document_path:
@@ -241,8 +225,8 @@ class RemoteDocumentParser:
mime_type:
Detected MIME type of the document.
produce_archive:
Whether an archive copy is wanted. For PDFs, False skips the
remote engine and uses locally-extracted text instead.
Ignored the remote engine always returns a searchable PDF,
which is stored as the archive copy regardless of this flag.
"""
config = RemoteEngineConfig(
engine=settings.REMOTE_OCR_ENGINE,
@@ -257,16 +241,6 @@ class RemoteDocumentParser:
self._text = ""
return
if not produce_archive and mime_type == "application/pdf":
logger.debug(
"Remote OCR: skipped — no archive requested, "
"using locally-extracted text",
)
self._text = (
post_process_text(extract_pdf_text(document_path, log=logger)) or ""
)
return
if config.engine == "azureai":
self._text = self._azure_ai_vision_parse(document_path, config)
@@ -337,117 +337,6 @@ class TestRemoteParserParse:
assert remote_parser.get_date() is None
# ---------------------------------------------------------------------------
# parse() — produce_archive=False skips the remote engine (PDFs only)
# ---------------------------------------------------------------------------
class TestRemoteParserSkipsWhenNoArchiveWanted:
"""When the caller has already decided no archive is needed for a PDF
(documents.consumer.should_produce_archive), the remote engine call is
skipped entirely in favor of locally-extracted text.
"""
def test_pdf_skips_azure_when_no_archive_requested(
self,
remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path,
azure_client: Mock,
) -> None:
"""
GIVEN: produce_archive=False for a PDF
WHEN: parse() is called
THEN: Azure is never invoked, no archive is produced, and text
comes from local pdftotext extraction
"""
remote_parser.parse(
simple_digital_pdf_file,
"application/pdf",
produce_archive=False,
)
azure_client.begin_analyze_document.assert_not_called()
assert remote_parser.get_archive_path() is None
assert remote_parser.get_text() != ""
def test_pdf_no_archive_requested_text_matches_local_extraction(
self,
remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path,
azure_client: Mock,
mocker: MockerFixture,
) -> None:
"""
GIVEN: produce_archive=False for a PDF
WHEN: parse() is called
THEN: the returned text is exactly the locally-extracted text,
not anything from the (unused) Azure mock
"""
mocker.patch(
"paperless.parsers.remote.extract_pdf_text",
return_value="Local digital text.",
)
remote_parser.parse(
simple_digital_pdf_file,
"application/pdf",
produce_archive=False,
)
assert remote_parser.get_text() == "Local digital text."
def test_pdf_no_archive_requested_closes_no_client(
self,
remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path,
azure_client: Mock,
) -> None:
remote_parser.parse(
simple_digital_pdf_file,
"application/pdf",
produce_archive=False,
)
azure_client.close.assert_not_called()
def test_non_pdf_still_calls_azure_when_no_archive_requested(
self,
remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path,
azure_client: Mock,
) -> None:
"""
Images have no local-text fallback, so produce_archive=False does
not skip the remote engine for non-PDF MIME types.
"""
remote_parser.parse(
simple_digital_pdf_file,
"image/png",
produce_archive=False,
)
azure_client.begin_analyze_document.assert_called_once()
assert remote_parser.get_text() == _DEFAULT_TEXT
@pytest.mark.usefixtures("no_engine_settings")
def test_unconfigured_engine_takes_precedence_over_skip(
self,
remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path,
) -> None:
"""An unconfigured engine still short-circuits before the
produce_archive check, returning empty text as before.
"""
remote_parser.parse(
simple_digital_pdf_file,
"application/pdf",
produce_archive=False,
)
assert remote_parser.get_text() == ""
assert remote_parser.get_archive_path() is None
# ---------------------------------------------------------------------------
# parse() — Azure failure path
# ---------------------------------------------------------------------------
@@ -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)