diff --git a/src/sentry/api/endpoints/organization_member/details.py b/src/sentry/api/endpoints/organization_member/details.py index cf3a54753f702..49fd1e1d6fe0d 100644 --- a/src/sentry/api/endpoints/organization_member/details.py +++ b/src/sentry/api/endpoints/organization_member/details.py @@ -44,6 +44,7 @@ ERR_NO_AUTH = "You cannot remove this member with an unauthenticated API request." ERR_INSUFFICIENT_ROLE = "You cannot remove a member who has more access than you." ERR_INSUFFICIENT_SCOPE = "You are missing the member:admin scope." +ERR_MEMBER_INVITE = "Your role cannot remove an invitation that was sent by someone else." ERR_ONLY_OWNER = "You cannot remove the only remaining owner of the organization." ERR_UNINVITABLE = "You cannot send an invitation to a user who is already a full member." ERR_EXPIRED = "You cannot resend an expired invitation without regenerating the token." @@ -363,6 +364,32 @@ def _change_org_role(member: OrganizationMember, role: str) -> None: tags={"target_org_role": role, "count": omt_update_count}, ) + def _handle_deletion_by_member( + self, + request: Request, + organization: Organization, + member: OrganizationMember, + acting_member: OrganizationMember, + ) -> Response: + # Members can only delete invitations + if not member.inviter_id: + return Response({"detail": ERR_INSUFFICIENT_SCOPE}, status=400) + # Members can only delete invitations that they sent + if member.inviter_id != acting_member.user_id: + return Response({"detail": ERR_MEMBER_INVITE}, status=400) + + audit_data = member.get_audit_log_data() + member.delete() + self.create_audit_entry( + request=request, + organization=organization, + target_object=member.id, + target_user_id=member.user_id, + event=audit_log.get_event_id("MEMBER_REMOVE"), + data=audit_data, + ) + return Response(status=204) + @extend_schema( operation_id="Delete an Organization Member", parameters=[ @@ -398,6 +425,14 @@ def delete( else: if acting_member != member: if not request.access.has_scope("member:admin"): + if ( + features.has("organizations:members-invite-teammates", organization) + and not organization.flags.disable_member_invite + and request.access.has_scope("member:invite") + ): + return self._handle_deletion_by_member( + request, organization, member, acting_member + ) return Response({"detail": ERR_INSUFFICIENT_SCOPE}, status=400) else: can_manage = roles.can_manage(acting_member.role, member.role) diff --git a/tests/sentry/api/endpoints/test_organization_member_details.py b/tests/sentry/api/endpoints/test_organization_member_details.py index c00453081358a..c24936ea4cb8e 100644 --- a/tests/sentry/api/endpoints/test_organization_member_details.py +++ b/tests/sentry/api/endpoints/test_organization_member_details.py @@ -662,6 +662,18 @@ def test_can_promote_team_member_to_role_where_team_roles_enabled(self, mock_get class DeleteOrganizationMemberTest(OrganizationMemberTestBase): method = "delete" + def setUp(self): + super().setUp() + + self.curr_user = self.create_user("member@example.com") + self.curr_member = self.create_member( + organization=self.organization, role="member", user=self.curr_user + ) + self.other_user = self.create_user("other@example.com") + self.other_member = self.create_member( + organization=self.organization, role="member", user=self.other_user + ) + def test_simple(self): member = self.create_user("bar@example.com") member_om = self.create_member(organization=self.organization, user=member, role="member") @@ -820,6 +832,47 @@ def test_cannot_delete_partnership_member(self): self.get_error_response(self.organization.slug, member_om.id, status_code=403) + @with_feature("organizations:members-invite-teammates") + def test_member_delete_pending_invite(self): + curr_invite = self.create_member( + organization=self.organization, + user=None, + email="member_invite@example.com", + role="member", + inviter_id=self.curr_user.id, + ) + other_invite = self.create_member( + organization=self.organization, + user=None, + email="other_invite@example.com", + role="member", + inviter_id=self.other_user.id, + ) + + self.login_as(self.curr_user) + + self.organization.flags.disable_member_invite = True + self.organization.save() + self.get_error_response(self.organization.slug, curr_invite.id, status_code=400) + self.get_error_response(self.organization.slug, curr_invite.id, status_code=400) + + self.organization.flags.disable_member_invite = False + self.organization.save() + self.get_success_response(self.organization.slug, curr_invite.id) + self.get_error_response(self.organization.slug, other_invite.id, status_code=400) + + @with_feature("organizations:members-invite-teammates") + def test_member_cannot_delete_members(self): + self.login_as(self.curr_user) + + self.organization.flags.disable_member_invite = True + self.organization.save() + self.get_error_response(self.organization.slug, self.other_member.id, status_code=400) + + self.organization.flags.disable_member_invite = False + self.organization.save() + self.get_error_response(self.organization.slug, self.other_member.id, status_code=400) + @override_settings(SENTRY_SELF_HOSTED=False) @override_options({"superuser.read-write.ga-rollout": True}) def test_cannot_delete_as_superuser_read(self):