Compare commits

...
Author SHA1 Message Date
stumpylogandClaude Sonnet 5 f68093fcb8 Fix (beta): don't batch permissions for tantivy search results
UnifiedSearchViewSet.list() returns SearchHit/dict-like objects for
text/title/query/more_like_id search requests, not Document ORM instances.
Adding BulkPermissionMixin to DocumentViewSet (previous commit) meant its
get_serializer_context() ran for search responses too, and its
_get_object_perms() -- which expects real model instances with .pk --
crashed on the dict-like hits with AttributeError, turning every search
request into a 400.

Skip the batching specifically for search requests (existing
_is_search_request() check) by calling past BulkPermissionMixin in the
MRO; non-search list() calls (which return a real Document queryset) are
unaffected and still get the batching.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 10:44:21 -07:00
stumpylogandClaude Sonnet 5 f613082249 Fix (beta): batch document user_can_change checks to avoid per-row N+1
DocumentSerializer.get_user_can_change() built a fresh
ObjectPermissionChecker and issued a guardian permission-table query for
every document row not owned by the requesting user -- correct, but O(N)
per page load for any non-superuser viewing documents owned by others.

BulkPermissionMixin already batches this exact lookup (2 queries total,
regardless of page size) for Correspondent/Tag/DocumentType/CustomField,
but was gated behind the rarely-used `full_perms` flag and DocumentViewSet
didn't inherit it at all. Changed the gate to "any list action" (cheap:
just two extra queries per page) and added BulkPermissionMixin to
DocumentViewSet, then updated get_user_can_change to consult that batched
context before falling back to a fresh guardian check.

