From 4629bbf83e7951222ae7954de371fee78f5ef9db Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:39:47 -0700 Subject: [PATCH] Enhancement: add view_global_statistics and view_system_status permissions (#12530) --- docs/usage.md | 40 +++--- .../admin/settings/settings.component.html | 4 +- .../admin/settings/settings.component.spec.ts | 22 +++- .../admin/settings/settings.component.ts | 12 +- .../user-edit-dialog.component.html | 4 +- .../permissions-select.component.html | 4 +- .../permissions-select.component.spec.ts | 67 +++++++++- .../permissions-select.component.ts | 121 +++++++++++++----- .../app/services/permissions.service.spec.ts | 12 +- .../src/app/services/permissions.service.ts | 2 + src/documents/permissions.py | 20 +++ src/documents/tests/test_api_custom_fields.py | 2 +- src/documents/tests/test_api_documents.py | 35 +++++ src/documents/tests/test_api_status.py | 18 +++ src/documents/views.py | 15 ++- ..._alter_applicationconfiguration_options.py | 22 ++++ src/paperless/models.py | 4 + 17 files changed, 331 insertions(+), 73 deletions(-) create mode 100644 src/paperless/migrations/0009_alter_applicationconfiguration_options.py diff --git a/docs/usage.md b/docs/usage.md index 412900df9..1528af390 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -398,25 +398,27 @@ Global permissions define what areas of the app and API endpoints users can acce determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves still have "object-level" permissions. -| Type | Details | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. | -| Correspondent | Add, edit, delete or view Correspondents. | -| CustomField | Add, edit, delete or view Custom Fields. | -| Document | Add, edit, delete or view Documents. | -| DocumentType | Add, edit, delete or view Document Types. | -| Group | Add, edit, delete or view Groups. | -| MailAccount | Add, edit, delete or view Mail Accounts. | -| MailRule | Add, edit, delete or view Mail Rules. | -| Note | Add, edit, delete or view Notes. | -| PaperlessTask | View or dismiss (_Change_) File Tasks. | -| SavedView | Add, edit, delete or view Saved Views. | -| ShareLink | Add, delete or view Share Links. | -| StoragePath | Add, edit, delete or view Storage Paths. | -| Tag | Add, edit, delete or view Tags. | -| UISettings | Add, edit, delete or view the UI settings that are used by the web app.
:warning: **Users that will access the web UI must be granted at least _View_ permissions.** | -| User | Add, edit, delete or view Users. | -| Workflow | Add, edit, delete or view Workflows.
Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). | +| Type | Details | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. | +| Correspondent | Add, edit, delete or view Correspondents. | +| CustomField | Add, edit, delete or view Custom Fields. | +| Document | Add, edit, delete or view Documents. | +| DocumentType | Add, edit, delete or view Document Types. | +| Group | Add, edit, delete or view Groups. | +| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. | +| MailAccount | Add, edit, delete or view Mail Accounts. | +| MailRule | Add, edit, delete or view Mail Rules. | +| Note | Add, edit, delete or view Notes. | +| PaperlessTask | View or dismiss (_Change_) File Tasks. | +| SavedView | Add, edit, delete or view Saved Views. | +| ShareLink | Add, delete or view Share Links. | +| StoragePath | Add, edit, delete or view Storage Paths. | +| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. | +| Tag | Add, edit, delete or view Tags. | +| UISettings | Add, edit, delete or view the UI settings that are used by the web app.
:warning: **Users that will access the web UI must be granted at least _View_ permissions.** | +| User | Add, edit, delete or view Users. | +| Workflow | Add, edit, delete or view Workflows.
Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). | #### Detailed Explanation of Object Permissions {#object-permissions} diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index c6fb526b1..c8a30ccea 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -7,7 +7,7 @@ - @if (permissionsService.isAdmin()) { + @if (canViewSystemStatus) { + } + @if (permissionsService.isAdmin()) { Open Django Admin diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 7e369c8c3..546231339 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -29,7 +29,11 @@ import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' -import { PermissionsService } from 'src/app/services/permissions.service' +import { + PermissionAction, + PermissionType, + PermissionsService, +} from 'src/app/services/permissions.service' import { GroupService } from 'src/app/services/rest/group.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { UserService } from 'src/app/services/rest/user.service' @@ -328,7 +332,13 @@ describe('SettingsComponent', () => { it('should load system status on initialize, show errors if needed', () => { jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) - jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true) + jest + .spyOn(permissionsService, 'currentUserCan') + .mockImplementation( + (action, type) => + action === PermissionAction.View && + type === PermissionType.SystemStatus + ) completeSetup() expect(component['systemStatus']).toEqual(status) // private expect(component.systemStatusHasErrors).toBeTruthy() @@ -344,7 +354,13 @@ describe('SettingsComponent', () => { it('should open system status dialog', () => { const modalOpenSpy = jest.spyOn(modalService, 'open') jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) - jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true) + jest + .spyOn(permissionsService, 'currentUserCan') + .mockImplementation( + (action, type) => + action === PermissionAction.View && + type === PermissionType.SystemStatus + ) completeSetup() component.showSystemStatus() expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, { diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index f548b71f4..09a2df92b 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -429,7 +429,7 @@ export class SettingsComponent this.settingsForm.patchValue(currentFormValue) } - if (this.permissionsService.isAdmin()) { + if (this.canViewSystemStatus) { this.systemStatusService.get().subscribe((status) => { this.systemStatus = status }) @@ -647,6 +647,16 @@ export class SettingsComponent .setValue(Array.from(hiddenFields)) } + public get canViewSystemStatus(): boolean { + return ( + this.permissionsService.isAdmin() || + this.permissionsService.currentUserCan( + PermissionAction.View, + PermissionType.SystemStatus + ) + ) + } + showSystemStatus() { const modal: NgbModalRef = this.modalService.open( SystemStatusDialogComponent, diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html index a2b3db67d..df1593073 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html @@ -23,11 +23,11 @@
- +
- +
diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.html b/src-ui/src/app/components/common/permissions-select/permissions-select.component.html index f176140cd..19382225a 100644 --- a/src-ui/src/app/components/common/permissions-select/permissions-select.component.html +++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.html @@ -26,8 +26,8 @@ - @for (action of PermissionAction | keyvalue; track action) { -
+ @for (action of PermissionAction | keyvalue: sortActions; track action.key) { +
diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts index 0aebf1ee3..4ad0bbd54 100644 --- a/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts +++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts @@ -26,7 +26,6 @@ const inheritedPermissions = ['change_tag', 'view_documenttype'] describe('PermissionsSelectComponent', () => { let component: PermissionsSelectComponent let fixture: ComponentFixture - let permissionsChangeResult: Permissions let settingsService: SettingsService beforeEach(async () => { @@ -45,7 +44,7 @@ describe('PermissionsSelectComponent', () => { fixture = TestBed.createComponent(PermissionsSelectComponent) fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) component = fixture.componentInstance - component.registerOnChange((r) => (permissionsChangeResult = r)) + component.registerOnChange((r) => r) fixture.detectChanges() }) @@ -75,7 +74,6 @@ describe('PermissionsSelectComponent', () => { it('should update on permissions set', () => { component.ngOnInit() component.writeValue(permissions) - expect(permissionsChangeResult).toEqual(permissions) expect(component.typesWithAllActions).toContain('Document') }) @@ -92,13 +90,12 @@ describe('PermissionsSelectComponent', () => { it('disable checkboxes when permissions are inherited', () => { component.ngOnInit() component.inheritedPermissions = inheritedPermissions + fixture.detectChanges() expect(component.isInherited('Document', 'Add')).toBeFalsy() expect(component.isInherited('Document')).toBeFalsy() expect(component.isInherited('Tag', 'Change')).toBeTruthy() - const input1 = fixture.debugElement.query(By.css('input#Document_Add')) - expect(input1.nativeElement.disabled).toBeFalsy() - const input2 = fixture.debugElement.query(By.css('input#Tag_Change')) - expect(input2.nativeElement.disabled).toBeTruthy() + expect(component.form.get('Document').get('Add').disabled).toBeFalsy() + expect(component.form.get('Tag').get('Change').disabled).toBeTruthy() }) it('should exclude history permissions if disabled', () => { @@ -107,4 +104,60 @@ describe('PermissionsSelectComponent', () => { component = fixture.componentInstance expect(component.allowedTypes).not.toContain('History') }) + + it('should treat global statistics as view-only', () => { + component.ngOnInit() + fixture.detectChanges() + + expect( + component.isActionSupported( + PermissionType.GlobalStatistics, + PermissionAction.View + ) + ).toBeTruthy() + expect( + component.isActionSupported( + PermissionType.GlobalStatistics, + PermissionAction.Add + ) + ).toBeFalsy() + + const addInput = fixture.debugElement.query( + By.css('input#GlobalStatistics_Add') + ) + const viewInput = fixture.debugElement.query( + By.css('input#GlobalStatistics_View') + ) + + expect(addInput.nativeElement.disabled).toBeTruthy() + expect(viewInput.nativeElement.disabled).toBeFalsy() + }) + + it('should treat system status as view-only', () => { + component.ngOnInit() + fixture.detectChanges() + + expect( + component.isActionSupported( + PermissionType.SystemStatus, + PermissionAction.View + ) + ).toBeTruthy() + expect( + component.isActionSupported( + PermissionType.SystemStatus, + PermissionAction.Change + ) + ).toBeFalsy() + + const changeInput = fixture.debugElement.query( + By.css('input#SystemStatus_Change') + ) + const viewInput = fixture.debugElement.query( + By.css('input#SystemStatus_View') + ) + + expect(changeInput.nativeElement.disabled).toBeTruthy() + expect(viewInput.nativeElement.disabled).toBeFalsy() + }) }) diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts b/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts index 689fefa17..2c39e597a 100644 --- a/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts +++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts @@ -1,4 +1,4 @@ -import { KeyValuePipe } from '@angular/common' +import { KeyValue, KeyValuePipe } from '@angular/common' import { Component, forwardRef, inject, Input, OnInit } from '@angular/core' import { AbstractControl, @@ -58,6 +58,13 @@ export class PermissionsSelectComponent typesWithAllActions: Set = new Set() + private readonly actionOrder = [ + PermissionAction.Add, + PermissionAction.Change, + PermissionAction.Delete, + PermissionAction.View, + ] + _inheritedPermissions: string[] = [] @Input() @@ -86,7 +93,7 @@ export class PermissionsSelectComponent } this.allowedTypes.forEach((type) => { const control = new FormGroup({}) - for (const action in PermissionAction) { + for (const action of Object.keys(PermissionAction)) { control.addControl(action, new FormControl(null)) } this.form.addControl(type, control) @@ -106,18 +113,14 @@ export class PermissionsSelectComponent this.permissionsService.getPermissionKeys(permissionStr) if (actionKey && typeKey) { - if (this.form.get(typeKey)?.get(actionKey)) { - this.form - .get(typeKey) - .get(actionKey) - .patchValue(true, { emitEvent: false }) - } + this.form + .get(typeKey) + ?.get(actionKey) + ?.patchValue(true, { emitEvent: false }) } }) this.allowedTypes.forEach((type) => { - if ( - Object.values(this.form.get(type).value).every((val) => val == true) - ) { + if (this.typeHasAllActionsSelected(type)) { this.typesWithAllActions.add(type) } else { this.typesWithAllActions.delete(type) @@ -149,12 +152,16 @@ export class PermissionsSelectComponent this.form.valueChanges.subscribe((newValue) => { let permissions = [] Object.entries(newValue).forEach(([typeKey, typeValue]) => { - // e.g. [Document, { Add: true, View: true ... }] const selectedActions = Object.entries(typeValue).filter( - ([actionKey, actionValue]) => actionValue == true + ([actionKey, actionValue]) => + actionValue && + this.isActionSupported( + PermissionType[typeKey], + PermissionAction[actionKey] + ) ) - selectedActions.forEach(([actionKey, actionValue]) => { + selectedActions.forEach(([actionKey]) => { permissions.push( (PermissionType[typeKey] as string).replace( '%s', @@ -163,7 +170,7 @@ export class PermissionsSelectComponent ) }) - if (selectedActions.length == Object.entries(typeValue).length) { + if (this.typeHasAllActionsSelected(typeKey)) { this.typesWithAllActions.add(typeKey) } else { this.typesWithAllActions.delete(typeKey) @@ -174,19 +181,23 @@ export class PermissionsSelectComponent permissions.filter((p) => !this._inheritedPermissions.includes(p)) ) }) + + this.updateDisabledStates() } toggleAll(event, type) { const typeGroup = this.form.get(type) - if (event.target.checked) { - Object.keys(PermissionAction).forEach((action) => { - typeGroup.get(action).patchValue(true) + Object.keys(PermissionAction) + .filter((action) => + this.isActionSupported(PermissionType[type], PermissionAction[action]) + ) + .forEach((action) => { + typeGroup.get(action).patchValue(event.target.checked) }) + + if (this.typeHasAllActionsSelected(type)) { this.typesWithAllActions.add(type) } else { - Object.keys(PermissionAction).forEach((action) => { - typeGroup.get(action).patchValue(false) - }) this.typesWithAllActions.delete(type) } } @@ -201,14 +212,21 @@ export class PermissionsSelectComponent ) ) } else { - return Object.values(PermissionAction).every((action) => { - return this._inheritedPermissions.includes( - this.permissionsService.getPermissionCode( - action as PermissionAction, - PermissionType[typeKey] + return Object.keys(PermissionAction) + .filter((action) => + this.isActionSupported( + PermissionType[typeKey], + PermissionAction[action] ) ) - }) + .every((action) => { + return this._inheritedPermissions.includes( + this.permissionsService.getPermissionCode( + PermissionAction[action], + PermissionType[typeKey] + ) + ) + }) } } @@ -216,12 +234,55 @@ export class PermissionsSelectComponent this.allowedTypes.forEach((type) => { const control = this.form.get(type) let actionControl: AbstractControl - for (const action in PermissionAction) { + for (const action of Object.keys(PermissionAction)) { actionControl = control.get(action) + if ( + !this.isActionSupported( + PermissionType[type], + PermissionAction[action] + ) + ) { + actionControl.patchValue(false, { emitEvent: false }) + actionControl.disable({ emitEvent: false }) + continue + } + this.isInherited(type, action) || this.disabled - ? actionControl.disable() - : actionControl.enable() + ? actionControl.disable({ emitEvent: false }) + : actionControl.enable({ emitEvent: false }) } }) } + + public isActionSupported( + type: PermissionType, + action: PermissionAction + ): boolean { + // Global statistics and system status only support view + if ( + type === PermissionType.GlobalStatistics || + type === PermissionType.SystemStatus + ) { + return action === PermissionAction.View + } + + return true + } + + private typeHasAllActionsSelected(typeKey: string): boolean { + return Object.keys(PermissionAction) + .filter((action) => + this.isActionSupported( + PermissionType[typeKey], + PermissionAction[action] + ) + ) + .every((action) => !!this.form.get(typeKey)?.get(action)?.value) + } + + public sortActions = ( + a: KeyValue, + b: KeyValue + ): number => + this.actionOrder.indexOf(a.value) - this.actionOrder.indexOf(b.value) } diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts index fb4937072..0249cb425 100644 --- a/src-ui/src/app/services/permissions.service.spec.ts +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -6,6 +6,11 @@ import { PermissionsService, } from './permissions.service' +const VIEW_ONLY_PERMISSION_TYPES = new Set([ + PermissionType.GlobalStatistics, + PermissionType.SystemStatus, +]) + describe('PermissionsService', () => { let permissionsService: PermissionsService @@ -264,6 +269,8 @@ describe('PermissionsService', () => { 'change_applicationconfiguration', 'delete_applicationconfiguration', 'view_applicationconfiguration', + 'view_global_statistics', + 'view_system_status', ], { username: 'testuser', @@ -274,7 +281,10 @@ describe('PermissionsService', () => { Object.values(PermissionType).forEach((type) => { Object.values(PermissionAction).forEach((action) => { - expect(permissionsService.currentUserCan(action, type)).toBeTruthy() + expect(permissionsService.currentUserCan(action, type)).toBe( + !VIEW_ONLY_PERMISSION_TYPES.has(type) || + action === PermissionAction.View + ) }) }) diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index 0c36b646f..cb045934d 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -29,6 +29,8 @@ export enum PermissionType { CustomField = '%s_customfield', Workflow = '%s_workflow', ProcessedMail = '%s_processedmail', + GlobalStatistics = '%s_global_statistics', + SystemStatus = '%s_system_status', } @Injectable({ diff --git a/src/documents/permissions.py b/src/documents/permissions.py index 140293887..648b0ded1 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -56,6 +56,26 @@ class PaperlessAdminPermissions(BasePermission): return request.user.is_staff +def has_global_statistics_permission(user: User | None) -> bool: + if user is None or not getattr(user, "is_authenticated", False): + return False + + return getattr(user, "is_superuser", False) or user.has_perm( + "paperless.view_global_statistics", + ) + + +def has_system_status_permission(user: User | None) -> bool: + if user is None or not getattr(user, "is_authenticated", False): + return False + + return ( + getattr(user, "is_superuser", False) + or getattr(user, "is_staff", False) + or user.has_perm("paperless.view_system_status") + ) + + def get_groups_with_only_permission(obj, codename): ctype = ContentType.objects.get_for_model(obj) permission = Permission.objects.get(content_type=ctype, codename=codename) diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index c02a05e8f..ac7679632 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -1309,7 +1309,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): # Test as user without access to the document non_superuser = User.objects.create_user(username="non_superuser") non_superuser.user_permissions.add( - *Permission.objects.all(), + *Permission.objects.exclude(codename="view_global_statistics"), ) non_superuser.save() self.client.force_authenticate(user=non_superuser) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 5174e4da5..6002149f9 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1314,6 +1314,41 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["documents_inbox"], 0) + def test_statistics_with_statistics_permission(self) -> None: + owner = User.objects.create_user("owner") + stats_user = User.objects.create_user("stats-user") + stats_user.user_permissions.add( + Permission.objects.get(codename="view_global_statistics"), + ) + + inbox_tag = Tag.objects.create( + name="stats_inbox", + is_inbox_tag=True, + owner=owner, + ) + Document.objects.create( + title="owned-doc", + checksum="stats-A", + mime_type="application/pdf", + content="abcdef", + owner=owner, + ).tags.add(inbox_tag) + Correspondent.objects.create(name="stats-correspondent", owner=owner) + DocumentType.objects.create(name="stats-type", owner=owner) + StoragePath.objects.create(name="stats-path", path="archive", owner=owner) + + self.client.force_authenticate(user=stats_user) + response = self.client.get("/api/statistics/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["documents_total"], 1) + self.assertEqual(response.data["documents_inbox"], 1) + self.assertEqual(response.data["inbox_tags"], [inbox_tag.pk]) + self.assertEqual(response.data["character_count"], 6) + self.assertEqual(response.data["correspondent_count"], 1) + self.assertEqual(response.data["document_type_count"], 1) + self.assertEqual(response.data["storage_path_count"], 1) + def test_upload(self) -> None: self.consume_file_mock.return_value = celery.result.AsyncResult( id=str(uuid.uuid4()), diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index 32717af63..4f4511c14 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -5,12 +5,14 @@ from pathlib import Path from unittest import mock from celery import states +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase from documents.models import PaperlessTask +from documents.permissions import has_system_status_permission from paperless import version @@ -91,6 +93,22 @@ class TestSystemStatus(APITestCase): self.client.force_login(normal_user) response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # test the permission helper function directly for good measure + self.assertFalse(has_system_status_permission(None)) + + def test_system_status_with_system_status_permission(self) -> None: + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + user = User.objects.create_user(username="status_user") + user.user_permissions.add( + Permission.objects.get(codename="view_system_status"), + ) + + self.client.force_login(user) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_system_status_with_bad_basic_auth_challenges(self) -> None: self.client.credentials(HTTP_AUTHORIZATION="Basic invalid") diff --git a/src/documents/views.py b/src/documents/views.py index 68d2b7961..dd9c4b837 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -165,7 +165,9 @@ from documents.permissions import ViewDocumentsPermissions from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import get_document_count_filter_for_user 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 set_permissions_for_object from documents.plugins.date_parsing import get_date_parser from documents.schema import generate_object_with_permissions_schema @@ -3265,10 +3267,11 @@ class StatisticsView(GenericAPIView): def get(self, request, format=None): user = request.user if request.user is not None else None + can_view_global_stats = has_global_statistics_permission(user) or user is None documents = ( Document.objects.all() - if user is None + if can_view_global_stats else get_objects_for_user_owner_aware( user, "documents.view_document", @@ -3277,12 +3280,12 @@ class StatisticsView(GenericAPIView): ) tags = ( Tag.objects.all() - if user is None + if can_view_global_stats else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag) ).only("id", "is_inbox_tag") correspondent_count = ( Correspondent.objects.count() - if user is None + if can_view_global_stats else get_objects_for_user_owner_aware( user, "documents.view_correspondent", @@ -3291,7 +3294,7 @@ class StatisticsView(GenericAPIView): ) document_type_count = ( DocumentType.objects.count() - if user is None + if can_view_global_stats else get_objects_for_user_owner_aware( user, "documents.view_documenttype", @@ -3300,7 +3303,7 @@ class StatisticsView(GenericAPIView): ) storage_path_count = ( StoragePath.objects.count() - if user is None + if can_view_global_stats else get_objects_for_user_owner_aware( user, "documents.view_storagepath", @@ -4257,7 +4260,7 @@ class SystemStatusView(PassUserMixin): permission_classes = (IsAuthenticated,) def get(self, request, format=None): - if not request.user.is_staff: + if not has_system_status_permission(request.user): return HttpResponseForbidden("Insufficient permissions") current_version = version.__full_version_str__ diff --git a/src/paperless/migrations/0009_alter_applicationconfiguration_options.py b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py new file mode 100644 index 000000000..ae2b5b88c --- /dev/null +++ b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.12 on 2026-04-07 23:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0008_replace_skip_archive_file"), + ] + + operations = [ + migrations.AlterModelOptions( + name="applicationconfiguration", + options={ + "permissions": [ + ("view_global_statistics", "Can view global object counts"), + ("view_system_status", "Can view system status information"), + ], + "verbose_name": "paperless application settings", + }, + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 192e429d4..6a38d28ac 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -341,6 +341,10 @@ class ApplicationConfiguration(AbstractSingletonModel): class Meta: verbose_name = _("paperless application settings") + permissions = [ + ("view_global_statistics", "Can view global object counts"), + ("view_system_status", "Can view system status information"), + ] def __str__(self) -> str: # pragma: no cover return "ApplicationConfiguration"