diff --git a/db/migrate/20240828103148_add_spp_repository_pipeline_access_to_project_settings.rb b/db/migrate/20240828103148_add_spp_repository_pipeline_access_to_project_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d1c274ab3850257f7176ee274f0dda932290b62 --- /dev/null +++ b/db/migrate/20240828103148_add_spp_repository_pipeline_access_to_project_settings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddSppRepositoryPipelineAccessToProjectSettings < Gitlab::Database::Migration[2.2] + enable_lock_retries! + milestone '17.4' + + def change + add_column :project_settings, :spp_repository_pipeline_access, :boolean + end +end diff --git a/db/schema_migrations/20240828103148 b/db/schema_migrations/20240828103148 new file mode 100644 index 0000000000000000000000000000000000000000..d5a5dcbeb528e344cfacea35c942765782541815 --- /dev/null +++ b/db/schema_migrations/20240828103148 @@ -0,0 +1 @@ +3a6da002969d32ed71e3a8700a007380d20193df5f3a3591e98136a135380f4f \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 91b21383f49e4483f011ee9e2246809dcae99807..409fc6aef8f96b6399a49d99c9c43c7960c4b634 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -16719,6 +16719,7 @@ CREATE TABLE project_settings ( duo_features_enabled boolean DEFAULT true NOT NULL, require_reauthentication_to_approve boolean, observability_alerts_enabled boolean DEFAULT true NOT NULL, + spp_repository_pipeline_access boolean, CONSTRAINT check_1a30456322 CHECK ((char_length(pages_unique_domain) <= 63)), CONSTRAINT check_3a03e7557a CHECK ((char_length(previous_default_branch) <= 4096)), CONSTRAINT check_3ca5cbffe6 CHECK ((char_length(issue_branch_template) <= 255)), diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index e3f12e99e9463aa48355b4c678c5b426c50d818b..b116d2cf181e1df3a4fc587cb36251c1bf7f4d1a 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -1224,6 +1224,22 @@ def auto_rollback_enabled? ci_cd_settings.auto_rollback_enabled? end + def affected_by_security_policy_management_project?(management_project) + all_parent_groups = group&.self_and_ancestor_ids + policies = ::Security::OrchestrationPolicyConfiguration + .for_management_project(management_project) + .for_project(id) + + if all_parent_groups.present? + policies = policies.or( + ::Security::OrchestrationPolicyConfiguration + .for_management_project(management_project) + .for_namespace(all_parent_groups)) + end + + policies.exists? + end + def all_security_orchestration_policy_configurations(include_invalid: false) all_parent_groups = group&.self_and_ancestor_ids return [] if all_parent_groups.blank? && !security_orchestration_policy_configuration&.policy_configuration_valid? && !include_invalid diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index 7f4e9066c5ee3809a954993e053a1c860c9de842..fb7d7d59a247434b504b42df9cbf93b2f2259d62 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -825,6 +825,16 @@ module ProjectPolicy enable :build_download_code end + desc "SPP project access to read policy config for pipeline execution policy" + condition(:spp_repository_access_allowed) do + Security::OrchestrationPolicyConfiguration.policy_management_project?(project) && + project.project_setting.spp_repository_pipeline_access + end + + rule { spp_repository_access_allowed & project_allowed_for_job_token_by_scope }.policy do + enable :download_code_spp_repository + end + rule do summarize_new_merge_request_enabled & can?(:create_merge_request_in) end.enable :summarize_new_merge_request diff --git a/ee/lib/ee/gitlab/ci/config/external/file/project.rb b/ee/lib/ee/gitlab/ci/config/external/file/project.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f4cbc4913a191dfbf093d3b462f5e39bd69cbc9 --- /dev/null +++ b/ee/lib/ee/gitlab/ci/config/external/file/project.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module Config + module External + module File + module Project + extend ::Gitlab::Utils::Override + + private + + override :project_access_allowed? + def project_access_allowed?(user, project) + super || security_policy_management_project_access_allowed?(user, project) + end + + def security_policy_management_project_access_allowed?(user, project) + return false unless context.pipeline_config&.pipeline_policy_context&.execution_policy_mode? + return false unless context.project.affected_by_security_policy_management_project?(project) + + Ability.allowed?(user, :download_code_spp_repository, project) + end + end + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/ee/spec/lib/gitlab/ci/config/external/file/project_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f3104301933998116e05163cb6c8c9c08afe9a5e --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/external/file/project_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :pipeline_composition do + include RepoHelpers + + let_it_be(:context_project) { create(:project) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user, developer_of: project) } + + let(:context_user) { user } + let(:context) { Gitlab::Ci::Config::External::Context.new(**context_params) } + let(:project_file) { described_class.new(params, context) } + let(:pipeline_config) { nil } + let(:context_params) do + { + project: context_project, + sha: project.commit.sha, + user: context_user, + pipeline_config: pipeline_config + } + end + + before do + allow_next_instance_of(Gitlab::Ci::Config::External::Context) do |instance| + allow(instance).to receive(:check_execution_time!) + end + end + + describe '#valid?' do + subject(:valid?) do + Gitlab::Ci::Config::External::Mapper::Verifier.new(context).process([project_file]) + project_file.valid? + end + + describe 'security_policy_management_project_access_allowed?' do + include_context 'with pipeline policy context' + + let(:params) { { file: 'pipeline-execution-policy.yml', project: project.full_path } } + let(:execution_policy_dry_run) { true } + let(:pipeline_config) do + instance_double(Gitlab::Ci::ProjectConfig, + internal_include_prepended?: true, + pipeline_policy_context: pipeline_policy_context) + end + + around do |example| + create_and_delete_files(project, + { '/pipeline-execution-policy.yml' => { compliance_job: { script: 'test' } }.to_yaml }) do + example.run + end + end + + shared_examples_for 'user has no access to the project' do + it 'returns false' do + expect(valid?).to be(false) + expect(project_file.error_message).to include("Project `#{project.full_path}` not found or access denied!") + end + end + + context 'when user does not have permission to access file' do + let(:context_user) { create(:user) } + + it_behaves_like 'user has no access to the project' + + context 'and project is a security policy project' do + let_it_be(:security_orchestration_policy_configuration, reload: true) do + create(:security_orchestration_policy_configuration, security_policy_management_project: project, + project: context_project) + end + + it_behaves_like 'user has no access to the project' + + context 'and project is linked to the context project as a security policy project' do + before_all do + security_orchestration_policy_configuration.update!(project: context_project) + end + + it_behaves_like 'user has no access to the project' + + context 'and project allows SPP repository access via project settings' do + before do + project.project_setting.update!(spp_repository_pipeline_access: true) + end + + it 'returns true' do + expect(valid?).to be(true) + end + + context 'when not running in execution_policy_mode' do + let(:execution_policy_dry_run) { false } + + it_behaves_like 'user has no access to the project' + end + end + end + end + end + end + end +end diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index 48d7b2494b5845a31f0c6561c74673d89ef87ceb..59c97325d182c35482709a6ca3538e2fd3c75632 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -3848,6 +3848,62 @@ def stub_default_url_options(host) it { is_expected.not_to include(scan_finding_rule, license_scanning_rule, any_merge_request_rule) } end + describe '#affected_by_security_policy_management_project?' do + subject { project.affected_by_security_policy_management_project?(security_policy_management_project) } + + let_it_be(:spp_project) { create(:project, :repository) } + let_it_be(:other_spp_project) { create(:project, :repository) } + + let(:security_policy_management_project) { spp_project } + + it { is_expected.to be(false) } + + context 'when security orchestration policy is configured for project' do + let_it_be(:project) { create(:project) } + let_it_be(:project_security_orchestration_policy_configuration) do + create(:security_orchestration_policy_configuration, project: project, + security_policy_management_project: spp_project) + end + + it { is_expected.to be(true) } + + context 'with other security policy management project' do + let(:security_policy_management_project) { other_spp_project } + + it { is_expected.to be(false) } + end + end + + context 'when security orchestration policy is configured for a parent namespace' do + let_it_be(:parent_group) { create(:group) } + let_it_be(:child_group) { create(:group, parent: parent_group) } + let_it_be(:project) { create(:project, group: child_group) } + + let_it_be(:parent_group_security_orchestration_policy_configuration) do + create(:security_orchestration_policy_configuration, :namespace, namespace: parent_group, + security_policy_management_project: spp_project) + end + + it { is_expected.to be(true) } + + context 'with other security policy management project' do + let(:security_policy_management_project) { other_spp_project } + + it { is_expected.to be(false) } + end + end + + context 'when security orchestration policy is configured for another project' do + let_it_be(:another_project) { create(:project) } + let_it_be(:project_security_orchestration_policy_configuration) do + create(:security_orchestration_policy_configuration, project: another_project, + security_policy_management_project: spp_project) + end + + it { is_expected.to be(false) } + end + end + describe '#all_security_orchestration_policy_configurations' do subject { project.all_security_orchestration_policy_configurations } diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 31b3f60c11c853f6b0888f15877d95e59fb032bd..2597bb0022b68755da91c0829f615250610d472f 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -3330,6 +3330,99 @@ def create_member_role(member, abilities = member_role_abilities) end end + describe 'download_code_spp_repository policy' do + let(:current_user) { guest } + + it { is_expected.not_to be_allowed(:download_code_spp_repository) } + + context 'when project is a security policy project' do + before do + create(:security_orchestration_policy_configuration, security_policy_management_project: project) + end + + it { is_expected.not_to be_allowed(:download_code_spp_repository) } + + context 'and project allows spp_repository_pipeline_access' do + before do + project.project_setting.update!(spp_repository_pipeline_access: true) + end + + context 'and the project is private' do + let(:project) { private_project } + + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + + context 'and the project is internal' do + let(:project) { internal_project } + + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + + context 'and the project is public' do + let(:project) { public_project } + + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + + context 'and the project is public in group' do + let(:project) { public_project_in_group } + + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + end + end + + context 'when user is authenticated via CI_JOB_TOKEN', :request_store do + let(:job) { build_stubbed(:ci_build, project: scope_project, user: current_user) } + let(:scope_project) { project } + + let_it_be(:other_private_project) { create(:project, :private) } + + before do + current_user.set_ci_job_token_scope!(job) + create(:security_orchestration_policy_configuration, security_policy_management_project: project) + project.project_setting.update!(spp_repository_pipeline_access: true) + project.update!( + ci_outbound_job_token_scope_enabled: token_scope_enabled, + ci_inbound_job_token_scope_enabled: token_scope_enabled + ) + scope_project.update!( + ci_outbound_job_token_scope_enabled: token_scope_enabled, + ci_inbound_job_token_scope_enabled: token_scope_enabled + ) + end + + context 'when token scope is disabled' do + let(:token_scope_enabled) { false } + + context 'when accessing from the same project' do + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + + context 'when accessing from other project' do + let(:scope_project) { other_private_project } + + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + end + + context 'when token scope is enabled' do + let(:token_scope_enabled) { true } + + context 'when accessing from the same project' do + it { is_expected.to be_allowed(:download_code_spp_repository) } + end + + context 'when accessing from other project' do + let(:scope_project) { other_private_project } + + it { is_expected.to be_disallowed(:download_code_spp_repository) } + end + end + end + end + describe 'generate_description' do let(:authorizer) { instance_double(::Gitlab::Llm::FeatureAuthorizer) } let(:current_user) { guest } diff --git a/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb b/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb index ec87f803094ee9f31c57008227a7c7f5991d359e..43c55e34f3fd6e74b26897480eaebb3bb7a718b0 100644 --- a/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb +++ b/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb @@ -587,6 +587,59 @@ end end + describe 'access to policy configs inside security policy project repository' do + let(:namespace_policy) do + build(:pipeline_execution_policy, + content: { include: [{ + project: namespace_policies_project.full_path, + file: namespace_policy_file, + ref: namespace_policies_project.default_branch_or_main + }] }) + end + + let(:project_policy) do + build(:pipeline_execution_policy, + content: { include: [{ + project: project_policies_project.full_path, + file: project_policy_file, + ref: project_policies_project.default_branch_or_main + }] }) + end + + around do |example| + create_and_delete_files( + project_policies_project, project_policy_file => project_policy_content.to_yaml + ) do + create_and_delete_files( + namespace_policies_project, namespace_policy_file => namespace_policy_content.to_yaml + ) do + example.run + end + end + end + + context 'when user does not have access to the policy repository' do + it 'responds with error' do + expect(execute).to be_error + expect(execute.payload.errors.full_messages) + .to contain_exactly( + "Pipeline execution policy error: Project `#{project_policies_project.full_path}` not found " \ + "or access denied! Make sure any includes in the pipeline configuration are correctly defined.") + end + + context 'when security policy projects enable `spp_repository_pipeline_access` project setting' do + before do + project_policies_project.project_setting.update!(spp_repository_pipeline_access: true) + namespace_policies_project.project_setting.update!(spp_repository_pipeline_access: true) + end + + it 'responds with success' do + expect(execute).to be_success + end + end + end + end + private def get_job_variable(job, key) diff --git a/lib/gitlab/ci/config/external/file/project.rb b/lib/gitlab/ci/config/external/file/project.rb index de726b57053b456b1b9e5e5959a99a36a695dd2f..d80323589dc6834ddae3fc8dda635fbfac72f2ce 100644 --- a/lib/gitlab/ci/config/external/file/project.rb +++ b/lib/gitlab/ci/config/external/file/project.rb @@ -90,12 +90,16 @@ def can_access_local_content? .batch(key: context.user) do |projects, loader, args| projects.uniq.each do |project| context.logger.instrument(:config_file_project_validate_access) do - loader.call(project, Ability.allowed?(args[:key], :download_code, project)) + loader.call(project, project_access_allowed?(args[:key], project)) end end end end + def project_access_allowed?(user, project) + Ability.allowed?(user, :download_code, project) + end + def sha return if project.nil? @@ -179,3 +183,5 @@ def get_project_name(project_name) end end end + +Gitlab::Ci::Config::External::File::Project.prepend_mod diff --git a/lib/gitlab/ci/project_config.rb b/lib/gitlab/ci/project_config.rb index f92b84abfa9afc0badea96b04c7e5f08928b061c..bb185cf0a7d6f4d13ff701873c7386737442895d 100644 --- a/lib/gitlab/ci/project_config.rb +++ b/lib/gitlab/ci/project_config.rb @@ -49,7 +49,7 @@ def initialize( end end - delegate :content, :source, :url, to: :@config, allow_nil: true + delegate :content, :source, :url, :pipeline_policy_context, to: :@config, allow_nil: true delegate :internal_include_prepended?, to: :@config def exists? diff --git a/lib/gitlab/ci/project_config/source.rb b/lib/gitlab/ci/project_config/source.rb index 755640ad965aab85d37d2e18ed2617bf0a86eb8b..7288ee7fcd314ade3e08abe1c1cb096f9a86d387 100644 --- a/lib/gitlab/ci/project_config/source.rb +++ b/lib/gitlab/ci/project_config/source.rb @@ -42,10 +42,12 @@ def url nil end + attr_reader :pipeline_policy_context + private attr_reader :project, :sha, :custom_content, :pipeline_source, :pipeline_source_bridge, :triggered_for_branch, - :ref, :pipeline_policy_context + :ref def ci_config_path @ci_config_path ||= project.ci_config_path_or_default diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index f4599f03a3ee1f8c4505e379633d06f5e147f819..7a55b8e7416509095ff1d7db29cf273a41331e0d 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -190,6 +190,7 @@ project_setting: - duo_features_enabled - require_reauthentication_to_approve - observability_alerts_enabled + - spp_repository_pipeline_access build_service_desk_setting: # service_desk_setting unexposed_attributes: