Performance: unify permission-filtering backends, fixes Correspondent/Tag list slowness (#13601)

* feat: add unified PermittedObjectsFilter backed by permitted_object_ids

* refactor: migrate all ViewSets to unified PermittedObjectsFilter

Replace the deprecated ObjectOwnedOrGrantedPermissionsFilter,
DocumentPermissionsFilter, and ObjectOwnedPermissionsFilter aliases
with PermittedObjectsFilter directly across documents/views.py (8
sites, including TrashView's include_granted=False subclass) and
paperless_mail/views.py (3 sites), then delete the now-unreferenced
alias classes from documents/filters.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* docs: document legacy status of get_objects_for_user_owner_aware/has_perms_owner_aware

Stage 4's PermittedObjectsFilter/permitted_object_ids() covers the
queryset-filtering use case, but both functions still have production
callers outside this plan's scope (documents/views.py,
documents/serialisers.py, documents/signals/handlers.py,
paperless_ai/matching.py, paperless_ai/ai_classifier.py). Per Task 20
Step 2, they are kept in place rather than partially deleted, with
docstrings updated to note their legacy status and remaining callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* Fix: address final review findings for permission-filter unification

- Add a permanent regression test pinning TrashView's include_granted=False
  wiring: an explicit view_document grant on a trashed document must not
  leak it into /api/trash/ for a non-owner, non-superuser requester.
- Drop the now-dead direct dependency djangorestframework-guardian; the
  last rest_framework_guardian import was removed by this branch's
  migration onto PermittedObjectsFilter. django-guardian is untouched.
- Replace the hand-maintained, already-stale caller lists in
  get_objects_for_user_owner_aware/has_perms_owner_aware docstrings with a
  pointer to grep for remaining callers instead.
- In PermittedObjectsFilter.filter_queryset, compute `model` only on the
  include_granted=True path that actually uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* perf: check bulk-edit-objects apply_to_all permissions via DB-side exclude/exists

Materialized the full permitted_object_ids() set into a Python set() just
to check membership for the request's objs queryset -- the same pattern
already fixed at four other sites for Document. This one is used by
apply_to_all, where objs can be an unbounded filtered selection (e.g. all
tags matching a filter) rather than a small request-supplied ID list,
making the wasted materialization worse here than at the sites already
fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Cleans up the comment about why this is still here for now

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Trenton H
2026-08-08 07:27:18 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent b192a419fd
commit fc242bb570
8 changed files with 681 additions and 606 deletions
@@ -446,6 +446,33 @@ class TestTrashRestorePermissionBoundary:
assert response.status_code == HTTPStatus.OK
@pytest.mark.django_db
class TestTrashViewExcludesExplicitlyGrantedDocuments:
"""
Regression test pinning TrashView's use of
``_TrashPermittedObjectsFilter`` (``include_granted = False``). If that
flag were ever flipped to the default ``True``, or the subclass removed
in favor of the base ``PermittedObjectsFilter``, a trashed document
would leak into ``/api/trash/`` results for any user holding an
explicit guardian grant on it, even though they are neither the owner
nor a superuser.
"""
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
owner = User.objects.create_user(username="trash_owner")
grantee = User.objects.create_user(username="trash_grantee")
doc = DocumentFactory(owner=owner)
doc.delete() # soft delete
assign_perm("view_document", grantee, doc)
rest_api_client.force_authenticate(user=grantee)
response = rest_api_client.get("/api/trash/")
assert response.status_code == HTTPStatus.OK
result_ids = {result["id"] for result in response.data["results"]}
assert doc.pk not in result_ids
@pytest.mark.django_db
@pytest.mark.parametrize(
("model", "factory", "perm"),
@@ -0,0 +1,70 @@
import pytest
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from rest_framework.test import APIRequestFactory
from documents.filters import PermittedObjectsFilter
from documents.models import Tag
from documents.tests.factories import TagFactory
class _DummyView:
queryset = Tag.objects.all()
@pytest.mark.django_db
class TestPermittedObjectsFilter:
def test_superuser_bypasses_filtering_entirely(self):
superuser = User.objects.create_superuser(username="root")
owner = User.objects.create_user(username="owner")
TagFactory(owner=owner)
request = APIRequestFactory().get("/")
request.user = superuser
result = PermittedObjectsFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
assert result.count() == Tag.objects.count()
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
owner = User.objects.create_user(username="owner")
grantee = User.objects.create_user(username="grantee")
owned = TagFactory(owner=grantee)
unowned = TagFactory(owner=None)
granted = TagFactory(owner=owner)
hidden = TagFactory(owner=owner)
assign_perm("view_tag", grantee, granted)
request = APIRequestFactory().get("/")
request.user = grantee
result = PermittedObjectsFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
visible_ids = set(result.values_list("id", flat=True))
assert visible_ids == {owned.pk, unowned.pk, granted.pk}
assert hidden.pk not in visible_ids
def test_include_granted_false_excludes_explicitly_shared_objects(self):
owner = User.objects.create_user(username="owner2")
grantee = User.objects.create_user(username="grantee2")
owned = TagFactory(owner=grantee)
granted = TagFactory(owner=owner)
assign_perm("view_tag", grantee, granted)
request = APIRequestFactory().get("/")
request.user = grantee
class _OwnerOnlyFilter(PermittedObjectsFilter):
include_granted = False
result = _OwnerOnlyFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
visible_ids = set(result.values_list("id", flat=True))
assert visible_ids == {owned.pk}
assert granted.pk not in visible_ids