Repository: getsentry/sentry

Issue:
Member roles cannot delete own saved search
Users with their role as `Member` cannot delete/remove their own Saved Search: 

- Members can save a search query and it will only be shown to the Member that created it.
- Members cannot delete their own saved search.
- Other users cannot see another Member's saved search, not even Admins, Managers, or Owners. Since no one else can see another Member's saved search, Admins and above cannot delete them either.
- If a Member is updated to a different role, they cannot see their own saved searches unless they're back as a Member.

cc @getsentry/workflow (fyi @getsentry/cops) 


Discussion:
Strongly vote for this issue. I can't neither remove my old searches (which obviously are becoming obsolete since old builds are getting out-of-production) nor rename them.
I don't have an administrator role.
Came across this issue today. Very hard to manage and set default search. Upvote!
Please take a look at this. The issue has existed for almost an year. I have a few searches that are now garbage but that I cannot remove.

Resolving patch:
diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py
--- a/src/sentry/api/bases/project.py
+++ b/src/sentry/api/bases/project.py
@@ -92,7 +92,8 @@ class RelaxedSearchPermission(ProjectPermission):
         # members can do writes
         'POST': ['project:write', 'project:admin', 'project:read'],
         'PUT': ['project:write', 'project:admin', 'project:read'],
-        'DELETE': ['project:admin'],
+        # members can delete their own searches
+        'DELETE': ['project:read', 'project:write', 'project:admin'],
     }
 
 
diff --git a/src/sentry/api/endpoints/project_search_details.py b/src/sentry/api/endpoints/project_search_details.py
--- a/src/sentry/api/endpoints/project_search_details.py
+++ b/src/sentry/api/endpoints/project_search_details.py
@@ -119,6 +119,14 @@ def delete(self, request, project, search_id):
         except SavedSearch.DoesNotExist:
             raise ResourceDoesNotExist
 
-        search.delete()
+        is_search_owner = request.user and request.user == search.owner
 
-        return Response(status=204)
+        if request.access.has_scope('project:write'):
+            if not search.owner or is_search_owner:
+                search.delete()
+                return Response(status=204)
+        elif is_search_owner:
+            search.delete()
+            return Response(status=204)
+
+        return Response(status=403)


Test patch:
diff --git a/tests/js/spec/views/__snapshots__/projectSavedSearches.spec.jsx.snap b/tests/js/spec/views/__snapshots__/projectSavedSearches.spec.jsx.snap
--- a/tests/js/spec/views/__snapshots__/projectSavedSearches.spec.jsx.snap
+++ b/tests/js/spec/views/__snapshots__/projectSavedSearches.spec.jsx.snap
@@ -322,6 +322,7 @@ exports[`ProjectSavedSearches renders 1`] = `
                                       >
                                         <input
                                           checked={true}
+                                          disabled={false}
                                           name="default"
                                           onChange={[Function]}
                                           type="radio"
@@ -348,6 +349,7 @@ exports[`ProjectSavedSearches renders 1`] = `
                                           cancelText="Cancel"
                                           confirmText="Confirm"
                                           disableConfirmButton={false}
+                                          disabled={false}
                                           message="Are you sure you want to remove this?"
                                           onConfirm={[Function]}
                                           priority="primary"
@@ -658,6 +660,7 @@ exports[`ProjectSavedSearches renders 1`] = `
                                       >
                                         <input
                                           checked={false}
+                                          disabled={false}
                                           name="default"
                                           onChange={[Function]}
                                           type="radio"
@@ -684,6 +687,7 @@ exports[`ProjectSavedSearches renders 1`] = `
                                           cancelText="Cancel"
                                           confirmText="Confirm"
                                           disableConfirmButton={false}
+                                          disabled={false}
                                           message="Are you sure you want to remove this?"
                                           onConfirm={[Function]}
                                           priority="primary"
diff --git a/tests/sentry/api/endpoints/test_project_search_details.py b/tests/sentry/api/endpoints/test_project_search_details.py
--- a/tests/sentry/api/endpoints/test_project_search_details.py
+++ b/tests/sentry/api/endpoints/test_project_search_details.py
@@ -197,26 +197,106 @@ def test_member_can_override_their_default(self):
 
 
 class DeleteProjectSearchTest(APITestCase):
-    def test_simple(self):
+    def setUp(self):
         self.login_as(user=self.user)
 
-        project = self.create_project(name='foo')
+    def get_url(self, search):
+        return reverse(
+            'sentry-api-0-project-search-details',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+                'project_slug': self.project.slug,
+                'search_id': search.id,
+            }
+        )
+
+    def create_user_with_member_role(self):
+        user = self.create_user()
+        self.create_member(
+            user=user,
+            role="member",
+            organization=self.organization
+        )
+        return user
+
+    def test_owner_can_delete_shared_searches(self):
         search = SavedSearch.objects.create(
-            project=project,
+            project=self.project,
             name='foo',
             query='',
         )
 
-        url = reverse(
-            'sentry-api-0-project-search-details',
-            kwargs={
-                'organization_slug': project.organization.slug,
-                'project_slug': project.slug,
-                'search_id': search.id,
-            }
+        response = self.client.delete(self.get_url(search))
+
+        assert response.status_code == 204, response.content
+        assert not SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_owner_can_delete_own_searches(self):
+        search = SavedSearch.objects.create(
+            project=self.project,
+            name='foo',
+            query='',
+            owner=self.user
         )
-        response = self.client.delete(url)
+
+        response = self.client.delete(self.get_url(search))
 
         assert response.status_code == 204, response.content
+        assert not SavedSearch.objects.filter(id=search.id).exists()
 
+    def test_owners_cannot_delete_searches_they_do_not_own(self):
+        search = SavedSearch.objects.create(
+            project=self.project,
+            name='foo',
+            query='',
+            owner=self.create_user()
+        )
+
+        response = self.client.delete(self.get_url(search))
+
+        assert response.status_code == 403, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_members_can_delete_own_searches(self):
+        user = self.create_user_with_member_role()
+        search = SavedSearch.objects.create(
+            project=self.project,
+            name='foo',
+            query='',
+            owner=user,
+        )
+
+        self.login_as(user=user)
+        response = self.client.delete(self.get_url(search))
+
+        assert response.status_code == 204, response.content
         assert not SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_members_cannot_delete_searches_they_do_not_own(self):
+        user = self.create_user_with_member_role()
+        search = SavedSearch.objects.create(
+            project=self.project,
+            name='foo',
+            query='',
+            owner=self.create_user(),
+        )
+
+        self.login_as(user=user)
+        response = self.client.delete(self.get_url(search))
+
+        assert response.status_code == 403, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()
+
+    def test_members_cannot_delete_shared_searches(self):
+        user = self.create_user_with_member_role()
+        search = SavedSearch.objects.create(
+            project=self.project,
+            name='foo',
+            query=''
+        )
+
+        self.login_as(user=user)
+        response = self.client.delete(self.get_url(search))
+
+        assert response.status_code == 403, response.content
+        assert SavedSearch.objects.filter(id=search.id).exists()