]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: password removal workflow action (#11665)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 3 Feb 2026 17:10:07 +0000 (09:10 -0800)
committerGitHub <noreply@github.com>
Tue, 3 Feb 2026 17:10:07 +0000 (17:10 +0000)
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
src-ui/src/app/data/workflow-action.ts
src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/signals/handlers.py
src/documents/tests/test_api_workflows.py
src/documents/tests/test_workflows.py
src/documents/workflows/actions.py

index 5af53e79d06271736592c908f3bf598896eaf97c..7f086ec6352ebdc557760bb11ca18c3f1d6ac166 100644 (file)
           </div>
         </div>
       }
+      @case (WorkflowActionType.PasswordRemoval) {
+        <div class="row">
+          <div class="col">
+            <p class="small" i18n>
+              One password per line. The workflow will try them in order until one succeeds.
+            </p>
+            <pngx-input-textarea
+              i18n-title
+              title="Passwords"
+              formControlName="passwords"
+              rows="4"
+              [error]="error?.actions?.[i]?.passwords"
+              hint="Passwords are stored in plain text. Use with caution."
+              i18n-hint
+            ></pngx-input-textarea>
+          </div>
+        </div>
+      }
     }
   </div>
 </ng-template>
index ac8a5d2c73c1fb869191029a8cd54a1141ec92c1..070e5124f18a715c22bd7e55f484ff84534fb23a 100644 (file)
@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 import { provideHttpClientTesting } from '@angular/common/http/testing'
 import { ComponentFixture, TestBed } from '@angular/core/testing'
 import {
+  FormArray,
   FormControl,
   FormGroup,
   FormsModule,
@@ -994,4 +995,32 @@ describe('WorkflowEditDialogComponent', () => {
     component.removeSelectedCustomField(3, formGroup)
     expect(formGroup.get('assign_custom_fields').value).toEqual([])
   })
+
+  it('should handle parsing of passwords from array to string and back on save', () => {
+    const passwordAction: WorkflowAction = {
+      id: 1,
+      type: WorkflowActionType.PasswordRemoval,
+      passwords: ['pass1', 'pass2'],
+    }
+    component.object = {
+      name: 'Workflow with Passwords',
+      id: 1,
+      order: 1,
+      enabled: true,
+      triggers: [],
+      actions: [passwordAction],
+    }
+    component.ngOnInit()
+
+    const formActions = component.objectForm.get('actions') as FormArray
+    expect(formActions.value[0].passwords).toBe('pass1\npass2')
+    formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
+    component.save()
+
+    expect(component.objectForm.get('actions').value[0].passwords).toEqual([
+      'pass1',
+      'pass2',
+      'pass3',
+    ])
+  })
 })
index 94d8318e053036c92f3641933234f2b9810d8c2e..37d8bef0de3200bd275f2876d0101e59eecd36fc 100644 (file)
@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
     id: WorkflowActionType.Webhook,
     name: $localize`Webhook`,
   },
+  {
+    id: WorkflowActionType.PasswordRemoval,
+    name: $localize`Password removal`,
+  },
 ]
 
 export enum TriggerFilterType {
@@ -1202,11 +1206,25 @@ export class WorkflowEditDialogComponent
           headers: new FormControl(action.webhook?.headers),
           include_document: new FormControl(!!action.webhook?.include_document),
         }),
+        passwords: new FormControl(
+          this.formatPasswords(action.passwords ?? [])
+        ),
       }),
       { emitEvent }
     )
   }
 
+  private formatPasswords(passwords: string[] = []): string {
+    return passwords.join('\n')
+  }
+
+  private parsePasswords(value: string = ''): string[] {
+    return value
+      .split(/[\n,]+/)
+      .map((entry) => entry.trim())
+      .filter((entry) => entry.length > 0)
+  }
+
   private updateAllTriggerActionFields(emitEvent: boolean = false) {
     this.triggerFields.clear({ emitEvent: false })
     this.object?.triggers.forEach((trigger) => {
@@ -1331,6 +1349,7 @@ export class WorkflowEditDialogComponent
         headers: null,
         include_document: false,
       },
+      passwords: [],
     }
     this.object.actions.push(action)
     this.createActionField(action)
@@ -1367,6 +1386,7 @@ export class WorkflowEditDialogComponent
         if (action.type !== WorkflowActionType.Email) {
           action.email = null
         }
+        action.passwords = this.parsePasswords(action.passwords as any)
       })
     super.save()
   }
