From 018c4484772e4e1948f5b68392f03fad31a4a550 Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:23:15 -0700 Subject: [PATCH] perf: check permitted document IDs via DB-side exclude/exists instead of materializing the full set email_documents, _has_document_permissions, TrashView.post, and validate_documentlink_targets each resolved permitted_document_ids() into a full Python set just to check membership for a small, bounded batch of request document IDs. For a user with broad permitted access that pulls their entire visible/editable document count into memory and across the wire regardless of how many documents the request actually touches. Pushing the membership check into the DB via exclude(...).exists() scales with the request's batch size instead, without reintroducing the per-row guardian join pathology from #13276 (confirmed via EXPLAIN ANALYZE: the permission subplans are hashed once, not re-executed per outer row). Co-Authored-By: Claude Sonnet 5 --- src/documents/serialisers.py | 7 +++++-- src/documents/views.py | 28 +++++++++++++++++----------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index b37e6c7bc..ca850e84f 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -864,8 +864,11 @@ def validate_documentlink_targets(user, doc_ids): if user is None: return - permitted_change_ids = set(permitted_document_ids(user, perm="change_document")) - if not set(doc_ids) <= permitted_change_ids: + if ( + Document.objects.filter(id__in=doc_ids) + .exclude(id__in=permitted_document_ids(user, perm="change_document")) + .exists() + ): raise PermissionDenied( _("Insufficient permissions."), ) diff --git a/src/documents/views.py b/src/documents/views.py index a29faf90f..a6bb6c955 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1930,10 +1930,13 @@ class DocumentViewSet( use_archive_version = validated_data.get("use_archive_version", True) documents = Document.objects.filter(pk__in=document_ids) - if request.user is not None: - permitted_ids = set(permitted_document_ids(request.user)) - if not all(document.pk in permitted_ids for document in documents): - return HttpResponseForbidden("Insufficient permissions") + if ( + request.user is not None + and documents.exclude( + pk__in=permitted_document_ids(request.user), + ).exists() + ): + return HttpResponseForbidden("Insufficient permissions") try: attachments: list[EmailAttachment] = [] @@ -2786,9 +2789,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin): ) # check global and object permissions for all documents - permitted_change_ids = set(permitted_document_ids(user, perm="change_document")) - has_perms = user.has_perm("documents.change_document") and all( - doc.pk in permitted_change_ids for doc in document_objs + has_perms = ( + user.has_perm( + "documents.change_document", + ) + and not document_objs.exclude( + pk__in=permitted_document_ids(user, perm="change_document"), + ).exists() ) # check ownership for methods that change original document @@ -5314,14 +5321,13 @@ class TrashView(ListModelMixin, PassUserMixin): if doc_ids is not None else self.filter_queryset(self.get_queryset()).all() ) - permitted_ids = set( - permitted_document_ids( + if docs.exclude( + pk__in=permitted_document_ids( request.user, perm="delete_document", include_deleted=True, ), - ) - if not all(doc.pk in permitted_ids for doc in docs): + ).exists(): return HttpResponseForbidden("Insufficient permissions") action = serializer.validated_data.get("action") if action == "restore":