Skip to content
Snippets Groups Projects
Commit 3e84b7c5 authored by Philip Cunningham's avatar Philip Cunningham Committed by Ash McKenzie
Browse files

Add DastProfileUpdate GraphQL mutation

Allow users to update existing Dast::Profiles.
parent dd6ec7c3
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -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
Loading
Loading
@@ -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.
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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",
Loading
Loading
@@ -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,
Loading
Loading
@@ -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.
Loading
Loading
Loading
Loading
@@ -43,6 +43,7 @@ module MutationType
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
Loading
Loading
# 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
Loading
Loading
@@ -7,12 +7,16 @@ class UpdateService < BaseContainerService
 
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
Loading
Loading
@@ -23,24 +27,38 @@ def allowed?
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
Loading
Loading
@@ -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,
Loading
Loading
@@ -19,6 +19,8 @@
}
end
 
let(:params) { default_params }
subject do
described_class.new(
container: project,
Loading
Loading
@@ -81,7 +83,7 @@
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])
Loading
Loading
@@ -91,13 +93,29 @@
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
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment