mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-15 20:47:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c85e8877a | ||
|
|
6b700393a0 |
No files matched your search
+1
-1
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock>=3.32,<4.1",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
|
|||||||
@@ -130,14 +130,12 @@ class DocumentMetadataOverrides:
|
|||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["view_document"],
|
only_with_perms_in=["view_document"],
|
||||||
with_group_users=False,
|
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.change_users = list(
|
overrides.change_users = list(
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["change_document"],
|
only_with_perms_in=["change_document"],
|
||||||
with_group_users=False,
|
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import ClassVar
|
||||||
|
from typing import Literal
|
||||||
|
from typing import TypeGuard
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -62,28 +68,23 @@ class PaperlessAdminPermissions(BasePermission):
|
|||||||
return request.user.is_active and request.user.is_staff
|
return request.user.is_active and request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
def has_global_statistics_permission(user: User | None) -> bool:
|
def _is_active_and_authenticated(user: User | None) -> TypeGuard[User]:
|
||||||
if (
|
return (
|
||||||
user is None
|
user is not None
|
||||||
or not getattr(user, "is_active", False)
|
and getattr(user, "is_active", False)
|
||||||
or not getattr(user, "is_authenticated", False)
|
and getattr(user, "is_authenticated", False)
|
||||||
):
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
return getattr(user, "is_superuser", False) or user.has_perm(
|
|
||||||
"paperless.view_global_statistics",
|
def has_global_statistics_permission(user: User | None) -> bool:
|
||||||
|
return _is_active_and_authenticated(user) and (
|
||||||
|
getattr(user, "is_superuser", False)
|
||||||
|
or user.has_perm("paperless.view_global_statistics")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_system_status_permission(user: User | None) -> bool:
|
def has_system_status_permission(user: User | None) -> bool:
|
||||||
if (
|
return _is_active_and_authenticated(user) and (
|
||||||
user is None
|
|
||||||
or not getattr(user, "is_active", False)
|
|
||||||
or not getattr(user, "is_authenticated", False)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
getattr(user, "is_superuser", False)
|
getattr(user, "is_superuser", False)
|
||||||
or getattr(user, "is_staff", False)
|
or getattr(user, "is_staff", False)
|
||||||
or user.has_perm("paperless.view_system_monitoring")
|
or user.has_perm("paperless.view_system_monitoring")
|
||||||
@@ -705,7 +706,32 @@ def has_perms_owner_aware(user, perms, obj):
|
|||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|
||||||
class ViewDocumentsPermissions(BasePermission):
|
# HEAD is deliberately absent: GlobalPermissionsByMethod checks it against GET.
|
||||||
|
MappedMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalPermissionsByMethod(BasePermission):
|
||||||
|
"""
|
||||||
|
Requires the global (model-level) permissions listed in ``perms_map`` for
|
||||||
|
the request's HTTP method. HEAD is checked against the ``"GET"`` entry,
|
||||||
|
since DRF runs the GET handler for it. A method missing from
|
||||||
|
``perms_map`` is denied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
perms_map: ClassVar[Mapping[MappedMethod, Sequence[str]]]
|
||||||
|
|
||||||
|
def has_permission(self, request: Any, view: Any) -> bool:
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
method = "GET" if request.method == "HEAD" else request.method
|
||||||
|
# An unknown method is not a MappedMethod, but .get() returns None for
|
||||||
|
# it, so it is still denied.
|
||||||
|
perms = self.perms_map.get(cast("MappedMethod", method))
|
||||||
|
return perms is not None and request.user.has_perms(perms)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewDocumentsPermissions(GlobalPermissionsByMethod):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for only viewing Documents.
|
Permissions class that checks for model permissions for only viewing Documents.
|
||||||
"""
|
"""
|
||||||
@@ -716,31 +742,18 @@ class ViewDocumentsPermissions(BasePermission):
|
|||||||
"POST": ["documents.view_document"],
|
"POST": ["documents.view_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
class TrashPermissions(GlobalPermissionsByMethod):
|
||||||
|
|
||||||
|
|
||||||
class TrashPermissions(BasePermission):
|
|
||||||
"""Check the global document permission for each trash operation."""
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_document"],
|
"OPTIONS": ["documents.view_document"],
|
||||||
"HEAD": ["documents.view_document"],
|
|
||||||
"GET": ["documents.view_document"],
|
"GET": ["documents.view_document"],
|
||||||
"POST": ["documents.delete_document"],
|
"POST": ["documents.delete_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
class PaperlessNotePermissions(GlobalPermissionsByMethod):
|
||||||
|
|
||||||
|
|
||||||
class PaperlessNotePermissions(BasePermission):
|
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
@@ -760,28 +773,13 @@ class PaperlessNotePermissions(BasePermission):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
perms = self.perms_map[request.method]
|
class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
|
||||||
|
|
||||||
return request.user.has_perms(perms)
|
|
||||||
|
|
||||||
|
|
||||||
class AcknowledgeTasksPermissions(BasePermission):
|
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for acknowledging tasks.
|
Permissions class that checks for model permissions for acknowledging tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
|
"OPTIONS": ["documents.change_paperlesstask"],
|
||||||
"POST": ["documents.change_paperlesstask"],
|
"POST": ["documents.change_paperlesstask"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request: Any, view: Any) -> bool:
|
|
||||||
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
perms = self.perms_map.get(request.method, [])
|
|
||||||
|
|
||||||
return request.user.has_perms(perms)
|
|
||||||
@@ -3653,6 +3653,34 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_notes_head_and_unsupported_methods(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user allowed to view notes on their own document
|
||||||
|
WHEN:
|
||||||
|
- The notes endpoint is requested with HEAD, PUT and PATCH
|
||||||
|
THEN:
|
||||||
|
- HEAD is allowed like GET
|
||||||
|
- PUT and PATCH are rejected with 403 rather than a server error
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
grant_global(user, "view_note", "view_document")
|
||||||
|
doc = DocumentFactory(owner=user)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.head(f"/api/documents/{doc.pk}/notes/").status_code,
|
||||||
|
status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.put(f"/api/documents/{doc.pk}/notes/").status_code,
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.patch(f"/api/documents/{doc.pk}/notes/").status_code,
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1001,6 +1001,21 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 10)
|
self.assertEqual(len(response.data), 10)
|
||||||
|
|
||||||
|
def test_search_autocomplete_head_requires_view_document(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user without the global view document permission
|
||||||
|
WHEN:
|
||||||
|
- A HEAD request is made for autocomplete
|
||||||
|
THEN:
|
||||||
|
- It is rejected like the equivalent GET request
|
||||||
|
"""
|
||||||
|
self.client.force_authenticate(user=UserFactory())
|
||||||
|
|
||||||
|
response = self.client.head("/api/search/autocomplete/?term=test")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_search_autocomplete_respect_permissions(self) -> None:
|
def test_search_autocomplete_respect_permissions(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ from documents.tests.utils import GetConsumerMixin
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
from paperless_testing.permissions import grant_object
|
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -1685,32 +1683,6 @@ class TestMetadataOverrides(TestCase):
|
|||||||
self.assertEqual(base.actor_id, 2)
|
self.assertEqual(base.actor_id, 2)
|
||||||
self.assertEqual(base.version_label, "v2")
|
self.assertEqual(base.version_label, "v2")
|
||||||
|
|
||||||
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document with view and change granted directly to one user and
|
|
||||||
to a group with another user as a member
|
|
||||||
WHEN:
|
|
||||||
- Overrides are built from the document
|
|
||||||
THEN:
|
|
||||||
- The user lists hold only the directly granted user
|
|
||||||
- The group lists hold the group
|
|
||||||
"""
|
|
||||||
direct_user = UserFactory()
|
|
||||||
group_member = UserFactory()
|
|
||||||
group = Group.objects.create(name="group")
|
|
||||||
group_member.groups.add(group)
|
|
||||||
document = DocumentFactory()
|
|
||||||
grant_object(direct_user, document, "view_document", "change_document")
|
|
||||||
grant_object(group, document, "view_document", "change_document")
|
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides.from_document(document)
|
|
||||||
|
|
||||||
self.assertEqual(overrides.view_users, [direct_user.pk])
|
|
||||||
self.assertEqual(overrides.change_users, [direct_user.pk])
|
|
||||||
self.assertEqual(overrides.view_groups, [group.pk])
|
|
||||||
self.assertEqual(overrides.change_groups, [group.pk])
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarcodeApplyDetectedASN(TestCase):
|
class TestBarcodeApplyDetectedASN(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1272,11 +1272,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "4.0.12"
|
version = "3.32.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3184,7 +3184,7 @@ requires-dist = [
|
|||||||
{ name = "drf-spectacular", specifier = "~=0.30" },
|
{ name = "drf-spectacular", specifier = "~=0.30" },
|
||||||
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = ">=3.32,<4.1" },
|
{ name = "filelock", specifier = "~=3.32.0" },
|
||||||
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
||||||
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
||||||
|
|||||||
Reference in new issue
Block a user