diff --git a/docs/usage.md b/docs/usage.md index 98eceb22a..3c5f6222a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -414,7 +414,7 @@ still have "object-level" permissions. | 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. | +| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. 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 other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. | 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 546231339..854444e98 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 @@ -337,7 +337,7 @@ describe('SettingsComponent', () => { .mockImplementation( (action, type) => action === PermissionAction.View && - type === PermissionType.SystemStatus + type === PermissionType.SystemMonitoring ) completeSetup() expect(component['systemStatus']).toEqual(status) // private @@ -359,7 +359,7 @@ describe('SettingsComponent', () => { .mockImplementation( (action, type) => action === PermissionAction.View && - type === PermissionType.SystemStatus + type === PermissionType.SystemMonitoring ) completeSetup() component.showSystemStatus() 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 09a2df92b..8d6805bbc 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -652,7 +652,7 @@ export class SettingsComponent this.permissionsService.isAdmin() || this.permissionsService.currentUserCan( PermissionAction.View, - PermissionType.SystemStatus + PermissionType.SystemMonitoring ) ) } 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 4ad0bbd54..7f2313a9b 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 @@ -133,28 +133,28 @@ describe('PermissionsSelectComponent', () => { expect(viewInput.nativeElement.disabled).toBeFalsy() }) - it('should treat system status as view-only', () => { + it('should treat system monitoring as view-only', () => { component.ngOnInit() fixture.detectChanges() expect( component.isActionSupported( - PermissionType.SystemStatus, + PermissionType.SystemMonitoring, PermissionAction.View ) ).toBeTruthy() expect( component.isActionSupported( - PermissionType.SystemStatus, + PermissionType.SystemMonitoring, PermissionAction.Change ) ).toBeFalsy() const changeInput = fixture.debugElement.query( - By.css('input#SystemStatus_Change') + By.css('input#SystemMonitoring_Change') ) const viewInput = fixture.debugElement.query( - By.css('input#SystemStatus_View') + By.css('input#SystemMonitoring_View') ) expect(changeInput.nativeElement.disabled).toBeTruthy() 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 2c39e597a..2032e84e0 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 @@ -261,7 +261,7 @@ export class PermissionsSelectComponent // Global statistics and system status only support view if ( type === PermissionType.GlobalStatistics || - type === PermissionType.SystemStatus + type === PermissionType.SystemMonitoring ) { return action === PermissionAction.View } diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts index 0249cb425..1beb0204a 100644 --- a/src-ui/src/app/services/permissions.service.spec.ts +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -8,7 +8,7 @@ import { const VIEW_ONLY_PERMISSION_TYPES = new Set([ PermissionType.GlobalStatistics, - PermissionType.SystemStatus, + PermissionType.SystemMonitoring, ]) describe('PermissionsService', () => { @@ -270,7 +270,7 @@ describe('PermissionsService', () => { 'delete_applicationconfiguration', 'view_applicationconfiguration', 'view_global_statistics', - 'view_system_status', + 'view_system_monitoring', ], { username: 'testuser', diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index cb045934d..0627dca63 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -30,7 +30,7 @@ export enum PermissionType { Workflow = '%s_workflow', ProcessedMail = '%s_processedmail', GlobalStatistics = '%s_global_statistics', - SystemStatus = '%s_system_status', + SystemMonitoring = '%s_system_monitoring', } @Injectable({ diff --git a/src/documents/permissions.py b/src/documents/permissions.py index 648b0ded1..99e1425de 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -72,7 +72,7 @@ def has_system_status_permission(user: User | None) -> bool: return ( getattr(user, "is_superuser", False) or getattr(user, "is_staff", False) - or user.has_perm("paperless.view_system_status") + or user.has_perm("paperless.view_system_monitoring") ) diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py index c53cbb401..876722be0 100644 --- a/src/documents/tests/test_api_schema.py +++ b/src/documents/tests/test_api_schema.py @@ -100,6 +100,14 @@ class TestTasksSummarySchema: "summary items must have 'total_count' (TaskSummarySerializer)" ) + def test_summary_days_parameter_constraints(self, api_schema: SchemaGenerator): + op = api_schema["paths"]["/api/tasks/summary/"]["get"] + params = {p["name"]: p for p in op.get("parameters", [])} + assert "days" in params, "days query parameter must be declared" + schema = params["days"]["schema"] + assert schema.get("minimum") == 1, "days must have minimum: 1" + assert schema.get("maximum") == 365, "days must have maximum: 365" + class TestTasksActiveSchema: """tasks_active_retrieve: response must be an array of TaskSerializerV10.""" diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index ef94f8b33..bb0039e67 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -102,7 +102,7 @@ class TestSystemStatus(APITestCase): user = User.objects.create_user(username="status_user") user.user_permissions.add( - Permission.objects.get(codename="view_system_status"), + Permission.objects.get(codename="view_system_monitoring"), ) self.client.force_login(user) diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index aee080900..b85622463 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -578,6 +578,100 @@ class TestSummary: assert response.status_code == status.HTTP_400_BAD_REQUEST assert "days" in response.data + def test_days_capped_at_365(self, admin_client: APIClient) -> None: + """?days= above 365 is silently clamped to 365 so tasks older than a year are excluded.""" + old_task = PaperlessTaskFactory(task_type=PaperlessTask.TaskType.CONSUME_FILE) + PaperlessTask.objects.filter(pk=old_task.pk).update( + date_created=timezone.now() - timedelta(days=400), + ) + + response = admin_client.get(ENDPOINT + "summary/", {"days": 10000}) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 0 + + +@pytest.mark.django_db() +class TestSummaryPermissions: + def test_monitoring_user_can_access_summary( + self, + user_client: APIClient, + regular_user, + ) -> None: + """A user with view_system_monitoring but no document permissions can access summary/.""" + regular_user.user_permissions.add( + Permission.objects.get(codename="view_system_monitoring"), + ) + + response = user_client.get(ENDPOINT + "summary/") + + assert response.status_code == status.HTTP_200_OK + + def test_monitoring_user_sees_all_tasks( + self, + user_client: APIClient, + regular_user, + admin_user, + ) -> None: + """Monitoring user sees aggregate data for all tasks, not just unowned ones.""" + regular_user.user_permissions.add( + Permission.objects.get(codename="view_system_monitoring"), + ) + PaperlessTaskFactory( + owner=admin_user, + task_type=PaperlessTask.TaskType.CONSUME_FILE, + status=PaperlessTask.Status.SUCCESS, + ) + + response = user_client.get(ENDPOINT + "summary/") + + assert response.status_code == status.HTTP_200_OK + total = sum(item["total_count"] for item in response.data) + assert total == 1 + + def test_regular_user_summary_scoped_to_own_and_unowned_tasks( + self, + user_client: APIClient, + regular_user: User, + admin_user: User, + ) -> None: + """A regular user with view_paperlesstask but not view_system_monitoring sees only + their own tasks and unowned tasks in the summary, not other users' tasks.""" + regular_user.user_permissions.add( + Permission.objects.get(codename="view_paperlesstask"), + ) + + PaperlessTaskFactory( + owner=regular_user, + task_type=PaperlessTask.TaskType.CONSUME_FILE, + status=PaperlessTask.Status.SUCCESS, + ) + PaperlessTaskFactory( + owner=None, + task_type=PaperlessTask.TaskType.CONSUME_FILE, + status=PaperlessTask.Status.SUCCESS, + ) + PaperlessTaskFactory( # other user's task — must not appear + owner=admin_user, + task_type=PaperlessTask.TaskType.CONSUME_FILE, + status=PaperlessTask.Status.SUCCESS, + ) + + response = user_client.get(ENDPOINT + "summary/") + + assert response.status_code == status.HTTP_200_OK + total = sum(item["total_count"] for item in response.data) + assert total == 2 + + def test_unauthenticated_cannot_access_summary( + self, + rest_api_client: APIClient, + ) -> None: + """Unauthenticated requests to summary/ return 401.""" + response = rest_api_client.get(ENDPOINT + "summary/") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + @pytest.mark.django_db() class TestActive: diff --git a/src/documents/views.py b/src/documents/views.py index 789d7a659..217550634 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -3844,10 +3844,10 @@ class RemoteVersionView(GenericAPIView[Any]): parameters=[ OpenApiParameter( name="days", - type=int, + type={"type": "integer", "minimum": 1, "maximum": 365, "default": 30}, location=OpenApiParameter.QUERY, required=False, - description="Number of days to include in aggregation (default 30)", + description="Number of days to include in aggregation (default 30, min 1, max 365)", ), ], ), @@ -3949,18 +3949,28 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): count = tasks.update(acknowledged=True) return Response({"result": count}) + def get_permissions(self): + if self.action == "summary" and has_system_status_permission( + getattr(self.request, "user", None), + ): + return [IsAuthenticated()] + return super().get_permissions() + @action(methods=["get"], detail=False) def summary(self, request): """Aggregated task statistics per task_type over the last N days (default 30).""" try: - days = max(1, int(request.query_params.get("days", 30))) + days = min(365, max(1, int(request.query_params.get("days", 30)))) except (TypeError, ValueError): return Response( {"days": "Must be a positive integer."}, status=status.HTTP_400_BAD_REQUEST, ) cutoff = timezone.now() - timedelta(days=days) - queryset = self.get_queryset().filter(date_created__gte=cutoff) + if has_system_status_permission(request.user): + queryset = PaperlessTask.objects.filter(date_created__gte=cutoff) + else: + queryset = self.get_queryset().filter(date_created__gte=cutoff) data = queryset.values("task_type").annotate( total_count=Count("id"), diff --git a/src/paperless/migrations/0009_alter_applicationconfiguration_options.py b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py index ae2b5b88c..4a374f23d 100644 --- a/src/paperless/migrations/0009_alter_applicationconfiguration_options.py +++ b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): options={ "permissions": [ ("view_global_statistics", "Can view global object counts"), - ("view_system_status", "Can view system status information"), + ("view_system_monitoring", "Can view system status information"), ], "verbose_name": "paperless application settings", }, diff --git a/src/paperless/models.py b/src/paperless/models.py index 6a38d28ac..07fe9207a 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -343,7 +343,7 @@ class ApplicationConfiguration(AbstractSingletonModel): verbose_name = _("paperless application settings") permissions = [ ("view_global_statistics", "Can view global object counts"), - ("view_system_status", "Can view system status information"), + ("view_system_monitoring", "Can view system status information"), ] def __str__(self) -> str: # pragma: no cover