Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions apps/api/plane/app/views/project/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def retrieve(self, request, slug, project_id, pk):
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)


# Fetch the target's workspace role (used to cap the new project role)
target_workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
Expand All @@ -216,8 +217,20 @@ def partial_update(self, request, slug, project_id, pk):
).role
is_workspace_admin = requester_workspace_role == ROLE.ADMIN.value

# Fetch the workspace role of the project member
target_workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role

# Fetch the workspace role of the requesting user for permission checks
requesting_workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user, is_active=True
).role
is_requesting_workspace_admin = requesting_workspace_role == ROLE.ADMIN.value


# Check if the user is not editing their own role if they are not an admin
if request.user.id == project_member.member_id and not is_workspace_admin:
if request.user.id == project_member.member_id and not is_requesting_workspace_admin:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
Expand All @@ -230,14 +243,8 @@ def partial_update(self, request, slug, project_id, pk):
is_active=True,
)

if "role" in request.data:
# Only Admins can modify roles
if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
return Response(
{"error": "You do not have permission to update roles"},
status=status.HTTP_403_FORBIDDEN,
)

if "role" in request.data:
# Cannot modify a member whose role is equal to or higher than your own
if project_member.role >= requested_project_member.role and not is_workspace_admin:
return Response(
Expand All @@ -261,6 +268,23 @@ def partial_update(self, request, slug, project_id, pk):
status=status.HTTP_400_BAD_REQUEST,
)

if target_workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
return Response(
{"error": "You cannot add a user with role higher than the workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)

if (
"role" in request.data
and int(request.data.get("role", project_member.role)) > requested_project_member.role
and not is_requesting_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)


serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)

if serializer.is_valid():
Expand Down
58 changes: 58 additions & 0 deletions apps/api/plane/tests/contract/app/test_project_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,64 @@ def test_create_project_with_all_optional_fields(self, session_client, workspace
assert response_data["network"] == project_data["network"]


@pytest.mark.contract
class TestProjectMemberAPI:
"""Test project member role operations"""

def get_project_member_url(self, workspace_slug: str, project_id: uuid.UUID, pk: uuid.UUID) -> str:
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/members/{pk}/"

@pytest.mark.django_db
def test_workspace_admin_can_promote_member_above_project_role(self, session_client, workspace, create_user):
"""Workspace admins can assign project roles above their own project role."""
project = Project.objects.create(name="Role Project", identifier="RP", workspace=workspace)
requesting_project_member = ProjectMember.objects.create(
project=project, member=create_user, role=5, is_active=True
)

target_user = User.objects.create_user(email="target@example.com", username="target")
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=15, is_active=True)
target_project_member = ProjectMember.objects.create(
project=project, member=target_user, role=15, is_active=True
)

url = self.get_project_member_url(workspace.slug, project.id, target_project_member.id)
response = session_client.patch(url, {"role": 20}, format="json")

assert response.status_code == status.HTTP_200_OK
target_project_member.refresh_from_db()
assert target_project_member.role == 20

requesting_project_member.refresh_from_db()
assert requesting_project_member.role == 5

@pytest.mark.django_db
def test_project_member_cannot_promote_member_above_own_project_role(self, api_client, workspace):
"""Non-workspace-admin project members cannot assign roles above their own project role."""
project = Project.objects.create(name="Protected Role Project", identifier="PRP", workspace=workspace)

requesting_user = User.objects.create_user(email="requester@example.com", username="requester")
WorkspaceMember.objects.create(workspace=workspace, member=requesting_user, role=15, is_active=True)
ProjectMember.objects.create(project=project, member=requesting_user, role=15, is_active=True)

target_user = User.objects.create_user(email="member-target@example.com", username="member-target")
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=15, is_active=True)
target_project_member = ProjectMember.objects.create(
project=project, member=target_user, role=15, is_active=True
)

api_client.force_authenticate(user=requesting_user)

url = self.get_project_member_url(workspace.slug, project.id, target_project_member.id)
response = api_client.patch(url, {"role": 20}, format="json")

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["error"] == "You cannot update a role that is higher than your own role"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

target_project_member.refresh_from_db()
assert target_project_member.role == 15


@pytest.mark.contract
class TestProjectAPIGet(TestProjectBase):
"""Test project GET operations"""
Expand Down
Loading