Preserves guardian's own superuser shortcut explicitly (has_perm() special-
cases is_superuser without a query; the batched-context path doesn't, so it
needed its own check) -- covered by a new regression test, since no
existing test exercised a superuser viewing an other-owned document.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 10:25:34 -07:00
shamoonandGitHub e106618c39 Fix (beta): dont include hidden buttons in virtual scroll container height (#13194) 2026-07-22 08:42:31 -07:00
shamoonandGitHub d113826cf8 Tweakhancement: allow some bulk operations with all (#13193) 2026-07-22 08:28:49 -07:00
shamoonandGitHub c063b3799f Fix (beta): fix custom field bulk editing for 'all' (#13191) 2026-07-22 08:14:53 -07:00
81795bc93a Security (beta): enforce current permissions in autocomplete (#13188)
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-07-22 08:07:43 -07:00
shamoonandGitHub 6664f05543 Security (beta): bound email linkification (#13187) 2026-07-22 07:41:12 -07:00
shamoonandGitHub 8aab34ea73 Security (beta): two small permission check fixes (#13186) 2026-07-21 16:50:28 +00:00
8404198ec8 Fix (beta): short-circuit ObjectOwnedOrGrantedPermissionsFilter for superusers (#13183)
Mirrors the existing short-circuit already present in its sibling class,
ObjectOwnedPermissionsFilter, which this one lacked.

Benchmarked as a superuser against a 100k-document corpus: no measurable
difference. django-guardian's own get_objects_for_user() (called via
super().filter_queryset()) already returns the queryset unrestricted for
superusers before this change, so the guardian permission tables were
never actually queried for superusers either way. This change only skips
building a redundant 3-way OR of conditions that are all subsets of the
already-unrestricted queryset -- worth keeping for clarity/consistency
with the sibling filter, not as a performance claim.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 07:31:58 -07:00
21 changed files with 491 additions and 86 deletions
@@ -34,30 +34,28 @@
</div>
@if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
</div>
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
@if (filteredItems.length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
@if (filteredItems.length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -265,7 +265,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
expect(component.buttonsViewport.getRenderedRange().end).toEqual(
items.length
) // all selectable items shown
component.filterText = 'Tag2'
fixture.detectChanges()
@@ -278,6 +280,29 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0)
})
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
@@ -801,12 +801,17 @@ export class FilterableDropdownComponent
private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number {
const filteredLength = this.filterPipe.transform(
this.items,
this.filterText
).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
return Math.min(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX,
400
)
}
constructor() {
@@ -878,7 +883,7 @@ export class FilterableDropdownComponent
}
listFilterEnter(): void {
let filtered = this.filterPipe.transform(this.items, this.filterText)
const filtered = this.filteredItems
if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id)
setTimeout(() => {
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary"
id="dropdownSend"
ngbDropdownToggle
[disabled]="disabled || !list.hasSelection || list.allSelected"
[disabled]="disabled || !canSendSelection"
>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button>
<button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,7 +117,7 @@
</button>
<div class="dropdown-divider"></div>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button>
}
@@ -208,6 +208,50 @@ describe('BulkEditorComponent', () => {
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should allow sending an all-filtered selection that fits on the current page', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(5)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(true)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(false)
})
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(6)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(false)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(true)
})
it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
@@ -1597,6 +1641,7 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.selectionCount).toEqual(2)
expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit()
@@ -1020,6 +1020,7 @@ export class BulkEditorComponent
)
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()
@@ -1040,6 +1041,14 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
public get canSendSelection(): boolean {
return (
this.list.hasSelection &&
(!this.list.allSelected ||
this.list.selectedCount === this.list.selected.size)
)
}
createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static',
@@ -1,9 +1,9 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i8n>{
documents.length,
documentCount,
plural,
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents}
}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
@@ -42,6 +42,20 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
expect(component.form.contains('2')).toBeTruthy()
})
it('should render the document count for a filtered selection', () => {
component.selection = {
all: true,
filters: { title__icontains: 'invoice' },
}
component.selectionCount = 42
fixture.detectChanges()
expect(component.documents).toEqual([])
expect(
fixture.nativeElement.querySelector('.modal-title').textContent
).toContain('Set custom fields for 42 documents')
})
it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest
.spyOn(documentService, 'bulkEdit')
@@ -81,8 +81,14 @@ export class CustomFieldsBulkEditDialogComponent {
public selection: DocumentSelectionQuery = { documents: [] }
public selectionCount: number
public get documents(): number[] {
return this.selection.documents
return this.selection.documents ?? []
}
public get documentCount(): number {
return this.selectionCount ?? this.documents.length
}
initForm() {
+2
View File
@@ -1024,6 +1024,8 @@ class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
objects_with_perms = super().filter_queryset(request, queryset, view)
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
+88 -35
View File
@@ -11,6 +11,7 @@ from enum import StrEnum
from itertools import islice
from typing import TYPE_CHECKING
from typing import Final
from typing import NamedTuple
from typing import Self
from typing import TypedDict
from typing import TypeVar
@@ -21,6 +22,7 @@ import tantivy
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.utils.timezone import get_current_timezone
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from documents.search._query import build_permission_filter
@@ -45,6 +47,8 @@ if TYPE_CHECKING:
from pathlib import Path
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db.models import QuerySet
from documents.models import Document
@@ -59,6 +63,18 @@ _LOCK_BACKOFF_CAP: Final[float] = 10.0 # seconds
T = TypeVar("T")
class ViewerGrant(NamedTuple):
"""Direct user and group view grants for a single document.
Named fields (rather than a bare 2-tuple) so ``viewer_ids`` and
``viewer_group_ids`` can't be silently transposed at a call site — both
are ``list[int]``, so a positional swap would type-check cleanly.
"""
viewer_ids: list[int]
viewer_group_ids: list[int]
class SearchMode(StrEnum):
QUERY = "query"
TEXT = "text"
@@ -367,7 +383,7 @@ class TantivyBackend:
) -> tantivy.Query:
"""Wrap a query with a permission filter if the user is not a superuser."""
if user is not None:
permission_filter = build_permission_filter(self._schema, user)
permission_filter = self._build_permission_filter(user)
return tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, query),
@@ -376,11 +392,21 @@ class TantivyBackend:
)
return query
def _build_permission_filter(self, user: AbstractUser) -> tantivy.Query:
"""Build a filter using the user's current group memberships."""
group_ids = user.groups.values_list("pk", flat=True)
return build_permission_filter(
self._schema,
user,
viewer_group_ids=group_ids,
)
def _build_tantivy_doc(
self,
document: Document,
effective_content: str | None = None,
viewer_ids: list[int] | None = None,
viewer_group_ids: list[int] | None = None,
) -> tantivy.Document:
"""Build a tantivy Document from a Django Document instance.
@@ -497,10 +523,26 @@ class TantivyBackend:
users_with_perms = get_users_with_perms(
document,
only_with_perms_in=["view_document"],
with_group_users=False,
)
viewer_ids = list(
cast("QuerySet[User]", users_with_perms).values_list("id", flat=True),
)
viewer_ids = [int(u.id) for u in users_with_perms]
for viewer_id in viewer_ids:
doc.add_unsigned("viewer_id", viewer_id)
if viewer_group_ids is None:
groups_with_perms = get_groups_with_perms(
document,
only_with_perms_in=["view_document"],
)
viewer_group_ids = list(
cast("QuerySet[Group]", groups_with_perms).values_list(
"id",
flat=True,
),
)
for viewer_group_id in viewer_group_ids:
doc.add_unsigned("viewer_group_id", viewer_group_id)
# Autocomplete words
text_sources = [document.title, content]
@@ -813,7 +855,7 @@ class TantivyBackend:
# Intersect with permission filter so autocomplete words from
# invisible documents don't leak to other users.
if user is not None and not user.is_superuser:
permission_query = build_permission_filter(self._schema, user)
permission_query = self._build_permission_filter(user)
matches = searcher.terms_with_prefix(
"autocomplete_word",
@@ -915,7 +957,7 @@ class TantivyBackend:
def rebuild(
self,
documents: QuerySet[Document],
iter_wrapper: IterWrapper[tuple[Document, list[int]]] = identity,
iter_wrapper: IterWrapper[tuple[Document, ViewerGrant]] = identity,
writer_heap_bytes: int = 512_000_000,
) -> None:
"""
@@ -928,8 +970,8 @@ class TantivyBackend:
documents: QuerySet of Document instances to index
iter_wrapper: Optional wrapper function for progress tracking
(e.g., progress bar). Wraps an iterable of
``(document, viewer_ids)`` pairs and should yield each pair
unchanged, advancing one step per document.
``(document, (viewer_ids, viewer_group_ids))`` pairs and should yield
each unchanged, advancing one step per document.
writer_heap_bytes: Tantivy writer memory budget (split across the
writer's threads). Larger values buffer more docs in RAM before
flushing a segment, deferring merge work; they do not avoid it.
@@ -953,11 +995,14 @@ class TantivyBackend:
documents_stream = _DocumentViewerStream(documents, chunk_size=1000)
try:
writer = new_index.writer(heap_size=writer_heap_bytes)
for document, viewer_ids in iter_wrapper(documents_stream):
for document, (viewer_ids, viewer_group_ids) in iter_wrapper(
documents_stream,
):
doc = self._build_tantivy_doc(
document,
document.get_effective_content(),
viewer_ids=viewer_ids,
viewer_group_ids=viewer_group_ids,
)
writer.add_document(doc)
writer.commit()
@@ -978,19 +1023,26 @@ def chunked(iterable, size):
yield chunk
class _DocumentViewerStream:
"""Yield (document, viewer_ids) pairs while batch-loading viewer ids.
_EMPTY_VIEWER_GRANT: Final[ViewerGrant] = ViewerGrant(
viewer_ids=[],
viewer_group_ids=[],
)
Viewer permissions are fetched one SQL query per chunk (see
``_bulk_get_viewer_ids``), but documents are yielded individually so a
class _DocumentViewerStream:
"""Yield document permission data while batch-loading grants.
Viewer permissions are fetched in batches (see
``_bulk_get_viewer_permissions``), but documents are yielded individually so a
progress bar wrapped around this stream advances per document rather than
jumping a whole chunk at a time. ``__len__`` lets the progress helper still
discover the total (it inspects ``QuerySet``/``Sized``).
The viewer ids travel with each document in the yielded pair rather than
through a separate mutable attribute, so the pairing survives regardless
of how ``iter_wrapper`` consumes the stream (buffering, batching, etc.) —
there is no reliance on the caller advancing this generator in lock-step.
The viewer and group ids travel with each document in the yielded pair
rather than through a separate mutable attribute, so the pairing survives
regardless of how ``iter_wrapper`` consumes the stream (buffering,
batching, etc.) — there is no reliance on the caller advancing this
generator in lock-step.
"""
def __init__(self, documents: QuerySet[Document], *, chunk_size: int) -> None:
@@ -1000,25 +1052,25 @@ class _DocumentViewerStream:
def __len__(self) -> int:
return self._documents.count()
def __iter__(self) -> Iterator[tuple[Document, list[int]]]:
def __iter__(self) -> Iterator[tuple[Document, ViewerGrant]]:
# iterator(chunk_size=…) streams from a server-side cursor instead of
# materialising the whole queryset in memory; since Django 4.1 it still
# honours prefetch_related, running the prefetches one batch at a time.
documents = self._documents.iterator(chunk_size=self._chunk_size)
for chunk in chunked(documents, self._chunk_size):
viewer_ids_by_pk = _bulk_get_viewer_ids([doc.pk for doc in chunk])
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
for doc in chunk:
yield doc, viewer_ids_by_pk.get(doc.pk, [])
yield doc, grants_by_pk.get(doc.pk, _EMPTY_VIEWER_GRANT)
def _bulk_get_viewer_ids(doc_pks: Sequence[int]) -> dict[int, list[int]]:
"""Fetch all view_document permissions for a batch of documents in one query.
def _bulk_get_viewer_permissions(
doc_pks: Sequence[int],
) -> dict[int, ViewerGrant]:
"""Fetch direct user and group view grants for a batch of documents, keyed by pk.
Mirrors get_users_with_perms(doc, only_with_perms_in=["view_document"])
(with_group_users defaults to True there): a user counts as a viewer if
they hold the permission directly, OR via membership in a group that
holds the permission. Missing the group case would silently drop search
access for group-only viewers.
Group grants remain group IDs in the index so permission checks use the
requesting user's current memberships. Expanding groups to user IDs here
would leave stale access behind after a user is removed from a group.
"""
from collections import defaultdict
@@ -1032,6 +1084,7 @@ def _bulk_get_viewer_ids(doc_pks: Sequence[int]) -> dict[int, list[int]]:
str_pks = [str(pk) for pk in doc_pks]
viewer_map: dict[int, set[int]] = defaultdict(set)
viewer_group_map: dict[int, set[int]] = defaultdict(set)
# Fold the permission lookup into the query via a join on codename instead
# of a separate Permission.objects.get(), which would otherwise run once per
@@ -1045,22 +1098,22 @@ def _bulk_get_viewer_ids(doc_pks: Sequence[int]) -> dict[int, list[int]]:
for object_pk, user_id in user_qs:
viewer_map[int(object_pk)].add(user_id)
# User.groups has related_query_name="user", so group__user__id joins
# through the group membership m2m to the member users' ids in the same
# single-query fashion as user_qs above (values_list compiles to one SQL
# JOIN; no per-row Python-side lookups follow, so select_related /
# prefetch_related do not apply here).
group_qs = GroupObjectPermission.objects.filter(
content_type=ct,
permission__content_type=ct,
permission__codename="view_document",
object_pk__in=str_pks,
).values_list("object_pk", "group__user__id")
for object_pk, user_id in group_qs:
if user_id is not None:
viewer_map[int(object_pk)].add(user_id)
).values_list("object_pk", "group_id")
for object_pk, group_id in group_qs:
viewer_group_map[int(object_pk)].add(group_id)
return {object_pk: list(user_ids) for object_pk, user_ids in viewer_map.items()}
return {
object_pk: ViewerGrant(
viewer_ids=list(viewer_map.get(object_pk, ())),
viewer_group_ids=list(viewer_group_map.get(object_pk, ())),
)
for object_pk in viewer_map.keys() | viewer_group_map.keys()
}
# Module-level singleton with proper thread safety
+11 -1
View File
@@ -20,6 +20,7 @@ from documents.search._translate import SearchQueryError
from documents.search._translate import translate_query
if TYPE_CHECKING:
from collections.abc import Iterable
from datetime import tzinfo
from django.contrib.auth.base_user import AbstractBaseUser
@@ -114,6 +115,7 @@ def normalize_query(query: str) -> str:
def build_permission_filter(
schema: tantivy.Schema,
user: AbstractBaseUser,
viewer_group_ids: Iterable[int] = (),
) -> tantivy.Query:
"""
Build a query filter for user document permissions.
@@ -123,10 +125,12 @@ def build_permission_filter(
- Public documents (no owner) are visible to all users
- Private documents are visible to their owner
- Documents explicitly shared with the user are visible
- Documents shared with one of the user's current groups are visible
Args:
schema: Tantivy schema for field validation
user: User to check permissions for
viewer_group_ids: Current group memberships for the user
Returns:
Tantivy query that filters results to visible documents
@@ -140,7 +144,13 @@ def build_permission_filter(
)
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
return tantivy.Query.disjunction_max_query([no_owner, owned, shared])
group_shared = [
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
for group_id in viewer_group_ids
]
return tantivy.Query.disjunction_max_query(
[no_owner, owned, shared, *group_shared],
)
DEFAULT_SEARCH_FIELDS = [
+1
View File
@@ -102,6 +102,7 @@ def build_schema() -> tantivy.Schema:
"tag_id",
"owner_id",
"viewer_id",
"viewer_group_id",
):
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
+27 -8
View File
@@ -392,15 +392,34 @@ class OwnedObjectSerializer(
}
def get_user_can_change(self, obj) -> bool:
checker = ObjectPermissionChecker(self.user) if self.user is not None else None
return (
obj.owner is None
or obj.owner == self.user
or (
self.user is not None
and checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
if obj.owner is None or obj.owner == self.user:
return True
if self.user is None:
return False
if self.user.is_active and self.user.is_superuser:
# Mirrors guardian's own ObjectPermissionChecker.has_perm() shortcut --
# superusers aren't necessarily granted explicit object permissions,
# so the batched context below would otherwise incorrectly say no.
return True
# Prefer the page-level batch computed by BulkPermissionMixin
# (get_serializer_context) over a fresh per-object guardian check,
# which would otherwise query the permission tables once per row.
users_change_perms = self.context.get("users_change_perms")
groups_change_perms = self.context.get("groups_change_perms")
if users_change_perms is not None and groups_change_perms is not None:
if self.user.pk in users_change_perms.get(obj.pk, []):
return True
user_group_ids = getattr(self, "_user_group_ids", None)
if user_group_ids is None:
user_group_ids = set(self.user.groups.values_list("id", flat=True))
self._user_group_ids = user_group_ids
return bool(
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
)
)
checker = ObjectPermissionChecker(self.user)
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
@staticmethod
def get_shared_object_pks(objects: Iterable):
+92
View File
@@ -1419,6 +1419,30 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.merge")
def test_merge_and_delete_requires_change_permission(self, m) -> None:
self.setup_mock(m, "merge")
user = User.objects.create_user(username="no-change")
user.user_permissions.add(
Permission.objects.get(codename="add_document"),
Permission.objects.get(codename="delete_document"),
)
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/merge/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"delete_originals": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge")
def test_merge_invalid_parameters(self, m) -> None:
self.setup_mock(m, "merge")
@@ -1668,6 +1692,74 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
self.setup_mock(m, "edit_pdf")
user = User.objects.create_user(username="no-change")
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1}],
"update_document": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_delete_original_requires_delete_permission(
self,
edit_pdf_mock,
remove_password_mock,
) -> None:
self.setup_mock(edit_pdf_mock, "edit_pdf")
self.setup_mock(remove_password_mock, "remove_password")
user = User.objects.create_user(username="no-delete")
user.user_permissions.add(
Permission.objects.get(codename="add_document"),
Permission.objects.get(codename="change_document"),
)
self.client.force_authenticate(user=user)
cases = [
(
"/api/documents/edit_pdf/",
{
"documents": [self.doc2.id],
"operations": [{"page": 1}],
"delete_original": True,
},
edit_pdf_mock,
),
(
"/api/documents/remove_password/",
{
"documents": [self.doc2.id],
"password": "secret",
"delete_original": True,
},
remove_password_mock,
),
]
for endpoint, payload, operation_mock in cases:
with self.subTest(endpoint=endpoint):
response = self.client.post(
endpoint,
json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
operation_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
def test_remove_password(self, m) -> None:
self.setup_mock(m, "remove_password")
@@ -669,6 +669,26 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
def test_update_version_requires_global_change_permission(self) -> None:
user = User.objects.create_user(username="add-only")
user.user_permissions.add(Permission.objects.get(codename="add_document"))
root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
)
self.client.force_authenticate(user=user)
with mock.patch("documents.views.consume_file") as consume_mock:
resp = self.client.post(
f"/api/documents/{root.id}/update_version/",
{"document": self._make_pdf_upload()},
format="multipart",
)
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
consume_mock.apply_async.assert_not_called()
def test_update_version_returns_404_for_missing_document(self) -> None:
resp = self.client.post(
"/api/documents/9999/update_version/",
@@ -131,6 +131,10 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.client.get("/api/saved_views/").status_code,
status.HTTP_403_FORBIDDEN,
)
self.assertEqual(
self.client.get("/api/search/autocomplete/?term=test").status_code,
status.HTTP_403_FORBIDDEN,
)
def test_api_sufficient_permissions(self) -> None:
user = User.objects.create_user(username="test")
@@ -564,6 +568,30 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.assertNotIn("user_can_change", results[0])
self.assertNotIn("is_shared_by_requester", results[0])
def test_superuser_user_can_change_without_explicit_grant(self) -> None:
"""
A superuser has implicit change access to every document, even one
owned by someone else with no explicit guardian grant -- mirrors
guardian's own ObjectPermissionChecker.has_perm() superuser shortcut.
"""
superuser = User.objects.create_superuser(username="admin")
other_user = User.objects.create_user(username="user2")
Document.objects.create(
title="Test",
content="content",
checksum="1",
owner=other_user,
)
self.client.force_authenticate(superuser)
response = self.client.get("/api/documents/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.json()["results"]
self.assertEqual(len(results), 1)
self.assertTrue(results[0]["user_can_change"])
@mock.patch("allauth.mfa.adapter.DefaultMFAAdapter.is_mfa_enabled")
def test_basic_auth_mfa_enabled(self, mock_is_mfa_enabled) -> None:
"""
+25
View File
@@ -832,6 +832,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"""
u1 = User.objects.create_user("user1")
u2 = User.objects.create_user("user2")
u1.user_permissions.add(Permission.objects.get(codename="view_document"))
self.client.force_authenticate(user=u1)
@@ -878,6 +879,30 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
def test_search_autocomplete_group_revocation_is_immediate(self) -> None:
user = User.objects.create_user("group-user")
owner = User.objects.create_user("document-owner")
group = Group.objects.create(name="temporary-viewers")
user.user_permissions.add(Permission.objects.get(codename="view_document"))
user.groups.add(group)
document = Document.objects.create(
title="private",
content="canarysecretautocomplete",
checksum="group-revocation",
owner=owner,
)
assign_perm("view_document", group, document)
get_backend().add_or_update(document)
self.client.force_authenticate(user=user)
response = self.client.get("/api/search/autocomplete/?term=canarysecret")
self.assertEqual(response.data, ["canarysecretautocomplete"])
user.groups.remove(group)
response = self.client.get("/api/search/autocomplete/?term=canarysecret")
self.assertEqual(response.data, [])
def test_search_autocomplete_field_name_match(self) -> None:
"""
GIVEN:
+29 -13
View File
@@ -431,14 +431,12 @@ class BulkPermissionMixin:
This avoid fetching permissions object by object in database.
"""
context = super().get_serializer_context()
try:
full_perms = get_boolean(
str(self.request.query_params.get("full_perms", "false")),
)
except ValueError:
full_perms = False
if not full_perms:
if getattr(self, "action", None) != "list":
# Batching only pays off across a page of objects; for single-object
# actions (retrieve, update, ...) the per-object fallback in
# get_user_can_change()/_get_perms() is cheap and avoids scanning
# the whole queryset here.
return context
# Check which objects are being paginated
@@ -943,6 +941,7 @@ class EmailDocumentDetailSchema(EmailSerializer):
),
)
class DocumentViewSet(
BulkPermissionMixin,
PassUserMixin,
RetrieveModelMixin,
UpdateModelMixin,
@@ -1947,10 +1946,13 @@ class DocumentViewSet(
"root_document",
).get(pk=pk)
root_doc = get_root_document(request_doc)
if request.user is not None and not has_perms_owner_aware(
request.user,
"change_document",
root_doc,
if request.user is not None and (
not request.user.has_perm("documents.change_document")
or not has_perms_owner_aware(
request.user,
"change_document",
root_doc,
)
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
@@ -2288,6 +2290,15 @@ class UnifiedSearchViewSet(DocumentViewSet):
return SearchResultSerializer
return DocumentSerializer
def get_serializer_context(self):
if self._is_search_request():
# BulkPermissionMixin.get_serializer_context() (inherited via
# DocumentViewSet) assumes it's batching permissions for a page of
# real Document instances. Tantivy search results are SearchHit/
# dict-like objects instead, so skip straight past it here.
return super(BulkPermissionMixin, self).get_serializer_context()
return super().get_serializer_context()
def _get_active_search_params(self, request: Request | None = None) -> list[str]:
request = request or self.request
return [
@@ -2756,7 +2767,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
)
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
):
has_perms = user_is_owner_of_all_documents
has_perms = has_perms and user_is_owner_of_all_documents
# check global add permissions for methods that create documents
if (
@@ -2781,6 +2792,11 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and parameters.get("delete_original")
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.delete_document")
):
@@ -3381,7 +3397,7 @@ class SelectionDataView(GenericAPIView[Any]):
),
)
class SearchAutoCompleteView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated,)
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
def get(self, request, format=None):
user = self.request.user if hasattr(self.request, "user") else None
+10 -1
View File
@@ -58,6 +58,12 @@ _SUPPORTED_MIME_TYPES: dict[str, str] = {
"message/rfc822": ".eml",
}
# Bleach's email-address linkifier uses a superlinear regular expression. Keep
# email linkification for ordinary headers and short messages, but never run it
# over an unbounded attacker-controlled field. URL linkification remains enabled
# for longer text.
_MAX_EMAIL_LINKIFY_LENGTH = 2048
class MailDocumentParser:
"""Parse .eml email files for Paperless-ngx.
@@ -627,7 +633,10 @@ class MailDocumentParser:
text = str(text)
text = escape(text)
text = clean(text)
text = linkify(text, parse_email=True)
text = linkify(
text,
parse_email="@" in text and len(text) <= _MAX_EMAIL_LINKIFY_LENGTH,
)
text = text.replace("\n", "<br>")
return text
@@ -700,6 +700,34 @@ class TestParser:
assert expected_html == actual_html
def test_mail_to_html_bounds_email_linkification(
self,
mail_parser: MailDocumentParser,
) -> None:
mail = mock.Mock(
subject="sender@example.com",
from_values=None,
to_values=[],
cc_values=[],
bcc_values=[],
attachments=[],
date=timezone.now(),
text=("a." * 1500) + "@example.com",
)
with mock.patch(
"paperless.parsers.mail.linkify",
side_effect=lambda text, **kwargs: text,
) as mock_linkify:
mail_parser.mail_to_html(mail)
parse_email_by_text = {
call.args[0]: call.kwargs["parse_email"]
for call in mock_linkify.call_args_list
}
assert parse_email_by_text["sender@example.com"] is True
assert parse_email_by_text[mail.text] is False
def test_generate_pdf_from_mail(
self,
httpx_mock: HTTPXMock,