jira/apps/api/plane/app/views/workspace/cycle.py
Manish Gupta b3591b9e63
[WEB-8068] fix: scope workspace cycles/modules listing to project membership (#9373)
* [WEB-8068] fix: scope workspace cycles/modules listing to project membership

WorkspaceCyclesEndpoint and WorkspaceModulesEndpoint are guarded only by
WorkspaceViewerPermission (any active workspace member) and filtered by
workspace__slug alone, letting any workspace member enumerate cycle/module
metadata (names, dates, issue counts) of private projects they are not a
member of (GHSA-wcc5-qgfr-8g9c).

Restrict both querysets to projects the requesting user is an active member
of, mirroring WorkspaceStatesEndpoint / WorkspaceLabelsEndpoint:
  project__project_projectmember__member=request.user
  project__project_projectmember__is_active=True
  project__archived_at__isnull=True
Add .distinct() to the Module query (the member join is to-many; Cycle already
had it).

Contract regression tests cover hidden cycles/modules for a non-project member,
the positive project-member path, and no row duplication; fail-before verified.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8068] refactor: drop unnecessary distinct() from module listing

Address Copilot review: the project-membership join is filtered to
request.user, and ProjectMember has a unique constraint on (project, member)
where deleted_at IS NULL, so the join yields at most one row per project and
cannot duplicate Module rows. distinct() was dead weight (and a planner cost
for large workspaces). Matches the reference WorkspaceStates/WorkspaceLabels
endpoints, which use no distinct().

Also drop the distinct-focused contract test: adding a *different* project
member never fans out the request.user-filtered join, so it would pass with or
without distinct() — misleading coverage.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 16:00:08 +05:30

109 lines
4.3 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.db.models import Q, Count
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
def get(self, request, slug):
cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.filter(archived_at__isnull=True)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
serializer = CycleSerializer(cycles, many=True).data
return Response(serializer, status=status.HTTP_200_OK)