mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-21 02:03:30 +00:00
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:
co-authored by
Claude Sonnet 5
parent
b192a419fd
commit
fc242bb570
@@ -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
|
||||
Reference in New Issue
Block a user