Commit 5caf8fc8 authored by Kerri Miller's avatar Kerri Miller

Merge branch 'philipcunningham-dast-scan-create-mutation-295244' into 'master'

Add GraphQL mutation for creating Dast::Profiles

See merge request gitlab-org/gitlab!51557
parents 0754b8d2 8495368a
......@@ -5675,6 +5675,71 @@ type DastProfileConnection {
pageInfo: PageInfo!
}
"""
Autogenerated input type of DastProfileCreate
"""
input DastProfileCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
ID of the scanner profile to be associated.
"""
dastScannerProfileId: DastScannerProfileID!
"""
ID of the site profile to be associated.
"""
dastSiteProfileId: DastSiteProfileID!
"""
The description of the profile. Defaults to an empty string.
"""
description: String = ""
"""
The project the profile belongs to.
"""
fullPath: ID!
"""
The name of the profile.
"""
name: String!
"""
Run scan using profile after creation. Defaults to false.
"""
runAfterCreate: Boolean = false
}
"""
Autogenerated return type of DastProfileCreate
"""
type DastProfileCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The created profile.
"""
dastProfile: DastProfile
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The URL of the pipeline that was created. Requires `runAfterCreate` to be set to `true`.
"""
pipelineUrl: String!
}
"""
An edge in a connection.
"""
......@@ -16139,6 +16204,7 @@ type Mutation {
createSnippet(input: CreateSnippetInput!): CreateSnippetPayload
createTestCase(input: CreateTestCaseInput!): CreateTestCasePayload
dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload
dastProfileCreate(input: DastProfileCreateInput!): DastProfileCreatePayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload
dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload
......
......@@ -15505,6 +15505,188 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastProfileCreateInput",
"description": "Autogenerated input type of DastProfileCreate",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "The project the profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "name",
"description": "The name of the profile.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "description",
"description": "The description of the profile. Defaults to an empty string.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": "\"\""
},
{
"name": "dastSiteProfileId",
"description": "ID of the site profile to be associated.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastSiteProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "dastScannerProfileId",
"description": "ID of the scanner profile to be associated.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastScannerProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "runAfterCreate",
"description": "Run scan using profile after creation. Defaults to false.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": "false"
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastProfileCreatePayload",
"description": "Autogenerated return type of DastProfileCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastProfile",
"description": "The created profile.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "DastProfile",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineUrl",
"description": "The URL of the pipeline that was created. Requires `runAfterCreate` to be set to `true`.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastProfileEdge",
......@@ -45170,6 +45352,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastProfileCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastProfileCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastProfileCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastScannerProfileCreate",
"description": null,
......@@ -905,6 +905,17 @@ Represents a DAST Profile.
| `id` | DastProfileID! | ID of the profile. |
| `name` | String | The name of the profile. |
### DastProfileCreatePayload
Autogenerated return type of DastProfileCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `dastProfile` | DastProfile | The created profile. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String! | The URL of the pipeline that was created. Requires `runAfterCreate` to be set to `true`. |
### DastScannerProfile
Represents a DAST scanner profile.
......
......@@ -41,6 +41,7 @@ module EE
mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject
mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject
mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::Dast::Profiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Update
mount_mutation ::Mutations::DastSiteProfiles::Delete
......
# frozen_string_literal: true
module Mutations
module Dast
module Profiles
class Create < BaseMutation
include FindsProject
graphql_name 'DastProfileCreate'
field :dast_profile, ::Types::Dast::ProfileType,
null: true,
description: 'The created profile.'
field :pipeline_url, GraphQL::STRING_TYPE,
null: false,
description: 'The URL of the pipeline that was created. Requires `runAfterCreate` to be set to `true`.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the profile belongs to.'
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'The name of the profile.'
argument :description, GraphQL::STRING_TYPE,
required: false,
description: 'The description of the profile. Defaults to an empty string.',
default_value: ''
argument :dast_site_profile_id, ::Types::GlobalIDType[::DastSiteProfile],
required: true,
description: 'ID of the site profile to be associated.'
argument :dast_scanner_profile_id, ::Types::GlobalIDType[::DastScannerProfile],
required: true,
description: 'ID of the scanner profile to be associated.'
argument :run_after_create, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Run scan using profile after creation. Defaults to false.',
default_value: false
authorize :create_on_demand_dast_scan
def resolve(full_path:, name:, description: '', dast_site_profile_id:, dast_scanner_profile_id:, run_after_create: false)
project = authorized_find!(full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
# TODO: remove explicit coercion once compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
site_profile_id = ::Types::GlobalIDType[::DastSiteProfile].coerce_isolated_input(dast_site_profile_id)
scanner_profile_id = ::Types::GlobalIDType[::DastScannerProfile].coerce_isolated_input(dast_scanner_profile_id)
dast_site_profile = project.dast_site_profiles.find(site_profile_id.model_id)
dast_scanner_profile = project.dast_scanner_profiles.find(scanner_profile_id.model_id)
response = ::Dast::Profiles::CreateService.new(
container: project,
current_user: current_user,
params: {
project: project,
name: name,
description: description,
dast_site_profile: dast_site_profile,
dast_scanner_profile: dast_scanner_profile,
run_after_create: run_after_create
}
).execute
return { errors: response.errors } if response.error?
{ errors: [], dast_profile: response.payload.fetch(:dast_profile), pipeline_url: response.payload.fetch(:pipeline_url) }
end
private
def allowed?(project)
project.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:dast_saved_scans, project, default_enabled: :yaml)
end
end
end
end
end
# frozen_string_literal: true
module Dast
module Profiles
class CreateService < BaseContainerService
def execute
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
dast_profile = Dast::Profile.create!(
project: container,
name: params.fetch(:name),
description: params.fetch(:description),
dast_site_profile: dast_site_profile,
dast_scanner_profile: dast_scanner_profile
)
return ServiceResponse.success(payload: { dast_profile: dast_profile, pipeline_url: nil }) unless params.fetch(:run_after_create)
response = ::DastOnDemandScans::CreateService.new(
container: container,
current_user: current_user,
params: {
dast_site_profile: dast_site_profile,
dast_scanner_profile: dast_scanner_profile
}
).execute
return response if response.error?
ServiceResponse.success(payload: { dast_profile: dast_profile, pipeline_url: response.payload.fetch(:pipeline_url) })
rescue ActiveRecord::RecordInvalid => err
ServiceResponse.error(message: err.record.errors.full_messages)
rescue KeyError => err
ServiceResponse.error(message: err.message.capitalize)
end
private
def allowed?
container.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:dast_saved_scans, container, default_enabled: :yaml)
end
def dast_site_profile
@dast_site_profile ||= params.fetch(:dast_site_profile)
end
def dast_scanner_profile
@dast_scanner_profile ||= params.fetch(:dast_scanner_profile)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Dast::Profiles::Create do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:developer) { create(:user, developer_projects: [project] ) }
let_it_be(:dast_site_profile) { create(:dast_site_profile, project: project) }
let_it_be(:dast_scanner_profile) { create(:dast_scanner_profile, project: project) }
let(:name) { SecureRandom.hex }
let(:description) { SecureRandom.hex }
let(:run_after_create) { false }
let(:dast_profile) { Dast::Profile.find_by(project: project, name: name) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: developer }, field: nil) }
before do
stub_licensed_features(security_on_demand_scans: true)
end
specify { expect(described_class).to require_graphql_authorizations(:create_on_demand_dast_scan) }
describe '#resolve' do
subject do
mutation.resolve(
full_path: project.full_path,
name: name,
description: description,
dast_site_profile_id: dast_site_profile.to_global_id.to_s,
dast_scanner_profile_id: dast_scanner_profile.to_global_id.to_s,
run_after_create: run_after_create
)
end
context 'when the feature is licensed' do
context 'when the feature is enabled' do
it 'raises an exception' do
stub_feature_flags(dast_saved_scans: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can run a dast scan' do
it 'returns the dast_profile' do
expect(subject[:dast_profile]).to eq(dast_profile)
end
context 'when run_after_create=true' do
let(:run_after_create) { true }
it 'returns the pipeline_url' do
actual_url = subject[:pipeline_url]
pipeline = Ci::Pipeline.find_by(
project: project,
sha: project.repository.commit.sha,
source: :ondemand_dast_scan,
config_source: :parameter_source
)
expected_url = Rails.application.routes.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(actual_url).to eq(expected_url)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating a DAST Profile' do
include GraphqlHelpers
let(:name) { SecureRandom.hex }
let(:dast_site_profile) { create(:dast_site_profile, project: project) }
let(:dast_scanner_profile) { create(:dast_scanner_profile, project: project) }
let(:dast_profile) { Dast::Profile.find_by(project: project, name: name) }
let(:mutation_name) { :dast_profile_create }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: full_path,
name: name,
dast_site_profile_id: global_id_of(dast_site_profile),
dast_scanner_profile_id: global_id_of(dast_scanner_profile),
run_after_create: true
)
end
it_behaves_like 'an on-demand scan mutation when user cannot run an on-demand scan'
it_behaves_like 'an on-demand scan mutation when user can run an on-demand scan' do
it 'returns dastProfile.id' do
subject
expect(mutation_response.dig('dastProfile', 'id')).to eq(global_id_of(dast_profile))
end
it 'returns dastProfile.editPath' do
subject
expect(mutation_response.dig('dastProfile', 'editPath')).to eq(edit_project_on_demand_scan_path(project, dast_profile))
end
it 'returns a non-empty pipelineUrl' do
subject
expect(mutation_response['pipelineUrl']).not_to be_blank
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Dast::Profiles::CreateService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:developer) { create(:user, developer_projects: [project] ) }
let_it_be(:dast_site_profile) { create(:dast_site_profile, project: project) }
let_it_be(:dast_scanner_profile) { create(:dast_scanner_profile, project: project) }
let_it_be(:default_params) do
{ name: SecureRandom.hex, description: :description, dast_site_profile: dast_site_profile, dast_scanner_profile: dast_scanner_profile, run_after_create: false }
end
let(:params) { default_params }
subject { described_class.new(container: project, current_user: developer, params: params).execute }
describe 'execute' do
before do
project.clear_memoization(:licensed_feature_available)
end
context 'when on demand scan feature is disabled' do
it 'communicates failure' do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: false)
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Insufficient permissions')
end
end
end
context 'when on demand scan licensed feature is not available' do
it 'communicates failure' do
stub_licensed_features(security_on_demand_scans: false)
stub_feature_flags(dast_saved_scans: true)
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Insufficient permissions')
end
end
end
context 'when the feature is enabled' do
before do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: true)
end
it 'communicates success' do
expect(subject.status).to eq(:success)
end
it 'creates a dast_profile' do
expect { subject }.to change { Dast::Profile.count }.by(1)
end
context 'when param run_after_create: true' do
let(:params) { default_params.merge(run_after_create: true) }
it 'calls DastOnDemandScans::CreateService' do
params = { dast_site_profile: dast_site_profile, dast_scanner_profile: dast_scanner_profile }
expect(DastOnDemandScans::CreateService).to receive(:new).with(hash_including(params: params)).and_call_original
subject
end
it 'creates a ci_pipeline' do
expect { subject }.to change { Ci::Pipeline.count }.by(1)
end
end
context 'when a param is missing' do
let(:params) { default_params.except(:run_after_create) }
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Key not found: :run_after_create')
end
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment