mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-06 08:10:35 +00:00
perf: resolve permitted_document_ids once before loop-based permission checks (#13509)
* perf: resolve permitted_document_ids once before email/share loops Replaces per-document has_perms_owner_aware calls in the email-document action and bulk share-link-bundle creation with a single permitted_document_ids(request.user) resolution before the loop, reducing DB round-trips while preserving identical permission semantics (including the per-document error message on the bundle endpoint). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRp4kf1mdn9ruv81zWAmh2 * perf: resolve permitted_document_ids(perm=change_document) once before bulk-edit loops Migrates the bulk document edit permission check in views.py and the custom-field DOCUMENTLINK validator in serialisers.py off of has_perms_owner_aware-per-document loops, resolving permitted_document_ids(user, perm="change_document") once instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRp4kf1mdn9ruv81zWAmh2 * perf: resolve permitted_document_ids once for root-document version-listing loop BulkDownloadView.post() previously called has_perms_owner_aware() per row inside the loop that resolves each document's root and latest version. Resolve permitted_document_ids(request.user) once before the loop and check membership by root_doc.pk instead, consistent with the other consolidated permission-filtering sites. * test: use HTTPStatus enum instead of bare integers in security test assertions * perf: resolve permitted_document_ids(perm=delete_document, include_deleted=True) once for trash loop Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRp4kf1mdn9ruv81zWAmh2 * perf: drop unused select_related("owner") from email_documents The permission check loop that used to call has_perms_owner_aware() (which read .owner) was already replaced with permitted_document_ids() resolved once into a set. No other code in email_documents touches .owner, so the select_related is dead weight. * test: repurpose inert grant into mixed-batch bulk-edit rejection case The unrelated view_document grant in test_bulk_edit_rejects_document_without_change_permission created a document that was never referenced in the request payload. Turn it into a genuinely useful case instead: a mixed batch containing one document the requester is fully permitted to change alongside one they are not, proving bulk_edit rejects the whole batch when any document lacks change permission (not just checking the first/last document in the list). * test: add version-only-grant case discriminating root-vs-version permission check The former "stranger" sub-case in test_permission_checked_on_root_not_on_version had zero grants on either root or version, so it passed under any implementation, correct or buggy. Replace it with a user granted view_document on the version itself (not the root): this only passes if bulk_download truly checks root-only, catching a regression to "root OR version" that the old case could never detect. * 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> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1f1725ba56
commit
765313926f
+27
-15
@@ -1929,14 +1929,14 @@ class DocumentViewSet(
|
||||
message = validated_data.get("message")
|
||||
use_archive_version = validated_data.get("use_archive_version", True)
|
||||
|
||||
documents = Document.objects.select_related("owner").filter(pk__in=document_ids)
|
||||
for document in documents:
|
||||
if request.user is not None and not has_perms_owner_aware(
|
||||
request.user,
|
||||
"view_document",
|
||||
document,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
documents = Document.objects.filter(pk__in=document_ids)
|
||||
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] = []
|
||||
@@ -2789,8 +2789,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
)
|
||||
|
||||
# check global and object permissions for all documents
|
||||
has_perms = user.has_perm("documents.change_document") and all(
|
||||
has_perms_owner_aware(user, "change_document", doc) 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
|
||||
@@ -3843,9 +3848,10 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
content = serializer.validated_data.get("content")
|
||||
follow_filename_format = serializer.validated_data.get("follow_formatting")
|
||||
|
||||
permitted_ids = set(permitted_document_ids(request.user))
|
||||
for document in documents:
|
||||
root_doc = get_root_document(document)
|
||||
if not has_perms_owner_aware(request.user, "view_document", root_doc):
|
||||
if root_doc.pk not in permitted_ids:
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
versioned_documents.append(
|
||||
get_latest_version_for_root(
|
||||
@@ -4509,8 +4515,9 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
)
|
||||
|
||||
documents = list(documents_qs)
|
||||
permitted_ids = set(permitted_document_ids(request.user))
|
||||
for document in documents:
|
||||
if not has_perms_owner_aware(request.user, "view_document", document):
|
||||
if document.pk not in permitted_ids:
|
||||
raise ValidationError(
|
||||
{
|
||||
"document_ids": _(
|
||||
@@ -5314,9 +5321,14 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
if doc_ids is not None
|
||||
else self.filter_queryset(self.get_queryset()).all()
|
||||
)
|
||||
for doc in docs:
|
||||
if not has_perms_owner_aware(request.user, "delete_document", doc):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
if docs.exclude(
|
||||
pk__in=permitted_document_ids(
|
||||
request.user,
|
||||
perm="delete_document",
|
||||
include_deleted=True,
|
||||
),
|
||||
).exists():
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
action = serializer.validated_data.get("action")
|
||||
if action == "restore":
|
||||
for doc in Document.deleted_objects.filter(id__in=doc_ids).all():
|
||||
|
||||
Reference in New Issue
Block a user