mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-08 20:03:18 +00:00
perf: migrate bulk-edit-objects dispatch to permitted_object_ids (#13576)
* perf: migrate bulk-edit-objects apply_to_all dispatch to permitted_object_ids Replaces get_objects_for_user_owner_aware/has_perms_owner_aware in the BulkEditObjectsView apply_to_all dispatch (Tag/Correspondent/DocumentType/ StoragePath) with permitted_object_ids and the resolve-once, check-membership pattern used elsewhere in this stage. Tag-descendant expansion logic left untouched. Adds a security test pinning that apply_to_all excludes objects the requester lacks object-level permission on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmMBGW9FKyDgmKRJ5H9rif * test: add tag-descendant partial-permission coverage, verify pre-migration characterization Adds TestBulkEditObjectsTagDescendantPartialPermission, exercising the tag-descendant-expansion block in BulkEditObjectsView.post as a non-superuser with object-level change_tag granted on a parent tag and one of two children but not the other, confirming the expansion only pulls in descendants the requester actually has permission on. Verified both this test and the existing apply_to_all boundary test pass unchanged against the pre-migration get_objects_for_user_owner_aware/has_perms_owner_aware code (reverted via a scratch patch of the prior commit's views.py hunk, then restored), confirming they characterize genuine pre-existing behavior rather than something the permitted_object_ids migration made necessary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmMBGW9FKyDgmKRJ5H9rif --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3986150f95
commit
b192a419fd
@@ -620,3 +620,117 @@ class TestMatchingRespectsObjectPermissions:
|
||||
matched_ids = {sp.pk for sp in matched}
|
||||
assert visible_storage_path.pk in matched_ids
|
||||
assert hidden_storage_path.pk not in matched_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="tags_owner")
|
||||
requester = User.objects.create_user(username="tags_requester")
|
||||
# grant the global change_tag permission so the object-level
|
||||
# filtering (not the global has_perm check) is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
visible = TagFactory(owner=owner)
|
||||
hidden = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", requester, visible)
|
||||
assign_perm("change_tag", requester, visible)
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
{
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {},
|
||||
"owner": requester.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
# The apply_to_all dispatch must resolve permitted objects up front:
|
||||
# the visible tag (object-level change_tag granted) gets its owner
|
||||
# reassigned, while the hidden tag (no object-level grant) is
|
||||
# excluded entirely and keeps its original owner.
|
||||
visible.refresh_from_db()
|
||||
hidden.refresh_from_db()
|
||||
assert visible.owner == requester
|
||||
assert hidden.owner == owner
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
def test_apply_to_all_descendant_expansion_respects_per_object_permissions(
|
||||
self,
|
||||
rest_api_client,
|
||||
):
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag hierarchy (parent -> permitted_child, unpermitted_child)
|
||||
- A non-superuser requester with object-level change_tag granted
|
||||
on the parent and on only ONE of the two children
|
||||
WHEN:
|
||||
- bulk_edit_objects is called with all=True and a filter that
|
||||
matches only the root (parent) tag, engaging the
|
||||
tag-descendant-expansion logic in BulkEditObjectsView.post
|
||||
THEN:
|
||||
- The descendant expansion only pulls in descendants the
|
||||
requester actually has permission on: the permitted child's
|
||||
owner is reassigned alongside the parent's, while the
|
||||
unpermitted child keeps its original owner. This pins that the
|
||||
expansion checks per-object permissions (editable_ids), not
|
||||
merely "is a descendant of a filter match".
|
||||
|
||||
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
||||
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
||||
cascades deletes to descendants at the database/ORM level regardless
|
||||
of which tags the view resolved into ``objs`` -- a delete-based test
|
||||
would pass/fail based on FK cascade behavior, not on whether the
|
||||
descendant-expansion logic itself respected per-object permissions.
|
||||
"""
|
||||
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
||||
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
||||
# global change_tag permission so the has_perm() gate passes and the
|
||||
# object-level permitted_object_ids filtering is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
|
||||
parent = TagFactory(owner=owner, name="parent-tag")
|
||||
permitted_child = TagFactory(
|
||||
owner=owner,
|
||||
name="permitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
unpermitted_child = TagFactory(
|
||||
owner=owner,
|
||||
name="unpermitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
assign_perm("change_tag", requester, parent)
|
||||
assign_perm("change_tag", requester, permitted_child)
|
||||
# unpermitted_child is intentionally NOT granted change_tag
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
{
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {"is_root": True},
|
||||
"owner": requester.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
parent.refresh_from_db()
|
||||
permitted_child.refresh_from_db()
|
||||
unpermitted_child.refresh_from_db()
|
||||
assert parent.owner == requester
|
||||
assert permitted_child.owner == requester
|
||||
assert unpermitted_child.owner == owner
|
||||
|
||||
@@ -178,6 +178,7 @@ from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
@@ -4764,10 +4765,8 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
"document_types": DocumentTypeFilterSet,
|
||||
"storage_paths": StoragePathFilterSet,
|
||||
}[object_type]
|
||||
user_permitted_objects = get_objects_for_user_owner_aware(
|
||||
user,
|
||||
perm_codename,
|
||||
object_class,
|
||||
user_permitted_objects = object_class.objects.filter(
|
||||
id__in=permitted_object_ids(user, object_class, perm_codename),
|
||||
)
|
||||
objs = filterset_class(
|
||||
data=filters,
|
||||
@@ -4792,8 +4791,9 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
|
||||
if not user.is_superuser:
|
||||
perm = f"documents.{perm_codename}"
|
||||
permitted_ids = set(permitted_object_ids(user, object_class, perm_codename))
|
||||
has_perms = user.has_perm(perm) and all(
|
||||
has_perms_owner_aware(user, perm_codename, obj) for obj in objs
|
||||
obj.pk in permitted_ids for obj in objs
|
||||
)
|
||||
|
||||
if not has_perms:
|
||||
|
||||
Reference in New Issue
Block a user