Commit 3e84b7c5 authored by Philip Cunningham's avatar Philip Cunningham Committed by Ash McKenzie

Add DastProfileUpdate GraphQL mutation

Allow users to update existing Dast::Profiles.
parent dd6ec7c3
......@@ -2,5 +2,4 @@
filenames:
- ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/api_fuzzing_ci_configuration.query.graphql
- ee/app/assets/javascripts/on_demand_scans/graphql/dast_profile_update.mutation.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
......@@ -5967,6 +5967,78 @@ type DastProfileRunPayload {
pipelineUrl: String
}
"""
Autogenerated input type of DastProfileUpdate
"""
input DastProfileUpdateInput {
"""
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!
"""
ID of the profile to be deleted.
"""
id: DastProfileID!
"""
The name of the profile.
"""
name: String
"""
Run scan using profile after update. Defaults to false.
"""
runAfterUpdate: Boolean = false
}
"""
Autogenerated return type of DastProfileUpdate
"""
type DastProfileUpdatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The updated profile.
"""
dastProfile: DastProfile
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The URL of the pipeline that was created. Requires the input argument
`runAfterUpdate` to be set to `true` when calling the mutation, otherwise no
pipeline will be created.
"""
pipelineUrl: String
}
enum DastScanTypeEnum {
"""
Active DAST scan. This scan will make active attacks against the target site.
......@@ -16652,6 +16724,7 @@ type Mutation {
dastProfileCreate(input: DastProfileCreateInput!): DastProfileCreatePayload
dastProfileDelete(input: DastProfileDeleteInput!): DastProfileDeletePayload
dastProfileRun(input: DastProfileRunInput!): DastProfileRunPayload
dastProfileUpdate(input: DastProfileUpdateInput!): DastProfileUpdatePayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload
dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload
......
......@@ -16233,6 +16233,186 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastProfileUpdateInput",
"description": "Autogenerated input type of DastProfileUpdate",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "ID of the profile to be deleted.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"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": "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": "SCALAR",
"name": "DastSiteProfileID",
"ofType": null
},
"defaultValue": null
},
{
"name": "dastScannerProfileId",
"description": "ID of the scanner profile to be associated.",
"type": {
"kind": "SCALAR",
"name": "DastScannerProfileID",
"ofType": null
},
"defaultValue": null
},
{
"name": "runAfterUpdate",
"description": "Run scan using profile after update. 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": "DastProfileUpdatePayload",
"description": "Autogenerated return type of DastProfileUpdate",
"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 updated 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 the input argument `runAfterUpdate` to be set to `true` when calling the mutation, otherwise no pipeline will be created.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastScanTypeEnum",
......@@ -46493,6 +46673,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastProfileUpdate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastProfileUpdateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastProfileUpdatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastScannerProfileCreate",
"description": null,
......@@ -963,6 +963,17 @@ Autogenerated return type of DastProfileRun.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
### DastProfileUpdatePayload
Autogenerated return type of DastProfileUpdate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `dastProfile` | DastProfile | The updated profile. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | The URL of the pipeline that was created. Requires the input argument `runAfterUpdate` to be set to `true` when calling the mutation, otherwise no pipeline will be created. |
### DastScannerProfile
Represents a DAST scanner profile.
......
......@@ -43,6 +43,7 @@ module EE
mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject
mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::Dast::Profiles::Create
mount_mutation ::Mutations::Dast::Profiles::Update
mount_mutation ::Mutations::Dast::Profiles::Delete
mount_mutation ::Mutations::Dast::Profiles::Run
mount_mutation ::Mutations::DastSiteProfiles::Create
......
# frozen_string_literal: true
module Mutations
module Dast
module Profiles
class Update < BaseMutation
include FindsProject
graphql_name 'DastProfileUpdate'
ProfileID = ::Types::GlobalIDType[::Dast::Profile]
SiteProfileID = ::Types::GlobalIDType[::DastSiteProfile]
ScannerProfileID = ::Types::GlobalIDType[::DastScannerProfile]
field :dast_profile, ::Types::Dast::ProfileType,
null: true,
description: 'The updated profile.'
field :pipeline_url, GraphQL::STRING_TYPE,
null: true,
description: 'The URL of the pipeline that was created. Requires the input ' \
'argument `runAfterUpdate` to be set to `true` when calling the ' \
'mutation, otherwise no pipeline will be created.'
argument :id, ProfileID,
required: true,
description: 'ID of the profile to be deleted.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the profile belongs to.'
argument :name, GraphQL::STRING_TYPE,
required: false,
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, SiteProfileID,
required: false,
description: 'ID of the site profile to be associated.'
argument :dast_scanner_profile_id, ScannerProfileID,
required: false,
description: 'ID of the scanner profile to be associated.'
argument :run_after_update, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Run scan using profile after update. Defaults to false.',
default_value: false
authorize :create_on_demand_dast_scan
def resolve(full_path:, id:, name:, description:, dast_site_profile_id: nil, dast_scanner_profile_id: nil, run_after_update: false)
project = authorized_find!(full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
dast_profile = find_dast_profile(project.id, id)
authorize!(dast_profile)
params = {
dast_profile: dast_profile,
name: name,
description: description,
dast_site_profile_id: as_model_id(SiteProfileID, dast_site_profile_id),
dast_scanner_profile_id: as_model_id(ScannerProfileID, dast_scanner_profile_id),
run_after_update: run_after_update
}.compact
response = ::Dast::Profiles::UpdateService.new(
container: project,
current_user: current_user,
params: params
).execute
{ errors: response.errors, **response.payload }
end
private
def allowed?(project)
project.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:dast_saved_scans, project, default_enabled: :yaml)
end
def as_model_id(klass, value)
return unless value
# TODO: remove explicit coercion once compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
klass.coerce_isolated_input(value).model_id
end
def find_dast_profile(project_id, id)
# TODO: remove this line once the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ProfileID.coerce_isolated_input(id).model_id
::Dast::ProfilesFinder.new(project_id: project_id, id: id)
.execute
.first
end
end
end
end
end
......@@ -7,12 +7,16 @@ module Dast
def execute
return unauthorized unless allowed?
return ServiceResponse.error(message: 'ID parameter missing') unless params[:id].present?
return ServiceResponse.error(message: 'Profile not found for given parameters') unless dast_profile
return error('Profile parameter missing') unless dast_profile
return error(dast_profile.errors.full_messages) unless dast_profile.update(dast_profile_params)
return ServiceResponse.error(message: dast_profile.errors.full_messages) unless dast_profile.update(dast_profile_params)
return success(dast_profile: dast_profile, pipeline_url: nil) unless params[:run_after_update]
ServiceResponse.success(payload: dast_profile)
response = create_scan(dast_profile)
return response if response.error?
success(dast_profile: dast_profile, pipeline_url: response.payload.fetch(:pipeline_url))
end
private
......@@ -23,24 +27,38 @@ module Dast
can?(current_user, :create_on_demand_dast_scan, container)
end
def error(message, opts = {})
ServiceResponse.error(message: message, **opts)
end
def success(payload)
ServiceResponse.success(payload: payload)
end
def unauthorized
ServiceResponse.error(
message: 'You are not authorized to update this profile',
http_status: 403
)
error('You are not authorized to update this profile', http_status: 403)
end
def dast_profile
strong_memoize(:dast_profile) do
Dast::ProfilesFinder.new(project_id: container.id, id: params[:id])
.execute
.first
end
params[:dast_profile]
end
def dast_profile_params
params.slice(:dast_site_profile_id, :dast_scanner_profile_id, :name, :description)
end
def create_scan(dast_profile)
params = {
dast_site_profile: dast_profile.dast_site_profile,
dast_scanner_profile: dast_profile.dast_scanner_profile
}
::DastOnDemandScans::CreateService.new(
container: container,
current_user: current_user,
params: params
).execute
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Dast::Profiles::Update do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:dast_profile, reload: true) { create(:dast_profile, project: project) }
let(:dast_profile_gid) { dast_profile.to_global_id }
let(:params) do
{
id: dast_profile_gid,
dast_site_profile_id: global_id_of(create(:dast_site_profile, project: project)),
dast_scanner_profile_id: global_id_of(create(:dast_scanner_profile, project: project)),
name: SecureRandom.hex,
description: SecureRandom.hex
}
end
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, 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 { mutation.resolve(**params.merge(full_path: project.full_path)) }
shared_examples 'an unrecoverable failure' do |parameter|
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the feature is licensed' do
context 'when the project does not exist' do
before do
allow_next_instance_of(ProjectsFinder) do |finder|
allow(finder).to receive(:execute).and_return(nil)
end
end
it_behaves_like 'an unrecoverable failure'
end
context 'when the user cannot read the project' do
it_behaves_like 'an unrecoverable failure'
end
context 'when the user can update a DAST profile' do
before do
project.add_developer(user)
end
it 'returns the profile' do
expect(subject[:dast_profile]).to be_a(Dast::Profile)
end
it 'updates the profile' do
subject
updated_dast_profile = dast_profile.reload
aggregate_failures do
expect(global_id_of(updated_dast_profile.dast_site_profile)).to eq(params[:dast_site_profile_id])
expect(global_id_of(updated_dast_profile.dast_scanner_profile)).to eq(params[:dast_scanner_profile_id])
expect(updated_dast_profile.name).to eq(params[:name])
expect(updated_dast_profile.description).to eq(params[:description])
end
end
context 'when the dast_profile does not exist' do
let(:dast_profile_gid) { Gitlab::GlobalId.build(nil, model_name: 'Dast::Profile', id: 'does_not_exist') }
it_behaves_like 'an unrecoverable failure'
end
context 'when updating fails' do
it 'returns an error' do
allow_next_instance_of(::Dast::Profiles::UpdateService) do |service|
allow(service).to receive(:execute).and_return(
ServiceResponse.error(message: 'Profile failed to update')
)
end
expect(subject[:errors]).to include('Profile failed to update')
end
end
context 'when the feature is not enabled' do
before do
stub_feature_flags(dast_saved_scans: false)
end
it_behaves_like 'an unrecoverable failure'
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Updating a DAST Profile' do
include GraphqlHelpers
let!(:dast_profile) { create(:dast_profile, project: project) }
let(:mutation_name) { :dast_profile_update }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: project.full_path,
id: global_id_of(dast_profile),
name: 'updated dast_profiles.name',
run_after_update: 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 a non-nil dastProfile' do
subject
expect(mutation_response['dastProfile']).not_to be_nil
end
it 'returns a non-nil pipelineUrl' do
subject
expect(mutation_response['pipelineUrl']).not_to be_nil
end
it 'updates the dast_profile' do
expect { subject }.to change { dast_profile.reload.name }.to('updated dast_profiles.name')
end
context 'when updating fails' do
it 'returns an error' do
allow_next_instance_of(::Dast::Profiles::UpdateService) do |service|
allow(service).to receive(:execute).and_return(
ServiceResponse.error(message: 'Profile failed to update')
)
end
subject
expect(mutation_response['errors']).to include('Profile failed to update')
end
end
end
end
......@@ -3,15 +3,15 @@
require 'spec_helper'
RSpec.describe Dast::Profiles::UpdateService do
let_it_be(:project) { create(:project) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let_it_be(:dast_profile, reload: true) { create(:dast_profile, project: 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(:params) do
let(:default_params) do
{
id: dast_profile.id,
dast_profile: dast_profile,
dast_site_profile_id: dast_site_profile.id,
dast_scanner_profile_id: dast_scanner_profile.id,
name: SecureRandom.hex,
......@@ -19,6 +19,8 @@ RSpec.describe Dast::Profiles::UpdateService do
}
end
let(:params) { default_params }
subject do
described_class.new(
container: project,
......@@ -81,7 +83,7 @@ RSpec.describe Dast::Profiles::UpdateService do
end
it 'updates the dast_profile' do
updated_dast_profile = subject.payload.reload
updated_dast_profile = subject.payload[:dast_profile].reload
aggregate_failures do
expect(updated_dast_profile.dast_site_profile.id).to eq(params[:dast_site_profile_id])
......@@ -91,13 +93,29 @@ RSpec.describe Dast::Profiles::UpdateService do
end
end
context 'when id param is missing' do
context 'when param run_after_update: true' do
let(:params) { default_params.merge(run_after_update: 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 dast_profile param is missing' do
let(:params) { {} }
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('ID parameter missing')
expect(subject.message).to eq('Profile parameter missing')
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