diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 4622618db42eec836dac13212489e749e1fd555f..d6fe56bea735d4a2bc14395768ce6500d3efaf6b 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1437,6 +1437,7 @@ Input type: `DastProfileCreateInput`
| ---- | ---- | ----------- |
| `branchName` | [`String`](#string) | Associated branch. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `dastProfileSchedule` | [`DastProfileScheduleInput`](#dastprofilescheduleinput) | Represents a DAST Profile Schedule. Results in an error if `dast_on_demand_scans_scheduler` feature flag is disabled. |
| `dastScannerProfileId` | [`DastScannerProfileID!`](#dastscannerprofileid) | ID of the scanner profile to be associated. |
| `dastSiteProfileId` | [`DastSiteProfileID!`](#dastsiteprofileid) | ID of the site profile to be associated. |
| `description` | [`String`](#string) | Description of the profile. Defaults to an empty string. |
@@ -15179,6 +15180,17 @@ Status of a container repository.
| `DELETE_FAILED` | Delete Failed status. |
| `DELETE_SCHEDULED` | Delete Scheduled status. |
+### `DastProfileCadenceUnit`
+
+Unit for the duration of Dast Profile Cadence.
+
+| Value | Description |
+| ----- | ----------- |
+| `DAY` | DAST Profile Cadence duration in days. |
+| `MONTH` | DAST Profile Cadence duration in months. |
+| `WEEK` | DAST Profile Cadence duration in weeks. |
+| `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
| `name` | [`String`](#string) | New name for the compliance framework. |
| `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 |
+| ---- | ---- | ----------- |
+| `duration` | [`Int`](#int) | Duration of the DAST Profile Cadence. |
+| `unit` | [`DastProfileCadenceUnit`](#dastprofilecadenceunit) | Unit for the duration of DAST Profile Cadence. |
+
+### `DastProfileScheduleInput`
+
+Input type for DAST Profile Schedules.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `active` | [`Boolean`](#boolean) | Status of a Dast Profile Schedule. |
+| `cadence` | [`DastProfileCadenceInput`](#dastprofilecadenceinput) | Cadence of a Dast Profile Schedule. |
+| `startsAt` | [`Time`](#time) | Start time of a Dast Profile Schedule. |
+| `timezone` | [`String`](#string) | Time Zone for the Start time of a Dast Profile Schedule. |
+
### `DastSiteProfileAuthInput`
Input type for DastSiteProfile authentication.
diff --git a/ee/app/graphql/mutations/dast/profiles/create.rb b/ee/app/graphql/mutations/dast/profiles/create.rb
index 0e26e765ca0e391338b480594008e8a884776eb1..33c10d74a99e67b04da8104f1e63c70e2a1382fc 100644
--- a/ee/app/graphql/mutations/dast/profiles/create.rb
+++ b/ee/app/graphql/mutations/dast/profiles/create.rb
@@ -20,6 +20,10 @@ class Create < BaseMutation
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 @@ class Create < BaseMutation
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 @@ def resolve(full_path:, name:, description: '', branch_name: nil, dast_site_prof
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
diff --git a/ee/app/graphql/types/dast/profile_cadence_input_type.rb b/ee/app/graphql/types/dast/profile_cadence_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d63ab54754b7755b0c81284c7d78916d04e1ecb3
--- /dev/null
+++ b/ee/app/graphql/types/dast/profile_cadence_input_type.rb
@@ -0,0 +1,18 @@
+# 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
diff --git a/ee/app/graphql/types/dast/profile_cadence_unit_enum.rb b/ee/app/graphql/types/dast/profile_cadence_unit_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d374c97fcf89610e3a3a3e6b64936eb5a6352a5a
--- /dev/null
+++ b/ee/app/graphql/types/dast/profile_cadence_unit_enum.rb
@@ -0,0 +1,15 @@
+# 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
diff --git a/ee/app/graphql/types/dast/profile_schedule_input_type.rb b/ee/app/graphql/types/dast/profile_schedule_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bd6aec857bf1f105e9706d5b9f5b26139355617f
--- /dev/null
+++ b/ee/app/graphql/types/dast/profile_schedule_input_type.rb
@@ -0,0 +1,25 @@
+# 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
diff --git a/ee/app/services/app_sec/dast/profiles/create_service.rb b/ee/app/services/app_sec/dast/profiles/create_service.rb
index 93660242590f8b80aae58c32d52d94e7e76e93da..168f4ae7504e4b80f3834ebf35b71d24e0fd68a9 100644
--- a/ee/app/services/app_sec/dast/profiles/create_service.rb
+++ b/ee/app/services/app_sec/dast/profiles/create_service.rb
@@ -7,7 +7,38 @@ class CreateService < BaseContainerService
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 @@ def execute
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 @@ def dast_scanner_profile
@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
diff --git a/ee/spec/factories/dast/profile_schedules.rb b/ee/spec/factories/dast/profile_schedules.rb
index d00f03fc308263cc45893846bcf69418ce15613a..e0618e2e05ef226e32f1d4b47facb9d49e1eeec8 100644
--- a/ee/spec/factories/dast/profile_schedules.rb
+++ b/ee/spec/factories/dast/profile_schedules.rb
@@ -5,8 +5,9 @@
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
diff --git a/ee/spec/graphql/mutations/dast/profiles/create_spec.rb b/ee/spec/graphql/mutations/dast/profiles/create_spec.rb
index 01646441c0da8ea6a714fd6ab4e3c1891b3afb38..5e9ac96b9b758e8646a204844cb66c21d3bcffc9 100644
--- a/ee/spec/graphql/mutations/dast/profiles/create_spec.rb
+++ b/ee/spec/graphql/mutations/dast/profiles/create_spec.rb
@@ -14,6 +14,7 @@
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 @@
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 @@
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
diff --git a/ee/spec/graphql/types/dast/profile_cadence_enum_spec.rb b/ee/spec/graphql/types/dast/profile_cadence_enum_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..996fcd5b1ea075d9401376b0a57acb8b73048726
--- /dev/null
+++ b/ee/spec/graphql/types/dast/profile_cadence_enum_spec.rb
@@ -0,0 +1,11 @@
+# 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
diff --git a/ee/spec/graphql/types/dast/profile_cadence_input_type_spec.rb b/ee/spec/graphql/types/dast/profile_cadence_input_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0dc7668f27e01a968ea18e9752cf86731d1526b1
--- /dev/null
+++ b/ee/spec/graphql/types/dast/profile_cadence_input_type_spec.rb
@@ -0,0 +1,13 @@
+# 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
diff --git a/ee/spec/graphql/types/dast/profile_schedule_input_type_spec.rb b/ee/spec/graphql/types/dast/profile_schedule_input_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..45850ad3026a0b9a910eb97000a94628da24a771
--- /dev/null
+++ b/ee/spec/graphql/types/dast/profile_schedule_input_type_spec.rb
@@ -0,0 +1,13 @@
+# 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
diff --git a/ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb b/ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb
index 1ee1c3b97be0528eba6026dedfe11f2079c2e74f..e08330e5bb38b759ed95fbcca3837baf5a68d509 100644
--- a/ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb
@@ -43,5 +43,32 @@
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
diff --git a/ee/spec/services/app_sec/dast/profiles/create_service_spec.rb b/ee/spec/services/app_sec/dast/profiles/create_service_spec.rb
index 6e6c18ff9e19642c88345f2a32d672063b9e4e01..304a17cc5d9cd6476b26d3238b051d494684d915 100644
--- a/ee/spec/services/app_sec/dast/profiles/create_service_spec.rb
+++ b/ee/spec/services/app_sec/dast/profiles/create_service_spec.rb
@@ -7,7 +7,7 @@
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 @@
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) }