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 <noreply@anthropic.com>
This commit is contained in:
stumpylog
2026-08-03 10:23:29 -07:00
co-authored by Claude Sonnet 5
parent a047d0e39d
commit 018c448477
2 changed files with 22 additions and 13 deletions
+5 -2
View File
@@ -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."),
)
+17 -11
View File
@@ -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":