diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index e747f573e74..37da30d4934 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -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 @@ -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, @@ -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( @@ -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(): diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py index 979c5e805c4..8530e3aee11 100644 --- a/apps/api/plane/tests/contract/app/test_project_app.py +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -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" + + target_project_member.refresh_from_db() + assert target_project_member.role == 15 + + @pytest.mark.contract class TestProjectAPIGet(TestProjectBase): """Test project GET operations"""