Compare commits

..
Author SHA1 Message Date
stumpylog 7f092b247f Fix: copy only direct user grants in DocumentMetadataOverrides.from_document
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 08:31:42 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
8 changed files with 114 additions and 107 deletions

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
+2
View File
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
get_users_with_perms( get_users_with_perms(
doc, doc,
only_with_perms_in=["view_document"], only_with_perms_in=["view_document"],
with_group_users=False,
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.change_users = list( overrides.change_users = list(
get_users_with_perms( get_users_with_perms(
doc, doc,
only_with_perms_in=["change_document"], only_with_perms_in=["change_document"],
with_group_users=False,
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.custom_fields = { overrides.custom_fields = {
+36
View File
@@ -21,6 +21,7 @@ from guardian.core import ObjectPermissionChecker
from guardian.models import GroupObjectPermission from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_objects_for_user
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from guardian.shortcuts import remove_perm from guardian.shortcuts import remove_perm
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
@@ -648,6 +649,41 @@ def annotate_document_count_for_related_queryset(
) )
def get_objects_for_user_owner_aware(
user: User | None,
perms: str | list[str],
Model: Any,
*,
include_deleted: bool = False,
) -> QuerySet[Any]:
"""
Returns objects the user owns, are unowned, or has explicit perms.
When include_deleted is True, soft-deleted items are also included.
Legacy slow path (guardian-backed, O(n) style permission resolution).
Most queryset-filtering call sites have migrated onto
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
is kept because production callers still remain. Several callers remain
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
grep for this function name before removing it.
"""
manager = (
Model.global_objects
if include_deleted and hasattr(Model, "global_objects")
else Model.objects
)
objects_owned = manager.filter(owner=user)
objects_unowned = manager.filter(owner__isnull=True)
objects_with_perms = get_objects_for_user(
user=user,
perms=perms,
klass=manager.all(),
accept_global_perms=False,
)
return objects_owned | objects_unowned | objects_with_perms
def has_perms_owner_aware(user, perms, obj): def has_perms_owner_aware(user, perms, obj):
""" """
Legacy slow path (guardian-backed) single-object permission check. Legacy slow path (guardian-backed) single-object permission check.
+10 -6
View File
@@ -52,7 +52,7 @@ from documents.models import Workflow
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowRun from documents.models import WorkflowRun
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import restrict_queryset_to_visible from documents.permissions import get_objects_for_user_owner_aware
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum from documents.utils import compute_checksum
@@ -81,11 +81,15 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None: def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
inbox_tags = restrict_queryset_to_visible( if document.owner is not None:
Tag.objects.filter(is_inbox_tag=True), tags = get_objects_for_user_owner_aware(
document.owner, document.owner,
"view_tag", "documents.view_tag",
) Tag,
)
else:
tags = Tag.objects.all()
inbox_tags = tags.filter(is_inbox_tag=True)
document.add_nested_tags(inbox_tags) document.add_nested_tags(inbox_tags)
+27 -60
View File
@@ -34,7 +34,7 @@ from documents.tests.utils import GetConsumerMixin
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import TagFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
@@ -516,65 +516,6 @@ class TestConsumer(
self.assertTrue(group_checker.has_perm("view_document", document)) self.assertTrue(group_checker.has_perm("view_document", document))
self._assert_first_last_send_progress() self._assert_first_last_send_progress()
def testInboxTagsScopedToOwner(self) -> None:
"""
GIVEN:
- Inbox tags that are owned by the document owner, unowned, owned by
another user but shared with the document owner, and owned by
another user and not shared
- A non-inbox tag owned by the document owner
WHEN:
- A document is consumed with that owner
THEN:
- The document gets the owned, unowned and shared inbox tags only
"""
owner = UserFactory()
other = UserFactory()
owned = TagFactory(is_inbox_tag=True, owner=owner)
unowned = TagFactory(is_inbox_tag=True, owner=None)
shared = TagFactory(is_inbox_tag=True, owner=other)
grant_object(owner, shared, "view_tag")
TagFactory(is_inbox_tag=True, owner=other)
TagFactory(is_inbox_tag=False, owner=owner)
with self.get_consumer(
self.get_test_file(),
DocumentMetadataOverrides(owner_id=owner.pk),
) as consumer:
consumer.run()
document = Document.objects.first()
assert document is not None
self.assertCountEqual(document.tags.all(), [owned, unowned, shared])
self._assert_first_last_send_progress()
def testInboxTagsUnownedDocument(self) -> None:
"""
GIVEN:
- Inbox tags owned by two different users, an unowned inbox tag and
a non-inbox tag
WHEN:
- A document is consumed with no owner
THEN:
- The document gets every inbox tag
"""
inbox_tags = [
TagFactory(is_inbox_tag=True, owner=UserFactory()),
TagFactory(is_inbox_tag=True, owner=UserFactory()),
TagFactory(is_inbox_tag=True, owner=None),
]
TagFactory(is_inbox_tag=False)
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
document = Document.objects.first()
assert document is not None
self.assertCountEqual(document.tags.all(), inbox_tags)
self._assert_first_last_send_progress()
def testNotAFile(self) -> None: def testNotAFile(self) -> None:
with self.assertRaisesMessage(ConsumerError, "File not found"): with self.assertRaisesMessage(ConsumerError, "File not found"):
with self.get_consumer(Path("non-existing-file")) as consumer: with self.get_consumer(Path("non-existing-file")) as consumer:
@@ -1744,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
self.assertEqual(base.actor_id, 2) self.assertEqual(base.actor_id, 2)
self.assertEqual(base.version_label, "v2") self.assertEqual(base.version_label, "v2")
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
"""
GIVEN:
- A document with view and change granted directly to one user and
to a group with another user as a member
WHEN:
- Overrides are built from the document
THEN:
- The user lists hold only the directly granted user
- The group lists hold the group
"""
direct_user = UserFactory()
group_member = UserFactory()
group = Group.objects.create(name="group")
group_member.groups.add(group)
document = DocumentFactory()
grant_object(direct_user, document, "view_document", "change_document")
grant_object(group, document, "view_document", "change_document")
overrides = DocumentMetadataOverrides.from_document(document)
self.assertEqual(overrides.view_users, [direct_user.pk])
self.assertEqual(overrides.change_users, [direct_user.pk])
self.assertEqual(overrides.view_groups, [group.pk])
self.assertEqual(overrides.change_groups, [group.pk])
class TestBarcodeApplyDetectedASN(TestCase): class TestBarcodeApplyDetectedASN(TestCase):
""" """
+31 -33
View File
@@ -175,12 +175,12 @@ from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_by_ids from documents.permissions import annotate_document_count_by_ids
from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_global_statistics_permission from documents.permissions import has_global_statistics_permission
from documents.permissions import has_perms_owner_aware from documents.permissions import has_perms_owner_aware
from documents.permissions import has_system_status_permission from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
from documents.permissions import user_is_unrestricted from documents.permissions import user_is_unrestricted
from documents.plugins.date_parsing import get_date_parser from documents.plugins.date_parsing import get_date_parser
@@ -3836,51 +3836,49 @@ class GlobalSearchView(PassUserMixin):
if doc_id in docs_by_id if doc_id in docs_by_id
][:OBJECT_LIMIT] ][:OBJECT_LIMIT]
saved_views = ( saved_views = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
SavedView.objects.filter(name__icontains=query),
request.user, request.user,
"view_savedview", "view_savedview",
) SavedView,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_savedview") if request.user.has_perm("documents.view_savedview")
else [] else []
) )
saved_views = saved_views[:OBJECT_LIMIT] saved_views = saved_views[:OBJECT_LIMIT]
tags = ( tags = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
Tag.objects.filter(name__icontains=query), name__icontains=query,
request.user,
"view_tag",
) )
if request.user.has_perm("documents.view_tag") if request.user.has_perm("documents.view_tag")
else [] else []
) )
tags = tags[:OBJECT_LIMIT] tags = tags[:OBJECT_LIMIT]
correspondents = ( correspondents = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
Correspondent.objects.filter(name__icontains=query),
request.user, request.user,
"view_correspondent", "view_correspondent",
) Correspondent,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_correspondent") if request.user.has_perm("documents.view_correspondent")
else [] else []
) )
correspondents = correspondents[:OBJECT_LIMIT] correspondents = correspondents[:OBJECT_LIMIT]
document_types = ( document_types = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
DocumentType.objects.filter(name__icontains=query),
request.user, request.user,
"view_documenttype", "view_documenttype",
) DocumentType,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_documenttype") if request.user.has_perm("documents.view_documenttype")
else [] else []
) )
document_types = document_types[:OBJECT_LIMIT] document_types = document_types[:OBJECT_LIMIT]
storage_paths = ( storage_paths = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
StoragePath.objects.filter(name__icontains=query),
request.user, request.user,
"view_storagepath", "view_storagepath",
) StoragePath,
).filter(name__icontains=query)
if request.user.has_perm("documents.view_storagepath") if request.user.has_perm("documents.view_storagepath")
else [] else []
) )
@@ -3898,21 +3896,21 @@ class GlobalSearchView(PassUserMixin):
) )
groups = groups[:OBJECT_LIMIT] groups = groups[:OBJECT_LIMIT]
mail_rules = ( mail_rules = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
MailRule.objects.filter(name__icontains=query),
request.user, request.user,
"view_mailrule", "view_mailrule",
) MailRule,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailrule") if request.user.has_perm("paperless_mail.view_mailrule")
else [] else []
) )
mail_rules = mail_rules[:OBJECT_LIMIT] mail_rules = mail_rules[:OBJECT_LIMIT]
mail_accounts = ( mail_accounts = (
restrict_queryset_to_visible( get_objects_for_user_owner_aware(
MailAccount.objects.filter(name__icontains=query),
request.user, request.user,
"view_mailaccount", "view_mailaccount",
) MailAccount,
).filter(name__icontains=query)
if request.user.has_perm("paperless_mail.view_mailaccount") if request.user.has_perm("paperless_mail.view_mailaccount")
else [] else []
) )
@@ -4028,33 +4026,33 @@ class StatisticsView(GenericAPIView[Any]):
tags = ( tags = (
Tag.objects.all() Tag.objects.all()
if can_view_global_stats if can_view_global_stats
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag") else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
).only("id", "is_inbox_tag") ).only("id", "is_inbox_tag")
correspondent_count = ( correspondent_count = (
Correspondent.objects.count() Correspondent.objects.count()
if can_view_global_stats if can_view_global_stats
else restrict_queryset_to_visible( else get_objects_for_user_owner_aware(
Correspondent.objects.all(),
user, user,
"view_correspondent", "documents.view_correspondent",
Correspondent,
).count() ).count()
) )
document_type_count = ( document_type_count = (
DocumentType.objects.count() DocumentType.objects.count()
if can_view_global_stats if can_view_global_stats
else restrict_queryset_to_visible( else get_objects_for_user_owner_aware(
DocumentType.objects.all(),
user, user,
"view_documenttype", "documents.view_documenttype",
DocumentType,
).count() ).count()
) )
storage_path_count = ( storage_path_count = (
StoragePath.objects.count() StoragePath.objects.count()
if can_view_global_stats if can_view_global_stats
else restrict_queryset_to_visible( else get_objects_for_user_owner_aware(
StoragePath.objects.all(),
user, user,
"view_storagepath", "documents.view_storagepath",
StoragePath,
).count() ).count()
) )
+3 -3
View File
@@ -2,8 +2,8 @@ from django.utils.translation import gettext as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied 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.permissions import has_perms_owner_aware
from documents.permissions import restrict_queryset_to_visible
from documents.serialisers import CorrespondentField from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer from documents.serialisers import OwnedObjectSerializer
@@ -68,10 +68,10 @@ class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
if user is None: if user is None:
return MailAccount.objects.none() return MailAccount.objects.none()
return restrict_queryset_to_visible( return get_objects_for_user_owner_aware(
MailAccount.objects.all(),
user, user,
"change_mailaccount", "change_mailaccount",
MailAccount,
).order_by("-id") ).order_by("-id")
Generated
+4 -4
View File
@@ -1272,11 +1272,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.32.6" version = "4.0.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" }, { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
] ]
[[package]] [[package]]
@@ -3184,7 +3184,7 @@ requires-dist = [
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" }, { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" }, { name = "filelock", specifier = ">=3.32,<4.1" },
{ name = "flower", specifier = ">=2.0.1,<2.3" }, { name = "flower", specifier = ">=2.0.1,<2.3" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" }, { name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },