Commit 626397fd authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'add-dast-site-token-create-mutation-245211' into 'master'

Add dastSiteTokenCreate mutation

See merge request gitlab-org/gitlab!44825
parents 7573c25d b521b713
...@@ -4399,6 +4399,61 @@ enum DastSiteProfileValidationStatusEnum { ...@@ -4399,6 +4399,61 @@ enum DastSiteProfileValidationStatusEnum {
PENDING_VALIDATION 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 Date represented in ISO 8601
""" """
...@@ -12092,6 +12147,7 @@ type Mutation { ...@@ -12092,6 +12147,7 @@ type Mutation {
dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload
dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload
dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload
dastSiteTokenCreate(input: DastSiteTokenCreateInput!): DastSiteTokenCreatePayload
deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload
designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
......
...@@ -11870,6 +11870,156 @@ ...@@ -11870,6 +11870,156 @@
], ],
"possibleTypes": null "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", "kind": "SCALAR",
"name": "Date", "name": "Date",
...@@ -34061,6 +34211,33 @@ ...@@ -34061,6 +34211,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "deleteAnnotation",
"description": null, "description": null,
...@@ -704,6 +704,18 @@ Autogenerated return type of DastSiteProfileUpdate. ...@@ -704,6 +704,18 @@ Autogenerated return type of DastSiteProfileUpdate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `id` | DastSiteProfileID | ID of the site profile. | | `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 ### DeleteAnnotationPayload
Autogenerated return type of DeleteAnnotation. 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 ...@@ -38,6 +38,7 @@ module EE
mount_mutation ::Mutations::DastScannerProfiles::Create mount_mutation ::Mutations::DastScannerProfiles::Create
mount_mutation ::Mutations::DastScannerProfiles::Update mount_mutation ::Mutations::DastScannerProfiles::Update
mount_mutation ::Mutations::DastScannerProfiles::Delete mount_mutation ::Mutations::DastScannerProfiles::Delete
mount_mutation ::Mutations::DastSiteTokens::Create
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureSast mount_mutation ::Mutations::Security::CiConfiguration::ConfigureSast
mount_mutation ::Mutations::Namespaces::IncreaseStorageTemporarily mount_mutation ::Mutations::Namespaces::IncreaseStorageTemporarily
mount_mutation ::Mutations::QualityManagement::TestCases::Create 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 ...@@ -13,6 +13,10 @@ class DastSiteValidation < ApplicationRecord
joins(:dast_site_token).where(dast_site_tokens: { project_id: project_id }) joins(:dast_site_token).where(dast_site_tokens: { project_id: project_id })
end end
scope :by_url_base, -> (url_base) do
where(url_base: url_base)
end
before_create :set_normalized_url_base before_create :set_normalized_url_base
enum validation_strategy: { text_file: 0, header: 1 } enum validation_strategy: { text_file: 0, header: 1 }
...@@ -59,11 +63,15 @@ class DastSiteValidation < ApplicationRecord ...@@ -59,11 +63,15 @@ class DastSiteValidation < ApplicationRecord
end end
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 private
def set_normalized_url_base def set_normalized_url_base
uri = URI(dast_site_token.url) self.url_base = self.class.get_normalized_url_base(dast_site_token.url)
self.url_base = "%{scheme}://%{host}:%{port}" % { scheme: uri.scheme, host: uri.host, port: uri.port }
end end
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' ...@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe DastSiteValidation, type: :model do RSpec.describe DastSiteValidation, type: :model do
subject { create(:dast_site_validation) } subject { create(:dast_site_validation) }
let_it_be(:another_dast_site_validation) { create(:dast_site_validation) }
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:dast_site_token) } it { is_expected.to belong_to(:dast_site_token) }
it { is_expected.to have_many(:dast_sites) } it { is_expected.to have_many(:dast_sites) }
...@@ -36,8 +38,6 @@ RSpec.describe DastSiteValidation, type: :model do ...@@ -36,8 +38,6 @@ RSpec.describe DastSiteValidation, type: :model do
describe 'scopes' do describe 'scopes' do
describe 'by_project_id' do describe 'by_project_id' do
let(:another_dast_site_validation) { create(:dast_site_validation) }
it 'includes the correct records' do it 'includes the correct records' do
result = described_class.by_project_id(subject.dast_site_token.project_id) result = described_class.by_project_id(subject.dast_site_token.project_id)
...@@ -47,6 +47,18 @@ RSpec.describe DastSiteValidation, type: :model do ...@@ -47,6 +47,18 @@ RSpec.describe DastSiteValidation, type: :model do
end end
end 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 end
describe 'enums' do 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