Compare commits

..
Author SHA1 Message Date
stumpylog b3e0cf0e21 chore(profiling): confirm Stage 2 loop-resolution reduces query count and wall time at scale 2026-07-28 16:36:44 -07:00
stumpylog 5b63a7bc41 chore(profiling): record Stage 1 after-profile, confirm speedup and no query-count regression 2026-07-27 21:20:55 -07:00
stumpylog fb99b3defb profiling(stage1): re-capture baseline with realistic document ownership
Guardian permission subplans now actually execute (previously
'never executed' due to unowned seed documents short-circuiting the
owner_id IS NULL branch). best_seconds rose from 0.0251s to 31.4648s,
confirming the varchar-cast nested-loop-semi-join pathology is real
and now measured under realistic conditions.
2026-07-27 19:02:30 -07:00
stumpylog e80ede4cb0 fix(profiling): assign realistic document ownership so seed data exercises the guardian permission path 2026-07-27 17:25:48 -07:00
stumpylog e0902c63d6 chore(profiling): record Stage 1 baseline for get_objects_for_user_owner_aware 2026-07-27 16:03:15 -07:00
stumpylogandClaude Sonnet 5 0506dcad05 chore(profiling): add append_profiling_history function for persistent profiling records
Adds two new functions to harness.py:
- _current_git_ref(): Gets the current git short SHA
- append_profiling_history(): Appends JSON line to profiling/results/history.jsonl

This enables profiling numbers to persist across sessions/dates, with every run
appended to an immutable timeline rather than overwriting per-stage snapshots.

