Compare commits

...

9 Commits

Author SHA1 Message Date
shamoon
02e590c70c Add hint for plain text password storage 2025-12-30 12:49:02 -08:00
shamoon
3c53e4bab1 Add docstrings to workflow password removal tests 2025-12-30 12:44:01 -08:00
shamoon
880b3e6d15 Better, attempt removal later for ConsumableDocument 2025-12-30 12:44:00 -08:00
shamoon
f7a6f79c8b Update test_workflows.py 2025-12-28 21:45:01 -08:00
shamoon
87dc22fbf6 Update test_workflows.py 2025-12-28 21:41:51 -08:00
shamoon
2332b3f6ad and this 2025-12-28 21:02:02 -08:00
shamoon
5fbc985b67 simplify this 2025-12-28 21:00:06 -08:00
shamoon
7f95160a63 add api tests 2025-12-28 20:58:10 -08:00
shamoon
1aaf128bcb Enhancement: password removal workflow action 2025-12-28 20:05:46 -08:00
10 changed files with 414 additions and 0 deletions

View File

@@ -430,6 +430,24 @@
</div> </div>
</div> </div>
} }
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One or more passwords separated by commas or new lines. 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> </div>
</ng-template> </ng-template>

View File

@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook, id: WorkflowActionType.Webhook,
name: $localize`Webhook`, name: $localize`Webhook`,
}, },
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
] ]
export enum TriggerFilterType { export enum TriggerFilterType {
@@ -1133,6 +1137,7 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers), headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document), include_document: new FormControl(!!action.webhook?.include_document),
}), }),
passwords: new FormControl(action.passwords),
}), }),
{ emitEvent } { emitEvent }
) )

View File

@@ -5,6 +5,7 @@ export enum WorkflowActionType {
Removal = 2, Removal = 2,
Email = 3, Email = 3,
Webhook = 4, Webhook = 4,
PasswordRemoval = 5,
} }
export interface WorkflowActionEmail extends ObjectWithId { export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook webhook?: WorkflowActionWebhook
passwords?: string
} }

View File

@@ -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", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="passwords",
field=models.TextField(
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",
),
),
]

View File

@@ -1287,6 +1287,10 @@ class WorkflowAction(models.Model):
4, 4,
_("Webhook"), _("Webhook"),
) )
PASSWORD_REMOVAL = (
5,
_("Password removal"),
)
type = models.PositiveIntegerField( type = models.PositiveIntegerField(
_("Workflow Action Type"), _("Workflow Action Type"),
@@ -1514,6 +1518,15 @@ class WorkflowAction(models.Model):
verbose_name=_("webhook"), verbose_name=_("webhook"),
) )
passwords = models.TextField(
_("passwords"),
null=True,
blank=True,
help_text=_(
"Passwords to try when removing PDF protection. Separate with commas or new lines.",
),
)
class Meta: class Meta:
verbose_name = _("workflow action") verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions") verbose_name_plural = _("workflow actions")

View File

@@ -2440,6 +2440,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_change_groups", "remove_change_groups",
"email", "email",
"webhook", "webhook",
"passwords",
] ]
def validate(self, attrs): def validate(self, attrs):
@@ -2496,6 +2497,20 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"Webhook data is required for webhook actions", "Webhook data is required for webhook actions",
) )
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
if passwords is None or not isinstance(passwords, str):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
if not passwords.strip():
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
return attrs return attrs

View File

@@ -46,6 +46,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format 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 build_workflow_action_context
from documents.workflows.actions import execute_email_action 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.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_assignment_to_overrides from documents.workflows.mutations import apply_assignment_to_overrides
@@ -792,6 +793,8 @@ def run_workflows(
logging_group, logging_group,
original_file, original_file,
) )
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(action, document, logging_group)
if not use_overrides: if not use_overrides:
# limit title to 128 characters # limit title to 128 characters

View File

@@ -808,3 +808,57 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.action.refresh_from_db() self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, "Patched Title") 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\npassword3"
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": passwords,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["passwords"], passwords)
def test_password_action_no_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with no passwords set
- A workflow password removal action is created with passwords set to empty string
THEN:
- The required validation error is raised
"""
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": "",
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)

View File

@@ -2,6 +2,7 @@ import datetime
import json import json
import shutil import shutil
import socket import socket
import tempfile
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING 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 DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin 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 MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@@ -3548,6 +3550,196 @@ class TestWorkflows(
mock_post.assert_called_once() 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: class TestWebhookSend:
def test_send_webhook_data_or_json( def test_send_webhook_data_or_json(

View File

@@ -1,4 +1,5 @@
import logging import logging
import re
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
@@ -14,6 +15,7 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook from documents.workflows.webhooks import send_webhook
@@ -259,3 +261,74 @@ def execute_webhook_action(
f"Error occurred sending webhook: {e}", f"Error occurred sending webhook: {e}",
extra={"group": logging_group}, 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},
)