[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>
This commit is contained in:
Manish Gupta
2026-07-16 16:00:08 +05:30
committed by GitHub
parent 8ef78bf0c1
commit b3591b9e63
3 changed files with 134 additions and 2 deletions

View File

@@ -21,7 +21,12 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
cycles = ( cycles = (
Cycle.objects.filter(workspace__slug=slug) 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("project")
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")

View File

@@ -21,7 +21,12 @@ class WorkspaceModulesEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
modules = ( modules = (
Module.objects.filter(workspace__slug=slug) Module.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("project")
.select_related("workspace") .select_related("workspace")
.select_related("lead") .select_related("lead")

View File

@@ -0,0 +1,122 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for workspace-wide cycles/modules project-scoping.
Regression coverage for GHSA-wcc5-qgfr-8g9c (WEB-8068). ``WorkspaceCyclesEndpoint``
and ``WorkspaceModulesEndpoint`` are guarded only by ``WorkspaceViewerPermission``
(any active workspace member) and previously filtered by ``workspace__slug`` alone.
That let any workspace member enumerate cycle/module metadata (names, dates, issue
counts) for private projects they were not a member of.
The fix restricts both querysets to projects the requesting user is an active
member of, mirroring ``WorkspaceStatesEndpoint`` / ``WorkspaceLabelsEndpoint``.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
Cycle,
Module,
Project,
ProjectMember,
User,
WorkspaceMember,
)
CYCLES_URL = "/api/workspaces/{slug}/cycles/"
MODULES_URL = "/api/workspaces/{slug}/modules/"
@pytest.fixture
def project(db, workspace, create_user):
"""A project in the fixture workspace; ``create_user`` is an active member."""
project = Project.objects.create(
name="Private Project",
identifier="PP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def cycle(db, workspace, project, create_user):
return Cycle.objects.create(
name="Private Cycle",
project=project,
workspace=workspace,
owned_by=create_user,
)
@pytest.fixture
def module(db, workspace, project):
return Module.objects.create(
name="Private Module",
project=project,
workspace=workspace,
)
@pytest.fixture
def outsider_client(db, workspace):
"""Session client for a workspace member who is NOT in ``project``."""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Outsider",
last_name="User",
)
outsider.set_password("test-password")
outsider.save()
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
client = APIClient()
client.force_authenticate(user=outsider)
return client
@pytest.mark.contract
class TestWorkspaceCyclesModulesProjectScope:
"""A workspace member must not see cycles/modules of projects they aren't in."""
@pytest.mark.django_db
def test_cycles_hidden_from_non_project_member(self, outsider_client, workspace, cycle):
response = outsider_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked cycle metadata: {response.data!r}"
@pytest.mark.django_db
def test_modules_hidden_from_non_project_member(self, outsider_client, workspace, module):
response = outsider_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked module metadata: {response.data!r}"
@pytest.mark.django_db
def test_cycles_visible_to_project_member(self, session_client, workspace, cycle):
"""Positive control: an active project member still sees the cycle."""
response = session_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(cycle.id) in ids, f"Expected cycle {cycle.id} in {response.data!r}"
@pytest.mark.django_db
def test_modules_visible_to_project_member(self, session_client, workspace, module):
"""Positive control: an active project member still sees the module."""
response = session_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(module.id) in ids, f"Expected module {module.id} in {response.data!r}"