Also adds corresponding test to verify the function writes valid JSON lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:49:14 -07:00
stumpylog 91647eecb5 chore(profiling): add throwaway-Postgres helper and typed profiling harness
Adds standalone profiling tooling (never merged into dev/main): a
persistent, self-healing Postgres 18 container helper
(run_with_postgres.sh/stop_postgres.sh), a scale-profile dataset seeder
(seed.py) whose document counts and guardian permission-row ratios
mirror real bug reports (#13276, #13161), and a typed profiling harness
(harness.py) for timing/query-count comparisons and EXPLAIN ANALYZE
capture, gated by require_postgres() so it never silently runs on
SQLite.

seed.py samples distinct (subject, document) pairs up front rather than
drawing with replacement, since guardian's assign_perm() dedupes on the
(subject, object, permission) triple -- with-replacement sampling
against a small group pool collides heavily (birthday paradox) and
undercounts the target permission-row ratios otherwise.
2026-07-27 15:08:23 -07:00
20 changed files with 905 additions and 461 deletions
View File
+123
View File
@@ -0,0 +1,123 @@
# profiling/harness.py
from __future__ import annotations
import json
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Generic
from typing import TypeVar
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
if TYPE_CHECKING:
from collections.abc import Callable
from django.db.models import QuerySet
from profiling.seed import ScaleProfile
T = TypeVar("T")
def require_postgres() -> None:
if connection.vendor != "postgresql":
pytest.skip(
"Profiling requires PostgreSQL to reflect production query "
"planning; SQLite does not reproduce the varchar-cast planner "
"pathology this work fixes.",
)
@dataclass(frozen=True, slots=True)
class ProfileResult(Generic[T]):
best_seconds: float
all_seconds: tuple[float, ...]
query_count: int
result: T
def run_profile(fn: Callable[[], T], *, repeat: int = 5) -> ProfileResult[T]:
require_postgres()
all_seconds: list[float] = []
result: T | None = None
query_count = 0
for i in range(repeat):
with CaptureQueriesContext(connection) as ctx:
start = time.perf_counter()
result = fn()
all_seconds.append(time.perf_counter() - start)
if i == repeat - 1:
query_count = len(ctx.captured_queries)
assert result is not None # repeat >= 1 guarantees at least one assignment
return ProfileResult(
best_seconds=min(all_seconds),
all_seconds=tuple(all_seconds),
query_count=query_count,
result=result,
)
def capture_explain_analyze(queryset: QuerySet[Any]) -> str:
if connection.vendor != "postgresql":
raise RuntimeError("EXPLAIN ANALYZE capture is Postgres-only")
sql, params = queryset.query.sql_with_params()
with connection.cursor() as cur:
cur.execute(f"EXPLAIN ANALYZE {sql}", params)
return "\n".join(row[0] for row in cur.fetchall())
def _current_git_ref() -> str:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() or "unknown"
def append_profiling_history(
*,
stage: str,
scenario: str,
best_seconds: float,
query_count: int,
scale: ScaleProfile,
code_ref: str | None = None,
) -> None:
"""
Append one line to profiling/results/history.jsonl -- an append-only,
cross-session record of every profiling run. Unlike the per-stage
before/after snapshot JSON files (which the next run of the same stage
overwrites), this never gets clobbered, so a profiling effort picked
back up days later has a full timeline to resume from instead of only
the single most recent comparison.
``code_ref`` should identify the *production* code under test (a short
SHA on the feature branch, not this `profiling` branch) -- pass it
explicitly when known. Left as ``None``, it falls back to this repo's
current ``git rev-parse --short HEAD``, which is only meaningful if
you're actually running from a checkout where that reflects the code
being measured.
"""
results_dir = Path(__file__).parent / "results"
results_dir.mkdir(exist_ok=True)
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"code_ref": code_ref or _current_git_ref(),
"stage": stage,
"scenario": scenario,
"scale": scale,
"best_seconds": best_seconds,
"query_count": query_count,
}
with (results_dir / "history.jsonl").open("a") as f:
f.write(json.dumps(entry) + "\n")
+18
View File
@@ -0,0 +1,18 @@
[tool.pytest]
ini_options.pythonpath = [ "../src", "..", "." ]
ini_options.markers = [
"""\
profiling: Profiling scripts comparing old vs new query patterns; require a throwaway PostgreSQL container, not run \
as part of the normal test suite\
""",
]
ini_options.DJANGO_SETTINGS_MODULE = "paperless.settings"
[tool.pytest_env]
# Standalone config: this directory must be independently runnable without
# the rest of the dev stack (Redis, etc) present -- only Postgres is required,
# via run_with_postgres.sh.
PAPERLESS_SECRET_KEY = "profiling-only-insecure-key"
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
+8
View File
@@ -0,0 +1,8 @@
{"timestamp": "2026-07-27T22:47:46.677504+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T22:49:03.762638+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T23:01:49.777269+00:00", "code_ref": "0506dcad0", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 0.025123266968876123, "query_count": 1}
{"timestamp": "2026-07-28T01:38:32.706519+00:00", "code_ref": "e80ede4cb", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.46481539506931, "query_count": 1}
{"timestamp": "2026-07-28T03:53:46.770669+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.671881378046237, "query_count": 1}
{"timestamp": "2026-07-28T04:02:41.942168+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "permitted_document_ids_after_stage1", "scale": "medium", "best_seconds": 0.07956207206007093, "query_count": 1}
{"timestamp": "2026-07-28T23:30:28.602501+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_old", "scale": "medium", "best_seconds": 3.4628250940004364, "query_count": 692}
{"timestamp": "2026-07-28T23:30:28.606987+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_new", "scale": "medium", "best_seconds": 0.10436739504802972, "query_count": 1}
+9
View File
@@ -0,0 +1,9 @@
{
"permitted_document_ids": {
"best_seconds": 0.07956207206007093,
"query_count": 1
},
"baseline_best_seconds": 31.671881378046237,
"baseline_query_count": 1,
"speedup_x": 398.07763370131113
}
@@ -0,0 +1,58 @@
Index Scan Backward using documents_document_created_bedd0818 on documents_document v0 (cost=91.31..99.33 rows=1 width=2900) (actual time=68.340..77.517 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 12) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1))))
Rows Removed by Filter: 7976
Index Searches: 1
Buffers: shared hit=74978
SubPlan 1
-> Unique (cost=91.04..91.05 rows=2 width=4) (actual time=64.698..66.622 rows=9136.00 loops=1)
Buffers: shared hit=73487
-> Sort (cost=91.04..91.05 rows=2 width=4) (actual time=64.697..65.202 rows=9262.00 loops=1)
Sort Key: ((u0.object_pk)::integer)
Sort Method: quicksort Memory: 385kB
Buffers: shared hit=73487
-> Append (cost=0.27..91.03 rows=2 width=4) (actual time=0.026..63.201 rows=9262.00 loops=1)
Buffers: shared hit=73487
-> Nested Loop (cost=0.27..12.79 rows=1 width=4) (actual time=0.026..1.834 rows=237.00 loops=1)
Join Filter: (u0.permission_id = u1.id)
Buffers: shared hit=696
-> Index Only Scan using guardian_userobjectpermi_user_id_permission_id_ob_b0b3d2fc_uniq on guardian_userobjectpermission u0 (cost=0.27..8.29 rows=1 width=520) (actual time=0.012..0.131 rows=237.00 loops=1)
Index Cond: (user_id = 12)
Heap Fetches: 237
Index Searches: 1
Buffers: shared hit=222
-> Seq Scan on auth_permission u1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Nested Loop (cost=4.73..78.23 rows=1 width=4) (actual time=1.119..60.528 rows=9025.00 loops=1)
Buffers: shared hit=72791
-> Nested Loop (cost=4.59..78.05 rows=1 width=524) (actual time=1.115..50.521 rows=9025.00 loops=1)
Buffers: shared hit=54741
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.109..13.226 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission u4 (cost=0.00..4.49 rows=1 width=4) (actual time=0.008..0.017 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission u0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.100..8.095 rows=45387.00 loops=1)
Recheck Cond: (permission_id = u4.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.052..1.053 rows=45387.00 loops=1)
Index Cond: (permission_id = u4.id)
Index Searches: 1
Buffers: shared hit=37
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u2 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 12) AND (group_id = u0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Index Only Scan using auth_group_pkey on auth_group u1_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=9025)
Index Cond: (id = u0_1.group_id)
Heap Fetches: 9025
Index Searches: 9025
Buffers: shared hit=18050
Planning:
Buffers: shared hit=2
Planning Time: 1.089 ms
Execution Time: 78.020 ms
+6
View File
@@ -0,0 +1,6 @@
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": 31.671881378046237,
"query_count": 1
}
}
@@ -0,0 +1,68 @@
Sort (cost=3790.80..3790.82 rows=9 width=2900) (actual time=39120.166..39120.831 rows=12024.00 loops=1)
Sort Key: documents_document.created DESC
Sort Method: quicksort Memory: 3230kB
Buffers: shared hit=8598270
-> Seq Scan on documents_document (cost=2550.72..3790.65 rows=9 width=2900) (actual time=0.009..39114.720 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 2) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1)) OR (ANY (id = (hashed SubPlan 2).col1))))
Rows Removed by Filter: 7976
Buffers: shared hit=8598270
SubPlan 1
-> Nested Loop Semi Join (cost=0.00..1245.63 rows=1 width=8) (actual time=4.254..969.033 rows=237.00 loops=1)
Join Filter: ((((v0.object_pk)::bigint)::character varying)::text = (((u0.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 2539276
Buffers: shared hit=214835
-> Nested Loop (cost=0.00..23.30 rows=1 width=516) (actual time=0.031..2.425 rows=237.00 loops=1)
Join Filter: (v0.permission_id = v2.id)
Buffers: shared hit=490
-> Seq Scan on guardian_userobjectpermission v0 (cost=0.00..18.80 rows=1 width=520) (actual time=0.021..0.424 rows=237.00 loops=1)
Filter: (user_id = 2)
Rows Removed by Filter: 2120
Buffers: shared hit=16
-> Seq Scan on auth_permission v2 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: ((content_type_id = 17) AND ((codename)::text = 'view_document'::text))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Seq Scan on documents_document u0 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.917 rows=10715.24 loops=237)
Filter: (deleted_at IS NULL)
Buffers: shared hit=214345
SubPlan 2
-> Nested Loop Semi Join (cost=4.73..1305.09 rows=1 width=8) (actual time=6.729..38127.912 rows=9025.00 loops=1)
Join Filter: ((((v0_1.object_pk)::bigint)::character varying)::text = (((u0_2.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 98955406
Buffers: shared hit=8382237
-> Nested Loop Semi Join (cost=4.73..82.77 rows=1 width=516) (actual time=1.109..137.661 rows=9025.00 loops=1)
Buffers: shared hit=145515
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.090..18.002 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission v2_1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.011..0.023 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission v0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.077..10.777 rows=45387.00 loops=1)
Recheck Cond: (permission_id = v2_1.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.026..1.026 rows=45387.00 loops=1)
Index Cond: (permission_id = v2_1.id)
Index Searches: 1
Buffers: shared hit=37
-> Nested Loop (cost=0.30..0.37 rows=1 width=8) (actual time=0.002..0.002 rows=0.20 loops=45387)
Join Filter: (u0_1.id = u1.group_id)
Buffers: shared hit=145186
-> Index Only Scan using auth_group_pkey on auth_group u0_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=45387)
Index Cond: (id = v0_1.group_id)
Heap Fetches: 45387
Index Searches: 45387
Buffers: shared hit=90774
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u1 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 2) AND (group_id = v0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Seq Scan on documents_document u0_2 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.956 rows=10965.59 loops=9025)
Filter: (deleted_at IS NULL)
Buffers: shared hit=8236722
Planning:
Buffers: shared hit=3
Planning Time: 1.119 ms
Execution Time: 39121.312 ms
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# profiling/run_with_postgres.sh
#
# Starts a PostgreSQL 18 container (matching
# docker/compose/docker-compose.postgres.yml's image) if one isn't already
# running, waits for it to accept connections, force-terminates any
# leftover connections from a previous run that crashed or was killed, then
# runs the given command against it with the right PAPERLESS_DB* env vars
# set. The container is intentionally left running afterward -- see
# stop_postgres.sh to tear it down explicitly once you're done profiling
# for the session.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
HOST_PORT="55432"
if ! docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker run --rm -d \
--name "$CONTAINER_NAME" \
-e POSTGRES_DB=paperless \
-e POSTGRES_USER=paperless \
-e POSTGRES_PASSWORD=paperless \
-p "${HOST_PORT}:5432" \
docker.io/library/postgres:18 >/dev/null
echo "Started profiling Postgres container '$CONTAINER_NAME' on port $HOST_PORT (left running -- see profiling/stop_postgres.sh)." >&2
fi
until docker exec "$CONTAINER_NAME" pg_isready -U paperless >/dev/null 2>&1; do
sleep 1
done
# Reap any connections left over from a previous run that crashed, was
# killed, or timed out mid-query -- without this, a persistent container
# can accumulate stuck backends that block later test-database
# create/drop operations indefinitely.
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname IN ('paperless', 'test_paperless')
AND pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
PAPERLESS_DBHOST=localhost \
PAPERLESS_DBNAME=paperless \
PAPERLESS_DBUSER=paperless \
PAPERLESS_DBPASS=paperless \
PAPERLESS_DBPORT="${HOST_PORT}" \
"$@"
+199
View File
@@ -0,0 +1,199 @@
# profiling/seed.py
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Literal
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from documents.models import Document
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
if TYPE_CHECKING:
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
ScaleProfile = Literal["medium", "large"]
# Fraction of seeded documents given a real owner; the remainder stays
# owner=None, mirroring a real install's mix of owned and legacy/imported
# unowned documents.
_OWNED_FRACTION = 0.9
class _ScaleCounts:
__slots__ = (
"correspondents",
"document_types",
"documents",
"groups",
"storage_paths",
"tags",
"users",
)
def __init__(
self,
*,
documents: int,
tags: int,
correspondents: int,
document_types: int,
storage_paths: int,
users: int,
groups: int,
) -> None:
self.documents = documents
self.tags = tags
self.correspondents = correspondents
self.document_types = document_types
self.storage_paths = storage_paths
self.users = users
self.groups = groups
_SCALE_PROFILES: dict[ScaleProfile, _ScaleCounts] = {
"medium": _ScaleCounts(
documents=20_000,
tags=100,
correspondents=300,
document_types=50,
storage_paths=20,
users=10,
groups=5,
),
"large": _ScaleCounts(
documents=360_000,
tags=1_000,
correspondents=5_000,
document_types=300,
storage_paths=50,
users=25,
groups=10,
),
}
# Ratios measured from a real install (discussion #13276): 1,414 user-perm
# rows / 27,232 group-perm rows over 12,000 documents.
_USER_PERM_ROWS_PER_DOC = 1_414 / 12_000
_GROUP_PERM_ROWS_PER_DOC = 27_232 / 12_000
def _sample_distinct_pairs(
rng: random.Random,
n_subjects: int,
n_objects: int,
count: int,
) -> set[tuple[int, int]]:
"""
Rejection-sample `count` distinct (subject_index, object_index) pairs.
`count` is always well under `n_subjects * n_objects` for every scale
profile, so this terminates quickly.
"""
if count > n_subjects * n_objects:
msg = (
f"cannot sample {count} distinct pairs from only "
f"{n_subjects * n_objects} possible (subject, object) slots"
)
raise ValueError(msg)
pairs: set[tuple[int, int]] = set()
while len(pairs) < count:
pairs.add((rng.randrange(n_subjects), rng.randrange(n_objects)))
return pairs
@dataclass(frozen=True, slots=True)
class SeededData:
users: tuple[User, ...]
groups: tuple[Group, ...]
documents: tuple[Document, ...]
tags: tuple[Tag, ...]
correspondents: tuple[Correspondent, ...]
document_types: tuple[DocumentType, ...]
storage_paths: tuple[StoragePath, ...]
def seed_permission_dataset(
scale: ScaleProfile = "medium",
*,
seed: int = 1337,
) -> SeededData:
"""
Build a dataset whose document count and guardian-permission-row ratios
mirror real bug reports, so profiling results are representative of
actual large installs rather than an arbitrary small fixture.
"""
counts = _SCALE_PROFILES[scale]
rng = random.Random(seed)
users = tuple(
User.objects.create_user(username=f"profile_user_{i}")
for i in range(counts.users)
)
groups = tuple(
Group.objects.create(name=f"profile_group_{i}") for i in range(counts.groups)
)
for user in users:
user.groups.add(rng.choice(groups))
documents = list(DocumentFactory.create_batch(counts.documents))
# Assign realistic ownership -- without this, every document stays
# owner=None and the "owned or unowned" visibility clause is trivially
# true for every row, so no guardian permission check ever needs to run
# (see the amendment note above this code block).
owned_documents = []
for document in documents:
if rng.random() < _OWNED_FRACTION:
document.owner = rng.choice(users)
owned_documents.append(document)
Document.objects.bulk_update(owned_documents, ["owner"], batch_size=2_000)
documents = tuple(documents)
tags = tuple(TagFactory.create_batch(counts.tags))
correspondents = tuple(CorrespondentFactory.create_batch(counts.correspondents))
document_types = tuple(DocumentTypeFactory.create_batch(counts.document_types))
storage_paths = tuple(StoragePathFactory.create_batch(counts.storage_paths))
n_user_perms = round(counts.documents * _USER_PERM_ROWS_PER_DOC)
n_group_perms = round(counts.documents * _GROUP_PERM_ROWS_PER_DOC)
# Grant permissions only on OWNED documents -- a grant on an already-
# unowned document is a no-op for visibility (unowned documents are
# visible to everyone regardless), so restricting the pool here is what
# makes each assign_perm() call actually matter for someone, and is
# what exercises the guardian-permission-join code path this profiling
# effort exists to measure.
for user_idx, owned_idx in _sample_distinct_pairs(
rng,
len(users),
len(owned_documents),
n_user_perms,
):
assign_perm("view_document", users[user_idx], owned_documents[owned_idx])
for group_idx, owned_idx in _sample_distinct_pairs(
rng,
len(groups),
len(owned_documents),
n_group_perms,
):
assign_perm("view_document", groups[group_idx], owned_documents[owned_idx])
return SeededData(
users=users,
groups=groups,
documents=documents,
tags=tags,
correspondents=correspondents,
document_types=document_types,
storage_paths=storage_paths,
)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# profiling/stop_postgres.sh
#
# Explicit, manual teardown of the profiling Postgres container. Not called
# automatically by run_with_postgres.sh -- run this yourself once you're
# done with a profiling session, so a seeded `large`-scale dataset can be
# reused across several script runs first. Terminates any active backends
# before stopping, so a stuck query can't stall `docker stop` waiting for
# a graceful Postgres shutdown that never comes.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
docker stop "$CONTAINER_NAME" >/dev/null
echo "Stopped and removed profiling Postgres container."
else
echo "No profiling Postgres container was running."
fi
+74
View File
@@ -0,0 +1,74 @@
# profiling/test_harness_self_check.py
from __future__ import annotations
import json
from pathlib import Path
import pytest
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_seed_medium_scale_produces_expected_counts() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
assert len(data.documents) == 20_000
assert len(data.tags) == 100
assert len(data.correspondents) == 300
assert len(data.document_types) == 50
assert len(data.storage_paths) == 20
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
user_perm_count = UserObjectPermission.objects.count()
group_perm_count = GroupObjectPermission.objects.count()
# within 5% of the ratio-derived target -- exact counts depend on random
# sampling without replacement, see seed.py
assert 2_242 <= user_perm_count <= 2_478
assert 43_111 <= group_perm_count <= 47_649
@pytest.mark.profiling
@pytest.mark.django_db
def test_run_profile_reports_query_count_and_timing() -> None:
require_postgres()
def trivial() -> list[int]:
from documents.models import Document
return list(Document.objects.values_list("id", flat=True)[:1])
result = run_profile(trivial, repeat=3)
assert result.best_seconds >= 0
assert len(result.all_seconds) == 3
assert result.query_count >= 1
def test_append_profiling_history_writes_a_valid_json_line() -> None:
history_path = Path(__file__).parent / "results" / "history.jsonl"
before_lines = (
history_path.read_text().splitlines() if history_path.exists() else []
)
append_profiling_history(
stage="self-check",
scenario="test_append_profiling_history_writes_a_valid_json_line",
best_seconds=0.001,
query_count=0,
scale="medium",
code_ref="test-run",
)
after_lines = history_path.read_text().splitlines()
assert len(after_lines) == len(before_lines) + 1
entry = json.loads(after_lines[-1])
assert entry["stage"] == "self-check"
assert entry["code_ref"] == "test-run"
assert entry["scale"] == "medium"
assert "timestamp" in entry
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from documents.models import Document
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import capture_explain_analyze
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
RESULTS_DIR = Path(__file__).parent / "results"
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_get_objects_for_user_owner_aware_document_baseline() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call() -> list[int]:
return list(
get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
).values_list("id", flat=True),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
get_objects_for_user_owner_aware(user, "documents.view_document", Document),
)
RESULTS_DIR.mkdir(exist_ok=True)
(RESULTS_DIR / "stage1_baseline.json").write_text(
json.dumps(
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
},
indent=2,
),
)
(RESULTS_DIR / "stage1_baseline_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="get_objects_for_user_owner_aware_document_baseline",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBASELINE best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
print(plan) # noqa: T201
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_permitted_document_ids_after_stage1() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call():
return list(
Document.objects.filter(id__in=permitted_document_ids(user)).values_list(
"id",
flat=True,
),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
Document.objects.filter(id__in=permitted_document_ids(user)),
)
baseline = json.loads((RESULTS_DIR / "stage1_baseline.json").read_text())
baseline_seconds = baseline["get_objects_for_user_owner_aware_document"][
"best_seconds"
]
baseline_queries = baseline["get_objects_for_user_owner_aware_document"][
"query_count"
]
(RESULTS_DIR / "stage1_after.json").write_text(
json.dumps(
{
"permitted_document_ids": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
"baseline_best_seconds": baseline_seconds,
"baseline_query_count": baseline_queries,
"speedup_x": baseline_seconds / profile.best_seconds
if profile.best_seconds
else None,
},
indent=2,
),
)
(RESULTS_DIR / "stage1_after_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="permitted_document_ids_after_stage1",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBEFORE best={baseline_seconds:.4f}s queries={baseline_queries}") # noqa: T201
print(f"AFTER best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
# Hard gate: must not be a regression, and should be meaningfully faster
# at this scale (the whole point of this stage).
assert profile.best_seconds < baseline_seconds
assert profile.query_count <= baseline_queries
+64
View File
@@ -0,0 +1,64 @@
# profiling/test_stage2_document_loops.py
from __future__ import annotations
import pytest
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_per_row_checker_vs_resolved_set_for_bulk_operation() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
# simulate a bulk operation over 500 documents the user can actually see
# (bulk-edit/share/trash all operate on a user's own selection) -- built
# from confirmed-visible documents so all() can't short-circuit unfairly
# in either old_style or new_style, see amendment note above
permitted_ids = set(permitted_document_ids(user))
batch = [doc for doc in data.documents if doc.pk in permitted_ids][:500]
assert len(batch) == 500, (
f"expected at least 500 visible documents to build a fair batch, "
f"got {len(batch)} -- scale profile or seed ratios may need adjusting"
)
def old_style():
return all(has_perms_owner_aware(user, "view_document", doc) for doc in batch)
def new_style():
permitted = set(permitted_document_ids(user))
return all(doc.pk in permitted for doc in batch)
old_profile = run_profile(old_style, repeat=3)
new_profile = run_profile(new_style, repeat=3)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_old",
best_seconds=old_profile.best_seconds,
query_count=old_profile.query_count,
scale="medium",
)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_new",
best_seconds=new_profile.best_seconds,
query_count=new_profile.query_count,
scale="medium",
)
print( # noqa: T201
f"\nOLD (per-row checker): best={old_profile.best_seconds:.4f}s queries={old_profile.query_count}",
)
print( # noqa: T201
f"NEW (resolved set): best={new_profile.best_seconds:.4f}s queries={new_profile.query_count}",
)
assert new_profile.query_count < old_profile.query_count
assert new_profile.best_seconds < old_profile.best_seconds
+6 -15
View File
@@ -163,23 +163,14 @@ def set_permissions_for_object(
)
def permitted_document_ids(
user,
*,
perm: str = "view_document",
include_deleted: bool = False,
):
def permitted_document_ids(user):
"""
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 a queryset of document IDs the user may view, limited to non-deleted
documents. This intentionally avoids ``get_objects_for_user`` to keep the
subquery small and index-friendly.
"""
manager = Document.global_objects if include_deleted else Document.objects
base_docs = manager.all()
base_docs = base_docs.only("id", "owner")
base_docs = Document.objects.filter(deleted_at__isnull=True).only("id", "owner")
if user is None or not getattr(user, "is_authenticated", False):
# Just Anonymous user e.g. for drf-spectacular
@@ -190,7 +181,7 @@ def permitted_document_ids(
document_ct = ContentType.objects.get_for_model(Document)
perm_filter = {
"permission__codename": perm,
"permission__codename": "view_document",
"permission__content_type": document_ct,
}
+25 -9
View File
@@ -39,6 +39,7 @@ from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_serializer
from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_objects_for_user
from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
@@ -79,8 +80,8 @@ from documents.models import WorkflowTrigger
from documents.parsers import is_mime_type_supported
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_groups_with_only_permission
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render
@@ -864,8 +865,11 @@ def validate_documentlink_targets(user, doc_ids):
if user is None:
return
permitted_change_ids = set(permitted_document_ids(user, perm="change_document"))
if not set(doc_ids) <= permitted_change_ids:
target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner")
if not all(
has_perms_owner_aware(user, "change_document", document)
for document in target_documents
):
raise PermissionDenied(
_("Insufficient permissions."),
)
@@ -1007,8 +1011,13 @@ def _get_viewable_duplicates(
).exclude(pk=document.pk)
duplicates = duplicates.filter(root_document__isnull=True)
duplicates = duplicates.order_by("-created")
allowed_ids = permitted_document_ids(user, include_deleted=True)
return duplicates.filter(id__in=allowed_ids)
allowed = get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
include_deleted=True,
)
return duplicates.filter(id__in=allowed)
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
@@ -2650,8 +2659,13 @@ class TaskSerializerV9(serializers.ModelSerializer[PaperlessTask]):
user = request.user
qs = Document.global_objects.filter(pk=dup_of)
if not user.is_staff:
allowed_ids = permitted_document_ids(user, include_deleted=True)
qs = qs.filter(pk__in=allowed_ids)
with_perms = get_objects_for_user(
user,
"documents.view_document",
qs,
accept_global_perms=False,
)
qs = with_perms | qs.filter(owner=user) | qs.filter(owner__isnull=True)
return list(qs.values("id", "title", "deleted_at"))
@@ -3501,6 +3515,8 @@ class StoragePathTestSerializer(SerializerWithPerms):
document_field = self.fields.get("document")
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
return
document_field.queryset = Document.objects.filter(
id__in=permitted_document_ids(user),
document_field.queryset = get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
)
@@ -1,392 +0,0 @@
from __future__ import annotations
from http import HTTPStatus
from unittest.mock import patch
import pytest
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework.test import APIClient
from documents.permissions import permitted_document_ids
from documents.serialisers import _get_viewable_duplicates
from documents.tests.factories import DocumentFactory
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
actual_ids = set(actual_ids)
for doc_id in expected_visible:
assert doc_id in actual_ids, (
f"document {doc_id} should be visible but was hidden"
)
for doc_id in expected_hidden:
assert doc_id not in actual_ids, (
f"document {doc_id} leaked but should be hidden"
)
@pytest.mark.django_db
class TestPermittedDocumentIdsSecurity:
def test_owner_sees_own_document(self):
user = User.objects.create_user(username="alice")
stranger = User.objects.create_user(username="mallory")
owned = DocumentFactory(owner=user)
strangers_doc = DocumentFactory(owner=stranger)
visible = permitted_document_ids(user)
assert_visible_document_ids(
visible,
expected_visible=[owned.pk],
expected_hidden=[strangers_doc.pk],
)
def test_unowned_document_visible_to_everyone(self):
user = User.objects.create_user(username="alice")
unowned = DocumentFactory(owner=None)
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[unowned.pk],
expected_hidden=[],
)
def test_explicit_user_permission_grants_visibility(self):
grantee = User.objects.create_user(username="alice")
stranger = User.objects.create_user(username="mallory")
owner = User.objects.create_user(username="owner")
shared = DocumentFactory(owner=owner)
not_shared = DocumentFactory(owner=owner)
assign_perm("view_document", grantee, shared)
assert_visible_document_ids(
permitted_document_ids(grantee),
expected_visible=[shared.pk],
expected_hidden=[not_shared.pk],
)
assert_visible_document_ids(
permitted_document_ids(stranger),
expected_visible=[],
expected_hidden=[shared.pk, not_shared.pk],
)
def test_explicit_group_permission_grants_visibility_to_members_only(self):
owner = User.objects.create_user(username="owner")
member = User.objects.create_user(username="member")
non_member = User.objects.create_user(username="non_member")
group = Group.objects.create(name="finance")
member.groups.add(group)
shared = DocumentFactory(owner=owner)
assign_perm("view_document", group, shared)
assert_visible_document_ids(
permitted_document_ids(member),
expected_visible=[shared.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_document_ids(non_member),
expected_visible=[],
expected_hidden=[shared.pk],
)
def test_soft_deleted_document_excluded_by_default(self):
owner = User.objects.create_user(username="owner")
doc = DocumentFactory(owner=owner)
doc.delete() # soft delete
assert_visible_document_ids(
permitted_document_ids(owner),
expected_visible=[],
expected_hidden=[doc.pk],
)
def test_superuser_sees_everything_including_no_perm_documents(self):
superuser = User.objects.create_superuser(username="root")
owner = User.objects.create_user(username="owner")
doc = DocumentFactory(owner=owner)
assert_visible_document_ids(
permitted_document_ids(superuser),
expected_visible=[doc.pk],
expected_hidden=[],
)
def test_anonymous_user_sees_only_unowned_documents(self):
owner = User.objects.create_user(username="owner")
owned = DocumentFactory(owner=owner)
unowned = DocumentFactory(owner=None)
assert_visible_document_ids(
permitted_document_ids(AnonymousUser()),
expected_visible=[unowned.pk],
expected_hidden=[owned.pk],
)
@pytest.mark.django_db
class TestPermittedDocumentIdsIncludeDeleted:
def test_include_deleted_true_reveals_soft_deleted_owned_document(self):
owner = User.objects.create_user(username="owner")
doc = DocumentFactory(owner=owner)
doc.delete()
assert_visible_document_ids(
permitted_document_ids(owner, include_deleted=True),
expected_visible=[doc.pk],
expected_hidden=[],
)
def test_include_deleted_true_still_respects_permission_boundary(self):
owner = User.objects.create_user(username="owner")
stranger = User.objects.create_user(username="mallory")
doc = DocumentFactory(owner=owner)
doc.delete()
assert_visible_document_ids(
permitted_document_ids(stranger, include_deleted=True),
expected_visible=[],
expected_hidden=[doc.pk],
)
@pytest.mark.django_db
class TestAiChatAllDocumentsPermissionBoundary:
"""
Regression test pinning the "ask across all documents" AI chat behavior
(ChatStreamingView.post, no document_id) to the same owner/permission
boundary enforced by permitted_document_ids(). This call site was
migrated from get_objects_for_user_owner_aware() to
permitted_document_ids() in Task 5; this test must stay green across
that swap.
"""
ENDPOINT = "/api/documents/chat/"
@override_settings(AI_ENABLED=True)
@patch("documents.views.stream_chat_with_documents")
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
mock_stream_chat.return_value = iter([b"data"])
owner = User.objects.create_user(username="owner")
asker = User.objects.create_user(username="asker")
asker.user_permissions.add(
*Permission.objects.filter(codename="view_document"),
)
shared = DocumentFactory(owner=owner)
not_shared = DocumentFactory(owner=owner)
assign_perm("view_document", asker, shared)
client = APIClient()
client.force_authenticate(user=asker)
response = client.post(
self.ENDPOINT,
data={"q": "question"},
format="json",
)
assert response.status_code == HTTPStatus.OK
mock_stream_chat.assert_called_once()
_, kwargs = mock_stream_chat.call_args
visible_ids = {doc.pk for doc in kwargs["documents"]}
assert shared.pk in visible_ids
assert not_shared.pk not in visible_ids
@pytest.mark.django_db
class TestDuplicateDocumentsPermissionBoundary:
def test_get_viewable_duplicates_includes_soft_deleted_but_respects_perms(self):
owner = User.objects.create_user(username="owner")
stranger = User.objects.create_user(username="mallory")
original = DocumentFactory(owner=owner, checksum="dupe-checksum")
dup_visible = DocumentFactory(owner=owner, checksum="dupe-checksum")
dup_hidden = DocumentFactory(owner=owner, checksum="dupe-checksum")
dup_hidden.delete() # soft delete, should still be found (include_deleted=True)
assign_perm("view_document", stranger, dup_visible)
result_owner = _get_viewable_duplicates(original, owner)
assert {d.pk for d in result_owner} == {dup_visible.pk, dup_hidden.pk}
result_stranger = _get_viewable_duplicates(original, stranger)
assert {d.pk for d in result_stranger} == {dup_visible.pk}
@pytest.mark.django_db
class TestPermittedDocumentIdsArbitraryPermission:
def test_change_document_permission_is_distinct_from_view(self):
owner = User.objects.create_user(username="owner")
viewer_only = User.objects.create_user(username="viewer")
editor = User.objects.create_user(username="editor")
doc = DocumentFactory(owner=owner)
assign_perm("view_document", viewer_only, doc)
assign_perm("change_document", editor, doc)
assign_perm("view_document", editor, doc)
assert_visible_document_ids(
permitted_document_ids(editor, perm="change_document"),
expected_visible=[doc.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_document_ids(viewer_only, perm="change_document"),
expected_visible=[],
expected_hidden=[doc.pk],
)
def test_delete_permission_with_include_deleted_for_trash_restore(self):
owner = User.objects.create_user(username="owner")
stranger = User.objects.create_user(username="mallory")
doc = DocumentFactory(owner=owner)
doc.delete()
assert_visible_document_ids(
permitted_document_ids(owner, perm="delete_document", include_deleted=True),
expected_visible=[doc.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_document_ids(
stranger,
perm="delete_document",
include_deleted=True,
),
expected_visible=[],
expected_hidden=[doc.pk],
)
@pytest.mark.django_db
class TestEmailDocumentPermissionBoundary:
def test_email_action_rejects_document_without_view_permission(
self,
rest_api_client,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
hidden = DocumentFactory(owner=owner)
response = rest_api_client.post(
"/api/documents/email/",
{
"documents": [hidden.pk],
"addresses": "someone@example.com",
"subject": "test",
"message": "test",
},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.django_db
class TestBulkEditChangePermissionBoundary:
def test_bulk_edit_rejects_document_without_change_permission(
self,
rest_api_client,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
# grant the global change_document permission so the object-level
# check (not the global has_perm check) is what's under test
requester.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
rest_api_client.force_authenticate(user=requester)
assign_perm(
"view_document",
requester,
DocumentFactory(owner=owner),
) # unrelated grant
target = DocumentFactory(owner=owner)
assign_perm("view_document", requester, target) # view only, NOT change
response = rest_api_client.post(
"/api/documents/bulk_edit/",
{
"documents": [target.pk],
"method": "modify_tags",
"parameters": {"add_tags": [], "remove_tags": []},
},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.django_db
class TestBulkDownloadPermissionChecksRootDocument:
def test_permission_checked_on_root_not_on_version(
self,
rest_api_client,
paperless_dirs,
_media_settings,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
root = DocumentFactory(owner=owner)
# a version of root that the requester has NOT been individually granted
version = DocumentFactory(owner=owner, root_document=root, version_index=1)
version.source_path.write_bytes(b"%PDF-1.4 test")
assign_perm("view_document", requester, root) # granted on ROOT only
response = rest_api_client.post(
"/api/documents/bulk_download/",
{"documents": [version.pk]},
format="json",
)
assert (
response.status_code == HTTPStatus.OK
) # visible because root is permitted
stranger = User.objects.create_user(username="mallory")
rest_api_client.force_authenticate(user=stranger)
response = rest_api_client.post(
"/api/documents/bulk_download/",
{"documents": [version.pk]},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.django_db
class TestTrashRestorePermissionBoundary:
def test_restore_rejects_document_without_delete_permission(
self,
rest_api_client,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner)
assign_perm("view_document", requester, doc) # view only, NOT delete
doc.delete()
response = rest_api_client.post(
"/api/trash/",
{"documents": [doc.pk], "action": "restore"},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_restore_allows_document_with_explicit_delete_permission(
self,
rest_api_client,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner)
assign_perm("delete_document", requester, doc)
doc.delete()
response = rest_api_client.post(
"/api/trash/",
{"documents": [doc.pk], "action": "restore"},
format="json",
)
assert response.status_code == HTTPStatus.OK
@@ -60,7 +60,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("document_ids", response.data)
@mock.patch("documents.views.permitted_document_ids", return_value=set())
@mock.patch("documents.views.has_perms_owner_aware", return_value=False)
def test_create_bundle_rejects_insufficient_permissions(self, perms_mock) -> None:
payload = {
"document_ids": [self.document.pk],
+16 -16
View File
@@ -612,11 +612,11 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertIn(b"AI is required for this feature", response.content)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@patch("documents.views.get_objects_for_user_owner_aware")
@override_settings(AI_ENABLED=True)
def test_post_no_document_id(self, mock_permitted_ids, mock_stream_chat) -> None:
def test_post_no_document_id(self, mock_get_objects, mock_stream_chat) -> None:
self.grant_view_document_permission()
mock_permitted_ids.return_value = [self.document.pk]
mock_get_objects.return_value = [self.document]
mock_stream_chat.return_value = iter([b"data"])
response = self.client.post(
self.ENDPOINT,
@@ -625,23 +625,23 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/event-stream")
mock_stream_chat.assert_called_once()
call_kwargs = mock_stream_chat.call_args.kwargs
self.assertEqual(call_kwargs["query_str"], "question")
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertIsNone(call_kwargs["output_language"])
mock_stream_chat.assert_called_once_with(
query_str="question",
documents=[self.document],
output_language=None,
)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids")
@patch("documents.views.get_objects_for_user_owner_aware")
@override_settings(AI_ENABLED=True)
def test_post_uses_user_display_language(
self,
mock_permitted_ids,
mock_get_objects,
mock_stream_chat,
) -> None:
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
self.grant_view_document_permission()
mock_permitted_ids.return_value = [self.document.pk]
mock_get_objects.return_value = [self.document]
mock_stream_chat.return_value = iter([b"data"])
response = self.client.post(
@@ -651,11 +651,11 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
)
self.assertEqual(response.status_code, 200)
mock_stream_chat.assert_called_once()
call_kwargs = mock_stream_chat.call_args.kwargs
self.assertEqual(call_kwargs["query_str"], "question")
self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertEqual(call_kwargs["output_language"], "de-de")
mock_stream_chat.assert_called_once_with(
query_str="question",
documents=[self.document],
output_language="de-de",
)
@patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
+33 -28
View File
@@ -177,7 +177,6 @@ from documents.permissions import get_objects_for_user_owner_aware
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 set_permissions_for_object
from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema
@@ -1925,9 +1924,12 @@ class DocumentViewSet(
use_archive_version = validated_data.get("use_archive_version", True)
documents = Document.objects.select_related("owner").filter(pk__in=document_ids)
if request.user is not None:
permitted_ids = set(permitted_document_ids(request.user))
if not all(document.pk in permitted_ids for document in documents):
for document in documents:
if request.user is not None and not has_perms_owner_aware(
request.user,
"view_document",
document,
):
return HttpResponseForbidden("Insufficient permissions")
try:
@@ -2263,8 +2265,10 @@ class ChatStreamingView(GenericAPIView[Any]):
documents = [document]
else:
documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
documents = get_objects_for_user_owner_aware(
request.user,
"view_document",
Document,
)
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
@@ -2732,8 +2736,10 @@ class DocumentSelectionMixin:
for key, value in filters.items()
if key not in _TANTIVY_SEARCH_PARAM_NAMES
}
permitted_documents = Document.objects.filter(
id__in=permitted_document_ids(user),
permitted_documents = get_objects_for_user_owner_aware(
user,
permission_codename,
Document,
)
# orm-filtered docs
filtered_documents = DocumentFilterSet(
@@ -2782,9 +2788,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
)
# check global and object permissions for all documents
permitted_change_ids = set(permitted_document_ids(user, perm="change_document"))
has_perms = user.has_perm("documents.change_document") and all(
doc.pk in permitted_change_ids for doc in document_objs
has_perms_owner_aware(user, "change_document", doc) for doc in document_objs
)
# check ownership for methods that change original document
@@ -3342,8 +3347,10 @@ class SelectionDataView(GenericAPIView[Any]):
serializer.is_valid(raise_exception=True)
ids = serializer.validated_data.get("documents")
permitted_documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
permitted_documents = get_objects_for_user_owner_aware(
request.user,
"documents.view_document",
Document,
)
if permitted_documents.filter(pk__in=ids).count() != len(ids):
return HttpResponseForbidden("Insufficient permissions")
@@ -3515,8 +3522,10 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
all_docs = Document.objects.filter(
id__in=permitted_document_ids(request.user),
all_docs = get_objects_for_user_owner_aware(
request.user,
"view_document",
Document,
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
@@ -3720,7 +3729,11 @@ class StatisticsView(GenericAPIView[Any]):
documents = (
Document.objects.all()
if can_view_global_stats
else Document.objects.filter(id__in=permitted_document_ids(user))
else get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
)
).filter(root_document__isnull=True)
tags = (
Tag.objects.all()
@@ -3837,10 +3850,9 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
content = serializer.validated_data.get("content")
follow_filename_format = serializer.validated_data.get("follow_formatting")
permitted_ids = set(permitted_document_ids(request.user))
for document in documents:
root_doc = get_root_document(document)
if root_doc.pk not in permitted_ids:
if not has_perms_owner_aware(request.user, "view_document", root_doc):
return HttpResponseForbidden("Insufficient permissions")
versioned_documents.append(
get_latest_version_for_root(
@@ -4504,9 +4516,8 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
)
documents = list(documents_qs)
permitted_ids = set(permitted_document_ids(request.user))
for document in documents:
if document.pk not in permitted_ids:
if not has_perms_owner_aware(request.user, "view_document", document):
raise ValidationError(
{
"document_ids": _(
@@ -5308,15 +5319,9 @@ class TrashView(ListModelMixin, PassUserMixin):
if doc_ids is not None
else self.filter_queryset(self.get_queryset()).all()
)
permitted_ids = set(
permitted_document_ids(
request.user,
perm="delete_document",
include_deleted=True,
),
)
if not all(doc.pk in permitted_ids for doc in docs):
return HttpResponseForbidden("Insufficient permissions")
for doc in docs:
if not has_perms_owner_aware(request.user, "delete_document", doc):
return HttpResponseForbidden("Insufficient permissions")
action = serializer.validated_data.get("action")
if action == "restore":
for doc in Document.deleted_objects.filter(id__in=doc_ids).all():