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 e16c08cb116c4d263d83b99759f92fd9ddbeef20..6f0f87b47a1036a1b90c182e07ad44f83794fd19 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 access scope."
- 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 0000000000000000000000000000000000000000..f52cf891af8ffa6fa88a6b1b8d34b0bfefc1ad34
--- /dev/null
+++ b/app/graphql/types/ci/job_token_scope/direction_enum.rb
@@ -0,0 +1,21 @@
+# 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 scope 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 ' \
+ 'through 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 9c9c7ccb8d12f024f85f00cd1f5c4468bac50c18..639bbaa22afd32d89da3fe7598d822245b95ec59 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.9'
+ }
+
+ field :outbound_allowlist,
+ Types::ProjectType.connection_type,
+ null: false,
+ 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 through its CI Job tokens.",
+ 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 9e9a0a68ebd438671464c6945bc09b7af8a76845..618dc2da05cb3420520f6ff5041166e66257f2d8 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/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb
index 93bfa35f5c6337ef605e6ab6766b750311c8b2d9..96faf3792d1cf44943bbb4b297e6badcc1141a42 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -39,6 +39,15 @@ def inbound_projects
inbound_allowlist.projects
end
+ def add!(added_project, user:, direction:)
+ case direction
+ when :inbound
+ inbound_allowlist.add!(added_project, user: user)
+ when :outbound
+ outbound_allowlist.add!(added_project, user: user)
+ end
+ end
+
private
def outbound_accessible?(accessed_project)
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 d03ae434b69a750406b25abdc90e913da0d6cd06..15553ad6e925dd83f883c13a189228595e742bbe 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 if Feature.disabled?(: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 3ab763526f57983546ddae68e56477583d8ee64c..18fe7435f1d614bda600bb381598cacb9d31db7a 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. |
@@ -1190,7 +1191,7 @@ Input type: `CiJobTokenScopeAddProjectInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
-| `ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's scope of access. |
+| `ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's access scope. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
@@ -11348,7 +11349,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 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`
@@ -21800,6 +21803,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 through their job tokens. |
+| `OUTBOUND` | Job token scope project can access target project in the outbound allowlist. |
+
### `CiRunnerAccessLevel`
| Value | Description |
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 07c2755a3f724a2dd18b2419b2f20c2dcf8a4984..44147987ebb4b0b47038161eb1380a7aeea2bae0 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 569b59d6c7090b724f2e4606ede6b76334924150..0104436488113d9b749e15250bd2d37ac2f205c4 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,8 @@
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
end
diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb
index c69dcba765a1f59bb33a0c77f7acef6d71b66a35..3a2673c7c26db7d17ed9ea312e16c81c07f1efd6 100644
--- a/spec/models/ci/job_token/allowlist_spec.rb
+++ b/spec/models/ci/job_token/allowlist_spec.rb
@@ -40,6 +40,26 @@
end
end
+ describe 'add!' do
+ let_it_be(:added_project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ subject { allowlist.add!(added_project, user: user) }
+
+ [:inbound, :outbound].each do |d|
+ let(:direction) { d }
+
+ it 'adds the project' do
+ subject
+
+ expect(allowlist.projects).to contain_exactly(source_project, added_project)
+ expect(subject.added_by_id).to eq(user.id)
+ expect(subject.source_project_id).to eq(source_project.id)
+ expect(subject.target_project_id).to eq(added_project.id)
+ end
+ end
+ end
+
describe '#includes?' do
subject { allowlist.includes?(includes_project) }
diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb
index da632622f1bccdf0104f799b78025def09652dc6..5fa0775e1f5a159da41382e6f469ed659346f91c 100644
--- a/spec/models/ci/job_token/scope_spec.rb
+++ b/spec/models/ci/job_token/scope_spec.rb
@@ -57,6 +57,35 @@
end
end
+ describe 'add!' do
+ let_it_be(:new_project) { create(:project) }
+
+ subject { scope.add!(new_project, direction: direction, user: user) }
+
+ [:inbound, :outbound].each do |d|
+ let(:direction) { d }
+
+ it 'adds the project' do
+ subject
+
+ expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project)
+ end
+ end
+
+ # Context and before block can go away leaving just the example in 16.0
+ context 'with inbound only enabled' do
+ before do
+ project.ci_cd_settings.update!(job_token_scope_enabled: false)
+ end
+
+ it 'provides access' do
+ expect do
+ scope.add!(new_project, direction: :inbound, user: user)
+ end.to change { described_class.new(new_project).accessible?(current_project) }.from(false).to(true)
+ end
+ end
+ end
+
RSpec.shared_examples 'enforces outbound scope only' do
include_context 'with accessible and inaccessible projects'
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 bf7df3a5595fe20f11ea6edf9886d7c5ebffc65e..e6674ee384f5d8fd8a1d6e245c810e8deba14509 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
diff --git a/spec/support/helpers/ci/job_token_scope_helpers.rb b/spec/support/helpers/ci/job_token_scope_helpers.rb
index 09084bc871542ce51c7655b087d6d9856ac265cd..2de3e29f82cd2995d78999713a40204472c50c32 100644
--- a/spec/support/helpers/ci/job_token_scope_helpers.rb
+++ b/spec/support/helpers/ci/job_token_scope_helpers.rb
@@ -2,14 +2,21 @@
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
- )
+ def create_project_in_allowlist(root_project, direction:, target_project: nil)
+ included_project = target_project || create(:project)
+ create(
+ :ci_job_token_project_scope_link,
+ source_project: root_project,
+ target_project: included_project,
+ direction: direction
+ )
+
+ included_project
+ end
+
+ def create_project_in_both_allowlists(root_project)
+ create_project_in_allowlist(root_project, direction: :outbound).tap do |new_project|
+ create_project_in_allowlist(root_project, target_project: new_project, direction: :inbound)
end
end