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:
Trenton H
2026-07-23 04:02:46 +00:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 97662b6c5c
commit f86bc57880
3 changed files with 66 additions and 15 deletions
@@ -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:
"""