mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-02 17:12:18 +00:00
Fix: batch document user_can_change checks to avoid per-row N+1 (#13204)
* Fix (beta): batch document user_can_change checks to avoid per-row N+1 DocumentSerializer.get_user_can_change() built a fresh ObjectPermissionChecker and issued a guardian permission-table query for every document row not owned by the requesting user -- correct, but O(N) per page load for any non-superuser viewing documents owned by others. BulkPermissionMixin already batches this exact lookup (2 queries total, regardless of page size) for Correspondent/Tag/DocumentType/CustomField, but was gated behind the rarely-used `full_perms` flag and DocumentViewSet didn't inherit it at all. Changed the gate to "any list action" (cheap: just two extra queries per page) and added BulkPermissionMixin to DocumentViewSet, then updated get_user_can_change to consult that batched context before falling back to a fresh guardian check. Preserves guardian's own superuser shortcut explicitly (has_perm() special- cases is_superuser without a query; the batched-context path doesn't, so it needed its own check) -- covered by a new regression test, since no existing test exercised a superuser viewing an other-owned document. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * Fix (beta): don't batch permissions for tantivy search results UnifiedSearchViewSet.list() returns SearchHit/dict-like objects for text/title/query/more_like_id search requests, not Document ORM instances. Adding BulkPermissionMixin to DocumentViewSet (previous commit) meant its get_serializer_context() ran for search responses too, and its _get_object_perms() -- which expects real model instances with .pk -- crashed on the dict-like hits with AttributeError, turning every search request into a 400. Skip the batching specifically for search requests (existing _is_search_request() check) by calling past BulkPermissionMixin in the MRO; non-search list() calls (which return a real Document queryset) are unaffected and still get the batching. 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
97662b6c5c
commit
f86bc57880
@@ -568,6 +568,30 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
self.assertNotIn("user_can_change", results[0])
|
||||
self.assertNotIn("is_shared_by_requester", results[0])
|
||||
|
||||
def test_superuser_user_can_change_without_explicit_grant(self) -> None:
|
||||
"""
|
||||
A superuser has implicit change access to every document, even one
|
||||
owned by someone else with no explicit guardian grant -- mirrors
|
||||
guardian's own ObjectPermissionChecker.has_perm() superuser shortcut.
|
||||
"""
|
||||
superuser = User.objects.create_superuser(username="admin")
|
||||
other_user = User.objects.create_user(username="user2")
|
||||
Document.objects.create(
|
||||
title="Test",
|
||||
content="content",
|
||||
checksum="1",
|
||||
owner=other_user,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(superuser)
|
||||
|
||||
response = self.client.get("/api/documents/", format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertTrue(results[0]["user_can_change"])
|
||||
|
||||
@mock.patch("allauth.mfa.adapter.DefaultMFAAdapter.is_mfa_enabled")
|
||||
def test_basic_auth_mfa_enabled(self, mock_is_mfa_enabled) -> None:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user