diff --git a/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml b/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml new file mode 100644 index 0000000000000000000000000000000000000000..bf7c1946f7faf966213221514c326b7b4862a560 --- /dev/null +++ b/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml @@ -0,0 +1,9 @@ +--- +name: new_issue_attachment_from_vulnerability_bulk_action +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373814 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178686 +rollout_issue_url: +milestone: '17.9' +group: group::security insights +type: wip +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index feec178ccf1ad070203535f1c46fb79240f5ea39..be6511666da8ce9c749cd62c33f7f02639c8f3b4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11471,6 +11471,30 @@ Input type: `VerifiedNamespaceCreateInput` | <a id="mutationverifiednamespacecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationverifiednamespacecreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.vulnerabilitiesCreateIssue` + +DETAILS: +**Introduced** in GitLab 17.9. +**Status**: Experiment. + +Input type: `VulnerabilitiesCreateIssueInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilitiescreateissueclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilitiescreateissueproject"></a>`project` | [`ProjectID!`](#projectid) | ID of the project to attach the issue to. | +| <a id="mutationvulnerabilitiescreateissuevulnerabilityids"></a>`vulnerabilityIds` | [`[VulnerabilityID!]!`](#vulnerabilityid) | IDs of vulnerabilities to link to the given issue. Up to 100 can be provided. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilitiescreateissueclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilitiescreateissueerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationvulnerabilitiescreateissueissue"></a>`issue` | [`Issue`](#issue) | Issue created after mutation. | + ### `Mutation.vulnerabilitiesDismiss` Input type: `VulnerabilitiesDismissInput` diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index b857ec8ea9aa9f24aa1726a3f0660bc5c6e7cd9a..4fef2aabf5249f6c6bb909c95e1927274374dd4a 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -81,6 +81,7 @@ def self.authorization_scopes mount_mutation ::Mutations::Vulnerabilities::CreateIssueLink mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink + mount_mutation ::Mutations::Vulnerabilities::CreateIssue, experiment: { milestone: '17.9' } mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences, deprecated: { reason: 'Replaced by WorkItem type', milestone: '17.5' } mount_mutation ::Mutations::Boards::EpicBoards::Create, diff --git a/ee/app/graphql/mutations/vulnerabilities/create_issue.rb b/ee/app/graphql/mutations/vulnerabilities/create_issue.rb new file mode 100644 index 0000000000000000000000000000000000000000..d8b81af6b5134135838c4765295a0e657890983b --- /dev/null +++ b/ee/app/graphql/mutations/vulnerabilities/create_issue.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Mutations + module Vulnerabilities + class CreateIssue < BaseMutation + graphql_name 'VulnerabilitiesCreateIssue' + + MAX_VULNERABILITIES = 100 + + # This authorization is used for the **Issue only**. The vulnerabilities + # access is checked against :admin_vulnerability_issue_link + authorize :create_issue + + field :issue, + Types::IssueType, + null: true, + description: 'Issue created after mutation.' + + argument :project, ::Types::GlobalIDType[::Project], + required: true, + description: 'ID of the project to attach the issue to.' + + argument :vulnerability_ids, + [::Types::GlobalIDType[::Vulnerability]], + required: true, + validates: { length: { minimum: 1, maximum: MAX_VULNERABILITIES } }, + description: "IDs of vulnerabilities to link to the given issue. Up to #{MAX_VULNERABILITIES} can be provided." + + def resolve(vulnerability_ids:, project:) + project = authorized_find!(id: project) + + unless Feature.enabled?(:new_issue_attachment_from_vulnerability_bulk_action, project) + raise_resource_not_available_error! + end + + issue_result = create_issue(project) + + return { errors: [issue_result[:message]] } if issue_result.error? + + result = create_issue_links(issue_result[:issue], vulnerabilities(vulnerability_ids)) + { + issue: result.success? ? issue_result[:issue] : nil, + errors: result.errors + } + end + + private + + def vulnerabilities(vulnerability_ids) + vulnerabilities = Vulnerability.id_in(vulnerability_ids.map(&:model_id)).with_projects + + raise_resource_not_available_error! unless vulnerabilities.all? do |vulnerability| + Ability.allowed?(current_user, :admin_vulnerability_issue_link, vulnerability) + end + + vulnerabilities + end + + def create_issue(project) + ::Vulnerabilities::CreateIssueFromBulkActionService.new( + project, + current_user).execute + end + + def create_issue_links(issue, vulnerabilities) + ::VulnerabilityIssueLinks::BulkCreateService.new(current_user, issue, vulnerabilities).execute + end + end + end +end diff --git a/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb b/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a06ab8732d7a9892c53401bfc20b44447bc38702 --- /dev/null +++ b/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Vulnerabilities + class CreateIssueFromBulkActionService < ::BaseService + def execute + unless can?(@current_user, :create_issue, @project) + return ServiceResponse.error(message: "User is not permitted to create issue") + end + + issue_params = { + title: title, + confidential: true + } + + result = ::Issues::CreateService + .new(container: @project, current_user: @current_user, params: issue_params, perform_spam_check: false) + .execute + + if result.success? + ServiceResponse.success(payload: { issue: result[:issue] }) + else + ServiceResponse.error(message: result.errors.join(', ')) + end + end + + private + + def title + _("Investigate vulnerabilities") + end + end +end diff --git a/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb b/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b393527cbb5fc7593907a72ae21d089aef4d35e1 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Creating Issue", feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:vulnerability_1) { create(:vulnerability, project: project) } + let_it_be(:vulnerability_2) { create(:vulnerability, project: project) } + + let(:vulnerabilities) { [vulnerability_1, vulnerability_2] } + let(:issue_global_id) { issue.to_global_id.to_s } + let(:vulnerability_global_ids) { vulnerabilities.map { |v| v.to_global_id.to_s } } + let(:project_gid) { GitlabSchema.id_from_object(project) } + + subject(:mutation) do + params = { + project: project_gid, + vulnerability_ids: vulnerability_global_ids + } + + graphql_mutation(:vulnerabilities_create_issue, params) + end + + def mutation_response + graphql_mutation_response(:vulnerabilities_create_issue) + end + + context "when the user does not have access" do + it_behaves_like "a mutation that returns a top-level access error" + end + + context "when the user has access" do + before_all do + project.add_developer(current_user) + end + + context "when security_dashboard is disabled" do + before do + stub_licensed_features(security_dashboard: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not ' \ + 'exist or you don\'t have permission to perform this action'] + end + + context "when security_dashboard is enabled" do + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when new_issue_attachment_from_vulnerability_bulk_action feature flag is disabled' do + before do + stub_feature_flags(new_issue_attachment_from_vulnerability_bulk_action: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not ' \ + 'exist or you don\'t have permission to perform this action'] + end + + it "creates an issue" do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change { ::Issue.count }.by(1) + end + + it "creates the issue links", :aggregate_failures do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change { ::Vulnerabilities::IssueLink.count }.by(2) + + response_issue_link_gids = ::Vulnerabilities::IssueLink.all.map do |issue_link| + issue_link.to_global_id.to_s + end + expected_issue_link_gids = ::Issue.last.vulnerability_links.map do |issue_link| + issue_link.to_global_id.to_s + end + + expect(response_issue_link_gids).to match_array(expected_issue_link_gids) + expect(response_issue_link_gids.count).to eq 2 + end + + context "when too many vulnerabilities are passed" do + let(:vulnerability_global_ids) do + Array.new(::Mutations::Vulnerabilities::CreateIssue::MAX_VULNERABILITIES + 1) do + 'gid://gitlab/Vulnerability/1' + end + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ["vulnerabilityIds is too long (maximum is 100)"] + end + + context "when issue_id is nil" do + let(:project_gid) { nil } + + it_behaves_like 'a mutation that returns top-level errors', errors: [/Expected value to not be null/] + end + + context "when vulnerability_id is nil" do + let(:vulnerability_global_ids) { [nil] } + + it_behaves_like 'a mutation that returns top-level errors', errors: [/Expected value to not be null/] + end + + context "when vulnerability_ids are empty" do + let(:vulnerability_global_ids) { [] } + + it_behaves_like 'a mutation that returns top-level errors', + errors: ["vulnerabilityIds is too short (minimum is 1)"] + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb b/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5e21d348b921e49dff8648517ddb1ea4905b9267 --- /dev/null +++ b/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::CreateIssueFromBulkActionService, '#execute', feature_category: :vulnerability_management do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :public, :repository, namespace: group) } + let_it_be(:user) { create(:user) } + + before_all do + group.add_developer(user) + end + + shared_examples 'a created issue' do + let(:result) { described_class.new(project, user, params).execute } + + it 'creates the issue with the given params' do + expect(result[:status]).to eq(:success) + issue = result[:issue] + expect(issue).to be_persisted + expect(issue.project).to eq(project) + expect(issue.author).to eq(user) + expect(issue.title).to eq(expected_title) + expect(issue.description).to eq(expected_description) + expect(issue).to be_confidential + end + + context 'when Issues::CreateService fails' do + before do + allow_next_instance_of(Issues::CreateService) do |create_service| + allow(create_service).to receive(:execute).and_return(ServiceResponse.error(message: 'unexpected error')) + end + end + + it 'returns an error' do + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('unexpected error') + end + end + end + + context 'when user does not have permission to create issue' do + let(:result) { described_class.new(project, user, {}).execute } + + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:can?).with(user, :create_issue, project).and_return(false) + end + end + + it 'returns expected error' do + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq("User is not permitted to create issue") + end + end + + context 'when issues are disabled on project' do + let(:result) { described_class.new(project, user, {}).execute } + let(:project) { build(:project, :public, namespace: group, issues_access_level: ProjectFeature::DISABLED) } + + it 'returns expected error' do + expect(result).to be_error + expect(result[:message]).to eq("User is not permitted to create issue") + end + end + + context 'when successful' do + let(:title) { "Vulnerability Title" } + let(:params) { {} } + + let(:expected_title) { "Investigate vulnerabilities" } + let(:expected_description) { nil } + + it_behaves_like 'a created issue' + + context 'when the title of the vulnerability is longer than maximum issue title' do + let(:max_title_length) { 255 } + let(:title) { ('a' * max_title_length) } + let(:expected_title) { "Investigate vulnerabilities" } + + it_behaves_like 'a created issue' + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e4c6e9929c87e059fc3baccb4a1a2dc1b086f16b..a11b016fc7c23ca6c280b22f8db8ca8bf16bb14b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -30807,6 +30807,9 @@ msgstr "" msgid "Invalidated" msgstr "" +msgid "Investigate vulnerabilities" +msgstr "" + msgid "Investigate vulnerability: %{title}" msgstr ""