Commit 01fa9eda authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '330308-dast-scheduler-graphql-create-mutation' into 'master'

Update dastProfileCreate mutation to include DAST Profile Schedules

See merge request gitlab-org/gitlab!68046
parents 42ca7d8a b236c6fd
......@@ -1437,6 +1437,7 @@ Input type: `DastProfileCreateInput`
| ---- | ---- | ----------- |
| <a id="mutationdastprofilecreatebranchname"></a>`branchName` | [`String`](#string) | Associated branch. |
| <a id="mutationdastprofilecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationdastprofilecreatedastprofileschedule"></a>`dastProfileSchedule` | [`DastProfileScheduleInput`](#dastprofilescheduleinput) | Represents a DAST Profile Schedule. Results in an error if `dast_on_demand_scans_scheduler` feature flag is disabled. |
| <a id="mutationdastprofilecreatedastscannerprofileid"></a>`dastScannerProfileId` | [`DastScannerProfileID!`](#dastscannerprofileid) | ID of the scanner profile to be associated. |
| <a id="mutationdastprofilecreatedastsiteprofileid"></a>`dastSiteProfileId` | [`DastSiteProfileID!`](#dastsiteprofileid) | ID of the site profile to be associated. |
| <a id="mutationdastprofilecreatedescription"></a>`description` | [`String`](#string) | Description of the profile. Defaults to an empty string. |
......@@ -15179,6 +15180,17 @@ Status of a container repository.
| <a id="containerrepositorystatusdelete_failed"></a>`DELETE_FAILED` | Delete Failed status. |
| <a id="containerrepositorystatusdelete_scheduled"></a>`DELETE_SCHEDULED` | Delete Scheduled status. |
### `DastProfileCadenceUnit`
Unit for the duration of Dast Profile Cadence.
| Value | Description |
| ----- | ----------- |
| <a id="dastprofilecadenceunitday"></a>`DAY` | DAST Profile Cadence duration in days. |
| <a id="dastprofilecadenceunitmonth"></a>`MONTH` | DAST Profile Cadence duration in months. |
| <a id="dastprofilecadenceunitweek"></a>`WEEK` | DAST Profile Cadence duration in weeks. |
| <a id="dastprofilecadenceunityear"></a>`YEAR` | DAST Profile Cadence duration in years. |
### `DastScanTypeEnum`
| Value | Description |
......@@ -17396,6 +17408,30 @@ Field that are available while modifying the custom mapping attributes for an HT
| <a id="complianceframeworkinputname"></a>`name` | [`String`](#string) | New name for the compliance framework. |
| <a id="complianceframeworkinputpipelineconfigurationfullpath"></a>`pipelineConfigurationFullPath` | [`String`](#string) | Full path of the compliance pipeline configuration stored in a project repository, such as `.gitlab/.compliance-gitlab-ci.yml@compliance/hipaa` **(ULTIMATE)**. |
### `DastProfileCadenceInput`
Represents DAST Profile Cadence.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dastprofilecadenceinputduration"></a>`duration` | [`Int`](#int) | Duration of the DAST Profile Cadence. |
| <a id="dastprofilecadenceinputunit"></a>`unit` | [`DastProfileCadenceUnit`](#dastprofilecadenceunit) | Unit for the duration of DAST Profile Cadence. |
### `DastProfileScheduleInput`
Input type for DAST Profile Schedules.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dastprofilescheduleinputactive"></a>`active` | [`Boolean`](#boolean) | Status of a Dast Profile Schedule. |
| <a id="dastprofilescheduleinputcadence"></a>`cadence` | [`DastProfileCadenceInput`](#dastprofilecadenceinput) | Cadence of a Dast Profile Schedule. |
| <a id="dastprofilescheduleinputstartsat"></a>`startsAt` | [`Time`](#time) | Start time of a Dast Profile Schedule. |
| <a id="dastprofilescheduleinputtimezone"></a>`timezone` | [`String`](#string) | Time Zone for the Start time of a Dast Profile Schedule. |
### `DastSiteProfileAuthInput`
Input type for DastSiteProfile authentication.
......
......@@ -20,6 +20,10 @@ module Mutations
required: true,
description: 'Project the profile belongs to.'
argument :dast_profile_schedule, ::Types::Dast::ProfileScheduleInputType,
required: false,
description: 'Represents a DAST Profile Schedule. Results in an error if `dast_on_demand_scans_scheduler` feature flag is disabled.'
argument :name, GraphQL::Types::String,
required: true,
description: 'Name of the profile.'
......@@ -48,9 +52,9 @@ module Mutations
authorize :create_on_demand_dast_scan
def resolve(full_path:, name:, description: '', branch_name: nil, dast_site_profile_id:, dast_scanner_profile_id:, run_after_create: false)
def resolve(full_path:, name:, description: '', branch_name: nil, dast_site_profile_id:, dast_scanner_profile_id:, run_after_create: false, dast_profile_schedule: nil)
project = authorized_find!(full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project, dast_profile_schedule)
# TODO: remove explicit coercion once compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
......@@ -70,19 +74,35 @@ module Mutations
branch_name: branch_name,
dast_site_profile: dast_site_profile,
dast_scanner_profile: dast_scanner_profile,
run_after_create: run_after_create
run_after_create: run_after_create,
dast_profile_schedule: dast_profile_schedule
}
).execute
return { errors: response.errors } if response.error?
{ errors: [], dast_profile: response.payload.fetch(:dast_profile), pipeline_url: response.payload.fetch(:pipeline_url) }
build_response(response.payload)
end
private
def allowed?(project)
project.feature_available?(:security_on_demand_scans)
def allowed?(project, dast_profile_schedule)
scheduler_flag_enabled?(dast_profile_schedule, project)
end
def scheduler_flag_enabled?(dast_profile_schedule, project)
return true unless dast_profile_schedule
Feature.enabled?(:dast_on_demand_scans_scheduler, project, default_enabled: :yaml)
end
def build_response(payload)
{
errors: [],
dast_profile: payload.fetch(:dast_profile),
pipeline_url: payload.fetch(:pipeline_url),
dast_profile_schedule: payload.fetch(:dast_profile_schedule)
}
end
end
end
......
# frozen_string_literal: true
module Types
module Dast
class ProfileCadenceInputType < BaseInputObject
graphql_name 'DastProfileCadenceInput'
description 'Represents DAST Profile Cadence.'
argument :unit, ::Types::Dast::ProfileCadenceUnitEnum,
required: false,
description: 'Unit for the duration of DAST Profile Cadence.'
argument :duration, GraphQL::Types::Int,
required: false,
description: 'Duration of the DAST Profile Cadence.'
end
end
end
# frozen_string_literal: true
module Types
module Dast
class ProfileCadenceUnitEnum < BaseEnum
graphql_name 'DastProfileCadenceUnit'
description 'Unit for the duration of Dast Profile Cadence.'
value 'DAY', value: 'day', description: 'DAST Profile Cadence duration in days.'
value 'WEEK', value: 'week', description: 'DAST Profile Cadence duration in weeks.'
value 'MONTH', value: 'month', description: 'DAST Profile Cadence duration in months.'
value 'YEAR', value: 'year', description: 'DAST Profile Cadence duration in years.'
end
end
end
# frozen_string_literal: true
module Types
module Dast
class ProfileScheduleInputType < BaseInputObject
graphql_name 'DastProfileScheduleInput'
description 'Input type for DAST Profile Schedules'
argument :active, GraphQL::Types::Boolean,
required: false,
description: 'Status of a Dast Profile Schedule.'
argument :starts_at, Types::TimeType,
required: false,
description: 'Start time of a Dast Profile Schedule.'
argument :timezone, GraphQL::Types::String,
required: false,
description: 'Time Zone for the Start time of a Dast Profile Schedule.'
argument :cadence, ::Types::Dast::ProfileCadenceInputType,
required: false,
description: 'Cadence of a Dast Profile Schedule.'
end
end
end
......@@ -7,7 +7,38 @@ module AppSec
def execute
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
dast_profile = ::Dast::Profile.create!(
ApplicationRecord.transaction do
@dast_profile = create_profile
@schedule = create_schedule(@dast_profile) if params.dig(:dast_profile_schedule, :active)
end
create_audit_event(@dast_profile, @schedule)
if params.fetch(:run_after_create)
on_demand_scan = create_on_demand_scan(@dast_profile)
return on_demand_scan if on_demand_scan.error?
pipeline_url = on_demand_scan.payload.fetch(:pipeline_url)
end
ServiceResponse.success(
payload: {
dast_profile: @dast_profile,
pipeline_url: pipeline_url,
dast_profile_schedule: @schedule
}
)
rescue ActiveRecord::RecordInvalid => err
ServiceResponse.error(message: err.record.errors.full_messages)
rescue KeyError => err
ServiceResponse.error(message: err.message.capitalize)
end
private
def create_profile
::Dast::Profile.create!(
project: container,
name: params.fetch(:name),
description: params.fetch(:description),
......@@ -15,28 +46,27 @@ module AppSec
dast_site_profile: dast_site_profile,
dast_scanner_profile: dast_scanner_profile
)
end
create_audit_event(dast_profile)
return ServiceResponse.success(payload: { dast_profile: dast_profile, pipeline_url: nil }) unless params.fetch(:run_after_create)
def create_schedule(dast_profile)
::Dast::ProfileSchedule.create!(
owner: current_user,
dast_profile: dast_profile,
project_id: container.id,
cadence: dast_profile_schedule[:cadence],
timezone: dast_profile_schedule[:timezone],
starts_at: dast_profile_schedule[:starts_at]
)
end
response = ::DastOnDemandScans::CreateService.new(
def create_on_demand_scan(dast_profile)
::DastOnDemandScans::CreateService.new(
container: container,
current_user: current_user,
params: { dast_profile: dast_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.licensed_feature_available?(:security_on_demand_scans)
end
......@@ -49,14 +79,28 @@ module AppSec
@dast_scanner_profile ||= params.fetch(:dast_scanner_profile)
end
def create_audit_event(profile)
def dast_profile_schedule
params[:dast_profile_schedule]
end
def create_audit_event(dast_profile, schedule)
::Gitlab::Audit::Auditor.audit(
name: 'dast_profile_create',
author: current_user,
scope: container,
target: profile,
target: dast_profile,
message: "Added DAST profile"
)
if schedule
::Gitlab::Audit::Auditor.audit(
name: 'dast_profile_schedule_create',
author: current_user,
scope: container,
target: schedule,
message: 'Added DAST profile schedule'
)
end
end
end
end
......
......@@ -5,8 +5,9 @@ FactoryBot.define do
project
dast_profile
owner { association(:user) }
timezone { FFaker::Address.time_zone }
timezone { ActiveSupport::TimeZone.all.map { |tz| tz.tzinfo.identifier }.sample }
starts_at { Time.now }
cadence { { unit: %w(day month year week).sample, duration: 1 } }
active { true }
end
end
......@@ -14,6 +14,7 @@ RSpec.describe Mutations::Dast::Profiles::Create do
let(:run_after_create) { false }
let(:dast_profile) { Dast::Profile.find_by(project: project, name: name) }
let(:dast_profile_schedule) { nil }
subject(:mutation) { described_class.new(object: nil, context: { current_user: developer }, field: nil) }
......@@ -32,7 +33,8 @@ RSpec.describe Mutations::Dast::Profiles::Create do
branch_name: branch_name,
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
run_after_create: run_after_create,
dast_profile_schedule: dast_profile_schedule
)
end
......@@ -52,6 +54,30 @@ RSpec.describe Mutations::Dast::Profiles::Create do
let(:delegated_params) { hash_including(dast_profile: instance_of(Dast::Profile)) }
end
end
context 'when dast_on_demand_scans_scheduler feature is enabled' do
let(:dast_profile_schedule) { attributes_for(:dast_profile_schedule) }
before do
stub_feature_flags(dast_on_demand_scans_scheduler: true)
end
it 'returns the dast_profile_schedule' do
expect(subject[:dast_profile_schedule]).to eq(dast_profile.dast_profile_schedule)
end
end
context 'when dast_on_demand_scans_scheduler feature is disabled' do
let(:dast_profile_schedule) { attributes_for(:dast_profile_schedule) }
before do
stub_feature_flags(dast_on_demand_scans_scheduler: false)
end
it 'returns the dast_profile_schedule' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['DastProfileCadenceUnit'] do
it 'exposes all alert field names' do
expect(described_class.values.keys).to match_array(
%w(DAY WEEK MONTH YEAR)
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['DastProfileCadenceInput'] do
include GraphqlHelpers
specify { expect(described_class.graphql_name).to eq('DastProfileCadenceInput') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[unit duration])
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['DastProfileScheduleInput'] do
include GraphqlHelpers
specify { expect(described_class.graphql_name).to eq('DastProfileScheduleInput') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[active startsAt timezone cadence])
end
end
......@@ -43,5 +43,32 @@ RSpec.describe 'Creating a DAST Profile' do
expect(mutation_response['pipelineUrl']).not_to be_blank
end
context 'when dastProfileSchedule is present' do
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: full_path,
name: name,
branch_name: project.default_branch,
dast_site_profile_id: global_id_of(dast_site_profile),
dast_scanner_profile_id: global_id_of(dast_scanner_profile),
run_after_create: true,
dast_profile_schedule: {
starts_at: Time.zone.now,
active: true,
cadence: {
duration: 1,
unit: "DAY"
},
timezone: "America/New_York"
}
)
end
it 'creates dastProfileSchedule when passed' do
expect { subject }.to change { Dast::ProfileSchedule.count }.by(1)
end
end
end
end
......@@ -7,7 +7,7 @@ RSpec.describe AppSec::Dast::Profiles::CreateService do
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(:time_zone) { Time.zone.tzinfo.name }
let_it_be(:default_params) do
{
name: SecureRandom.hex,
......@@ -81,6 +81,71 @@ RSpec.describe AppSec::Dast::Profiles::CreateService do
end
end
context 'when param dast_profile_schedule is present' do
let(:params) do
default_params.merge(
dast_profile_schedule: {
active: true,
starts_at: Time.zone.now,
timezone: time_zone,
cadence: { unit: 'day', duration: 1 }
}
)
end
it 'creates the dast_profile_schedule' do
expect { subject }.to change { ::Dast::ProfileSchedule.count }.by(1)
end
it 'responds with dast_profile_schedule' do
expect(subject.payload[:dast_profile_schedule]).to be_a ::Dast::ProfileSchedule
end
it 'audits the creation' do
schedule = subject.payload[:dast_profile_schedule]
audit_event = AuditEvent.find_by(target_id: schedule.id)
aggregate_failures do
expect(audit_event.author).to eq(developer)
expect(audit_event.entity).to eq(project)
expect(audit_event.target_id).to eq(schedule.id)
expect(audit_event.target_type).to eq('Dast::ProfileSchedule')
expect(audit_event.details).to eq({
author_name: developer.name,
custom_message: 'Added DAST profile schedule',
target_id: schedule.id,
target_type: 'Dast::ProfileSchedule',
target_details: developer.name
})
end
end
context 'when invalid schedule it present' do
let(:bad_time_zone) { 'BadZone' }
let(:params) do
default_params.merge(
dast_profile_schedule: {
active: true,
starts_at: Time.zone.now,
timezone: bad_time_zone,
cadence: { unit: 'bad_day', duration: 100 }
}
)
end
it 'rollback the transaction' do
expect { subject }.to change { ::Dast::ProfileSchedule.count }.by(0)
.and change { ::Dast::Profile.count }.by(0)
end
it 'returns the error service response' do
expect(subject.error?).to be true
end
end
end
context 'when a param is missing' do
let(:params) { default_params.except(:run_after_create) }
......
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