index 06c46806e850c4b48486beaa3fd2be054aa0bbac..ff15096931954e4d00073cbaa04ced5f58347a6b 100644 (file)
@@ -5,6 +5,7 @@ export enum WorkflowActionType {
   Removal = 2,
   Email = 3,
   Webhook = 4,
+  PasswordRemoval = 5,
 }
 
 export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
   email?: WorkflowActionEmail
 
   webhook?: WorkflowActionWebhook
+
+  passwords?: string[]
 }
diff --git a/src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py b/src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py
new file mode 100644 (file)
index 0000000..ae3fef7
--- /dev/null
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.7 on 2025-12-29 03:56
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "0008_sharelinkbundle"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="workflowaction",
+            name="passwords",
+            field=models.JSONField(
+                blank=True,
+                help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
+                null=True,
+                verbose_name="passwords",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="workflowaction",
+            name="type",
+            field=models.PositiveIntegerField(
+                choices=[
+                    (1, "Assignment"),
+                    (2, "Removal"),
+                    (3, "Email"),
+                    (4, "Webhook"),
+                    (5, "Password removal"),
+                ],
+                default=1,
+                verbose_name="Workflow Action Type",
+            ),
+        ),
+    ]
index 2e187e98c23303c34839da8c31120e300720e5fe..5a813f9b538a8eab5787f6d39f31ef5e29335f04 100644 (file)
@@ -1405,6 +1405,10 @@ class WorkflowAction(models.Model):
             4,
             _("Webhook"),
         )
+        PASSWORD_REMOVAL = (
+            5,
+            _("Password removal"),
+        )
 
     type = models.PositiveIntegerField(
         _("Workflow Action Type"),
@@ -1634,6 +1638,15 @@ class WorkflowAction(models.Model):
         verbose_name=_("webhook"),
     )
 
+    passwords = models.JSONField(
+        _("passwords"),
+        null=True,
+        blank=True,
+        help_text=_(
+            "Passwords to try when removing PDF protection. Separate with commas or new lines.",
+        ),
+    )
+
     class Meta:
         verbose_name = _("workflow action")
         verbose_name_plural = _("workflow actions")
index cfd2ad3cfaef0975d7f8296ca611801fab63cded..5fd1597720779739f273383aae56cebe0826e49e 100644 (file)
@@ -2627,6 +2627,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
             "remove_change_groups",
             "email",
             "webhook",
+            "passwords",
         ]
 
     def validate(self, attrs):
@@ -2683,6 +2684,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
                 "Webhook data is required for webhook actions",
             )
 
+        if (
+            "type" in attrs
+            and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
+        ):
+            passwords = attrs.get("passwords")
+            # ensure passwords is a non-empty list of non-empty strings
+            if (
+                passwords is None
+                or not isinstance(passwords, list)
+                or len(passwords) == 0
+                or any(not isinstance(pw, str) for pw in passwords)
+                or any(len(pw.strip()) == 0 for pw in passwords)
+            ):
+                raise serializers.ValidationError(
+                    "Passwords are required for password removal actions",
+                )
+
         return attrs
 
 
index 8ef5cad04530418b1c4f620ef8ee065e463d36d2..47ebab6f5fa5c3eba2831de8a32674dc688a94d3 100644 (file)
@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
 from documents.templating.utils import convert_format_str_to_template_format
 from documents.workflows.actions import build_workflow_action_context
 from documents.workflows.actions import execute_email_action
+from documents.workflows.actions import execute_password_removal_action
 from documents.workflows.actions import execute_webhook_action
 from documents.workflows.mutations import apply_assignment_to_document
 from documents.workflows.mutations import apply_assignment_to_overrides
@@ -831,6 +832,8 @@ def run_workflows(
                         logging_group,
                         original_file,
                     )
+                elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
+                    execute_password_removal_action(action, document, logging_group)
 
             if not use_overrides:
                 # limit title to 128 characters
index a11cb490ad63fb6fd63413d89a9633c4a992950c..f07b2b60cb467fe5d58c0c3a866a1c1b8d48ce21 100644 (file)
@@ -838,3 +838,61 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.action.refresh_from_db()
         self.assertEqual(self.action.assign_title, "Patched Title")
