Commit 3e69d50e authored by Philip Cunningham's avatar Philip Cunningham Committed by Adam Hegyi

Add DastSiteValidationCreate GraphQL mutation

Adds new mutation and associated tests for additions to on-demand DAST.
parent cf605972
---
title: Add db index for DastSiteValidation#state
merge_request: 45019
author:
type: added
# frozen_string_literal: true
class AddIndexOnStateForDastSiteValidations < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
OLD_INDEX_NAME = 'index_dast_site_validations_on_url_base'
NEW_INDEX_NAME = 'index_dast_site_validations_on_url_base_and_state'
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :dast_site_validations, [:url_base, :state], name: NEW_INDEX_NAME
remove_concurrent_index_by_name :dast_site_validations, OLD_INDEX_NAME
end
def down
add_concurrent_index :dast_site_validations, :url_base, name: OLD_INDEX_NAME
remove_concurrent_index_by_name :dast_site_validations, NEW_INDEX_NAME
end
end
1b12f68f7d8c56ecdd7d6d7551d81f5d95d1b4dc5f8d4c67eb239b8640128531
\ No newline at end of file
......@@ -20334,7 +20334,7 @@ CREATE INDEX index_dast_site_tokens_on_project_id ON dast_site_tokens USING btre
CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON dast_site_validations USING btree (dast_site_token_id);
CREATE INDEX index_dast_site_validations_on_url_base ON dast_site_validations USING btree (url_base);
CREATE INDEX index_dast_site_validations_on_url_base_and_state ON dast_site_validations USING btree (url_base, state);
CREATE INDEX index_dast_sites_on_dast_site_validation_id ON dast_sites USING btree (dast_site_validation_id);
......
......@@ -4569,6 +4569,78 @@ Identifier of DastSiteToken
"""
scalar DastSiteTokenID
"""
Autogenerated input type of DastSiteValidationCreate
"""
input DastSiteValidationCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
ID of the site token.
"""
dastSiteTokenId: DastSiteTokenID!
"""
The project the site profile belongs to.
"""
fullPath: ID!
"""
The validation strategy to be used.
"""
strategy: DastSiteValidationStrategyEnum
"""
The path to be requested during validation.
"""
validationPath: String!
}
"""
Autogenerated return type of DastSiteValidationCreate
"""
type DastSiteValidationCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
ID of the site validation.
"""
id: DastSiteValidationID
"""
The current validation status.
"""
status: DastSiteProfileValidationStatusEnum
}
"""
Identifier of DastSiteValidation
"""
scalar DastSiteValidationID
enum DastSiteValidationStrategyEnum {
"""
Header validation
"""
HEADER
"""
Text file validation
"""
TEXT_FILE
}
"""
Date represented in ISO 8601
"""
......@@ -12377,6 +12449,7 @@ type Mutation {
dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload
dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload
dastSiteTokenCreate(input: DastSiteTokenCreateInput!): DastSiteTokenCreatePayload
dastSiteValidationCreate(input: DastSiteValidationCreateInput!): DastSiteValidationCreatePayload
deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload
designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
......
......@@ -12360,6 +12360,193 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastSiteValidationCreateInput",
"description": "Autogenerated input type of DastSiteValidationCreate",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "The project the site profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "dastSiteTokenId",
"description": "ID of the site token.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastSiteTokenID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "validationPath",
"description": "The path to be requested during validation.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "strategy",
"description": "The validation strategy to be used.",
"type": {
"kind": "ENUM",
"name": "DastSiteValidationStrategyEnum",
"ofType": null
},
"defaultValue": null
},
{
"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": "DastSiteValidationCreatePayload",
"description": "Autogenerated return type of DastSiteValidationCreate",
"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": "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": "id",
"description": "ID of the site validation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "DastSiteValidationID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "The current validation status.",
"args": [
],
"type": {
"kind": "ENUM",
"name": "DastSiteProfileValidationStatusEnum",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "DastSiteValidationID",
"description": "Identifier of DastSiteValidation",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastSiteValidationStrategyEnum",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "TEXT_FILE",
"description": "Text file validation",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "HEADER",
"description": "Header validation",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Date",
......@@ -34883,6 +35070,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastSiteValidationCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastSiteValidationCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastSiteValidationCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteAnnotation",
"description": null,
......@@ -733,6 +733,17 @@ Autogenerated return type of DastSiteTokenCreate.
| `status` | DastSiteProfileValidationStatusEnum | The current validation status of the target. |
| `token` | String | Token string. |
### DastSiteValidationCreatePayload
Autogenerated return type of DastSiteValidationCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `id` | DastSiteValidationID | ID of the site validation. |
| `status` | DastSiteProfileValidationStatusEnum | The current validation status. |
### DeleteAnnotationPayload
Autogenerated return type of DeleteAnnotation.
......@@ -3286,6 +3297,13 @@ Status of a container repository.
| `PASSED_VALIDATION` | Site validation process finished successfully |
| `PENDING_VALIDATION` | Site validation process has not started |
### DastSiteValidationStrategyEnum
| Value | Description |
| ----- | ----------- |
| `HEADER` | Header validation |
| `TEXT_FILE` | Text file validation |
### DesignCollectionCopyState
Copy state of a DesignCollection.
......
......@@ -12,6 +12,7 @@ class DastSiteValidationsFinder
relation = DastSiteValidation.all
relation = by_project(relation)
relation = by_url_base(relation)
relation = by_state(relation)
sort(relation)
end
......@@ -32,6 +33,12 @@ class DastSiteValidationsFinder
relation.by_url_base(params[:url_base])
end
def by_state(relation)
return relation if params[:state].nil?
relation.with_state(params[:state])
end
# rubocop: disable CodeReuse/ActiveRecord
def sort(relation)
relation.order(DEFAULT_SORT_VALUE => DEFAULT_SORT_DIRECTION)
......
......@@ -35,6 +35,7 @@ module EE
mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Update
mount_mutation ::Mutations::DastSiteProfiles::Delete
mount_mutation ::Mutations::DastSiteValidations::Create
mount_mutation ::Mutations::DastScannerProfiles::Create
mount_mutation ::Mutations::DastScannerProfiles::Update
mount_mutation ::Mutations::DastScannerProfiles::Delete
......
# frozen_string_literal: true
module Mutations
module DastSiteValidations
class Create < BaseMutation
include AuthorizesProject
graphql_name 'DastSiteValidationCreate'
field :id, ::Types::GlobalIDType[::DastSiteValidation],
null: true,
description: 'ID of the site validation.'
field :status, ::Types::DastSiteProfileValidationStatusEnum,
null: true,
description: 'The current validation status.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the site profile belongs to.'
argument :dast_site_token_id, ::Types::GlobalIDType[::DastSiteToken],
required: true,
description: 'ID of the site token.'
argument :validation_path, GraphQL::STRING_TYPE,
required: true,
description: 'The path to be requested during validation.'
argument :strategy, ::Types::DastSiteValidationStrategyEnum,
required: false,
description: 'The validation strategy to be used.'
authorize :create_on_demand_dast_scan
def resolve(full_path:, dast_site_token_id:, validation_path:, strategy: :text_file)
project = authorized_find_project!(full_path: full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
dast_site_token = dast_site_token_id.find
response = ::DastSiteValidations::CreateService.new(
container: project,
params: {
dast_site_token: dast_site_token,
url_path: validation_path,
validation_strategy: strategy
}
).execute
return error_response(response.errors) if response.error?
success_response(response.payload)
end
private
def allowed?(project)
Feature.enabled?(:security_on_demand_scans_site_validation, project)
end
def error_response(errors)
{ errors: errors }
end
def success_response(dast_site_validation)
status = "#{dast_site_validation.state}_VALIDATION".upcase
{ errors: [], id: dast_site_validation.to_global_id, status: status }
end
end
end
end
# frozen_string_literal: true
module Types
class DastSiteValidationStrategyEnum < BaseEnum
value 'TEXT_FILE', description: 'Text file validation', value: 'text_file'
value 'HEADER', description: 'Header validation', value: 'header'
end
end
# frozen_string_literal: true
module DastSiteValidations
class CreateService < BaseContainerService
def execute
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
return ServiceResponse.success(payload: existing_validation) if existing_validation
dast_site_validation = create_validation!
perform_async_validation(dast_site_validation)
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?(:security_on_demand_scans_site_validation, container) &&
dast_site_token.project == container
end
def dast_site_token
@dast_site_token ||= params.fetch(:dast_site_token)
end
def url_path
@url_path ||= params.fetch(:url_path)
end
def validation_strategy
@validation_strategy ||= params.fetch(:validation_strategy)
end
def existing_validation
@existing_validation ||= find_latest_successful_dast_site_validation
end
def url_base
@url_base ||= DastSiteValidation.get_normalized_url_base(dast_site_token.url)
end
def find_latest_successful_dast_site_validation
DastSiteValidationsFinder.new(
project_id: container.id,
state: :passed,
url_base: url_base
).execute.first
end
def create_validation!
DastSiteValidation.create!(
dast_site_token: dast_site_token,
url_path: url_path,
validation_strategy: validation_strategy
)
end
def perform_async_validation(dast_site_validation)
jid = DastSiteValidationWorker.perform_async(dast_site_validation.id)
unless jid.present?
log_error(message: 'Unable to validate dast_site_validation', dast_site_validation_id: dast_site_validation.id)
dast_site_validation.fail_op
return ServiceResponse.error(message: 'Validation failed')
end
ServiceResponse.success(payload: dast_site_validation)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::DastSiteValidations::Create do
let(:group) { create(:group) }
let(:project) { dast_site_token.project }
let(:user) { create(:user) }
let(:full_path) { project.full_path }
let(:dast_site_token) { create(:dast_site_token, project: create(:project, group: group)) }
let(:dast_site_validation) { DastSiteValidation.find_by!(url_path: validation_path) }
let(:validation_path) { SecureRandom.hex }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
before do
stub_licensed_features(security_on_demand_scans: true)
end
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
dast_site_token_id: dast_site_token.to_global_id,
validation_path: validation_path,
strategy: :text_file
)
end
context 'when on demand scan feature is enabled' do
context 'when the project does not exist' do
let(:full_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is not associated with the project' do
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is an owner' do
it 'returns the dast_site_validation id' do
group.add_owner(user)
expect(subject[:id]).to eq(dast_site_validation.to_global_id)
end
end
context 'when the user is a maintainer' do
it 'returns the dast_site_validation id' do
project.add_maintainer(user)
expect(subject[:id]).to eq(dast_site_validation.to_global_id)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns the dast_site_validation id' do
expect(subject[:id]).to eq(dast_site_validation.to_global_id)
end
it 'returns the dast_site_validation status' do
expect(subject[:status]).to eq('PENDING_VALIDATION')
end
context 'when on demand scan feature is not enabled' do
it 'raises an exception' do
stub_feature_flags(security_on_demand_scans_feature_flag: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when on demand scan site validations feature is not enabled' do
it 'raises an exception' do
stub_feature_flags(security_on_demand_scans_site_validation: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when on demand scan licensed feature is not available' do
it 'raises an exception' do
stub_licensed_features(security_on_demand_scans: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating a DAST Site Token' do
include GraphqlHelpers
let(:dast_site_token) { create(:dast_site_token, project: project) }
let(:dast_site_validation) { DastSiteValidation.find_by!(url_path: validation_path) }
let(:validation_path) { SecureRandom.hex }
let(:mutation_name) { :dast_site_validation_create }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: full_path,
dast_site_token_id: dast_site_token.to_global_id.to_s,
validation_path: validation_path,
strategy: Types::DastSiteValidationStrategyEnum.values['TEXT_FILE'].graphql_name
)
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 the dast_site_validation id' do
subject
expect(mutation_response["id"]).to eq(dast_site_validation.to_global_id.to_s)
end
it 'creates a new dast_site_validation' do
expect { subject }.to change { DastSiteValidation.count }.by(1)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DastSiteValidations::CreateService do
let(:project) { dast_site_token.project }
let(:dast_site_token) { create(:dast_site_token, project: create(:project)) }
let(:url_path) { SecureRandom.hex }
let(:params) { { dast_site_token: dast_site_token, url_path: url_path, validation_strategy: :text_file } }
subject { described_class.new(container: project, params: params).execute }
describe 'execute' do
context 'when on demand scan feature is disabled' do
it 'communicates failure' do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(security_on_demand_scans_site_validation: 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(security_on_demand_scans_site_validation: 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(security_on_demand_scans_site_validation: true)
end
it 'communicates success' do
expect(subject.status).to eq(:success)
end
it 'attempts to validate' do
aggregate_failures do
expect(DastSiteValidationWorker).to receive(:perform_async)
expect { subject }.to change { DastSiteValidation.count }.by(1)
end
end
context 'when the associated dast_site_validation has successfully been validated' do
it 'returns the existing successful dast_site_validation' do
dast_site_validation = create(:dast_site_validation, dast_site_token: dast_site_token, state: :passed)
expect(subject.payload).to eq(dast_site_validation)
end
it 'does not attempt to re-validate' do
create(:dast_site_validation, dast_site_token: dast_site_token, state: :passed)
aggregate_failures do
expect(DastSiteValidationWorker).not_to receive(:perform_async)
expect { subject }.not_to change { DastSiteValidation.count }
end
end
end
context 'when a param is missing' do
let(:params) { { dast_site_token: dast_site_token, validation_strategy: :text_file } }
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Key not found: :url_path')
end
end
end
context 'when the dast_site_token.project and container do not match' do
let(:project) { create(:project) }
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Insufficient permissions')
end
end
end
context 'when worker does not return a job id' do
before do
allow(DastSiteValidationWorker).to receive(:perform_async).and_return(nil)
end
let(:dast_site_validation) do
DastSiteValidation.find_by!(dast_site_token: dast_site_token, url_path: url_path)
end
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Validation failed')
end
end
it 'sets dast_site_validation.state to failed' do
subject
expect(dast_site_validation.state).to eq('failed')
end
it 'logs an error' do
allow(Gitlab::AppLogger).to receive(:error)
subject
expect(Gitlab::AppLogger).to have_received(:error).with(message: 'Unable to validate dast_site_validation', dast_site_validation_id: dast_site_validation.id)
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