Commit b521b713 authored by Philip Cunningham's avatar Philip Cunningham Committed by Bob Van Landuyt

Add dastSiteTokenCreate GraphQL mutation

Adds new mutation for creating new tokens for validating targets.
parent dcc29126
......@@ -4394,6 +4394,61 @@ enum DastSiteProfileValidationStatusEnum {
PENDING_VALIDATION
}
"""
Autogenerated input type of DastSiteTokenCreate
"""
input DastSiteTokenCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The project the site token belongs to.
"""
fullPath: ID!
"""
The URL of the target to be validated.
"""
targetUrl: String
}
"""
Autogenerated return type of DastSiteTokenCreate
"""
type DastSiteTokenCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
ID of the site token.
"""
id: DastSiteTokenID
"""
The current validation status of the target.
"""
status: DastSiteProfileValidationStatusEnum
"""
Token string.
"""
token: String
}
"""
Identifier of DastSiteToken
"""
scalar DastSiteTokenID
"""
Date represented in ISO 8601
"""
......@@ -12087,6 +12142,7 @@ type Mutation {
dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload
dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload
dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload
dastSiteTokenCreate(input: DastSiteTokenCreateInput!): DastSiteTokenCreatePayload
deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload
designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
......
......@@ -11860,6 +11860,156 @@
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastSiteTokenCreateInput",
"description": "Autogenerated input type of DastSiteTokenCreate",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "The project the site token belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "targetUrl",
"description": "The URL of the target to be validated.",
"type": {
"kind": "SCALAR",
"name": "String",
"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": "DastSiteTokenCreatePayload",
"description": "Autogenerated return type of DastSiteTokenCreate",
"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 token.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "DastSiteTokenID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "The current validation status of the target.",
"args": [
],
"type": {
"kind": "ENUM",
"name": "DastSiteProfileValidationStatusEnum",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "token",
"description": "Token string.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "DastSiteTokenID",
"description": "Identifier of DastSiteToken",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Date",
......@@ -34051,6 +34201,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastSiteTokenCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastSiteTokenCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastSiteTokenCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteAnnotation",
"description": null,
......@@ -704,6 +704,18 @@ Autogenerated return type of DastSiteProfileUpdate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `id` | DastSiteProfileID | ID of the site profile. |
### DastSiteTokenCreatePayload
Autogenerated return type of DastSiteTokenCreate.
| 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` | DastSiteTokenID | ID of the site token. |
| `status` | DastSiteProfileValidationStatusEnum | The current validation status of the target. |
| `token` | String | Token string. |
### DeleteAnnotationPayload
Autogenerated return type of DeleteAnnotation.
......
# frozen_string_literal: true
class DastSiteValidationsFinder
DEFAULT_SORT_VALUE = 'id'.freeze
DEFAULT_SORT_DIRECTION = 'desc'.freeze
def initialize(params = {})
@params = params
end
def execute
relation = DastSiteValidation.all
relation = by_project(relation)
relation = by_url_base(relation)
sort(relation)
end
private
attr_reader :params
def by_project(relation)
return relation if params[:project_id].nil?
relation.by_project_id(params[:project_id])
end
def by_url_base(relation)
return relation if params[:url_base].nil?
relation.by_url_base(params[:url_base])
end
# rubocop: disable CodeReuse/ActiveRecord
def sort(relation)
relation.order(DEFAULT_SORT_VALUE => DEFAULT_SORT_DIRECTION)
end
# rubocop: enable CodeReuse/ActiveRecord
end
......@@ -38,6 +38,7 @@ module EE
mount_mutation ::Mutations::DastScannerProfiles::Create
mount_mutation ::Mutations::DastScannerProfiles::Update
mount_mutation ::Mutations::DastScannerProfiles::Delete
mount_mutation ::Mutations::DastSiteTokens::Create
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureSast
mount_mutation ::Mutations::Namespaces::IncreaseStorageTemporarily
mount_mutation ::Mutations::QualityManagement::TestCases::Create
......
# frozen_string_literal: true
module Mutations
module DastSiteTokens
class Create < BaseMutation
include AuthorizesProject
graphql_name 'DastSiteTokenCreate'
field :id, ::Types::GlobalIDType[::DastSiteToken],
null: true,
description: 'ID of the site token.'
field :token, GraphQL::STRING_TYPE,
null: true,
description: 'Token string.'
field :status, Types::DastSiteProfileValidationStatusEnum,
null: true,
description: 'The current validation status of the target.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the site token belongs to.'
argument :target_url, GraphQL::STRING_TYPE,
required: false,
description: 'The URL of the target to be validated.'
authorize :create_on_demand_dast_scan
def resolve(full_path:, target_url:)
project = authorized_find_project!(full_path: full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
response = ::DastSiteTokens::CreateService.new(
container: project,
params: { target_url: target_url }
).execute
return error_response(response.errors) if response.error?
success_response(response.payload[:dast_site_token], response.payload[:status])
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_token, status)
{ errors: [], id: dast_site_token.to_global_id, status: status, token: dast_site_token.token }
end
end
end
end
......@@ -13,6 +13,10 @@ class DastSiteValidation < ApplicationRecord
joins(:dast_site_token).where(dast_site_tokens: { project_id: project_id })
end
scope :by_url_base, -> (url_base) do
where(url_base: url_base)
end
before_create :set_normalized_url_base
enum validation_strategy: { text_file: 0, header: 1 }
......@@ -59,11 +63,15 @@ class DastSiteValidation < ApplicationRecord
end
end
def self.get_normalized_url_base(url)
uri = URI(url)
"%{scheme}://%{host}:%{port}" % { scheme: uri.scheme, host: uri.host, port: uri.port }
end
private
def set_normalized_url_base
uri = URI(dast_site_token.url)
self.url_base = "%{scheme}://%{host}:%{port}" % { scheme: uri.scheme, host: uri.host, port: uri.port }
self.url_base = self.class.get_normalized_url_base(dast_site_token.url)
end
end
# frozen_string_literal: true
module DastSiteTokens
class CreateService < BaseContainerService
def execute
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
target_url = params[:target_url]
url_base = normalize_target_url(target_url)
dast_site_token = DastSiteToken.create!(
project: container,
token: SecureRandom.uuid,
url: target_url
)
dast_site_validation = find_dast_site_validation(url_base)
status = calculate_status(dast_site_validation)
ServiceResponse.success(
payload: { dast_site_token: dast_site_token, status: status }
)
rescue ActiveRecord::RecordInvalid => err
ServiceResponse.error(message: err.record.errors.full_messages)
rescue URI::InvalidURIError
ServiceResponse.error(message: 'Invalid target_url')
end
private
def allowed?
container.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:security_on_demand_scans_site_validation, container)
end
def normalize_target_url(target_url)
DastSiteValidation.get_normalized_url_base(target_url)
end
def find_dast_site_validation(url_base)
DastSiteValidationsFinder.new(project_id: container.id, url_base: url_base)
.execute
.first
end
def calculate_status(dast_site_validation)
state = dast_site_validation&.state || DastSiteValidation::INITIAL_STATE
"#{state}_VALIDATION".upcase
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DastSiteValidationsFinder do
let_it_be(:dast_site_validation_1) { create(:dast_site_validation) }
let_it_be(:dast_site_validation_2) { create(:dast_site_validation) }
let_it_be(:dast_site_validation_3) { create(:dast_site_validation, dast_site_token: dast_site_validation_1.dast_site_token) }
let(:params) { {} }
subject do
described_class.new(params).execute
end
describe '#execute' do
it 'returns all dast_site_validation_validations most recent first' do
expect(subject).to eq([dast_site_validation_3, dast_site_validation_2, dast_site_validation_1])
end
context 'filtering by url_base' do
let(:params) { { url_base: dast_site_validation_1.url_base } }
it 'returns the matching dast_site_validations' do
expect(subject).to eq([dast_site_validation_3, dast_site_validation_1])
end
end
context 'filtering by project_id' do
let(:params) { { project_id: dast_site_validation_2.project.id } }
it 'returns the matching dast_site_validations' do
expect(subject).to eq([dast_site_validation_2])
end
end
context 'when url_base is for a different project' do
let(:params) { { url_base: dast_site_validation_1.url_base, project_id: dast_site_validation_2.project.id } }
it 'returns an empty relation' do
expect(subject).to be_empty
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::DastSiteTokens::Create do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
let(:full_path) { project.full_path }
let(:target_url) { generate(:url) }
let(:dast_site_token) { DastSiteToken.find_by!(project: project, token: uuid) }
let(:uuid) { '0000-0000-0000-0000' }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
before do
stub_licensed_features(security_on_demand_scans: true)
allow(SecureRandom).to receive(:uuid).and_return(uuid)
end
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
target_url: target_url
)
end
context 'when on demand scan feature is enabled' do
context 'when the project does not exist' do
let(:full_path) { 'project-does-not-exist' }
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_token id' do
group.add_owner(user)
expect(subject[:id]).to eq(dast_site_token.to_global_id)
end
end
context 'when the user is a maintainer' do
it 'returns the dast_site_token id' do
project.add_maintainer(user)
expect(subject[:id]).to eq(dast_site_token.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_token id' do
expect(subject[:id]).to eq(dast_site_token.to_global_id)
end
it 'returns the dast_site_token status' do
expect(subject[:status]).to eq('PENDING_VALIDATION')
end
it 'returns the dast_site_token token' do
expect(subject[:token]).to eq(SecureRandom.uuid)
end
context 'when the associated dast_site_validation has been validated' do
it 'returns the correct status' do
create(:dast_site_validation, dast_site_token: subject[:id].find, state: :failed)
result = mutation.resolve(
full_path: full_path,
target_url: target_url
)
expect(result[:status]).to eq('FAILED_VALIDATION')
end
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
......@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe DastSiteValidation, type: :model do
subject { create(:dast_site_validation) }
let_it_be(:another_dast_site_validation) { create(:dast_site_validation) }
describe 'associations' do
it { is_expected.to belong_to(:dast_site_token) }
it { is_expected.to have_many(:dast_sites) }
......@@ -36,8 +38,6 @@ RSpec.describe DastSiteValidation, type: :model do
describe 'scopes' do
describe 'by_project_id' do
let(:another_dast_site_validation) { create(:dast_site_validation) }
it 'includes the correct records' do
result = described_class.by_project_id(subject.dast_site_token.project_id)
......@@ -47,6 +47,18 @@ RSpec.describe DastSiteValidation, type: :model do
end
end
end
describe 'by_url_base' do
let(:more_dast_site_validations) do
create_list(:dast_site_validation, 5, dast_site_token: subject.dast_site_token).prepend(subject)
end
it 'includes the correct records' do
result = described_class.by_url_base(subject.url_base)
expect(result).not_to include(another_dast_site_validation)
end
end
end
describe 'enums' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating a DAST Site Token' do
include GraphqlHelpers
let(:target_url) { generate(:url) }
let(:dast_site_token) { DastSiteToken.find_by!(project: project, token: uuid) }
let(:uuid) { '0000-0000-0000-0000' }
let(:mutation_name) { :dast_site_token_create }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: full_path,
target_url: target_url
)
end
before do
allow(SecureRandom).to receive(:uuid).and_return(uuid)
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_token id' do
subject
expect(mutation_response["id"]).to eq(dast_site_token.to_global_id.to_s)
end
it 'creates a new dast_site_token' do
expect { subject }.to change { DastSiteToken.count }.by(1)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DastSiteTokens::CreateService do
let(:project) { create(:project) }
let(:target_url) { generate(:url) }
subject do
described_class.new(
container: project,
params: { target_url: target_url }
).execute
end
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 'contains a dast_site_validation' do
expect(subject.payload[:dast_site_token]).to be_a(DastSiteToken)
end
it 'contains a status' do
expect(subject.payload[:status]).to eq('PENDING_VALIDATION')
end
context 'when an invalid target_url is supplied' do
let(:target_url) { 'http://bogus:broken' }
it 'communicates failure' do
aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Invalid target_url')
end
end
it 'does not create a dast_site_validation' do
expect { subject }.to not_change { DastSiteValidation.count }
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