diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb index c5248823d567f0aa54d661e116c0d9926c3f3160..d2a696d2e5ee5411209186a4c6a270278c7ab652 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -2,13 +2,15 @@ module Ci module JobToken class Allowlist + include ::Gitlab::Utils::StrongMemoize + def initialize(source_project, direction: :inbound) @source_project = source_project @direction = direction end def includes_project?(target_project) - source_links + project_links .with_target(target_project) .exists? end @@ -18,7 +20,7 @@ def includes_group?(target_project) end def nearest_scope_for_target_project(target_project) - source_links.with_target(target_project).first || + project_links.with_target(target_project).first || group_links_for_target(target_project).first end @@ -53,13 +55,7 @@ def add_group!(target_group, user:, policies: []) ) end - private - - def add_policies_to_ci_job_token_enabled - Feature.enabled?(:add_policies_to_ci_job_token, @source_project) - end - - def source_links + def project_links Ci::JobToken::ProjectScopeLink .with_source(@source_project) .where(direction: @direction) @@ -70,6 +66,49 @@ def group_links .with_source(@source_project) end + def bulk_add_projects!(target_projects, user:, autopopulated: false, policies: []) + now = Time.zone.now + job_token_policies = add_policies_to_ci_job_token_enabled ? policies : [] + + projects = target_projects.map do |target_project| + Ci::JobToken::ProjectScopeLink.new( + source_project_id: @source_project.id, + target_project: target_project, + autopopulated: autopopulated, + added_by: user, + job_token_policies: job_token_policies, + created_at: now + ) + end + + Ci::JobToken::ProjectScopeLink.bulk_insert!(projects) + end + + def bulk_add_groups!(target_groups, user:, autopopulated: false, policies: []) + now = Time.zone.now + job_token_policies = add_policies_to_ci_job_token_enabled ? policies : [] + + groups = target_groups.map do |target_group| + Ci::JobToken::GroupScopeLink.new( + source_project_id: @source_project.id, + target_group: target_group, + autopopulated: autopopulated, + added_by: user, + job_token_policies: job_token_policies, + created_at: now + ) + end + + Ci::JobToken::GroupScopeLink.bulk_insert!(groups) + end + + private + + def add_policies_to_ci_job_token_enabled + Feature.enabled?(:add_policies_to_ci_job_token, @source_project) + end + strong_memoize_attr :add_policies_to_ci_job_token_enabled + def group_links_for_target(target_project) target_group_ids = target_project.parent_groups.pluck(:id) group_links.where(target_group_id: target_group_ids).order( @@ -80,7 +119,7 @@ def group_links_for_target(target_project) end def target_project_ids - source_links + project_links # pluck needed to avoid ci and main db join .pluck(:target_project_id) end diff --git a/app/models/ci/job_token/group_scope_link.rb b/app/models/ci/job_token/group_scope_link.rb index 07b73530fefc2fe852c17ec78d4ec242d273af02..709a18163557e3246d0c9ee5048f4875421b87fa 100644 --- a/app/models/ci/job_token/group_scope_link.rb +++ b/app/models/ci/job_token/group_scope_link.rb @@ -6,6 +6,8 @@ module Ci module JobToken class GroupScopeLink < Ci::ApplicationRecord + include BulkInsertSafe + self.table_name = 'ci_job_token_group_scope_links' GROUP_LINK_LIMIT = 200 @@ -19,6 +21,7 @@ class GroupScopeLink < Ci::ApplicationRecord scope :with_source, ->(project) { where(source_project: project) } scope :with_target, ->(group) { where(target_group: group) } + scope :autopopulated, -> { where(autopopulated: true) } validates :source_project, presence: true validates :target_group, presence: true diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index e6908a0fcfebccb00cc7515ecec31379303054d4..4b9975efffda490887fcc5ab10c5c1bf74cfa56b 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -6,6 +6,8 @@ module Ci module JobToken class ProjectScopeLink < Ci::ApplicationRecord + include BulkInsertSafe + self.table_name = 'ci_job_token_project_scope_links' PROJECT_LINK_DIRECTIONAL_LIMIT = 200 @@ -20,6 +22,7 @@ class ProjectScopeLink < Ci::ApplicationRecord scope :with_access_direction, ->(direction) { where(direction: direction) } scope :with_source, ->(project) { where(source_project: project) } scope :with_target, ->(project) { where(target_project: project) } + scope :autopopulated, -> { where(autopopulated: true) } validates :source_project, presence: true validates :target_project, presence: true diff --git a/app/services/ci/job_token/autopopulate_allowlist_service.rb b/app/services/ci/job_token/autopopulate_allowlist_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..f11e6d4921b5ebf1979cfee57d6f47805992e02c --- /dev/null +++ b/app/services/ci/job_token/autopopulate_allowlist_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Ci + module JobToken + class AutopopulateAllowlistService + include ::Gitlab::Loggable + include ::Gitlab::Utils::StrongMemoize + + COMPACTION_LIMIT = Ci::JobToken::ProjectScopeLink::PROJECT_LINK_DIRECTIONAL_LIMIT + + def initialize(project, user) + @project = project + @user = user + end + + def execute + raise Gitlab::Access::AccessDeniedError unless authorized? + + allowlist = Ci::JobToken::Allowlist.new(@project) + groups = compactor.allowlist_groups + projects = compactor.allowlist_projects + + ApplicationRecord.transaction do + allowlist.bulk_add_groups!(groups, user: @user, autopopulated: true) + allowlist.bulk_add_projects!(projects, user: @user, autopopulated: true) + end + end + + private + + def compactor + Ci::JobToken::AuthorizationsCompactor.new(@project.id).tap do |compactor| + compactor.compact(COMPACTION_LIMIT) + end + end + strong_memoize_attr :compactor + + def authorized? + @user.can?(:admin_project, @project) + end + end + end +end diff --git a/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb b/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..24e1761bc285736485fbdde83a899b3f02d654b5 --- /dev/null +++ b/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + module JobToken + class ClearAutopopulatedAllowlistService + def initialize(project, user) + @project = project + @user = user + end + + def execute + raise Gitlab::Access::AccessDeniedError unless authorized? + + allowlist = Ci::JobToken::Allowlist.new(@project) + + ApplicationRecord.transaction do + allowlist.project_links.autopopulated.delete_all + allowlist.group_links.autopopulated.delete_all + end + end + + private + + def authorized? + @user.can?(:admin_project, @project) + end + end + end +end diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb index c08020a5380afba7f4afc2f76d7cdbd17665d91d..00290e6e7a9fae67ad4084b9647395940c702bde 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -271,4 +271,93 @@ it { is_expected.to eq scope_by_subgroup } end end + + describe '#bulk_add_projects!' do + let_it_be(:added_project1) { create(:project) } + let_it_be(:added_project2) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:policies) { %w[read_containers read_packages] } + + subject(:add_projects) do + allowlist.bulk_add_projects!([added_project1, added_project2], policies: policies, user: user, + autopopulated: true) + end + + it 'adds the project scope links' do + add_projects + + project_links = Ci::JobToken::ProjectScopeLink.where(source_project_id: source_project.id) + project_link = project_links.first + + expect(allowlist.projects).to match_array([source_project, added_project1, added_project2]) + expect(project_link.added_by_id).to eq(user.id) + expect(project_link.source_project_id).to eq(source_project.id) + expect(project_link.target_project_id).to eq(added_project1.id) + expect(project_link.job_token_policies).to eq(policies) + end + + context 'when feature-flag `add_policies_to_ci_job_token` is disabled' do + before do + stub_feature_flags(add_policies_to_ci_job_token: false) + end + + it 'adds the project scope link but with empty job token policies' do + add_projects + + project_links = Ci::JobToken::ProjectScopeLink.where(source_project_id: source_project.id) + project_link = project_links.first + + expect(allowlist.projects).to match_array([source_project, added_project1, added_project2]) + expect(project_link.added_by_id).to eq(user.id) + expect(project_link.source_project_id).to eq(source_project.id) + expect(project_link.target_project_id).to eq(added_project1.id) + expect(project_link.job_token_policies).to eq([]) + end + end + end + + describe '#bulk_add_groups!' do + let_it_be(:added_group1) { create(:group) } + let_it_be(:added_group2) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:policies) { %w[read_containers read_packages] } + + subject(:add_groups) do + allowlist.bulk_add_groups!([added_group1, added_group2], policies: policies, user: user, autopopulated: true) + end + + it 'adds the group scope links' do + add_groups + + group_links = Ci::JobToken::GroupScopeLink.where(source_project_id: source_project.id) + group_link = group_links.first + + expect(allowlist.groups).to match_array([added_group1, added_group2]) + expect(group_link.added_by_id).to eq(user.id) + expect(group_link.source_project_id).to eq(source_project.id) + expect(group_link.target_group_id).to eq(added_group1.id) + expect(group_link.job_token_policies).to eq(policies) + expect(group_link.autopopulated).to be true + end + + context 'when feature-flag `add_policies_to_ci_job_token` is disabled' do + before do + stub_feature_flags(add_policies_to_ci_job_token: false) + end + + it 'adds the group scope link but with empty job token policies' do + add_groups + + group_links = Ci::JobToken::GroupScopeLink.where(source_project_id: source_project.id) + group_link = group_links.first + + expect(allowlist.groups).to match_array([added_group1, added_group2]) + expect(group_link.added_by_id).to eq(user.id) + expect(group_link.source_project_id).to eq(source_project.id) + expect(group_link.target_group_id).to eq(added_group1.id) + expect(group_link.job_token_policies).to eq([]) + expect(group_link.autopopulated).to be true + end + end + end end diff --git a/spec/models/ci/job_token/group_scope_link_spec.rb b/spec/models/ci/job_token/group_scope_link_spec.rb index 1e5770fb9c56f7a78d5ea377f3238b40b8880a52..fb116a914f80bd681fcbc1ae308baf47d16111ba 100644 --- a/spec/models/ci/job_token/group_scope_link_spec.rb +++ b/spec/models/ci/job_token/group_scope_link_spec.rb @@ -15,6 +15,19 @@ let!(:model) { create(:ci_job_token_group_scope_link, added_by: parent) } end + it_behaves_like 'a BulkInsertSafe model', described_class do + let(:current_time) { Time.zone.now } + + let(:valid_items_for_bulk_insertion) do + build_list(:ci_job_token_group_scope_link, 10, source_project_id: project.id, + created_at: current_time) do |project_scope_link| + project_scope_link.target_group = create(:group) + end + end + + let(:invalid_items_for_bulk_insertion) { [] } # class does not have any validations defined + end + describe 'unique index' do let!(:link) { create(:ci_job_token_group_scope_link) } diff --git a/spec/models/ci/job_token/project_scope_link_spec.rb b/spec/models/ci/job_token/project_scope_link_spec.rb index 155da77a32c9e70f5d6445c2da3184e58b96e8fc..04c36e97b2fdbcc74e9728f4b5c9ab405d825fab 100644 --- a/spec/models/ci/job_token/project_scope_link_spec.rb +++ b/spec/models/ci/job_token/project_scope_link_spec.rb @@ -15,6 +15,19 @@ let!(:model) { create(:ci_job_token_project_scope_link, added_by: parent) } end + it_behaves_like 'a BulkInsertSafe model', described_class do + let(:current_time) { Time.zone.now } + + let(:valid_items_for_bulk_insertion) do + build_list(:ci_job_token_project_scope_link, 10, source_project_id: project.id, + created_at: current_time) do |project_scope_link| + project_scope_link.target_project = create(:project) + end + end + + let(:invalid_items_for_bulk_insertion) { [] } # class does not have any validations defined + end + describe 'unique index' do let!(:link) { create(:ci_job_token_project_scope_link) } diff --git a/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb b/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e449711fa014383da25dcebe29df64017e316b51 --- /dev/null +++ b/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobToken::AutopopulateAllowlistService, feature_category: :secrets_management do + let_it_be(:maintainer) { create(:user) } + let_it_be(:developer) { create(:user) } + let_it_be(:accessed_project) { create(:project) } + let(:service) { described_class.new(accessed_project, maintainer) } + # [1, 21], ns1, p1 + # [1, 2, 3], ns1, ns2, p2 + # [1, 2, 4], ns1, ns2, p3 + # [1, 2, 5], ns1, ns2, p4 + # [1, 2, 12, 13], ns1, ns2, ns3, p5 + # [1, 6, 7], ns1, ns4, p6 + # [1, 6, 8], ns1, ns4, p7 + # [9, 10, 11] ns5, ns6, p8 + + let_it_be(:ns1) { create(:group, name: 'ns1') } + let_it_be(:ns2) { create(:group, parent: ns1, name: 'ns2') } + let_it_be(:ns3) { create(:group, parent: ns2, name: 'ns3') } + let_it_be(:ns4) { create(:group, parent: ns1, name: 'ns4') } + let_it_be(:ns5) { create(:group, name: 'ns5') } + let_it_be(:ns6) { create(:group, parent: ns5, name: 'ns6') } + + let_it_be(:pns1) { create(:project_namespace, parent: ns1) } + let_it_be(:pns2) { create(:project_namespace, parent: ns2) } + let_it_be(:pns3) { create(:project_namespace, parent: ns2) } + let_it_be(:pns4) { create(:project_namespace, parent: ns2) } + let_it_be(:pns5) { create(:project_namespace, parent: ns3) } + let_it_be(:pns6) { create(:project_namespace, parent: ns4) } + let_it_be(:pns7) { create(:project_namespace, parent: ns4) } + let_it_be(:pns8) { create(:project_namespace, parent: ns6) } + + let(:compaction_limit) { 4 } + + before_all do + accessed_project.add_maintainer(maintainer) + accessed_project.add_developer(developer) + end + + before do + origin_project_namespaces = [ + pns1, pns2, pns3, pns4, pns5, pns6, pns7, pns8 + ] + + origin_project_namespaces.each do |project_namespace| + create(:ci_job_token_authorization, origin_project: project_namespace.project, accessed_project: accessed_project, + last_authorized_at: 1.day.ago) + end + + stub_const("#{described_class.name}::COMPACTION_LIMIT", compaction_limit) + end + + describe '#execute' do + context 'with a user with the developer role' do + let(:service) { described_class.new(accessed_project, developer) } + + it 'raises an access denied error' do + expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + it 'creates the expected group and project links for the given limit' do + service.execute + + expect(Ci::JobToken::GroupScopeLink.autopopulated.pluck(:target_group_id)).to match_array([ns2.id, ns4.id]) + expect(Ci::JobToken::ProjectScopeLink.autopopulated.pluck(:target_project_id)).to match_array([pns1.project.id, + pns8.project.id]) + end + + context 'with a compaction_limit of 3' do + let(:compaction_limit) { 3 } + + it 'creates the expected group and project links' do + service.execute + + expect(Ci::JobToken::GroupScopeLink.autopopulated.pluck(:target_group_id)).to match_array([ns1.id]) + expect(Ci::JobToken::ProjectScopeLink.autopopulated.pluck(:target_project_id)).to match_array([pns8.project.id]) + end + end + + context 'with a compaction_limit of 1' do + let(:compaction_limit) { 1 } + + it 'logs a CompactionLimitCannotBeAchievedError error' do + expect do + service.execute + end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + + expect(Ci::JobToken::GroupScopeLink.count).to be(0) + expect(Ci::JobToken::ProjectScopeLink.count).to be(0) + end + end + + context 'when validation fails' do + let(:compaction_limit) { 5 } + + it 'logs an UnexpectedCompactionEntry error' do + allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args| + original_response = original_method.call(*args) + original_response << [1, 2, 3] + end + + expect { service.execute }.to raise_error(Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry) + end + + it 'logs a RedundantCompactionEntry error' do + allow(Gitlab::Utils::TraversalIdCompactor).to receive(:compact).and_wrap_original do |original_method, *args| + original_response = original_method.call(*args) + original_response << original_response.last.first(2) + end + + expect { service.execute }.to raise_error(Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry) + end + end + + context 'with three top-level namespaces' do + # [1, 21], ns1, p1 + # [1, 2, 3], ns1, ns2, p2 + # [1, 2, 4], ns1, ns2, p3 + # [1, 2, 5], ns1, ns2, p4 + # [1, 2, 12, 13], ns1, ns2, ns3, p5 + # [1, 6, 7], ns1, ns4, p6 + # [1, 6, 8], ns1, ns4, p7 + # [9, 10, 11] ns5, ns6, p8 + # [14, 15] ns7, p9 + let(:ns7) { create(:group, name: 'ns7') } + let(:pns9) { create(:project_namespace, parent: ns7) } + + before do + create(:ci_job_token_authorization, origin_project: pns9.project, accessed_project: accessed_project, + last_authorized_at: 1.day.ago) + end + + context 'with a compaction_limit of 2' do + let(:compaction_limit) { 2 } + + it 'raises when the limit cannot be achieved' do + expect do + service.execute + end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + expect(Ci::JobToken::GroupScopeLink.count).to be(0) + expect(Ci::JobToken::ProjectScopeLink.count).to be(0) + end + end + + context 'with a compaction_limit of 3' do + let(:compaction_limit) { 3 } + + it 'creates creates the expected group and project links' do + service.execute + + expect(Ci::JobToken::GroupScopeLink.autopopulated.pluck(:target_group_id)).to match_array([ns1.id]) + expect(Ci::JobToken::ProjectScopeLink.autopopulated.pluck(:target_project_id)).to match_array( + [pns8.project.id, pns9.project.id] + ) + end + end + end + end +end diff --git a/spec/services/ci/job_token/clear_autopopulated_allowlist_service_spec.rb b/spec/services/ci/job_token/clear_autopopulated_allowlist_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..245ac053c890befecd0d39752d770752519ffcf8 --- /dev/null +++ b/spec/services/ci/job_token/clear_autopopulated_allowlist_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobToken::ClearAutopopulatedAllowlistService, feature_category: :secrets_management do + let_it_be(:accessed_project) { create(:project) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:developer) { create(:user) } + + let(:service) { described_class.new(accessed_project, maintainer) } + + before_all do + accessed_project.add_maintainer(maintainer) + accessed_project.add_developer(developer) + end + + describe '#execute' do + context 'with a user with the developer role' do + let(:service) { described_class.new(accessed_project, developer) } + + it 'raises an access denied error' do + expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + it 'deletes autopopulated group scope links' do + create(:ci_job_token_group_scope_link, source_project: accessed_project, autopopulated: true) + create(:ci_job_token_group_scope_link, source_project: accessed_project, autopopulated: false) + + expect do + service.execute + end.to change { Ci::JobToken::GroupScopeLink.autopopulated.count }.by(-1) + .and not_change { Ci::JobToken::ProjectScopeLink.autopopulated.count } + end + + it 'deletes autopopulated project scope links' do + create(:ci_job_token_project_scope_link, source_project: accessed_project, direction: :inbound, + autopopulated: true) + create(:ci_job_token_project_scope_link, source_project: accessed_project, direction: :inbound, + autopopulated: false) + + expect do + service.execute + end.to not_change { Ci::JobToken::GroupScopeLink.autopopulated.count } + .and change { Ci::JobToken::ProjectScopeLink.autopopulated.count }.by(-1) + end + + it 'does not delete non-autopopulated links' do + create(:ci_job_token_group_scope_link, source_project: accessed_project, autopopulated: false) + create(:ci_job_token_project_scope_link, source_project: accessed_project, direction: :inbound, + autopopulated: false) + + expect do + service.execute + end.to not_change { Ci::JobToken::GroupScopeLink.autopopulated.count } + .and not_change { Ci::JobToken::ProjectScopeLink.autopopulated.count } + end + + it 'executes within a transaction' do + expect(ApplicationRecord).to receive(:transaction).and_yield + + service.execute + end + + it 'only deletes links for the given project' do + create(:ci_job_token_group_scope_link, source_project: accessed_project, autopopulated: true) + create(:ci_job_token_group_scope_link, source_project: create(:project), autopopulated: true) + create(:ci_job_token_project_scope_link, source_project: accessed_project, direction: :inbound, + autopopulated: true) + create(:ci_job_token_project_scope_link, source_project: create(:project), direction: :inbound, + autopopulated: true) + + expect do + service.execute + end.to change { Ci::JobToken::GroupScopeLink.autopopulated.count }.by(-1) + .and change { Ci::JobToken::ProjectScopeLink.autopopulated.count }.by(-1) + end + end +end