Compare commits

..
Author SHA1 Message Date
stumpylogandClaude Sonnet 5 a71986847a 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
2026-08-07 13:16:04 -07:00
stumpylogandClaude Sonnet 5 cb51fbccaa 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
2026-08-07 13:16:04 -07:00
stumpylogandClaude Sonnet 5 8e558a2c30 test: add matching.py permission coverage for correspondents, document types, storage paths
Completes the parametrized coverage started for tags -- proves all 4
matching.py lookups migrated to permitted_object_ids respect
per-object view permissions, not just the tag case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:16:04 -07:00
stumpylog 20be62a30e perf: migrate matching.py's 4 permission-filtered lookups to permitted_object_ids 2026-08-07 13:16:04 -07:00
3 changed files with 220 additions and 19 deletions
+10 -14
View File
@@ -19,7 +19,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_object_ids
from documents.regex import safe_regex_search
if TYPE_CHECKING:
@@ -55,10 +55,8 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
correspondents = get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
correspondents = Correspondent.objects.filter(
id__in=permitted_object_ids(user, Correspondent, "view_correspondent"),
)
else:
correspondents = Correspondent.objects.all()
@@ -86,10 +84,8 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
document_types = get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
document_types = DocumentType.objects.filter(
id__in=permitted_object_ids(user, DocumentType, "view_documenttype"),
)
else:
document_types = DocumentType.objects.all()
@@ -116,7 +112,9 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
user = document.owner
if user is not None:
tags = get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
tags = Tag.objects.filter(
id__in=permitted_object_ids(user, Tag, "view_tag"),
)
else:
tags = Tag.objects.all()
@@ -145,10 +143,8 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
user = document.owner
if user is not None:
storage_paths = get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
storage_paths = StoragePath.objects.filter(
id__in=permitted_object_ids(user, StoragePath, "view_storagepath"),
)
else:
storage_paths = StoragePath.objects.all()
@@ -12,6 +12,10 @@ from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework.test import APIClient
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
from documents.matching import match_tags
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
@@ -529,3 +533,204 @@ class TestPermittedObjectIdsGenericModels:
expected_visible=[obj.pk],
expected_hidden=[],
)
@pytest.mark.django_db
class TestMatchingRespectsObjectPermissions:
def test_match_tags_only_considers_tags_visible_to_user(self):
owner = User.objects.create_user(username="tag_owner")
classifying_user = User.objects.create_user(username="classifier_user")
visible_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
hidden_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
assign_perm("view_tag", classifying_user, visible_tag)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_tags(doc, classifier=None, user=classifying_user)
matched_ids = {t.pk for t in matched}
assert visible_tag.pk in matched_ids
assert hidden_tag.pk not in matched_ids
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
owner = User.objects.create_user(username="correspondent_owner")
classifying_user = User.objects.create_user(username="classifier_user2")
visible_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
hidden_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
assign_perm("view_correspondent", classifying_user, visible_correspondent)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_correspondents(doc, classifier=None, user=classifying_user)
matched_ids = {c.pk for c in matched}
assert visible_correspondent.pk in matched_ids
assert hidden_correspondent.pk not in matched_ids
def test_match_document_types_only_considers_document_types_visible_to_user(self):
owner = User.objects.create_user(username="document_type_owner")
classifying_user = User.objects.create_user(username="classifier_user3")
visible_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
hidden_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
assign_perm("view_documenttype", classifying_user, visible_document_type)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_document_types(doc, classifier=None, user=classifying_user)
matched_ids = {dt.pk for dt in matched}
assert visible_document_type.pk in matched_ids
assert hidden_document_type.pk not in matched_ids
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
owner = User.objects.create_user(username="storage_path_owner")
classifying_user = User.objects.create_user(username="classifier_user4")
visible_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
hidden_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
assign_perm("view_storagepath", classifying_user, visible_storage_path)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
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
+5 -5
View File
@@ -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: