From 60be9adf73136398f2f4a1179333c5c34b4d0731 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Tue, 13 Dec 2022 16:46:09 -0500 Subject: [PATCH 01/11] Add inbound job token access control Add logic to restrict access to only those projects in an allowlist on the project being accessed by a particular job token --- app/graphql/types/ci/job_token_scope_type.rb | 2 +- app/models/ci/job_token/scope.rb | 41 ++++-- .../ci/job_token_scope_resolver_spec.rb | 8 +- spec/models/ci/job_token/allowlist_spec.rb | 18 +-- spec/models/ci/job_token/scope_spec.rb | 135 +++++++++++++----- .../models/ci/job_token_scope.rb | 74 ++++++++-- 6 files changed, 205 insertions(+), 73 deletions(-) diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 37c0af944a7b25..9c9c7ccb8d12f0 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -11,7 +11,7 @@ class JobTokenScopeType < BaseObject Types::ProjectType.connection_type, null: false, description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.', - method: :all_projects + method: :outbound_projects end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index e320c0f92d14eb..bc8bd3071d073b 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -2,18 +2,17 @@ # This model represents the scope of access for a CI_JOB_TOKEN. # -# A scope is initialized with a project. +# A scope is initialized with a current project. # # Projects can be added to the scope by adding ScopeLinks to # create an allowlist of projects in either access direction (inbound, outbound). # -# Currently, projects in the outbound allowlist can be accessed via the token -# in the source project. +# Projects in the outbound allowlist can be accessed via the source projects job token. # -# TODO(Issue #346298) Projects in the inbound allowlist can use their token to access -# the source project. +# Projects in the inbound allowlist can use their projects job token to +# access the source project. # -# CI_JOB_TOKEN should be considered untrusted without these features enabled. +# CI_JOB_TOKEN should be considered untrusted without a scope in some direction enabled. # module Ci @@ -26,16 +25,18 @@ def initialize(current_project) end def allows?(accessed_project) - self_referential?(accessed_project) || outbound_allows?(accessed_project) + self_referential?(accessed_project) || ( + outbound_allows?(accessed_project) && + inbound_allows?(accessed_project) + ) end def outbound_projects outbound_allowlist.projects end - # Deprecated: use outbound_projects, TODO(Issue #346298) remove references to all_project - def all_projects - outbound_projects + def inbound_projects + inbound_allowlist.projects end private @@ -47,6 +48,26 @@ def outbound_allows?(accessed_project) outbound_allowlist.includes?(accessed_project) end + def inbound_allows?(accessed_project) + # if the flag or setting is disabled any project is considered to be in scope. + return true unless Feature.enabled?(:ci_inbound_job_token_scope, current_project) + return true unless current_project.ci_inbound_job_token_scope_enabled? + + inbound_link?(accessed_project) + end + + def inbound_link?(accessed_project) + Ci::JobToken::ProjectScopeLink + .with_target(@current_project) + .with_source(accessed_project) + .where(direction: :inbound) + .exists? + end + + def inbound_allowlist + Ci::JobToken::Allowlist.new(@current_project, direction: :inbound) + end + def outbound_allowlist Ci::JobToken::Allowlist.new(@current_project, direction: :outbound) end diff --git a/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb b/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb index 59ece15b745cb1..92f4d3dd8e8021 100644 --- a/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb +++ b/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb @@ -23,18 +23,18 @@ it 'returns the same project in the allow list of projects for the Ci Job Token when scope is not enabled' do allow(project).to receive(:ci_outbound_job_token_scope_enabled?).and_return(false) - expect(resolve_scope.all_projects).to contain_exactly(project) + expect(resolve_scope.outbound_projects).to contain_exactly(project) end it 'returns the same project in the allow list of projects for the Ci Job Token' do - expect(resolve_scope.all_projects).to contain_exactly(project) + expect(resolve_scope.outbound_projects).to contain_exactly(project) end context 'when another projects gets added to the allow list' do let!(:link) { create(:ci_job_token_project_scope_link, source_project: project) } it 'returns both projects' do - expect(resolve_scope.all_projects).to contain_exactly(project, link.target_project) + expect(resolve_scope.outbound_projects).to contain_exactly(project, link.target_project) end end @@ -44,7 +44,7 @@ end it 'resolves projects' do - expect(resolve_scope.all_projects).to contain_exactly(project) + expect(resolve_scope.outbound_projects).to contain_exactly(project) end end end diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb index 45083d64393a77..47ba4be3fc4770 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -24,11 +24,11 @@ end context 'when projects are added to the scope' do - include_context 'with scoped projects' + include_context 'with a project in each allowlist' where(:direction, :additional_project) do - :outbound | ref(:outbound_scoped_project) - :inbound | ref(:inbound_scoped_project) + :outbound | ref(:outbound_allowlist_project) + :inbound | ref(:inbound_allowlist_project) end with_them do @@ -57,16 +57,16 @@ end end - context 'with scoped projects' do - include_context 'with scoped projects' + context 'with a project in each allowlist' do + include_context 'with a project in each allowlist' where(:includes_project, :direction, :result) do ref(:source_project) | :outbound | false ref(:source_project) | :inbound | false - ref(:inbound_scoped_project) | :outbound | false - ref(:inbound_scoped_project) | :inbound | true - ref(:outbound_scoped_project) | :outbound | true - ref(:outbound_scoped_project) | :inbound | false + ref(:inbound_allowlist_project) | :outbound | false + ref(:inbound_allowlist_project) | :inbound | true + ref(:outbound_allowlist_project) | :outbound | true + ref(:outbound_allowlist_project) | :inbound | false ref(:unscoped_project1) | :outbound | false ref(:unscoped_project1) | :inbound | false ref(:unscoped_project2) | :outbound | false diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb index 37c5697350658b..c4081a1121063c 100644 --- a/spec/models/ci/job_token/scope_spec.rb +++ b/spec/models/ci/job_token/scope_spec.rb @@ -3,77 +3,138 @@ require 'spec_helper' RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration do - let_it_be(:source_project) { create(:project, ci_outbound_job_token_scope_enabled: true) } + using RSpec::Parameterized::TableSyntax - let(:scope) { described_class.new(source_project) } + let_it_be(:source_project) do + create(:project, + ci_outbound_job_token_scope_enabled: true, + ci_inbound_job_token_scope_enabled: true + ) + end + + let(:current_project) { source_project } - describe '#all_projects' do - subject(:all_projects) { scope.all_projects } + let(:scope) { described_class.new(current_project) } + + describe '#outbound_projects' do + subject { scope.outbound_projects } context 'when no projects are added to the scope' do it 'returns the project defining the scope' do - expect(all_projects).to contain_exactly(source_project) + expect(subject).to contain_exactly(current_project) end end context 'when projects are added to the scope' do - include_context 'with scoped projects' + include_context 'with allowed projects' it 'returns all projects that can be accessed from a given scope' do - expect(subject).to contain_exactly(source_project, outbound_scoped_project) + expect(subject).to contain_exactly(current_project, outbound_allowlist_project, fully_allowed_project) end end end - describe '#allows?' do - subject { scope.allows?(includes_project) } + describe '#inbound_projects' do + subject { scope.inbound_projects } - context 'without scoped projects' do - context 'when self referential' do - let(:includes_project) { source_project } + context 'when no projects are added to the scope' do + it 'returns the project defining the scope' do + expect(subject).to contain_exactly(current_project) + end + end - it { is_expected.to be_truthy } + context 'when projects are added to the scope' do + include_context 'with allowed projects' + + it 'returns all projects that can be accessed from a given scope' do + expect(subject).to contain_exactly(current_project, inbound_allowlist_project) end end + end + + RSpec.shared_examples 'enforces outbound scope only' do + include_context 'with allowed projects' + + where(:allows_project, :result) do + ref(:current_project) | true + ref(:inbound_allowlist_project) | false + ref(:unscoped_project1) | false + ref(:unscoped_project2) | false + ref(:outbound_allowlist_project) | true + ref(:inbound_allowed_project) | false + ref(:fully_allowed_project) | true + end - context 'with scoped projects' do - include_context 'with scoped projects' + with_them do + it { is_expected.to eq(result) } + end + end - context 'when project is in outbound scope' do - let(:includes_project) { outbound_scoped_project } + describe 'allows?' do + subject { scope.allows?(allows_project) } + + context 'with inbound and outbound scopes enabled' do + context 'when inbound and outbound access setup' do + include_context 'with allowed projects' + + where(:allows_project, :result) do + ref(:current_project) | true + ref(:inbound_allowlist_project) | false + ref(:unscoped_project1) | false + ref(:unscoped_project2) | false + ref(:outbound_allowlist_project) | false + ref(:inbound_allowed_project) | false + ref(:fully_allowed_project) | true + end - it { is_expected.to be_truthy } + with_them do + it 'allows self and projects allowed from both directions' do + is_expected.to eq(result) + end + end end + end - context 'when project is in inbound scope' do - let(:includes_project) { inbound_scoped_project } - - it { is_expected.to be_falsey } + context 'with inbound scope enabled and outbound scope disabled' do + before do + source_project.ci_inbound_job_token_scope_enabled = true + source_project.ci_outbound_job_token_scope_enabled = false + source_project.save! end - context 'when project is linked to a different project' do - let(:includes_project) { unscoped_project1 } + include_context 'with allowed projects' - it { is_expected.to be_falsey } + where(:allows_project, :result) do + ref(:current_project) | true + ref(:inbound_allowlist_project) | false + ref(:unscoped_project1) | false + ref(:unscoped_project2) | false + ref(:outbound_allowlist_project) | false + ref(:inbound_allowed_project) | true + ref(:fully_allowed_project) | true end - context 'when project is unlinked to a project' do - let(:includes_project) { unscoped_project2 } - - it { is_expected.to be_falsey } + with_them do + it { is_expected.to eq(result) } end + end - context 'when project scope setting is disabled' do - let(:includes_project) { unscoped_project1 } + context 'with inbound scope disabled and outbound scope enabled' do + before do + source_project.ci_inbound_job_token_scope_enabled = false + source_project.ci_outbound_job_token_scope_enabled = true + source_project.save! + end - before do - source_project.ci_outbound_job_token_scope_enabled = false - end + include_examples 'enforces outbound scope only' + end - it 'considers any project to be part of the scope' do - expect(subject).to be_truthy - end + context 'when inbound scope flag disabled' do + before do + stub_feature_flags(ci_inbound_job_token_scope: false) end + + include_examples 'enforces outbound scope only' end end end diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb index 51f671b139d69e..23528221720f0c 100644 --- a/spec/support/shared_contexts/models/ci/job_token_scope.rb +++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb @@ -1,21 +1,71 @@ # frozen_string_literal: true -RSpec.shared_context 'with scoped projects' do - let_it_be(:inbound_scoped_project) { create_scoped_project(source_project, direction: :inbound) } - let_it_be(:outbound_scoped_project) { create_scoped_project(source_project, direction: :outbound) } +RSpec.shared_context 'with a project in each allowlist' do + let_it_be(:outbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :outbound) } + + include_context 'with not allowed projects' +end + +RSpec.shared_context 'with allowed projects' do + # outbound allowed + let_it_be(:outbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :outbound) } + # inbound allowed + let_it_be(:inbound_allowed_project) { create_inbound_allowed_project(source_project) } + let_it_be(:fully_allowed_project) { create_inbound_and_outbound_allowed_project(source_project) } + + include_context 'with not allowed projects' +end + +RSpec.shared_context 'with not allowed projects' do + let_it_be(:inbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :inbound) } + include_context 'with unscoped projects' +end + +RSpec.shared_context 'with unscoped projects' do let_it_be(:unscoped_project1) { create(:project) } let_it_be(:unscoped_project2) { create(:project) } let_it_be(:link_out_of_scope) { create(:ci_job_token_project_scope_link, target_project: unscoped_project1) } +end - def create_scoped_project(source_project, direction:) - create(:project).tap do |scoped_project| - create( - :ci_job_token_project_scope_link, - source_project: source_project, - target_project: scoped_project, - direction: direction - ) - end +def create_project_in_allowlist(root_project, direction:) + create(:project).tap do |scoped_project| + create( + :ci_job_token_project_scope_link, + source_project: root_project, + target_project: scoped_project, + direction: direction + ) end end + +def create_inbound_allowed_project(project) + create(:project).tap do |scoped_project| + add_inbound_allowed_linkage(project, scoped_project) + end +end + +def create_inbound_and_outbound_allowed_project(project) + create(:project).tap do |scoped_project| + add_outbound_allowed_linkage(project, scoped_project) + add_inbound_allowed_linkage(project, scoped_project) + end +end + +def add_outbound_allowed_linkage(project, scoped_project) + create( + :ci_job_token_project_scope_link, + source_project: project, + target_project: scoped_project, + direction: :outbound + ) +end + +def add_inbound_allowed_linkage(project, scoped_project) + create( + :ci_job_token_project_scope_link, + source_project: scoped_project, + target_project: project, + direction: :inbound + ) +end -- GitLab From ab3d4ac77962f8ec9514ad6b9cccb283466c80fd Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Fri, 6 Jan 2023 15:02:37 -0500 Subject: [PATCH 02/11] Code review feedback --- app/models/ci/job_token/scope.rb | 10 +++++----- .../shared_contexts/models/ci/job_token_scope.rb | 2 -- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index bc8bd3071d073b..9a0cd673b9e035 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -43,7 +43,7 @@ def inbound_projects def outbound_allows?(accessed_project) # if the setting is disabled any project is considered to be in scope. - return true unless @current_project.ci_outbound_job_token_scope_enabled? + return true unless current_project.ci_outbound_job_token_scope_enabled? outbound_allowlist.includes?(accessed_project) end @@ -58,22 +58,22 @@ def inbound_allows?(accessed_project) def inbound_link?(accessed_project) Ci::JobToken::ProjectScopeLink - .with_target(@current_project) + .with_target(current_project) .with_source(accessed_project) .where(direction: :inbound) .exists? end def inbound_allowlist - Ci::JobToken::Allowlist.new(@current_project, direction: :inbound) + Ci::JobToken::Allowlist.new(current_project, direction: :inbound) end def outbound_allowlist - Ci::JobToken::Allowlist.new(@current_project, direction: :outbound) + Ci::JobToken::Allowlist.new(current_project, direction: :outbound) end def self_referential?(accessed_project) - @current_project.id == accessed_project.id + current_project.id == accessed_project.id end end end diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb index 23528221720f0c..44fb0061b75a2c 100644 --- a/spec/support/shared_contexts/models/ci/job_token_scope.rb +++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb @@ -7,9 +7,7 @@ end RSpec.shared_context 'with allowed projects' do - # outbound allowed let_it_be(:outbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :outbound) } - # inbound allowed let_it_be(:inbound_allowed_project) { create_inbound_allowed_project(source_project) } let_it_be(:fully_allowed_project) { create_inbound_and_outbound_allowed_project(source_project) } -- GitLab From e222df6d634c9e40acf3ceb983ab81ded380231e Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Mon, 23 Jan 2023 17:08:56 -0500 Subject: [PATCH 03/11] Fix the scope link comments --- app/models/ci/job_token/project_scope_link.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index b784f93651a2b1..4fb577f58bc6d6 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -# The connection between a source project (which defines the job token scope) -# and a target project which is the one allowed to be accessed by the job token. +# The connection between a source project (which the job token scope's allowlist applies too) +# and a target project which is added to the scope's allowlist. module Ci module JobToken @@ -9,6 +9,7 @@ class ProjectScopeLink < Ci::ApplicationRecord self.table_name = 'ci_job_token_project_scope_links' belongs_to :source_project, class_name: 'Project' + # the project added to the scope's allowlist belongs_to :target_project, class_name: 'Project' belongs_to :added_by, class_name: 'User' @@ -19,6 +20,8 @@ class ProjectScopeLink < Ci::ApplicationRecord validates :target_project, presence: true validate :not_self_referential_link + # When outbound the target project is allowed to be accessed by the source job token. + # When inbound the source project is allowed to be accessed by the target job token. enum direction: { outbound: 0, inbound: 1 -- GitLab From 9dafa4d5ab90f3b3b2ede19ad4b20aafd3baf509 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Mon, 23 Jan 2023 17:15:24 -0500 Subject: [PATCH 04/11] Rename source to current --- app/models/ci/job_token/scope.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 9a0cd673b9e035..9aa4ba63f03005 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -10,9 +10,9 @@ # Projects in the outbound allowlist can be accessed via the source projects job token. # # Projects in the inbound allowlist can use their projects job token to -# access the source project. +# access the current project. # -# CI_JOB_TOKEN should be considered untrusted without a scope in some direction enabled. +# CI_JOB_TOKEN should be considered untrusted without a scope enabled. # module Ci -- GitLab From bdff1100ac9da73e6c4d699d1fb8050e6df5e13c Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Tue, 24 Jan 2023 20:25:44 -0500 Subject: [PATCH 05/11] Rename and clarify comments --- app/models/ci/job_token/scope.rb | 19 +++++---- app/policies/project_policy.rb | 2 +- ee/spec/requests/api/ci/triggers_spec.rb | 4 +- spec/models/ci/job_token/scope_spec.rb | 40 ++++++++++--------- .../ci/job_token_scope/add_project_spec.rb | 2 +- .../ci/job_token_scope/remove_project_spec.rb | 2 +- .../models/ci/job_token_scope.rb | 16 ++++---- 7 files changed, 46 insertions(+), 39 deletions(-) diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 9aa4ba63f03005..0b86bd97eb564a 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -7,7 +7,7 @@ # Projects can be added to the scope by adding ScopeLinks to # create an allowlist of projects in either access direction (inbound, outbound). # -# Projects in the outbound allowlist can be accessed via the source projects job token. +# Projects in the outbound allowlist can be accessed via the current projects job token. # # Projects in the inbound allowlist can use their projects job token to # access the current project. @@ -24,10 +24,10 @@ def initialize(current_project) @current_project = current_project end - def allows?(accessed_project) + def accessible?(accessed_project) self_referential?(accessed_project) || ( - outbound_allows?(accessed_project) && - inbound_allows?(accessed_project) + outbound_accessible?(accessed_project) && + inbound_accessible?(accessed_project) ) end @@ -41,22 +41,25 @@ def inbound_projects private - def outbound_allows?(accessed_project) + def outbound_accessible?(accessed_project) # if the setting is disabled any project is considered to be in scope. return true unless current_project.ci_outbound_job_token_scope_enabled? outbound_allowlist.includes?(accessed_project) end - def inbound_allows?(accessed_project) + def inbound_accessible?(accessed_project) # if the flag or setting is disabled any project is considered to be in scope. return true unless Feature.enabled?(:ci_inbound_job_token_scope, current_project) return true unless current_project.ci_inbound_job_token_scope_enabled? - inbound_link?(accessed_project) + inbound_linked_as_accessible?(accessed_project) end - def inbound_link?(accessed_project) + # We don't check the inbound allowlist here. That is because + # the access check starts from the current project but the inbound + # allowlist contains projects that can access the current project. + def inbound_linked_as_accessible?(accessed_project) Ci::JobToken::ProjectScopeLink .with_target(current_project) .with_source(accessed_project) diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 34c5be7d972beb..eb9d13cc9a3f23 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -121,7 +121,7 @@ class ProjectPolicy < BasePolicy desc "If user is authenticated via CI job token then the target project should be in scope" condition(:project_allowed_for_job_token) do - !@user&.from_ci_job_token? || @user.ci_job_token_scope.allows?(project) + !@user&.from_ci_job_token? || @user.ci_job_token_scope.accessible?(project) end with_scope :subject diff --git a/ee/spec/requests/api/ci/triggers_spec.rb b/ee/spec/requests/api/ci/triggers_spec.rb index d7c5b21bf1771e..39154cf8745bc1 100644 --- a/ee/spec/requests/api/ci/triggers_spec.rb +++ b/ee/spec/requests/api/ci/triggers_spec.rb @@ -19,7 +19,7 @@ end before do - allow_next(Ci::JobToken::Scope).to receive(:allows?).with(project).and_return(true) + allow_next(Ci::JobToken::Scope).to receive(:accessible?).with(project).and_return(true) end context 'without user' do @@ -80,7 +80,7 @@ context 'when project is not in the job token scope' do before do allow_next(Ci::JobToken::Scope) - .to receive(:allows?) + .to receive(:accessible?) .with(project) .and_return(false) end diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb index c4081a1121063c..4cd58753fdec26 100644 --- a/spec/models/ci/job_token/scope_spec.rb +++ b/spec/models/ci/job_token/scope_spec.rb @@ -2,9 +2,13 @@ require 'spec_helper' -RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration do +RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, factory_default: :keep do using RSpec::Parameterized::TableSyntax + let_it_be(:project) { create_default(:project) } + let_it_be(:user) { create_default(:user) } + let_it_be(:namespace) { create_default(:namespace) } + let_it_be(:source_project) do create(:project, ci_outbound_job_token_scope_enabled: true, @@ -26,10 +30,10 @@ end context 'when projects are added to the scope' do - include_context 'with allowed projects' + include_context 'with accessible and inaccessible projects' it 'returns all projects that can be accessed from a given scope' do - expect(subject).to contain_exactly(current_project, outbound_allowlist_project, fully_allowed_project) + expect(subject).to contain_exactly(current_project, outbound_allowlist_project, fully_accessible_project) end end end @@ -44,7 +48,7 @@ end context 'when projects are added to the scope' do - include_context 'with allowed projects' + include_context 'with accessible and inaccessible projects' it 'returns all projects that can be accessed from a given scope' do expect(subject).to contain_exactly(current_project, inbound_allowlist_project) @@ -53,16 +57,16 @@ end RSpec.shared_examples 'enforces outbound scope only' do - include_context 'with allowed projects' + include_context 'with accessible and inaccessible projects' - where(:allows_project, :result) do + where(:accessed_project, :result) do ref(:current_project) | true ref(:inbound_allowlist_project) | false ref(:unscoped_project1) | false ref(:unscoped_project2) | false ref(:outbound_allowlist_project) | true - ref(:inbound_allowed_project) | false - ref(:fully_allowed_project) | true + ref(:inbound_accessible_project) | false + ref(:fully_accessible_project) | true end with_them do @@ -70,21 +74,21 @@ end end - describe 'allows?' do - subject { scope.allows?(allows_project) } + describe 'accessible?' do + subject { scope.accessible?(accessed_project) } context 'with inbound and outbound scopes enabled' do context 'when inbound and outbound access setup' do - include_context 'with allowed projects' + include_context 'with accessible and inaccessible projects' - where(:allows_project, :result) do + where(:accessed_project, :result) do ref(:current_project) | true ref(:inbound_allowlist_project) | false ref(:unscoped_project1) | false ref(:unscoped_project2) | false ref(:outbound_allowlist_project) | false - ref(:inbound_allowed_project) | false - ref(:fully_allowed_project) | true + ref(:inbound_accessible_project) | false + ref(:fully_accessible_project) | true end with_them do @@ -102,16 +106,16 @@ source_project.save! end - include_context 'with allowed projects' + include_context 'with accessible and inaccessible projects' - where(:allows_project, :result) do + where(:accessed_project, :result) do ref(:current_project) | true ref(:inbound_allowlist_project) | false ref(:unscoped_project1) | false ref(:unscoped_project2) | false ref(:outbound_allowlist_project) | false - ref(:inbound_allowed_project) | true - ref(:fully_allowed_project) | true + ref(:inbound_accessible_project) | true + ref(:fully_accessible_project) | true end with_them do diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb index 490716ddbe2fd8..ea328908002c0a 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -60,7 +60,7 @@ post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::Scope.new(project).allows?(target_project) }.from(false).to(true) + end.to change { Ci::JobToken::Scope.new(project).accessible?(target_project) }.from(false).to(true) end context 'when invalid target project is provided' do diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb index 607c6bd85c25e3..fc634c8ed70d0b 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb @@ -66,7 +66,7 @@ post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::Scope.new(project).allows?(target_project) }.from(true).to(false) + end.to change { Ci::JobToken::Scope.new(project).accessible?(target_project) }.from(true).to(false) end context 'when invalid target project is provided' do diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb index 44fb0061b75a2c..b6c44daf700d72 100644 --- a/spec/support/shared_contexts/models/ci/job_token_scope.rb +++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb @@ -3,18 +3,18 @@ RSpec.shared_context 'with a project in each allowlist' do let_it_be(:outbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :outbound) } - include_context 'with not allowed projects' + include_context 'with inaccessible projects' end -RSpec.shared_context 'with allowed projects' do +RSpec.shared_context 'with accessible and inaccessible projects' do let_it_be(:outbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :outbound) } - let_it_be(:inbound_allowed_project) { create_inbound_allowed_project(source_project) } - let_it_be(:fully_allowed_project) { create_inbound_and_outbound_allowed_project(source_project) } + let_it_be(:inbound_accessible_project) { create_inbound_accessible_project(source_project) } + let_it_be(:fully_accessible_project) { create_inbound_and_outbound_accessible_project(source_project) } - include_context 'with not allowed projects' + include_context 'with inaccessible projects' end -RSpec.shared_context 'with not allowed projects' do +RSpec.shared_context 'with inaccessible projects' do let_it_be(:inbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :inbound) } include_context 'with unscoped projects' end @@ -37,13 +37,13 @@ def create_project_in_allowlist(root_project, direction:) end end -def create_inbound_allowed_project(project) +def create_inbound_accessible_project(project) create(:project).tap do |scoped_project| add_inbound_allowed_linkage(project, scoped_project) end end -def create_inbound_and_outbound_allowed_project(project) +def create_inbound_and_outbound_accessible_project(project) create(:project).tap do |scoped_project| add_outbound_allowed_linkage(project, scoped_project) add_inbound_allowed_linkage(project, scoped_project) -- GitLab From 8067292fa82e08171b40e18ebba9fc4c5c680836 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Mon, 30 Jan 2023 14:10:35 -0500 Subject: [PATCH 06/11] Use an inverse allowlist for the check --- app/models/ci/job_token/scope.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 0b86bd97eb564a..93bfa35f5c6337 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -7,9 +7,9 @@ # Projects can be added to the scope by adding ScopeLinks to # create an allowlist of projects in either access direction (inbound, outbound). # -# Projects in the outbound allowlist can be accessed via the current projects job token. +# Projects in the outbound allowlist can be accessed via the current project's job token. # -# Projects in the inbound allowlist can use their projects job token to +# Projects in the inbound allowlist can use their project's job token to # access the current project. # # CI_JOB_TOKEN should be considered untrusted without a scope enabled. @@ -60,17 +60,19 @@ def inbound_accessible?(accessed_project) # the access check starts from the current project but the inbound # allowlist contains projects that can access the current project. def inbound_linked_as_accessible?(accessed_project) - Ci::JobToken::ProjectScopeLink - .with_target(current_project) - .with_source(accessed_project) - .where(direction: :inbound) - .exists? + inbound_accessible_projects(accessed_project).includes?(current_project) end + def inbound_accessible_projects(accessed_project) + Ci::JobToken::Allowlist.new(accessed_project, direction: :inbound) + end + + # User created list of projects allowed to access the current project def inbound_allowlist Ci::JobToken::Allowlist.new(current_project, direction: :inbound) end + # User created list of projects that can be accessed from the current project def outbound_allowlist Ci::JobToken::Allowlist.new(current_project, direction: :outbound) end -- GitLab From ec78d1e5c8aae1b311e8734cd30cc7eeff33c537 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Tue, 31 Jan 2023 08:55:09 -0500 Subject: [PATCH 07/11] Fix specs broken by pulling master Code was merged to keep the outbound scope enabled when the feature flag is on. Fixes a few specs that broke by associating the correct project with a build or by ensuring the correct scope links exist to make the project fully accessible according to the job token scope model. --- ee/spec/requests/api/ci/runner_spec.rb | 7 ++- .../app_sec/dast/site_validations_spec.rb | 6 +-- .../ci/job_token_scope/add_project_spec.rb | 2 +- spec/models/ci/job_token/allowlist_spec.rb | 1 + .../ci/job_token/project_scope_link_spec.rb | 5 +- spec/models/ci/job_token/scope_spec.rb | 1 + spec/policies/project_policy_spec.rb | 5 +- spec/requests/api/ci/job_artifacts_spec.rb | 14 +++-- .../ci/job_token_scope/add_project_spec.rb | 2 +- .../ci/job_token_scope/remove_project_spec.rb | 10 +++- spec/requests/api/project_packages_spec.rb | 14 ++--- spec/requests/api/release/links_spec.rb | 12 +++-- spec/requests/git_http_spec.rb | 39 +++++--------- .../helpers/ci/job_token_scope_helpers.rb | 51 +++++++++++++++++++ .../models/ci/job_token_scope.rb | 42 --------------- .../api/conan_packages_shared_context.rb | 2 +- .../services/packages_shared_examples.rb | 2 +- 17 files changed, 116 insertions(+), 99 deletions(-) create mode 100644 spec/support/helpers/ci/job_token_scope_helpers.rb diff --git a/ee/spec/requests/api/ci/runner_spec.rb b/ee/spec/requests/api/ci/runner_spec.rb index 388bf299e7d8b5..7f54f5a4d43dc7 100644 --- a/ee/spec/requests/api/ci/runner_spec.rb +++ b/ee/spec/requests/api/ci/runner_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe API::Ci::Runner, feature_category: :runner do + include Ci::JobTokenScopeHelpers + let_it_be_with_reload(:project) { create(:project, :repository) } let_it_be(:user) { create(:user) } @@ -127,10 +129,10 @@ def request_job(token = runner.token, **params) end describe 'GET api/v4/jobs/:id/artifacts' do - let_it_be(:job) { create(:ci_build, :success, ref: ref, pipeline: pipeline, user: user) } + let_it_be(:job) { create(:ci_build, :success, ref: ref, pipeline: pipeline, user: user, project: project) } before_all do - create(:ci_job_artifact, :archive, job: job) + create(:ci_job_artifact, :archive, job: job, project: project) end shared_examples 'successful artifact download' do @@ -176,6 +178,7 @@ def request_job(token = runner.token, **params) before_all do downstream_project.add_developer(user) downstream_project.add_developer(downstream_project_dev) + make_project_fully_accessible(downstream_project, project) end before do diff --git a/ee/spec/requests/api/internal/app_sec/dast/site_validations_spec.rb b/ee/spec/requests/api/internal/app_sec/dast/site_validations_spec.rb index be43c7a8761e31..8377a53eec1102 100644 --- a/ee/spec/requests/api/internal/app_sec/dast/site_validations_spec.rb +++ b/ee/spec/requests/api/internal/app_sec/dast/site_validations_spec.rb @@ -4,6 +4,7 @@ RSpec.describe API::Internal::AppSec::Dast::SiteValidations, feature_category: :dynamic_application_security_testing do include AfterNextHelpers + include Ci::JobTokenScopeHelpers let_it_be(:project) { create(:project) } let_it_be(:developer) { create(:user, developer_projects: [project]) } @@ -69,10 +70,7 @@ let_it_be(:job) { create(:ci_build, :running, user: developer) } before do - create(:ci_job_token_project_scope_link, - source_project: job.project, - target_project: project, - added_by: developer) + make_project_fully_accessible(job.project, project) end it 'returns 400', :aggregate_failures do diff --git a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb index 727db7e23611b7..07c2755a3f724a 100644 --- a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -45,7 +45,7 @@ it 'adds target project to the job token scope' do expect do expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty) - end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1) + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) end context 'when the service returns an error' do diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb index 47ba4be3fc4770..c69dcba765a1f5 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe Ci::JobToken::Allowlist, feature_category: :continuous_integration do + include Ci::JobTokenScopeHelpers using RSpec::Parameterized::TableSyntax let_it_be(:source_project) { create(:project) } 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 91491733c444ba..30ae8bc6d8842f 100644 --- a/spec/models/ci/job_token/project_scope_link_spec.rb +++ b/spec/models/ci/job_token/project_scope_link_spec.rb @@ -18,11 +18,12 @@ describe 'unique index' do let!(:link) { create(:ci_job_token_project_scope_link) } - it 'raises an error' do + it 'raises an error, when not unique' do expect do create(:ci_job_token_project_scope_link, source_project: link.source_project, - target_project: link.target_project) + target_project: link.target_project, + direction: link.direction) end.to raise_error(ActiveRecord::RecordNotUnique) end end diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb index 4cd58753fdec26..da632622f1bccd 100644 --- a/spec/models/ci/job_token/scope_spec.rb +++ b/spec/models/ci/job_token/scope_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, factory_default: :keep do + include Ci::JobTokenScopeHelpers using RSpec::Parameterized::TableSyntax let_it_be(:project) { create_default(:project) } diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index a98f091b9fca72..434f7a43665127 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -2478,7 +2478,10 @@ def set_access_level(access_level) before do current_user.set_ci_job_token_scope!(job) current_user.external = external_user - scope_project.update!(ci_outbound_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 it "enforces the expected permissions" do diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb index a4a38179d11411..ee390773f298d2 100644 --- a/spec/requests/api/ci/job_artifacts_spec.rb +++ b/spec/requests/api/ci/job_artifacts_spec.rb @@ -5,6 +5,7 @@ RSpec.describe API::Ci::JobArtifacts, feature_category: :build_artifacts do include HttpBasicAuthHelpers include DependencyProxyHelpers + include Ci::JobTokenScopeHelpers include HttpIOHelpers @@ -312,7 +313,7 @@ def get_artifact_file(artifact_path) context 'normal authentication' do context 'job with artifacts' do context 'when artifacts are stored locally' do - let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, project: project) } subject { get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user) } @@ -329,11 +330,12 @@ def get_artifact_file(artifact_path) stub_licensed_features(cross_project_pipelines: true) end - it_behaves_like 'downloads artifact' - context 'when job token scope is enabled' do before do - other_job.project.ci_cd_settings.update!(job_token_scope_enabled: true) + other_job.project.ci_cd_settings.update!( + job_token_scope_enabled: true, + inbound_job_token_scope_enabled: true + ) end it 'does not allow downloading artifacts' do @@ -343,7 +345,9 @@ def get_artifact_file(artifact_path) end context 'when project is added to the job token scope' do - let!(:link) { create(:ci_job_token_project_scope_link, source_project: other_job.project, target_project: job.project) } + before do + make_project_fully_accessible(other_job.project, job.project) + end it_behaves_like 'downloads artifact' end diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb index ea328908002c0a..55e728b2141deb 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -60,7 +60,7 @@ post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::Scope.new(project).accessible?(target_project) }.from(false).to(true) + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) end context 'when invalid target project is provided' do diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb index fc634c8ed70d0b..61d5c56ae8a2c3 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb @@ -5,7 +5,13 @@ RSpec.describe 'CiJobTokenScopeRemoveProject', feature_category: :continuous_integration do include GraphqlHelpers - let_it_be(:project) { create(:project, ci_outbound_job_token_scope_enabled: true).tap(&:save!) } + let_it_be(:project) do + create(:project, + ci_outbound_job_token_scope_enabled: true, + ci_inbound_job_token_scope_enabled: true + ) + end + let_it_be(:target_project) { create(:project) } let_it_be(:link) do @@ -66,7 +72,7 @@ post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::Scope.new(project).accessible?(target_project) }.from(true).to(false) + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(-1) end context 'when invalid target project is provided' do diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb index d3adef85f8d509..c003ae9cd48d77 100644 --- a/spec/requests/api/project_packages_spec.rb +++ b/spec/requests/api/project_packages_spec.rb @@ -88,7 +88,7 @@ end context 'with JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } subject { get api(url, job_token: job.token) } @@ -130,7 +130,7 @@ end context 'with JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } subject { get api(url, job_token: job.token) } @@ -229,8 +229,8 @@ get api(package_url, user) end - pipeline = create(:ci_pipeline, user: user) - create(:ci_build, user: user, pipeline: pipeline) + pipeline = create(:ci_pipeline, user: user, project: project) + create(:ci_build, user: user, pipeline: pipeline, project: project) create(:package_build_info, package: package1, pipeline: pipeline) expect do @@ -262,7 +262,7 @@ it_behaves_like 'no destroy url' context 'with JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } subject { get api(package_url, job_token: job.token) } @@ -324,7 +324,7 @@ end context 'with JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } subject { get api(package_url, job_token: job.token) } @@ -430,7 +430,7 @@ end context 'with JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } it 'returns 403 for a user without enough permissions' do project.add_developer(user) diff --git a/spec/requests/api/release/links_spec.rb b/spec/requests/api/release/links_spec.rb index 4a7821fcb0a137..462cc1e3b5db85 100644 --- a/spec/requests/api/release/links_spec.rb +++ b/spec/requests/api/release/links_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe API::Release::Links, feature_category: :release_orchestration do + include Ci::JobTokenScopeHelpers + let(:project) { create(:project, :repository, :private) } let(:maintainer) { create(:user) } let(:developer) { create(:user) } @@ -51,7 +53,7 @@ end context 'when using JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: maintainer) } + let(:job) { create(:ci_build, :running, user: maintainer, project: project) } it 'returns releases links' do get api("/projects/#{project.id}/releases/v0.1/assets/links", job_token: job.token) @@ -127,7 +129,7 @@ end context 'when using JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: maintainer) } + let(:job) { create(:ci_build, :running, user: maintainer, project: project) } it 'returns releases link' do get api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", job_token: job.token) @@ -241,7 +243,7 @@ end context 'when using JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: maintainer) } + let(:job) { create(:ci_build, :running, user: maintainer, project: project) } it 'creates a new release link' do expect do @@ -385,7 +387,7 @@ end context 'when using JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: maintainer) } + let(:job) { create(:ci_build, :running, user: maintainer, project: project) } it 'updates the release link' do put api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}"), params: params.merge(job_token: job.token) @@ -496,7 +498,7 @@ end context 'when using JOB-TOKEN auth' do - let(:job) { create(:ci_build, :running, user: maintainer) } + let(:job) { create(:ci_build, :running, user: maintainer, project: project) } it 'deletes the release link' do expect do diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 66337b94c75939..02b99eba8ced5b 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -7,6 +7,7 @@ include TermsHelper include GitHttpHelpers include WorkhorseHelpers + include Ci::JobTokenScopeHelpers shared_examples 'pulls require Basic HTTP Authentication' do context "when no credentials are provided" do @@ -869,14 +870,15 @@ def attempt_login(include_password) context "when a gitlab ci token is provided" do let(:project) { create(:project, :repository) } - let(:build) { create(:ci_build, :running) } - let(:other_project) { create(:project, :repository) } - - before do - build.update!(project: project) # can't associate it on factory create + let(:build) { create(:ci_build, :running, project: project, user: user) } + let(:other_project) do + create(:project, :repository).tap do |o| + make_project_fully_accessible(project, o) + end end context 'when build created by system is authenticated' do + let(:user) { nil } let(:path) { "#{project.full_path}.git" } let(:env) { { user: 'gitlab-ci-token', password: build.token } } @@ -899,12 +901,7 @@ def pull context 'and build created by' do before do - build.update!(user: user) project.add_reporter(user) - create(:ci_job_token_project_scope_link, - source_project: project, - target_project: other_project, - added_by: user) end shared_examples 'can download code only' do @@ -1474,19 +1471,16 @@ def attempt_login(include_password) context "when a gitlab ci token is provided" do let(:project) { create(:project, :repository) } - let(:build) { create(:ci_build, :running) } - let(:other_project) { create(:project, :repository) } - - before do - build.update!(project: project) # can't associate it on factory create - create(:ci_job_token_project_scope_link, - source_project: project, - target_project: other_project, - added_by: user) + let(:build) { create(:ci_build, :running, project: project, user: user) } + let(:other_project) do + create(:project, :repository).tap do |o| + make_project_fully_accessible(project, o) + end end # legacy behavior that is blocked/deprecated context 'when build created by system is authenticated' do + let(:user) { nil } let(:path) { "#{project.full_path}.git" } let(:env) { { user: 'gitlab-ci-token', password: build.token } } @@ -1505,7 +1499,6 @@ def attempt_login(include_password) context 'and build created by' do before do - build.update!(user: user) project.add_reporter(user) end @@ -1862,13 +1855,9 @@ def attempt_login(include_password) end context 'from CI' do - let(:build) { create(:ci_build, :running) } + let(:build) { create(:ci_build, :running, user: user, project: project) } let(:env) { { user: 'gitlab-ci-token', password: build.token } } - before do - build.update!(user: user, project: project) - end - it_behaves_like 'pulls are allowed' end end diff --git a/spec/support/helpers/ci/job_token_scope_helpers.rb b/spec/support/helpers/ci/job_token_scope_helpers.rb new file mode 100644 index 00000000000000..09084bc871542c --- /dev/null +++ b/spec/support/helpers/ci/job_token_scope_helpers.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Ci + module JobTokenScopeHelpers + def create_project_in_allowlist(root_project, direction:) + create(:project).tap do |scoped_project| + create( + :ci_job_token_project_scope_link, + source_project: root_project, + target_project: scoped_project, + direction: direction + ) + end + end + + def create_inbound_accessible_project(project) + create(:project).tap do |accessible_project| + add_inbound_accessible_linkage(project, accessible_project) + end + end + + def create_inbound_and_outbound_accessible_project(project) + create(:project).tap do |accessible_project| + make_project_fully_accessible(project, accessible_project) + end + end + + def make_project_fully_accessible(project, accessible_project) + add_outbound_accessible_linkage(project, accessible_project) + add_inbound_accessible_linkage(project, accessible_project) + end + + def add_outbound_accessible_linkage(project, accessible_project) + create( + :ci_job_token_project_scope_link, + source_project: project, + target_project: accessible_project, + direction: :outbound + ) + end + + def add_inbound_accessible_linkage(project, accessible_project) + create( + :ci_job_token_project_scope_link, + source_project: accessible_project, + target_project: project, + direction: :inbound + ) + end + end +end diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb index b6c44daf700d72..d0fee23b57c434 100644 --- a/spec/support/shared_contexts/models/ci/job_token_scope.rb +++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb @@ -25,45 +25,3 @@ let_it_be(:link_out_of_scope) { create(:ci_job_token_project_scope_link, target_project: unscoped_project1) } end - -def create_project_in_allowlist(root_project, direction:) - create(:project).tap do |scoped_project| - create( - :ci_job_token_project_scope_link, - source_project: root_project, - target_project: scoped_project, - direction: direction - ) - end -end - -def create_inbound_accessible_project(project) - create(:project).tap do |scoped_project| - add_inbound_allowed_linkage(project, scoped_project) - end -end - -def create_inbound_and_outbound_accessible_project(project) - create(:project).tap do |scoped_project| - add_outbound_allowed_linkage(project, scoped_project) - add_inbound_allowed_linkage(project, scoped_project) - end -end - -def add_outbound_allowed_linkage(project, scoped_project) - create( - :ci_job_token_project_scope_link, - source_project: project, - target_project: scoped_project, - direction: :outbound - ) -end - -def add_inbound_allowed_linkage(project, scoped_project) - create( - :ci_job_token_project_scope_link, - source_project: scoped_project, - target_project: project, - direction: :inbound - ) -end diff --git a/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb index 7c37e5189f1168..f6e10543c840fa 100644 --- a/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb +++ b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb @@ -11,7 +11,7 @@ let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let(:project) { package.project } - let(:job) { create(:ci_build, :running, user: user) } + let(:job) { create(:ci_build, :running, user: user, project: project) } let(:job_token) { job.token } let(:auth_token) { personal_access_token.token } let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb index e0dd08ec50e291..f63693dbf2603f 100644 --- a/spec/support/shared_examples/services/packages_shared_examples.rb +++ b/spec/support/shared_examples/services/packages_shared_examples.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'assigns build to package' do context 'with build info' do - let(:job) { create(:ci_build, user: user) } + let(:job) { create(:ci_build, user: user, project: project) } let(:params) { super().merge(build: job) } it 'assigns the pipeline to the package' do -- GitLab From 9c2f10d32997f4dfdecdd2381aa48cc18aded4b9 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Wed, 25 Jan 2023 19:24:50 -0500 Subject: [PATCH 08/11] Add direction to the job token scope mutation Add ability to add projects to the inbound allowlist via the graphql api. Add the ability to read the inbound allowlist of projects --- .../ci/job_token_scope/add_project.rb | 15 ++++++++---- .../ci/job_token_scope/direction_enum.rb | 20 ++++++++++++++++ app/graphql/types/ci/job_token_scope_type.rb | 16 +++++++++++++ app/models/ci/job_token/allowlist.rb | 9 +++++++ .../ci/job_token_scope/add_project_service.rb | 18 +++++++------- doc/api/graphql/reference/index.md | 14 ++++++++++- .../add_project_service_spec.rb | 24 ++++++++++++++++++- 7 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 app/graphql/types/ci/job_token_scope/direction_enum.rb diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb index e16c08cb116c4d..d4ada7fc64a0ae 100644 --- a/app/graphql/mutations/ci/job_token_scope/add_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb @@ -18,18 +18,23 @@ class AddProject < BaseMutation required: true, description: 'Project to be added to the CI job token scope.' + argument :direction, + ::Types::Ci::JobTokenScope::DirectionEnum, + required: false, + description: 'Direction of access which defaults to outbound.' + field :ci_job_token_scope, - Types::Ci::JobTokenScopeType, - null: true, - description: "CI job token's scope of access." + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's scope of access." - def resolve(project_path:, target_project_path:) + def resolve(project_path:, target_project_path:, direction: :outbound) project = authorized_find!(project_path) target_project = Project.find_by_full_path(target_project_path) result = ::Ci::JobTokenScope::AddProjectService .new(project, current_user) - .execute(target_project) + .execute(target_project, direction: direction) if result.success? { diff --git a/app/graphql/types/ci/job_token_scope/direction_enum.rb b/app/graphql/types/ci/job_token_scope/direction_enum.rb new file mode 100644 index 00000000000000..6f7ba22d6aee81 --- /dev/null +++ b/app/graphql/types/ci/job_token_scope/direction_enum.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Ci + module JobTokenScope + class DirectionEnum < BaseEnum + graphql_name 'CiJobTokenScopeDirection' + description 'Direction of access.' + + value 'OUTBOUND', + value: :outbound, + description: 'Job token scopes project can access target project in the outbound allowlist.' + + value 'INBOUND', + value: :inbound, + description: 'Target projects in the inbound allowlist can access the scope project via their job tokens.' + end + end + end +end diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 9c9c7ccb8d12f0..66a4cf37c1205b 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -11,7 +11,23 @@ class JobTokenScopeType < BaseObject Types::ProjectType.connection_type, null: false, description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.', + method: :outbound_projects, + deprecated: { + reason: 'The `projects` attribute is being deprecated. Use `outbound_allowlist`', + milestone: '15.8' + } + + field :outbound_allowlist, + Types::ProjectType.connection_type, + null: false, + description: "Allow list of projects that are accessible using the current projects CI Job token's.", method: :outbound_projects + + field :inbound_allowlist, + Types::ProjectType.connection_type, + null: false, + description: "Allow list of projects that can access the current project via their CI Job token's.", + method: :inbound_projects end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb index 9e9a0a68ebd438..618dc2da05cb34 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -17,6 +17,15 @@ def projects Project.from_union(target_projects, remove_duplicates: false) end + def add!(target_project, user:) + Ci::JobToken::ProjectScopeLink.create!( + source_project: @source_project, + direction: @direction, + target_project: target_project, + added_by: user + ) + end + private def source_links diff --git a/app/services/ci/job_token_scope/add_project_service.rb b/app/services/ci/job_token_scope/add_project_service.rb index d03ae434b69a75..12a36998043181 100644 --- a/app/services/ci/job_token_scope/add_project_service.rb +++ b/app/services/ci/job_token_scope/add_project_service.rb @@ -5,10 +5,14 @@ module JobTokenScope class AddProjectService < ::BaseService include EditScopeValidations - def execute(target_project) + def execute(target_project, direction: :outbound) + direction = :outbound unless Feature.enabled?(:ci_inbound_job_token_scope) + validate_edit!(project, target_project, current_user) - link = add_project!(target_project) + link = allowlist(direction) + .add!(target_project, user: current_user) + ServiceResponse.success(payload: { project_link: link }) rescue ActiveRecord::RecordNotUnique @@ -19,12 +23,10 @@ def execute(target_project) ServiceResponse.error(message: e.message) end - def add_project!(target_project) - ::Ci::JobToken::ProjectScopeLink.create!( - source_project: project, - target_project: target_project, - added_by: current_user - ) + private + + def allowlist(direction) + Ci::JobToken::Allowlist.new(project, direction: direction) end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d01e8e3e7c3bd1..4e9e6a0ef5086a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1183,6 +1183,7 @@ Input type: `CiJobTokenScopeAddProjectInput` | Name | Type | Description | | ---- | ---- | ----------- | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `direction` | [`CiJobTokenScopeDirection`](#cijobtokenscopedirection) | Direction of access which defaults to outbound. | | `projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. | | `targetProjectPath` | [`ID!`](#id) | Project to be added to the CI job token scope. | @@ -11299,7 +11300,9 @@ CI/CD variables for a GitLab instance. | Name | Type | Description | | ---- | ---- | ----------- | -| `projects` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can be accessed by CI Job tokens created by this project. (see [Connections](#connections)) | +| `inboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can access the current project via their CI Job token's. (see [Connections](#connections)) | +| `outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can be accessed by the current projects CI Job token. (see [Connections](#connections)) | +| `projects` **{warning-solid}** | [`ProjectConnection!`](#projectconnection) | **Deprecated** in 15.8. The `projects` attribute is being deprecated. Use `outbound_allowlist`. | ### `CiJobsDurationStatistics` @@ -21706,6 +21709,15 @@ Deploy freeze period status. | `SUCCESS` | A job that is success. | | `WAITING_FOR_RESOURCE` | A job that is waiting for resource. | +### `CiJobTokenScopeDirection` + +Direction of access. + +| Value | Description | +| ----- | ----------- | +| `INBOUND` | Target projects in the inbound allowlist can access the scope project via their job tokens. | +| `OUTBOUND` | Job token scopes project can access target project in the outbound allowlist. | + ### `CiRunnerAccessLevel` | Value | Description | diff --git a/spec/services/ci/job_token_scope/add_project_service_spec.rb b/spec/services/ci/job_token_scope/add_project_service_spec.rb index bf7df3a5595fe2..e6674ee384f5d8 100644 --- a/spec/services/ci/job_token_scope/add_project_service_spec.rb +++ b/spec/services/ci/job_token_scope/add_project_service_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Ci::JobTokenScope::AddProjectService do +RSpec.describe Ci::JobTokenScope::AddProjectService, feature_category: :continuous_integration do let(:service) { described_class.new(project, current_user) } let_it_be(:project) { create(:project, ci_outbound_job_token_scope_enabled: true).tap(&:save!) } @@ -21,6 +21,8 @@ it_behaves_like 'editable job token scope' do context 'when user has permissions on source and target projects' do + let(:resulting_direction) { result.payload.fetch(:project_link)&.direction } + before do project.add_maintainer(current_user) target_project.add_developer(current_user) @@ -34,6 +36,26 @@ end it_behaves_like 'adds project' + + it 'creates an outbound link by default' do + expect(resulting_direction).to eq('outbound') + end + + context 'when direction is specified' do + subject(:result) { service.execute(target_project, direction: direction) } + + context 'when the direction is outbound' do + let(:direction) { :outbound } + + specify { expect(resulting_direction).to eq('outbound') } + end + + context 'when the direction is inbound' do + let(:direction) { :inbound } + + specify { expect(resulting_direction).to eq('inbound') } + end + end end end -- GitLab From b9025e79f8448ea11ec2a04135700c180361065b Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Tue, 31 Jan 2023 16:42:20 -0500 Subject: [PATCH 09/11] Update documentation based on review --- .../mutations/ci/job_token_scope/add_project.rb | 2 +- app/graphql/types/ci/job_token_scope/direction_enum.rb | 3 ++- app/graphql/types/ci/job_token_scope_type.rb | 6 +++--- doc/api/graphql/reference/index.md | 10 +++++----- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb index d4ada7fc64a0ae..f31c800d209c3f 100644 --- a/app/graphql/mutations/ci/job_token_scope/add_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb @@ -21,7 +21,7 @@ class AddProject < BaseMutation argument :direction, ::Types::Ci::JobTokenScope::DirectionEnum, required: false, - description: 'Direction of access which defaults to outbound.' + description: 'Direction of access, which defaults to outbound.' field :ci_job_token_scope, Types::Ci::JobTokenScopeType, diff --git a/app/graphql/types/ci/job_token_scope/direction_enum.rb b/app/graphql/types/ci/job_token_scope/direction_enum.rb index 6f7ba22d6aee81..7dc57c2226c0d5 100644 --- a/app/graphql/types/ci/job_token_scope/direction_enum.rb +++ b/app/graphql/types/ci/job_token_scope/direction_enum.rb @@ -13,7 +13,8 @@ class DirectionEnum < BaseEnum value 'INBOUND', value: :inbound, - description: 'Target projects in the inbound allowlist can access the scope project via their job tokens.' + description: 'Target projects in the inbound allowlist can access the scope project ' \ + 'through their job tokens.' end end end diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 66a4cf37c1205b..639bbaa22afd32 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -14,19 +14,19 @@ class JobTokenScopeType < BaseObject method: :outbound_projects, deprecated: { reason: 'The `projects` attribute is being deprecated. Use `outbound_allowlist`', - milestone: '15.8' + milestone: '15.9' } field :outbound_allowlist, Types::ProjectType.connection_type, null: false, - description: "Allow list of projects that are accessible using the current projects CI Job token's.", + description: "Allow list of projects that are accessible using the current project's CI Job tokens.", method: :outbound_projects field :inbound_allowlist, Types::ProjectType.connection_type, null: false, - description: "Allow list of projects that can access the current project via their CI Job token's.", + description: "Allow list of projects that can access the current project through its CI Job tokens.", method: :inbound_projects end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 4e9e6a0ef5086a..c680882942ea0b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1183,7 +1183,7 @@ Input type: `CiJobTokenScopeAddProjectInput` | Name | Type | Description | | ---- | ---- | ----------- | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | -| `direction` | [`CiJobTokenScopeDirection`](#cijobtokenscopedirection) | Direction of access which defaults to outbound. | +| `direction` | [`CiJobTokenScopeDirection`](#cijobtokenscopedirection) | Direction of access, which defaults to outbound. | | `projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. | | `targetProjectPath` | [`ID!`](#id) | Project to be added to the CI job token scope. | @@ -11300,9 +11300,9 @@ CI/CD variables for a GitLab instance. | Name | Type | Description | | ---- | ---- | ----------- | -| `inboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can access the current project via their CI Job token's. (see [Connections](#connections)) | -| `outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can be accessed by the current projects CI Job token. (see [Connections](#connections)) | -| `projects` **{warning-solid}** | [`ProjectConnection!`](#projectconnection) | **Deprecated** in 15.8. The `projects` attribute is being deprecated. Use `outbound_allowlist`. | +| `inboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can access the current project through its CI Job tokens. (see [Connections](#connections)) | +| `outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that are accessible using the current project's CI Job tokens. (see [Connections](#connections)) | +| `projects` **{warning-solid}** | [`ProjectConnection!`](#projectconnection) | **Deprecated** in 15.9. The `projects` attribute is being deprecated. Use `outbound_allowlist`. | ### `CiJobsDurationStatistics` @@ -21715,7 +21715,7 @@ Direction of access. | Value | Description | | ----- | ----------- | -| `INBOUND` | Target projects in the inbound allowlist can access the scope project via their job tokens. | +| `INBOUND` | Target projects in the inbound allowlist can access the scope project through their job tokens. | | `OUTBOUND` | Job token scopes project can access target project in the outbound allowlist. | ### `CiRunnerAccessLevel` -- GitLab From dee62caa805df765224a2791bd8c2592371fa6b2 Mon Sep 17 00:00:00 2001 From: Allison Browne Date: Wed, 1 Feb 2023 10:03:11 -0500 Subject: [PATCH 10/11] Adds specs for inbound scope mutation Add new examples to test the mutations with the new direction param allowed for the Ci::JobTokenScope::AddProject muration. --- .../ci/job_token_scope/add_project_spec.rb | 38 +++++++-- .../types/ci/job_token_scope_type_spec.rb | 83 ++++++++++++++++--- .../helpers/ci/job_token_scope_helpers.rb | 15 +++- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb index 07c2755a3f724a..44147987ebb4b0 100644 --- a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Mutations::Ci::JobTokenScope::AddProject do +RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :continuous_integration do let(:mutation) do described_class.new(object: nil, context: { current_user: current_user }, field: nil) end @@ -14,9 +14,10 @@ let_it_be(:target_project) { create(:project) } let(:target_project_path) { target_project.full_path } + let(:mutation_args) { { project_path: project.full_path, target_project_path: target_project_path } } subject do - mutation.resolve(project_path: project.full_path, target_project_path: target_project_path) + mutation.resolve(**mutation_args) end context 'when user is not logged in' do @@ -42,18 +43,45 @@ target_project.add_guest(current_user) end - it 'adds target project to the job token scope' do + it 'adds target project to the outbound job token scope by default' do expect do expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty) end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) end + context 'when mutation uses the direction argument' do + let(:mutation_args) { super().merge!(direction: direction) } + + context 'when targeting the outbound allowlist' do + let(:direction) { :outbound } + + it 'adds the target project' do + expect do + expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty) + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) + end + end + + context 'when targeting the inbound allowlist' do + let(:direction) { :inbound } + + it 'adds the target project' do + expect do + expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty) + end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1) + end + end + end + context 'when the service returns an error' do let(:service) { double(:service) } it 'returns an error response' do - expect(::Ci::JobTokenScope::AddProjectService).to receive(:new).with(project, current_user).and_return(service) - expect(service).to receive(:execute).with(target_project).and_return(ServiceResponse.error(message: 'The error message')) + expect(::Ci::JobTokenScope::AddProjectService).to receive(:new).with( + project, + current_user + ).and_return(service) + expect(service).to receive(:execute).with(target_project, direction: :outbound).and_return(ServiceResponse.error(message: 'The error message')) expect(subject.fetch(:ci_job_token_scope)).to be_nil expect(subject.fetch(:errors)).to include("The error message") diff --git a/spec/graphql/types/ci/job_token_scope_type_spec.rb b/spec/graphql/types/ci/job_token_scope_type_spec.rb index 569b59d6c7090b..24597929b5e5db 100644 --- a/spec/graphql/types/ci/job_token_scope_type_spec.rb +++ b/spec/graphql/types/ci/job_token_scope_type_spec.rb @@ -2,17 +2,24 @@ require 'spec_helper' -RSpec.describe GitlabSchema.types['CiJobTokenScopeType'] do +RSpec.describe GitlabSchema.types['CiJobTokenScopeType'], feature_category: :continuous_integration do specify { expect(described_class.graphql_name).to eq('CiJobTokenScopeType') } it 'has the correct fields' do - expected_fields = [:projects] + expected_fields = [:projects, :inboundAllowlist, :outboundAllowlist] expect(described_class).to have_graphql_fields(*expected_fields) end describe 'query' do - let(:project) { create(:project, ci_outbound_job_token_scope_enabled: true).tap(&:save!) } + let(:project) do + create( + :project, + ci_outbound_job_token_scope_enabled: true, + ci_inbound_job_token_scope_enabled: true + ).tap(&:save!) + end + let_it_be(:current_user) { create(:user) } let(:query) do @@ -25,6 +32,16 @@ path } } + inboundAllowlist { + nodes { + path + } + } + outboundAllowlist { + nodes { + path + } + } } } } @@ -33,30 +50,73 @@ subject { GitlabSchema.execute(query, context: { current_user: current_user }).as_json } - let(:projects_field) { subject.dig('data', 'project', 'ciJobTokenScope', 'projects', 'nodes') } - let(:returned_project_paths) { projects_field.map { |project| project['path'] } } + let(:scope_field) { subject.dig('data', 'project', 'ciJobTokenScope') } + let(:errors_field) { subject['errors'] } + let(:projects_field) { scope_field&.dig('projects', 'nodes') } + let(:outbound_allowlist_field) { scope_field&.dig('outboundAllowlist', 'nodes') } + let(:inbound_allowlist_field) { scope_field&.dig('inboundAllowlist', 'nodes') } + let(:returned_project_paths) { projects_field.map { |p| p['path'] } } + let(:returned_outbound_paths) { outbound_allowlist_field.map { |p| p['path'] } } + let(:returned_inbound_paths) { inbound_allowlist_field.map { |p| p['path'] } } + + context 'without access to scope' do + before do + project.add_member(current_user, :developer) + end + + it 'returns no projects' do + expect(projects_field).to be_nil + expect(outbound_allowlist_field).to be_nil + expect(inbound_allowlist_field).to be_nil + expect(errors_field.first['message']).to include "don't have permission" + end + end context 'with access to scope' do before do project.add_member(current_user, :maintainer) end - context 'when multiple projects in the allow list' do - let!(:link) { create(:ci_job_token_project_scope_link, source_project: project) } + context 'when multiple projects in the allow lists' do + include Ci::JobTokenScopeHelpers + let!(:outbound_allowlist_project) { create_project_in_allowlist(project, direction: :outbound) } + let!(:inbound_allowlist_project) { create_project_in_allowlist(project, direction: :inbound) } + let!(:both_allowlists_project) { create_project_in_both_allowlists(project) } context 'when linked projects are readable' do before do - link.target_project.add_member(current_user, :developer) + outbound_allowlist_project.add_member(current_user, :developer) + inbound_allowlist_project.add_member(current_user, :developer) + both_allowlists_project.add_member(current_user, :developer) end - it 'returns readable projects in scope' do - expect(returned_project_paths).to contain_exactly(project.path, link.target_project.path) + shared_examples 'returns projects' do + it 'returns readable projects in scope' do + outbound_paths = [project.path, outbound_allowlist_project.path, both_allowlists_project.path] + inbound_paths = [project.path, inbound_allowlist_project.path, both_allowlists_project.path] + + expect(returned_project_paths).to contain_exactly(*outbound_paths) + expect(returned_outbound_paths).to contain_exactly(*outbound_paths) + expect(returned_inbound_paths).to contain_exactly(*inbound_paths) + end + end + + it_behaves_like 'returns projects' + + context 'when job token scope is disabled' do + before do + project.ci_cd_settings.update!(job_token_scope_enabled: false) + end + + it_behaves_like 'returns projects' end end - context 'when linked project is not readable' do + context 'when linked projects are not readable' do it 'returns readable projects in scope' do expect(returned_project_paths).to contain_exactly(project.path) + expect(returned_outbound_paths).to contain_exactly(project.path) + expect(returned_inbound_paths).to contain_exactly(project.path) end end @@ -71,6 +131,7 @@ it 'returns readable projects in scope' do expect(returned_project_paths).to contain_exactly(project.path) + expect(returned_outbound_paths).to contain_exactly(project.path) end end end diff --git a/spec/support/helpers/ci/job_token_scope_helpers.rb b/spec/support/helpers/ci/job_token_scope_helpers.rb index 09084bc871542c..f4c747d2d4bcab 100644 --- a/spec/support/helpers/ci/job_token_scope_helpers.rb +++ b/spec/support/helpers/ci/job_token_scope_helpers.rb @@ -3,16 +3,27 @@ module Ci module JobTokenScopeHelpers def create_project_in_allowlist(root_project, direction:) - create(:project).tap do |scoped_project| + create(:project).tap do |new_project| create( :ci_job_token_project_scope_link, source_project: root_project, - target_project: scoped_project, + target_project: new_project, direction: direction ) end end + def create_project_in_both_allowlists(root_project) + create_project_in_allowlist(root_project, direction: :outbound).tap do |new_project| + create( + :ci_job_token_project_scope_link, + source_project: root_project, + target_project: new_project, + direction: :inbound + ) + end + end + def create_inbound_accessible_project(project) create(:project).tap do |accessible_project| add_inbound_accessible_linkage(project, accessible_project) -- GitLab From 2433a9d20d04b5758b33739d7d447920c2ec2cb7 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 31 Jan 2023 13:04:57 -0500 Subject: [PATCH 11/11] Refactor Ci::JobToken::Allowlist to prepare for inbound link changes --- app/models/ci/job_token/allowlist.rb | 8 +++++ .../job_token_scope/remove_project_service.rb | 14 +++++--- spec/models/ci/job_token/allowlist_spec.rb | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb index 618dc2da05cb34..6b43f11b688434 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -26,8 +26,16 @@ def add!(target_project, user:) ) end + def remove!(target_project) + link_for(target_project).destroy + end + private + def link_for(target_project) + source_links.with_target(target_project).take + end + def source_links Ci::JobToken::ProjectScopeLink .with_source(@source_project) diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb index 15644e529d9987..322330af8f1b25 100644 --- a/app/services/ci/job_token_scope/remove_project_service.rb +++ b/app/services/ci/job_token_scope/remove_project_service.rb @@ -5,20 +5,18 @@ module JobTokenScope class RemoveProjectService < ::BaseService include EditScopeValidations - def execute(target_project) + def execute(target_project, direction: :outbound) validate_edit!(project, target_project, current_user) if project == target_project return ServiceResponse.error(message: "Source project cannot be removed from the job token scope") end - link = ::Ci::JobToken::ProjectScopeLink.for_source_and_target(project, target_project) - - unless link + unless allowlist(direction).includes?(target_project) return ServiceResponse.error(message: "Target project is not in the job token scope") end - if link.destroy + if allowlist(direction).remove!(target_project) ServiceResponse.success else ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { project_link: link }) @@ -26,6 +24,12 @@ def execute(target_project) rescue EditScopeValidations::ValidationError => e ServiceResponse.error(message: e.message) end + + private + + def allowlist(direction) + Ci::JobToken::Allowlist.new(project, direction: direction) + 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 c69dcba765a1f5..57b76630e10154 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -79,4 +79,37 @@ end end end + + describe '#remove!' do + subject { allowlist.remove!(target_project) } + + let!(:target_project) { create_project_in_allowlist(source_project, direction: direction) } + + context 'when a link does not exist' do + let(:target_project) { create(:project) } + + it 'returns a ServiceResponse error explaining that the link does not exist' do + expect(subject).to be_error + expect(subject.errors).to include('Target project is not in the job token scope') + end + end + + context 'for an inbound link' do + let(:direction) { :inbound } + + it 'successfully destroys the link' do + expect { subject }.to change { Ci::JobToken::ProjectScopeLink.count }.by(-1) + expect(allowlist.projects).not_to include(target_project) + end + end + + context 'when an outbound direction' do + let(:direction) { :outbound } + + it 'successfully destroys the outbound link' do + expect { subject }.to change { Ci::JobToken::ProjectScopeLink.count }.by(-1) + expect(allowlist.projects).not_to include(target_project) + end + end + end end -- GitLab