Skip to content

Commit af1be50

Browse files
mguptahubPlane AIDheeraj Kumar KetireddyCopilot
authored
[WEB-8074] fix: scope IssueListEndpoint to guest created_by (#9374)
* [WEB-8074] fix: scope IssueListEndpoint to guest created_by IssueListEndpoint.get (/workspaces/<slug>/projects/<project_id>/issues/list/) returned any issue whose id was passed in ?issues=, without the guest created_by restriction its sibling IssueViewSet.list enforces. A project GUEST (role=5) on a project with guest_view_all_features=False could read issues they did not author by supplying their ids (GHSA-32c7-84jc-4w67). Replicate the guest scope: when the requester is an active role=5 ProjectMember and not project.guest_view_all_features, filter the queryset to created_by=request.user. Applied to the base queryset so it flows through filtering, annotation and grouping. Contract regression tests cover the restricted guest (own-only), a full member (sees all), and a guest with guest_view_all_features enabled (sees all); fail-before verified. Co-authored-by: Plane AI <noreply@plane.so> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Plane AI <noreply@plane.so> Co-authored-by: Dheeraj Kumar Ketireddy <dheeraj.ketireddy@plane.so> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent cfe951c commit af1be50

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

apps/api/plane/app/views/issue/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ def get(self, request, slug, project_id):
9393
# Base queryset with basic filters
9494
queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids)
9595

96+
# Restrict guests without full feature access to issues they created,
97+
# mirroring IssueViewSet.list.
98+
if ProjectMember.objects.filter(
99+
workspace__slug=slug,
100+
project_id=project_id,
101+
member=request.user,
102+
role=ROLE.GUEST.value,
103+
is_active=True,
104+
project__guest_view_all_features=False,
105+
).exists():
106+
queryset = queryset.filter(created_by=request.user)
107+
96108
# Apply filtering from filterset
97109
queryset = self.filter_queryset(queryset)
98110

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Copyright (c) 2023-present Plane Software, Inc. and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
# See the LICENSE file for details.
4+
5+
"""Contract tests for ``IssueListEndpoint`` guest scoping.
6+
7+
Regression coverage for GHSA-32c7-84jc-4w67 (WEB-8074). ``IssueListEndpoint.get``
8+
(``/workspaces/<slug>/projects/<project_id>/issues/list/``) returned any issue
9+
whose id was passed in ``?issues=``, without applying the guest ``created_by``
10+
restriction that its sibling ``IssueViewSet.list`` enforces. A project GUEST on a
11+
project with ``guest_view_all_features=False`` could therefore read issues they
12+
did not author by supplying their ids.
13+
14+
The fix scopes the queryset to ``created_by=request.user`` for such guests,
15+
mirroring ``IssueViewSet.list``.
16+
"""
17+
18+
from uuid import uuid4
19+
20+
import pytest
21+
from rest_framework import status
22+
from rest_framework.test import APIClient
23+
24+
from plane.db.models import (
25+
Issue,
26+
Project,
27+
ProjectMember,
28+
User,
29+
WorkspaceMember,
30+
)
31+
32+
LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/list/"
33+
34+
35+
@pytest.fixture
36+
def project(db, workspace, create_user):
37+
"""A project (guest_view_all_features defaults to False); owner is a member."""
38+
project = Project.objects.create(
39+
name="Scoped Project",
40+
identifier="SP",
41+
workspace=workspace,
42+
created_by=create_user,
43+
)
44+
ProjectMember.objects.create(
45+
project=project, member=create_user, workspace=workspace, role=20
46+
)
47+
return project
48+
49+
50+
@pytest.fixture
51+
def guest(db, workspace, project):
52+
"""An active project GUEST (role=5)."""
53+
unique_id = uuid4().hex[:8]
54+
user = User.objects.create(
55+
email=f"guest-{unique_id}@plane.so",
56+
username=f"guest_{unique_id}",
57+
first_name="Guest",
58+
last_name="User",
59+
)
60+
user.set_password("test-password")
61+
user.save()
62+
WorkspaceMember.objects.create(workspace=workspace, member=user, role=5)
63+
ProjectMember.objects.create(
64+
project=project, member=user, workspace=workspace, role=5
65+
)
66+
return user
67+
68+
69+
@pytest.fixture
70+
def guest_client(guest):
71+
client = APIClient()
72+
client.force_authenticate(user=guest)
73+
return client
74+
75+
76+
def _make_issue(name, project, workspace, author):
77+
"""Create an issue with a deterministic ``created_by``.
78+
79+
``BaseModel.save`` auto-sets ``created_by`` from the current request user
80+
(None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is
81+
overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly.
82+
"""
83+
issue = Issue(name=name, project=project, workspace=workspace)
84+
issue.save(created_by_id=author.id)
85+
return issue
86+
87+
88+
@pytest.fixture
89+
def own_issue(db, workspace, project, guest):
90+
"""An issue authored by the guest."""
91+
return _make_issue("Guest's own issue", project, workspace, guest)
92+
93+
94+
@pytest.fixture
95+
def foreign_issue(db, workspace, project, create_user):
96+
"""An issue authored by someone other than the guest."""
97+
return _make_issue("Someone else's issue", project, workspace, create_user)
98+
99+
100+
@pytest.mark.contract
101+
class TestIssueListGuestScope:
102+
"""A restricted guest must only get back issues they authored."""
103+
104+
@pytest.mark.django_db
105+
def test_guest_cannot_read_foreign_issue(
106+
self, guest_client, workspace, project, own_issue, foreign_issue
107+
):
108+
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
109+
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
110+
111+
assert response.status_code == status.HTTP_200_OK, (
112+
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
113+
)
114+
returned_ids = {str(row["id"]) for row in response.data}
115+
assert str(own_issue.id) in returned_ids
116+
assert str(foreign_issue.id) not in returned_ids, (
117+
f"Guest read a foreign issue: {response.data!r}"
118+
)
119+
120+
@pytest.mark.django_db
121+
def test_project_member_reads_all_requested_issues(
122+
self, session_client, workspace, project, own_issue, foreign_issue
123+
):
124+
"""Positive control: a full member (owner) still gets every requested issue."""
125+
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
126+
response = session_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
127+
128+
assert response.status_code == status.HTTP_200_OK
129+
returned_ids = {str(row["id"]) for row in response.data}
130+
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids
131+
132+
@pytest.mark.django_db
133+
def test_guest_with_view_all_reads_all_requested_issues(
134+
self, guest_client, workspace, project, own_issue, foreign_issue
135+
):
136+
"""When guest_view_all_features is enabled, the guest sees all requested issues."""
137+
project.guest_view_all_features = True
138+
project.save(update_fields=["guest_view_all_features"])
139+
140+
url = LIST_URL.format(slug=workspace.slug, project_id=project.id)
141+
response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"})
142+
143+
assert response.status_code == status.HTTP_200_OK
144+
returned_ids = {str(row["id"]) for row in response.data}
145+
assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids

0 commit comments

Comments
 (0)