+
+    def test_password_action_passwords_field(self):
+        """
+        GIVEN:
+            - Nothing
+        WHEN:
+            - A workflow password removal action is created with passwords set
+        THEN:
+            - The passwords field is correctly stored and retrieved
+        """
+        passwords = ["password1", "password2", "password3"]
+        response = self.client.post(
+            "/api/workflow_actions/",
+            json.dumps(
+                {
+                    "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+                    "passwords": passwords,
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.data["passwords"], passwords)
+
+    def test_password_action_invalid_passwords_field(self):
+        """
+        GIVEN:
+            - Nothing
+        WHEN:
+            - A workflow password removal action is created with invalid passwords field
+        THEN:
+            - The required validation error is raised
+        """
+        for payload in [
+            {"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL},
+            {
+                "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+                "passwords": "",
+            },
+            {
+                "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+                "passwords": [],
+            },
+            {
+                "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+                "passwords": ["", "password2"],
+            },
+        ]:
+            response = self.client.post(
+                "/api/workflow_actions/",
+                json.dumps(payload),
+                content_type="application/json",
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+            self.assertIn(
+                "Passwords are required",
+                str(response.data["non_field_errors"][0]),
+            )
index 964d7eef6838b6f27a1bf97f60eb085db3edebbd..1cd0a98269e9b971ae926de9ebfe50d80fb24aab 100644 (file)
@@ -2,6 +2,7 @@ import datetime
 import json
 import shutil
 import socket
+import tempfile
 from datetime import timedelta
 from pathlib import Path
 from typing import TYPE_CHECKING
@@ -60,6 +61,7 @@ from documents.tests.utils import DirectoriesMixin
 from documents.tests.utils import DummyProgressManager
 from documents.tests.utils import FileSystemAssertsMixin
 from documents.tests.utils import SampleDirMixin
+from documents.workflows.actions import execute_password_removal_action
 from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
 
@@ -3722,6 +3724,196 @@ class TestWorkflows(
 
         mock_post.assert_called_once()
 
+    @mock.patch("documents.bulk_edit.remove_password")
+    def test_password_removal_action_attempts_multiple_passwords(
+        self,
+        mock_remove_password,
+    ):
+        """
+        GIVEN:
+            - Workflow password removal action
+            - Multiple passwords provided
+        WHEN:
+            - Document updated triggering the workflow
+        THEN:
+            - Password removal is attempted until one succeeds
+        """
+        doc = Document.objects.create(
+            title="Protected",
+            checksum="pw-checksum",
+        )
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+            passwords="wrong, right\n extra ",
+        )
+        workflow = Workflow.objects.create(name="Password workflow")
+        workflow.triggers.add(trigger)
+        workflow.actions.add(action)
+
+        mock_remove_password.side_effect = [
+            ValueError("wrong password"),
+            "OK",
+        ]
+
+        run_workflows(trigger.type, doc)
+
+        assert mock_remove_password.call_count == 2
+        mock_remove_password.assert_has_calls(
+            [
+                mock.call(
+                    [doc.id],
+                    password="wrong",
+                    update_document=True,
+                    user=doc.owner,
+                ),
+                mock.call(
+                    [doc.id],
+                    password="right",
+                    update_document=True,
+                    user=doc.owner,
+                ),
+            ],
+        )
+
+    @mock.patch("documents.bulk_edit.remove_password")
+    def test_password_removal_action_fails_without_correct_password(
+        self,
+        mock_remove_password,
+    ):
+        """
+        GIVEN:
+            - Workflow password removal action
+            - No correct password provided
+        WHEN:
+            - Document updated triggering the workflow
+        THEN:
+            - Password removal is attempted for all passwords and fails
+        """
+        doc = Document.objects.create(
+            title="Protected",
+            checksum="pw-checksum-2",
+        )
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+            passwords=" \n , ",
+        )
+        workflow = Workflow.objects.create(name="Password workflow missing passwords")
+        workflow.triggers.add(trigger)
+        workflow.actions.add(action)
+
+        run_workflows(trigger.type, doc)
+
+        mock_remove_password.assert_not_called()
+
+    @mock.patch("documents.bulk_edit.remove_password")
+    def test_password_removal_action_skips_without_passwords(
+        self,
+        mock_remove_password,
+    ):
+        """
+        GIVEN:
+            - Workflow password removal action with no passwords
+        WHEN:
+            - Workflow is run
+        THEN:
+            - Password removal is not attempted
+        """
+        doc = Document.objects.create(
+            title="Protected",
+            checksum="pw-checksum-2",
+        )
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+            passwords="",
+        )
+        workflow = Workflow.objects.create(name="Password workflow missing passwords")
+        workflow.triggers.add(trigger)
+        workflow.actions.add(action)
+
+        run_workflows(trigger.type, doc)
+
+        mock_remove_password.assert_not_called()
+
+    @mock.patch("documents.bulk_edit.remove_password")
+    def test_password_removal_consumable_document_deferred(
+        self,
+        mock_remove_password,
+    ):
+        """
+        GIVEN:
+            - Workflow password removal action
+            - Simulated consumption trigger (a ConsumableDocument is used)
+        WHEN:
+            - Document consumption is finished
+        THEN:
+            - Password removal is attempted
+        """
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+            passwords="first, second",
+        )
+
+        temp_dir = Path(tempfile.mkdtemp())
+        original_file = temp_dir / "file.pdf"
+        original_file.write_bytes(b"pdf content")
+        consumable = ConsumableDocument(
+            source=DocumentSource.ApiUpload,
+            original_file=original_file,
+        )
+
+        execute_password_removal_action(action, consumable, logging_group=None)
+
+        mock_remove_password.assert_not_called()
+
+        mock_remove_password.side_effect = [
+            ValueError("bad password"),
+            "OK",
+        ]
+
+        doc = Document.objects.create(
+            checksum="pw-checksum-consumed",
+            title="Protected",
+        )
+
+        document_consumption_finished.send(
+            sender=self.__class__,
+            document=doc,
+        )
+
+        assert mock_remove_password.call_count == 2
+        mock_remove_password.assert_has_calls(
+            [
+                mock.call(
+                    [doc.id],
+                    password="first",
+                    update_document=True,
+                    user=doc.owner,
+                ),
+                mock.call(
+                    [doc.id],
+                    password="second",
+                    update_document=True,
+                    user=doc.owner,
+                ),
+            ],
+        )
+
+        # ensure handler disconnected after first run
+        document_consumption_finished.send(
+            sender=self.__class__,
+            document=doc,
+        )
+        assert mock_remove_password.call_count == 2
+
 
 class TestWebhookSend:
     def test_send_webhook_data_or_json(
index a61b9930e828c0f99b409893956fccd346fa9883..442bc0abee91829c2b279f0d89f25d55bba831fd 100644 (file)
@@ -1,4 +1,5 @@
 import logging
+import re
 from pathlib import Path
 
 from django.conf import settings
@@ -14,6 +15,7 @@ from documents.models import Document
 from documents.models import DocumentType
 from documents.models import WorkflowAction
 from documents.models import WorkflowTrigger
+from documents.signals import document_consumption_finished
 from documents.templating.workflows import parse_w_workflow_placeholders
 from documents.workflows.webhooks import send_webhook
 
@@ -265,3 +267,74 @@ def execute_webhook_action(
             f"Error occurred sending webhook: {e}",
             extra={"group": logging_group},
         )
+
+
+def execute_password_removal_action(
+    action: WorkflowAction,
+    document: Document | ConsumableDocument,
+    logging_group,
+) -> None:
+    """
+    Try to remove a password from a document using the configured list.
+    """
+    passwords = action.passwords
+    if not passwords:
+        logger.warning(
+            "Password removal action %s has no passwords configured",
+            action.pk,
+            extra={"group": logging_group},
+        )
+        return
+
+    passwords = [
+        password.strip()
+        for password in re.split(r"[,\n]", passwords)
+        if password.strip()
+    ]
+
+    if isinstance(document, ConsumableDocument):
+        # hook the consumption-finished signal to attempt password removal later
+        def handler(sender, **kwargs):
+            consumed_document: Document = kwargs.get("document")
+            if consumed_document is not None:
+                execute_password_removal_action(
+                    action,
+                    consumed_document,
+                    logging_group,
+                )
+            document_consumption_finished.disconnect(handler)
+
+        document_consumption_finished.connect(handler, weak=False)
+        return
+
+    # import here to avoid circular dependency
+    from documents.bulk_edit import remove_password
+
+    for password in passwords:
+        try:
+            remove_password(
+                [document.id],
+                password=password,
+                update_document=True,
+                user=document.owner,
+            )
+            logger.info(
+                "Removed password from document %s using workflow action %s",
+                document.pk,
+                action.pk,
+                extra={"group": logging_group},
+            )
+            return
+        except ValueError as e:
+            logger.warning(
+                "Password removal failed for document %s with supplied password: %s",
+                document.pk,
+                e,
+                extra={"group": logging_group},
+            )
+
+    logger.error(
+        "Password removal failed for document %s after trying all provided passwords",
+        document.pk,
+        extra={"group": logging_group},
+    )