Performance: generalize permitted_document_ids into permitted_object_ids for any model (#13578)

* feat: generalize permitted_document_ids into permitted_object_ids for any model

Implements Task 14 of the permission-filtering consolidation plan:
- Add generic permitted_object_ids(user, model, perm, include_deleted=False)
- Refactor permitted_document_ids to delegate to permitted_object_ids
- Add comprehensive tests for Tag/Correspondent/DocumentType/StoragePath
- Preserve exact public behavior of permitted_document_ids (100% regression-free)

All 38 tests pass (18 existing + 20 new). The include_deleted parameter
correctly handles soft-delete patterns (effective only for Document).

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

* refactor: add type hints to permitted_object_ids and permitted_document_ids

Add missing type annotations to match the established conventions in this file
(see get_objects_for_user_owner_aware). Also added Model import from django.db.models.

- permitted_object_ids: user: User | None, model: type[Model], return -> QuerySet[int]
- permitted_document_ids: user: User | None, return -> QuerySet[int]

All 38 permission filtering security tests pass; this is a type-annotation-only change.

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

* refactor: remove redundant deleted_at filter in permitted_object_ids

SoftDeleteManager's own get_queryset() already excludes soft-deleted
rows, so the extra deleted_at__isnull=True filter was dead code.

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-08-08 07:27:16 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 2635a12281
commit ee5588ade3
2 changed files with 141 additions and 25 deletions
+43 -25
View File
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import Model
from django.db.models import Q
from django.db.models import QuerySet
from django.db.models import Value
@@ -163,30 +164,32 @@ def set_permissions_for_object(
)
def permitted_document_ids(
user,
def permitted_object_ids(
user: User | None,
model: type[Model],
perm: str,
*,
perm: str = "view_document",
include_deleted: bool = False,
):
) -> QuerySet[int]:
"""
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
Generic version of ``permitted_document_ids`` for any model with an
``owner`` field and guardian object-level permissions. ``include_deleted``
only has an effect for models exposing a ``global_objects``/``deleted_at``
soft-delete pattern (currently only ``Document``); for every other model
it is accepted but has no effect, since those models have no soft-delete
concept.
"""
manager = Document.global_objects if include_deleted else Document.objects
base_docs = manager.all()
base_docs = base_docs.only("id", "owner")
has_soft_delete = hasattr(model, "global_objects")
manager = (
model.global_objects if include_deleted and has_soft_delete else model.objects
)
base_qs = manager.all().only("id", "owner")
if user is None or not getattr(user, "is_authenticated", False):
# Just Anonymous user e.g. for drf-spectacular
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
if getattr(user, "is_superuser", False):
return base_docs.values_list("id", flat=True)
return base_qs.values_list("id", flat=True)
# Guardian's UserObjectPermission/GroupObjectPermission always store a bare
# codename, but has_perm()-style callers commonly pass the qualified
@@ -194,31 +197,46 @@ def permitted_document_ids(
# codename, so just drop any prefix rather than silently under-permitting.
perm = perm.rsplit(".", 1)[-1]
document_ct = ContentType.objects.get_for_model(Document)
content_type = ContentType.objects.get_for_model(model)
perm_filter = {
"permission__codename": perm,
"permission__content_type": document_ct,
"permission__content_type": content_type,
}
user_perm_docs = (
user_perm_ids = (
UserObjectPermission.objects.filter(user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
group_perm_docs = (
group_perm_ids = (
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
permitted_ids = user_perm_ids.union(group_perm_ids)
permitted_documents = user_perm_docs.union(group_perm_docs)
return base_docs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
return base_qs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
).values_list("id", flat=True)
def permitted_document_ids(
user: User | None,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[int]:
"""
Document-specific convenience wrapper around ``permitted_object_ids``.
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
"""
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
def get_document_count_filter_for_user(user, related_name: str = "documents"):
"""
Return the Q object used to filter document counts for the given user.