Commit f1deba57 authored by Stan Hu's avatar Stan Hu

Merge branch 'sk/333383-commit-policy-mutation' into 'master'

Create GraphQL mutation to create/update/delete a policy

See merge request gitlab-org/gitlab!64505
parents b41057f7 62dc7b66
......@@ -3591,6 +3591,27 @@ Input type: `RunnersRegistrationTokenResetInput`
| <a id="mutationrunnersregistrationtokenreseterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationrunnersregistrationtokenresettoken"></a>`token` | [`String`](#string) | The runner token after mutation. |
### `Mutation.scanExecutionPolicyCommit`
Input type: `ScanExecutionPolicyCommitInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationscanexecutionpolicycommitclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationscanexecutionpolicycommitoperationmode"></a>`operationMode` | [`MutationOperationMode!`](#mutationoperationmode) | Changes the operation mode. |
| <a id="mutationscanexecutionpolicycommitpolicyyaml"></a>`policyYaml` | [`String!`](#string) | YAML snippet of the policy. |
| <a id="mutationscanexecutionpolicycommitprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationscanexecutionpolicycommitbranch"></a>`branch` | [`String`](#string) | Name of the branch to which the policy changes are committed. |
| <a id="mutationscanexecutionpolicycommitclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationscanexecutionpolicycommiterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.terraformStateDelete`
Input type: `TerraformStateDeleteInput`
......
......@@ -80,6 +80,7 @@ module EE
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create
mount_mutation ::Mutations::SecurityPolicy::CommitScanExecutionPolicy
prepend(Types::DeprecatedMutations)
end
......
# frozen_string_literal: true
module Mutations
module SecurityPolicy
class CommitScanExecutionPolicy < BaseMutation
include FindsProject
graphql_name 'ScanExecutionPolicyCommit'
authorize :security_orchestration_policies
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path of the project.'
argument :policy_yaml, GraphQL::STRING_TYPE,
required: true,
description: 'YAML snippet of the policy.'
argument :operation_mode,
Types::MutationOperationModeEnum,
required: true,
description: 'Changes the operation mode.'
field :branch,
GraphQL::STRING_TYPE,
null: true,
description: 'Name of the branch to which the policy changes are committed.'
def resolve(args)
project = authorized_find!(args[:project_path])
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
result = commit_policy(project, args[:policy_yaml], args[:operation_mode])
error_message = result[:status] == :error ? result[:message] : nil
{
branch: result[:branch],
errors: [error_message].compact
}
end
private
def allowed?(project)
Feature.enabled?(:security_orchestration_policies_configuration, project)
end
def commit_policy(project, policy_yaml, operation_mode)
::Security::SecurityOrchestrationPolicies::PolicyCommitService
.new(project: project, current_user: current_user, params: { policy_yaml: policy_yaml, operation: Types::MutationOperationModeEnum.enum.key(operation_mode).to_sym })
.execute
end
end
end
end
......@@ -17,6 +17,7 @@ module Security
}.freeze
ON_DEMAND_SCANS = %w[dast].freeze
AVAILABLE_POLICY_TYPES = %i{scan_execution_policy}.freeze
belongs_to :project, inverse_of: :security_orchestration_policy_configuration
belongs_to :security_policy_management_project, class_name: 'Project', foreign_key: 'security_policy_management_project_id'
......@@ -39,14 +40,22 @@ module Security
::Feature.enabled?(:security_orchestration_policies_configuration, project)
end
def policy_hash
strong_memoize(:policy_hash) do
next if policy_blob.blank?
Gitlab::Config::Loader::Yaml.new(policy_blob).load!
end
end
def policy_configuration_exists?
policy_hash.present?
end
def policy_configuration_valid?
def policy_configuration_valid?(policy = policy_hash)
JSONSchemer
.schema(Rails.root.join(POLICY_SCHEMA_PATH))
.valid?(policy_hash.to_h.deep_stringify_keys)
.valid?(policy.to_h.deep_stringify_keys)
end
def active_policies
......@@ -92,16 +101,16 @@ module Security
policy_hash.fetch(:scan_execution_policy, [])
end
def default_branch_or_main
security_policy_management_project.default_branch_or_main
end
private
def policy_repo
security_policy_management_project.repository
end
def default_branch_or_main
security_policy_management_project.default_branch_or_main
end
def active_policy_names_with_dast_profiles
strong_memoize(:active_policy_names_with_dast_profiles) do
profiles = { site_profiles: Hash.new { Set.new }, scanner_profiles: Hash.new { Set.new } }
......@@ -119,12 +128,6 @@ module Security
end
end
def policy_hash
return if policy_blob.blank?
Gitlab::Config::Loader::Yaml.new(policy_blob).load!
end
def policy_blob
strong_memoize(:policy_blob) do
policy_repo.blob_data_at(default_branch_or_main, POLICY_PATH)
......
# frozen_string_literal: true
module Security
module SecurityOrchestrationPolicies
class PolicyCommitService < ::BaseProjectService
def execute
@policy_configuration = project.security_orchestration_policy_configuration
return error('Security Policy Project does not exist') unless policy_configuration.present?
result = commit_policy(process_policy_yaml)
return error(result[:message], :bad_request) if result[:status] != :success
success({ branch: branch_name })
rescue StandardError => e
error(e.message, :bad_request)
end
private
def process_policy_yaml
policy = Gitlab::Config::Loader::Yaml.new(params[:policy_yaml]).load!
updated_policy = ProcessPolicyService.new(
policy_configuration: policy_configuration,
params: { operation: params[:operation], policy: policy, type: policy.delete(:type)&.to_sym }
).execute
YAML.dump(updated_policy.deep_stringify_keys)
end
def commit_policy(policy_yaml)
return create_commit(::Files::UpdateService, policy_yaml) if policy_configuration.policy_configuration_exists?
create_commit(::Files::CreateService, policy_yaml)
end
def create_commit(service, policy_yaml)
service.new(policy_configuration.security_policy_management_project, current_user, policy_commit_attrs(policy_yaml)).execute
end
def policy_commit_attrs(policy_yaml)
{
commit_message: commit_message,
file_path: Security::OrchestrationPolicyConfiguration::POLICY_PATH,
file_content: policy_yaml,
branch_name: branch_name,
start_branch: policy_configuration.default_branch_or_main
}
end
def commit_message
operation = case params[:operation]
when :append then 'Add a new policy to'
when :replace then 'Update policy in'
when :remove then 'Delete policy in'
end
"#{operation} #{Security::OrchestrationPolicyConfiguration::POLICY_PATH}"
end
def branch_name
@branch_name ||= "update-policy-#{Time.now.to_i}"
end
attr_reader :project, :policy_configuration
end
end
end
# frozen_string_literal: true
module Security
module SecurityOrchestrationPolicies
class ProcessPolicyService
def initialize(policy_configuration:, params:)
@policy_configuration = policy_configuration
@params = params
end
def execute
policy = params[:policy]
type = params[:type]
raise StandardError, "Invalid policy type" unless Security::OrchestrationPolicyConfiguration::AVAILABLE_POLICY_TYPES.include?(type)
policy_hash = policy_configuration.policy_hash.dup || {}
case params[:operation]
when :append then append_to_policy_hash(policy_hash, policy, type)
when :replace then replace_in_policy_hash(policy_hash, policy, type)
when :remove then remove_from_policy_hash(policy_hash, policy, type)
end
raise StandardError, "Invalid policy yaml" unless policy_configuration.policy_configuration_valid?(policy_hash)
policy_hash
end
private
def append_to_policy_hash(policy_hash, policy, type)
if policy_hash[type].blank?
policy_hash[type] = [policy]
return
end
raise StandardError, "Policy already exists with same name" if policy_exists?(policy_hash, policy, type)
policy_hash[type] += [policy]
end
def replace_in_policy_hash(policy_hash, policy, type)
existing_policy_index = check_if_policy_exists!(policy_hash, policy, type)
policy_hash[type][existing_policy_index] = policy
end
def remove_from_policy_hash(policy_hash, policy, type)
check_if_policy_exists!(policy_hash, policy, type)
policy_hash[type].reject! { |p| p[:name] == policy[:name] }
end
def check_if_policy_exists!(policy_hash, policy, type)
existing_policy_index = policy_exists?(policy_hash, policy, type)
raise StandardError, "Policy does not exist" if existing_policy_index.nil?
existing_policy_index
end
def policy_exists?(policy_hash, policy, type)
policy_hash[type].find_index { |p| p[:name] == policy[:name] }
end
attr_reader :policy_configuration, :params
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::SecurityPolicy::CommitScanExecutionPolicy do
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let_it_be(:policy_management_project) { create(:project, :repository, namespace: user.namespace) }
let_it_be(:policy_configuration) { create(:security_orchestration_policy_configuration, security_policy_management_project: policy_management_project, project: project) }
let_it_be(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] }
let_it_be(:policy_yaml) do
<<-EOS
name: Run DAST in every pipeline
type: scan_execution_policy
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
subject { mutation.resolve(project_path: project.full_path, policy_yaml: policy_yaml, operation_mode: operation_mode) }
context 'when feature is enabled and permission is set for user' do
before do
project.add_maintainer(user)
stub_licensed_features(security_orchestration_policies: true)
stub_feature_flags(security_orchestration_policies_configuration: true)
end
it 'returns branch name' do
result = subject
expect(result[:errors]).to be_empty
expect(result[:branch]).not_to be_empty
end
end
context 'when feature is disabled' do
before do
stub_licensed_features(security_orchestration_policies: true)
stub_feature_flags(security_orchestration_policies_configuration: false)
end
it 'raises exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when permission is not enabled' do
before do
stub_licensed_features(security_orchestration_policies: false)
end
it 'raises exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
......@@ -107,6 +107,42 @@ RSpec.describe Security::OrchestrationPolicyConfiguration do
end
end
describe '#policy_hash' do
subject { security_orchestration_policy_configuration.policy_hash }
before do
allow(security_policy_management_project).to receive(:repository).and_return(repository)
allow(repository).to receive(:blob_data_at).with(default_branch, Security::OrchestrationPolicyConfiguration::POLICY_PATH).and_return(policy_yaml)
end
context 'when policy is present' do
let(:policy_yaml) do
<<-EOS
scan_execution_policy:
- name: Run DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
it { expect(subject.dig(:scan_execution_policy, 0, :name)).to eq('Run DAST in every pipeline') }
end
context 'when policy is nil' do
let(:policy_yaml) { nil }
it { expect(subject).to be_nil }
end
end
describe '#policy_configuration_valid?' do
subject { security_orchestration_policy_configuration.policy_configuration_valid? }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create scan execution policy for a project' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :repository, namespace: current_user.namespace) }
let_it_be(:policy_yaml) do
<<-EOS
name: Run DAST in every pipeline
type: scan_execution_policy
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
def mutation
variables = { project_path: project.full_path, policy_yaml: policy_yaml, operation_mode: 'APPEND' }
graphql_mutation(:scan_execution_policy_commit, variables) do
<<-QL.strip_heredoc
clientMutationId
errors
branch
QL
end
end
def mutation_response
graphql_mutation_response(:scan_execution_policy_commit)
end
context 'when feature is disabled' do
before do
project.add_maintainer(current_user)
stub_licensed_features(security_orchestration_policies: true)
stub_feature_flags(security_orchestration_policies_configuration: false)
end
it 'does not create branch' do
post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled'))
end
end
context 'when security_orchestration_policies_configuration already exists for project' do
let_it_be(:security_policy_management_project) { create(:project, :repository, namespace: current_user.namespace) }
let_it_be(:policy_configuration) { create(:security_orchestration_policy_configuration, project: project, security_policy_management_project: security_policy_management_project) }
before do
project.add_maintainer(current_user)
security_policy_management_project.add_developer(current_user)
stub_licensed_features(security_orchestration_policies: true)
stub_feature_flags(security_orchestration_policies_configuration: true)
end
it 'creates a branch with commit' do
post_graphql_mutation(mutation, current_user: current_user)
branch = mutation_response['branch']
commit = security_policy_management_project.repository.commits(branch, limit: 5).first
expect(response).to have_gitlab_http_status(:success)
expect(branch).not_to be_nil
expect(commit.message).to eq('Add a new policy to .gitlab/security-policies/policy.yml')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::SecurityOrchestrationPolicies::PolicyCommitService do
describe '#execute' do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { project.owner }
let_it_be(:policy_configuration) { create(:security_orchestration_policy_configuration, project: project) }
let(:policy_yaml) do
<<-EOS
name: Run DAST in every pipeline
type: scan_execution_policy
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
let(:policy) do
<<-EOS
scan_execution_policy:
- name: Run DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
let(:operation) { :append }
let(:params) { { policy_yaml: policy_yaml, operation: operation } }
subject(:service) do
described_class.new(project: project, current_user: current_user, params: params)
end
before do
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:blob_data_at).and_return(policy)
end
end
context 'when policy_yaml is invalid' do
let(:invalid_policy_yaml) do
<<-EOS
invalid_name: invalid
type: scan_execution_policy
EOS
end
let(:params) { { policy_yaml: invalid_policy_yaml, operation: operation } }
it 'returns error' do
response = service.execute
expect(response[:status]).to eq(:error)
expect(response[:message]).to eq("Invalid policy yaml")
end
end
context 'when security_orchestration_policies_configuration does not exist for project' do
let_it_be(:project) { create(:project) }
it 'does not create new project' do
response = service.execute
expect(response[:status]).to eq(:error)
expect(response[:message]).to eq('Security Policy Project does not exist')
end
end
context 'when policy already exists in policy project' do
let(:policy) do
<<-EOS
scan_execution_policy:
- name: Run DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
before do
allow_next_instance_of(::Files::UpdateService) do |instance|
allow(instance).to receive(:execute).and_return({ status: :success })
end
policy_configuration.security_policy_management_project.add_developer(current_user)
end
context 'append' do
it 'does not create branch' do
response = service.execute
expect(response[:status]).to eq(:error)
expect(response[:message]).to eq("Policy already exists with same name")
end
end
context 'replace' do
let(:operation) { :replace }
it 'creates branch' do
response = service.execute
expect(response[:status]).to eq(:success)
expect(response[:branch]).not_to be_nil
end
end
context 'remove' do
let(:operation) { :remove }
it 'creates branch' do
response = service.execute
expect(response[:status]).to eq(:success)
expect(response[:branch]).not_to be_nil
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::SecurityOrchestrationPolicies::ProcessPolicyService do
describe '#execute' do
let_it_be(:policy_configuration) { create(:security_orchestration_policy_configuration) }
let(:policy) do
<<-EOS
name: Run DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: false
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
let(:repository_with_existing_policy_yaml) do
<<-EOS
scan_execution_policy:
- name: Run DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
- name: Scheduled DAST
description: This policy executes DAST in a scheduled pipeline
enabled: true
rules:
- type: schedule
branches:
- "production"
cadence: '*/15 * * * *'
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
let(:repository_policy_yaml) do
<<-EOS
scan_execution_policy:
- name: Execute DAST in every pipeline
description: This policy enforces to run DAST for every pipeline within the project
enabled: true
rules:
- type: pipeline
branches:
- "production"
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
- name: Scheduled DAST
description: This policy executes DAST in a scheduled pipeline
enabled: true
rules:
- type: schedule
branches:
- "production"
cadence: '*/15 * * * *'
actions:
- scan: dast
site_profile: Site Profile
scanner_profile: Scanner Profile
EOS
end
let(:policy_yaml) { Gitlab::Config::Loader::Yaml.new(policy).load! }
let(:type) { :scan_execution_policy }
let(:operation) { :append }
subject(:service) { described_class.new(policy_configuration: policy_configuration, params: { policy: policy_yaml, operation: operation, type: type }) }
context 'when policy is invalid' do
let(:policy) do
<<-EOS
invalid_name: invalid
EOS
end
it 'raises StandardError' do
expect { service.execute }.to raise_error(StandardError, 'Invalid policy yaml')
end
end
context 'when type is invalid' do
let(:type) { :invalid_type}
it 'raises StandardError' do
expect { service.execute }.to raise_error(StandardError, 'Invalid policy type')
end
end
context 'append policy' do
context 'when policy is present in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_policy_yaml).load!)
end
it 'appends the new policy' do
result = service.execute
expect(result[:scan_execution_policy].count).to eq(3)
end
end
context 'when policy with same name already exists in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_with_existing_policy_yaml).load!)
end
it 'raises StandardError' do
expect { service.execute }.to raise_error(StandardError, 'Policy already exists with same name')
end
end
context 'when policy is not present in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(nil)
end
it 'appends the new policy' do
result = service.execute
expect(result[:scan_execution_policy].count).to eq(1)
end
end
end
context 'replace policy' do
let(:operation) { :replace }
context 'when policy is not present in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_policy_yaml).load!)
end
it 'raises StandardError' do
expect { service.execute }.to raise_error(StandardError, 'Policy does not exist')
end
end
context 'when policy with same name already exists in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_with_existing_policy_yaml).load!)
end
it 'replaces the policy' do
result = service.execute
expect(result[:scan_execution_policy].first[:enabled]).to be_falsey
end
end
end
context 'remove policy' do
let(:operation) { :remove }
context 'when policy is not present in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_policy_yaml).load!)
end
it 'raises StandardError' do
expect { service.execute }.to raise_error(StandardError, 'Policy does not exist')
end
end
context 'when policy with same name already exists in repository' do
before do
allow(policy_configuration).to receive(:policy_hash).and_return(Gitlab::Config::Loader::Yaml.new(repository_with_existing_policy_yaml).load!)
end
it 'removes the policy' do
result = service.execute
expect(result[:scan_execution_policy].count).to eq(1)
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