From 2f6931217be6375c62415ea9f6c0d90ffeec4d4a Mon Sep 17 00:00:00 2001
From: Dmytro Biryukov <dbiryukov@gitlab.com>
Date: Wed, 7 Feb 2024 11:13:32 +0000
Subject: [PATCH] Allow adding groups to CI_JOB_TOKEN allowlist graphQL
 mutation

Changelog: added
---
 .../job_token_scope/add_group_or_project.rb   |  57 ++++
 .../ci/job_token_scope/remove_group.rb        |  49 ++++
 app/graphql/types/ci/job_token_scope_type.rb  |   6 +
 app/graphql/types/mutation_type.rb            |   2 +
 app/models/ci/job_token/allowlist.rb          |  12 +
 app/models/ci/job_token/scope.rb              |   4 +
 .../add_group_or_project_service.rb           |  22 ++
 .../ci/job_token_scope/add_group_service.rb   |  31 +++
 .../job_token_scope/remove_group_service.rb   |  28 ++
 .../job_token_scope/edit_scope_validations.rb |  25 ++
 doc/api/graphql/reference/index.md            |  41 +++
 rubocop/cop/graphql/id_type.rb                |   2 +-
 .../add_group_or_project_spec.rb              | 127 +++++++++
 .../ci/job_token_scope/remove_group_spec.rb   |  82 ++++++
 .../ci/job_token_scope_resolver_spec.rb       |  25 ++
 .../types/ci/job_token_scope_type_spec.rb     |   2 +-
 spec/models/ci/job_token/allowlist_spec.rb    |  40 ++-
 spec/models/ci/job_token/scope_spec.rb        |  24 +-
 .../add_group_or_project_spec.rb              | 116 ++++++++
 .../ci/job_token_scope/remove_group_spec.rb   |  90 ++++++
 .../add_group_or_project_service_spec.rb      |  69 +++++
 .../job_token_scope/add_group_service_spec.rb |  88 ++++++
 .../remove_group_service_spec.rb              |  97 +++++++
 .../edit_scope_validations_spec.rb            | 257 ++++++++++++++++++
 .../models/ci/job_token_scope.rb              |   2 +-
 ...t_group_job_token_scope_shared_examples.rb |  32 +++
 26 files changed, 1325 insertions(+), 5 deletions(-)
 create mode 100644 app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb
 create mode 100644 app/graphql/mutations/ci/job_token_scope/remove_group.rb
 create mode 100644 app/services/ci/job_token_scope/add_group_or_project_service.rb
 create mode 100644 app/services/ci/job_token_scope/add_group_service.rb
 create mode 100644 app/services/ci/job_token_scope/remove_group_service.rb
 create mode 100644 spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
 create mode 100644 spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
 create mode 100644 spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
 create mode 100644 spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
 create mode 100644 spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb
 create mode 100644 spec/services/ci/job_token_scope/add_group_service_spec.rb
 create mode 100644 spec/services/ci/job_token_scope/remove_group_service_spec.rb
 create mode 100644 spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb
 create mode 100644 spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb

diff --git a/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb b/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb
new file mode 100644
index 0000000000000..1955b1101eba6
--- /dev/null
+++ b/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Mutations
+  module Ci
+    module JobTokenScope
+      class AddGroupOrProject < BaseMutation
+        graphql_name 'CiJobTokenScopeAddGroupOrProject'
+
+        include FindsProject
+
+        authorize :admin_project
+
+        argument :project_path, GraphQL::Types::ID,
+          required: true,
+          description: 'Project that the CI job token scope belongs to.'
+
+        argument :target_path, GraphQL::Types::ID,
+          required: true,
+          description: 'Group or project to be added to the CI job token scope.'
+
+        field :ci_job_token_scope,
+          Types::Ci::JobTokenScopeType,
+          null: true,
+          description: "CI job token's access scope."
+
+        def resolve(project_path:, target_path:)
+          project = authorized_find!(project_path)
+
+          target = find_target_path(target_path)
+
+          result = ::Ci::JobTokenScope::AddGroupOrProjectService
+            .new(project, current_user)
+            .execute(target)
+
+          if result.success?
+            {
+              ci_job_token_scope: ::Ci::JobToken::Scope.new(project),
+              errors: []
+            }
+          else
+            {
+              ci_job_token_scope: nil,
+              errors: [result.message]
+            }
+          end
+        end
+
+        private
+
+        def find_target_path(target_path)
+          ::Group.find_by_full_path(target_path) ||
+            ::Project.find_by_full_path(target_path)
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/mutations/ci/job_token_scope/remove_group.rb b/app/graphql/mutations/ci/job_token_scope/remove_group.rb
new file mode 100644
index 0000000000000..40c654cd113c1
--- /dev/null
+++ b/app/graphql/mutations/ci/job_token_scope/remove_group.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Mutations
+  module Ci
+    module JobTokenScope
+      class RemoveGroup < BaseMutation
+        graphql_name 'CiJobTokenScopeRemoveGroup'
+
+        include FindsProject
+
+        authorize :admin_project
+
+        argument :project_path, GraphQL::Types::ID,
+          required: true,
+          description: 'Project that the CI job token scope belongs to.'
+
+        argument :target_group_path, GraphQL::Types::ID,
+          required: true,
+          description: 'Group to be removed from the CI job token scope.'
+
+        field :ci_job_token_scope,
+          Types::Ci::JobTokenScopeType,
+          null: true,
+          description: "CI job token's access scope."
+
+        def resolve(project_path:, target_group_path:)
+          project = authorized_find!(project_path)
+          target_group = Group.find_by_full_path(target_group_path)
+
+          result = ::Ci::JobTokenScope::RemoveGroupService
+            .new(project, current_user)
+            .execute(target_group)
+
+          if result.success?
+            {
+              ci_job_token_scope: ::Ci::JobToken::Scope.new(project),
+              errors: []
+            }
+          else
+            {
+              ci_job_token_scope: nil,
+              errors: [result.message]
+            }
+          end
+        end
+      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 639bbaa22afd3..6e91bc395e128 100644
--- a/app/graphql/types/ci/job_token_scope_type.rb
+++ b/app/graphql/types/ci/job_token_scope_type.rb
@@ -28,6 +28,12 @@ class JobTokenScopeType < BaseObject
             null: false,
             description: "Allow list of projects that can access the current project through its CI Job tokens.",
             method: :inbound_projects
+
+      field :groups_allowlist,
+            Types::GroupType.connection_type,
+            null: false,
+            description: "Allow list of groups that can access the current project through its CI Job tokens.",
+            method: :groups
     end
   end
   # rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 94bb3055bc4d8..8c3e5c6920586 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -152,9 +152,11 @@ class MutationType < BaseObject
     mount_mutation Mutations::Ci::Job::Retry
     mount_mutation Mutations::Ci::Job::ArtifactsDestroy
     mount_mutation Mutations::Ci::Job::Unschedule
+    mount_mutation Mutations::Ci::JobTokenScope::AddGroupOrProject
     mount_mutation Mutations::Ci::JobTokenScope::AddProject
     mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, alpha: { milestone: '15.10' }
     mount_mutation Mutations::Ci::JobArtifact::Destroy
+    mount_mutation Mutations::Ci::JobTokenScope::RemoveGroup
     mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
     mount_mutation Mutations::Ci::Pipeline::Cancel
     mount_mutation Mutations::Ci::Pipeline::Destroy
diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb
index c4071ac32717e..5626624547d59 100644
--- a/app/models/ci/job_token/allowlist.rb
+++ b/app/models/ci/job_token/allowlist.rb
@@ -24,6 +24,10 @@ def projects
         Project.from_union(target_projects, remove_duplicates: false)
       end
 
+      def groups
+        ::Group.id_in(group_links.pluck(:target_group_id))
+      end
+
       def add!(target_project, user:)
         Ci::JobToken::ProjectScopeLink.create!(
           source_project: @source_project,
@@ -33,6 +37,14 @@ def add!(target_project, user:)
         )
       end
 
+      def add_group!(target_group, user:)
+        Ci::JobToken::GroupScopeLink.create!(
+          source_project: @source_project,
+          target_group: target_group,
+          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 a512c9417ff29..35dcb6d7d592b 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -39,6 +39,10 @@ def inbound_projects
         inbound_allowlist.projects
       end
 
+      def groups
+        inbound_allowlist.groups
+      end
+
       private
 
       def outbound_accessible?(accessed_project)
diff --git a/app/services/ci/job_token_scope/add_group_or_project_service.rb b/app/services/ci/job_token_scope/add_group_or_project_service.rb
new file mode 100644
index 0000000000000..5d4914d82c756
--- /dev/null
+++ b/app/services/ci/job_token_scope/add_group_or_project_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+  module JobTokenScope
+    class AddGroupOrProjectService < ::BaseService
+      include EditScopeValidations
+
+      def execute(target)
+        validate_target_exists!(target)
+
+        if target.is_a?(::Group)
+          ::Ci::JobTokenScope::AddGroupService.new(project, current_user).execute(target)
+        else
+          ::Ci::JobTokenScope::AddProjectService.new(project, current_user).execute(target)
+        end
+
+      rescue EditScopeValidations::NotFoundError => e
+        ServiceResponse.error(message: e.message, reason: :not_found)
+      end
+    end
+  end
+end
diff --git a/app/services/ci/job_token_scope/add_group_service.rb b/app/services/ci/job_token_scope/add_group_service.rb
new file mode 100644
index 0000000000000..6e83f8b9b6311
--- /dev/null
+++ b/app/services/ci/job_token_scope/add_group_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+  module JobTokenScope
+    class AddGroupService < ::BaseService
+      include EditScopeValidations
+
+      def execute(target_group)
+        validate_group_add!(project, target_group, current_user)
+
+        link = allowlist
+          .add_group!(target_group, user: current_user)
+
+        ServiceResponse.success(payload: { group_link: link })
+
+      rescue ActiveRecord::RecordNotUnique
+        ServiceResponse.error(message: 'Target group is already in the job token scope')
+      rescue ActiveRecord::RecordInvalid => e
+        ServiceResponse.error(message: e.message)
+      rescue EditScopeValidations::ValidationError => e
+        ServiceResponse.error(message: e.message, reason: :insufficient_permissions)
+      end
+
+      private
+
+      def allowlist
+        Ci::JobToken::Allowlist.new(project)
+      end
+    end
+  end
+end
diff --git a/app/services/ci/job_token_scope/remove_group_service.rb b/app/services/ci/job_token_scope/remove_group_service.rb
new file mode 100644
index 0000000000000..aef9742c7ffec
--- /dev/null
+++ b/app/services/ci/job_token_scope/remove_group_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+  module JobTokenScope
+    class RemoveGroupService < ::BaseService
+      include EditScopeValidations
+
+      def execute(target_group)
+        validate_group_remove!(project, current_user)
+
+        link = ::Ci::JobToken::GroupScopeLink
+          .for_source_and_target(project, target_group)
+
+        return ServiceResponse.error(message: 'Target group is not in the job token scope') unless link
+
+        if link.destroy
+          ServiceResponse.success
+        else
+          ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { group_link: link })
+        end
+      rescue EditScopeValidations::ValidationError => e
+        ServiceResponse.error(message: e.message, reason: :insufficient_permissions)
+      end
+    end
+  end
+end
+
+Ci::JobTokenScope::RemoveGroupService.prepend_mod_with('Ci::JobTokenScope::RemoveGroupService')
diff --git a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
index 427aebf397e32..3fa3ce9812e0e 100644
--- a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
+++ b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
@@ -4,10 +4,16 @@ module Ci
   module JobTokenScope
     module EditScopeValidations
       ValidationError = Class.new(StandardError)
+      NotFoundError = Class.new(StandardError)
 
       TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND = "The target_project that you are attempting to access does " \
           "not exist or you don't have permission to perform this action"
 
+      TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND = "The target_group that you are attempting to access does " \
+          "not exist or you don't have permission to perform this action"
+
+      TARGET_DOES_NOT_EXIST = 'The target does not exists'
+
       def validate_edit!(source_project, target_project, current_user)
         unless can?(current_user, :admin_project, source_project)
           raise ValidationError, "Insufficient permissions to modify the job token scope"
@@ -17,6 +23,25 @@ def validate_edit!(source_project, target_project, current_user)
           raise ValidationError, TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND
         end
       end
+
+      def validate_group_add!(source_project, target_group, current_user)
+        unless can?(current_user, :admin_project, source_project)
+          raise ValidationError, "Insufficient permissions to modify the job token scope"
+        end
+
+        raise ValidationError, TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND unless can?(current_user, :read_group,
+          target_group)
+      end
+
+      def validate_group_remove!(source_project, current_user)
+        unless can?(current_user, :admin_project, source_project)
+          raise ValidationError, "Insufficient permissions to modify the job token scope"
+        end
+      end
+
+      def validate_target_exists!(target)
+        raise NotFoundError, TARGET_DOES_NOT_EXIST if target.nil?
+      end
     end
   end
 end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 5a62f47d76e9c..6215ed6e89b12 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2229,6 +2229,26 @@ Input type: `CatalogResourcesDestroyInput`
 | <a id="mutationcatalogresourcesdestroyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationcatalogresourcesdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 
+### `Mutation.ciJobTokenScopeAddGroupOrProject`
+
+Input type: `CiJobTokenScopeAddGroupOrProjectInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcijobtokenscopeaddgrouporprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcijobtokenscopeaddgrouporprojectprojectpath"></a>`projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. |
+| <a id="mutationcijobtokenscopeaddgrouporprojecttargetpath"></a>`targetPath` | [`ID!`](#id) | Group or project to be added to the CI job token scope. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcijobtokenscopeaddgrouporprojectcijobtokenscope"></a>`ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's access scope. |
+| <a id="mutationcijobtokenscopeaddgrouporprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcijobtokenscopeaddgrouporprojecterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
 ### `Mutation.ciJobTokenScopeAddProject`
 
 Input type: `CiJobTokenScopeAddProjectInput`
@@ -2250,6 +2270,26 @@ Input type: `CiJobTokenScopeAddProjectInput`
 | <a id="mutationcijobtokenscopeaddprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationcijobtokenscopeaddprojecterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 
+### `Mutation.ciJobTokenScopeRemoveGroup`
+
+Input type: `CiJobTokenScopeRemoveGroupInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcijobtokenscoperemovegroupclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcijobtokenscoperemovegroupprojectpath"></a>`projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. |
+| <a id="mutationcijobtokenscoperemovegrouptargetgrouppath"></a>`targetGroupPath` | [`ID!`](#id) | Group to be removed from the CI job token scope. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcijobtokenscoperemovegroupcijobtokenscope"></a>`ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's access scope. |
+| <a id="mutationcijobtokenscoperemovegroupclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcijobtokenscoperemovegrouperrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
 ### `Mutation.ciJobTokenScopeRemoveProject`
 
 Input type: `CiJobTokenScopeRemoveProjectInput`
@@ -15995,6 +16035,7 @@ CI/CD variables for a GitLab instance.
 
 | Name | Type | Description |
 | ---- | ---- | ----------- |
+| <a id="cijobtokenscopetypegroupsallowlist"></a>`groupsAllowlist` | [`GroupConnection!`](#groupconnection) | Allow list of groups that can access the current project through its CI Job tokens. (see [Connections](#connections)) |
 | <a id="cijobtokenscopetypeinboundallowlist"></a>`inboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can access the current project through its CI Job tokens. (see [Connections](#connections)) |
 | <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that are accessible using the current project's CI Job tokens. (see [Connections](#connections)) |
 | <a id="cijobtokenscopetypeprojects"></a>`projects` **{warning-solid}** | [`ProjectConnection!`](#projectconnection) | **Deprecated** in 15.9. The `projects` attribute is being deprecated. Use `outbound_allowlist`. |
diff --git a/rubocop/cop/graphql/id_type.rb b/rubocop/cop/graphql/id_type.rb
index deed68da0b33e..430f69ff67c08 100644
--- a/rubocop/cop/graphql/id_type.rb
+++ b/rubocop/cop/graphql/id_type.rb
@@ -7,7 +7,7 @@ class IDType < RuboCop::Cop::Base
         MSG = 'Do not use GraphQL::Types::ID, use a specific GlobalIDType instead'
 
         ALLOWLISTED_ARGUMENTS = %i[
-          iid full_path project_path group_path target_project_path namespace_path
+          iid full_path project_path group_path target_project_path target_group_path target_path namespace_path
           context_namespace_path
         ].freeze
 
diff --git a/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
new file mode 100644
index 0000000000000..1c8c8e9c2908e
--- /dev/null
+++ b/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Ci::JobTokenScope::AddGroupOrProject, feature_category: :continuous_integration do
+  let(:mutation) do
+    described_class.new(object: nil, context: { current_user: current_user }, field: nil)
+  end
+
+  describe '#resolve' do
+    let_it_be(:project) do
+      create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!)
+    end
+
+    subject(:resolver) do
+      mutation.resolve(**mutation_args)
+    end
+
+    shared_examples 'when user is not logged in' do
+      let(:current_user) { nil }
+
+      it 'raises error' do
+        expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+      end
+    end
+
+    shared_examples 'when user does not have permissions to admin project' do
+      it 'raises error' do
+        expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+      end
+    end
+
+    context 'when we add a project' do
+      let_it_be(:target_project) { create(:project) }
+
+      let(:target_project_path) { target_project.full_path }
+      let(:mutation_args) { { project_path: project.full_path, target_path: target_project_path } }
+
+      it_behaves_like 'when user is not logged in'
+
+      context 'when user is logged in' do
+        let_it_be(:current_user) { create(:user) }
+
+        it_behaves_like 'when user does not have permissions to admin project'
+
+        context 'when user has permissions to admin project and read target project' do
+          before_all do
+            project.add_maintainer(current_user)
+            target_project.add_guest(current_user)
+          end
+
+          it 'adds target project to the inbound job token scope by default' do
+            expect do
+              expect(resolver).to include(ci_job_token_scope: be_present, errors: be_empty)
+            end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1)
+          end
+        end
+
+        context 'when user has no permissions to read target project' do
+          before_all do
+            project.add_maintainer(current_user)
+          end
+
+          it 'returns an error message' do
+            response = resolver
+
+            expect(response.fetch(:errors))
+            .to include(Ci::JobTokenScope::EditScopeValidations::TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND)
+          end
+        end
+      end
+    end
+
+    context 'when we add a group' do
+      let_it_be(:target_group) { create(:group, :private) }
+
+      let(:target_group_path) { target_group.full_path }
+      let(:mutation_args) { { project_path: project.full_path, target_path: target_group_path } }
+
+      it_behaves_like 'when user is not logged in'
+
+      context 'when user is logged in' do
+        let_it_be(:current_user) { create(:user) }
+
+        it_behaves_like 'when user does not have permissions to admin project'
+
+        context 'when user has permissions to admin project and read target group' do
+          before_all do
+            project.add_maintainer(current_user)
+            target_group.add_guest(current_user)
+          end
+
+          it 'adds target group to the job token scope' do
+            expect do
+              expect(resolver).to include(ci_job_token_scope: be_present, errors: be_empty)
+            end.to change { Ci::JobToken::GroupScopeLink.count }.by(1)
+          end
+        end
+
+        context 'when user has no permissions to admin project' do
+          before_all do
+            target_group.add_guest(current_user)
+          end
+
+          it 'raises an error' do
+            expect do
+              resolver
+            end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+          end
+        end
+
+        context 'when user has no permissions to read target group' do
+          before_all do
+            project.add_maintainer(current_user)
+          end
+
+          it 'returns an error message' do
+            response = resolver
+
+            expect(response.fetch(:errors))
+            .to include(::Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb b/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
new file mode 100644
index 0000000000000..f17b34ba2d837
--- /dev/null
+++ b/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Ci::JobTokenScope::RemoveGroup, feature_category: :continuous_integration do
+  let(:mutation) do
+    described_class.new(object: nil, context: { current_user: current_user }, field: nil)
+  end
+
+  describe '#resolve' do
+    let_it_be(:project) { create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!) }
+    let_it_be(:target_group) { create(:group, :private) }
+
+    let_it_be(:link) do
+      create(:ci_job_token_group_scope_link,
+        source_project: project,
+        target_group: target_group)
+    end
+
+    let(:target_group_path) { target_group.full_path }
+    let(:links_relation) { Ci::JobToken::GroupScopeLink.with_source(project).with_target(target_group) }
+
+    subject(:resolver) do
+      mutation.resolve(project_path: project.full_path, target_group_path: target_group_path)
+    end
+
+    context 'when user is not logged in' do
+      let_it_be(:current_user) { nil }
+
+      it 'raises error' do
+        expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+      end
+    end
+
+    context 'when user is logged in' do
+      let_it_be(:current_user) { create(:user) }
+
+      context 'when user does not have permissions to admin project' do
+        it 'raises error' do
+          expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+        end
+      end
+
+      context 'when user has permissions to admin project and read target project' do
+        before_all do
+          project.add_maintainer(current_user)
+          target_group.add_guest(current_user)
+        end
+
+        let(:service) { instance_double('Ci::JobTokenScope::RemoveGroupService') }
+
+        it 'calls the RemoveGroupService to remove a group' do
+          expect(::Ci::JobTokenScope::RemoveGroupService)
+            .to receive(:new).with(project, current_user).and_return(service)
+          expect(service).to receive(:execute).with(target_group)
+            .and_return(instance_double('ServiceResponse', success?: true))
+
+          resolver
+        end
+      end
+
+      context 'when the service returns an error' do
+        let(:service) { instance_double('Ci::JobTokenScope::RemoveGroupService') }
+
+        before_all do
+          project.add_maintainer(current_user)
+          target_group.add_guest(current_user)
+        end
+
+        it 'returns an error response' do
+          expect(::Ci::JobTokenScope::RemoveGroupService).to receive(:new).with(project,
+            current_user).and_return(service)
+          expect(service).to receive(:execute).with(target_group)
+            .and_return(ServiceResponse.error(message: 'The error message'))
+
+          expect(resolver.fetch(:ci_job_token_scope)).to be_nil
+          expect(resolver.fetch(:errors)).to include("The error message")
+        end
+      end
+    end
+  end
+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 d2bcc7cd597b8..a48c535e563cc 100644
--- a/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb
@@ -49,6 +49,31 @@
       end
     end
 
+    context 'when groups list is requested' do
+      let_it_be(:group) { create(:group, :private) }
+      let!(:group_scope_link) { create(:ci_job_token_group_scope_link, source_project: project, target_group: group) }
+
+      context 'with access to scope' do
+        before do
+          project.add_member(current_user, :maintainer)
+        end
+
+        it 'resolves groups' do
+          expect(resolve_scope.groups).to contain_exactly(group)
+        end
+
+        context 'when job token scope is disabled' do
+          before do
+            project.update!(ci_inbound_job_token_scope_enabled: false)
+          end
+
+          it 'resolves groups' do
+            expect(resolve_scope.groups).to contain_exactly(group)
+          end
+        end
+      end
+    end
+
     context 'without access to scope' do
       before do
         project.add_member(current_user, :developer)
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 0104436488113..daf30b417ae9e 100644
--- a/spec/graphql/types/ci/job_token_scope_type_spec.rb
+++ b/spec/graphql/types/ci/job_token_scope_type_spec.rb
@@ -6,7 +6,7 @@
   specify { expect(described_class.graphql_name).to eq('CiJobTokenScopeType') }
 
   it 'has the correct fields' do
-    expected_fields = [:projects, :inboundAllowlist, :outboundAllowlist]
+    expected_fields = [:projects, :inboundAllowlist, :outboundAllowlist, :groupsAllowlist]
 
     expect(described_class).to have_graphql_fields(*expected_fields)
   end
diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb
index feea8758490d8..dd45e69217e99 100644
--- a/spec/models/ci/job_token/allowlist_spec.rb
+++ b/spec/models/ci/job_token/allowlist_spec.rb
@@ -42,6 +42,28 @@
     end
   end
 
+  context 'when no groups are added to the scope' do
+    subject(:groups) { allowlist.groups }
+
+    it 'returns an empty list' do
+      expect(groups).to be_empty
+    end
+  end
+
+  context 'when groups are added to the scope' do
+    subject(:groups) { allowlist.groups }
+
+    let_it_be(:target_group) { create(:group) }
+
+    include_context 'with projects that are with and without groups added in allowlist'
+
+    with_them do
+      it 'returns all groups that are allowed access in the job token scope' do
+        expect(groups).to contain_exactly(target_group)
+      end
+    end
+  end
+
   describe 'add!' do
     let_it_be(:added_project) { create(:project) }
     let_it_be(:user) { create(:user) }
@@ -64,6 +86,22 @@
     end
   end
 
+  describe 'add_group!' do
+    let_it_be(:added_group) { create(:group) }
+    let_it_be(:user) { create(:user) }
+
+    subject { allowlist.add_group!(added_group, user: user) }
+
+    it 'adds the group' do
+      subject
+
+      expect(allowlist.groups).to contain_exactly(added_group)
+      expect(subject.added_by_id).to eq(user.id)
+      expect(subject.source_project_id).to eq(source_project.id)
+      expect(subject.target_group_id).to eq(added_group.id)
+    end
+  end
+
   describe '#includes_project?' do
     subject { allowlist.includes_project?(includes_project) }
 
@@ -127,7 +165,7 @@
       end
 
       context 'with a group in each allowlist' do
-        include_context 'with accessible and inaccessible project groups'
+        include_context 'with projects that are with and without groups added in allowlist'
 
         where(:source_project, :result) do
           ref(:project_with_target_project_group_in_allowlist) | true
diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb
index f2ad674b3f530..56714ac323d84 100644
--- a/spec/models/ci/job_token/scope_spec.rb
+++ b/spec/models/ci/job_token/scope_spec.rb
@@ -57,6 +57,28 @@
     end
   end
 
+  describe '#groups' do
+    subject { scope.groups }
+
+    context 'when no groups are added to the scope' do
+      it 'returns an empty list' do
+        expect(subject).to be_empty
+      end
+    end
+
+    context 'when groups are added to the scope' do
+      let_it_be(:target_group) { create(:group) }
+
+      include_context 'with projects that are with and without groups added in allowlist'
+
+      with_them do
+        it 'returns all groups that are allowed access in the job token scope' do
+          expect(subject).to contain_exactly(target_group)
+        end
+      end
+    end
+  end
+
   describe 'accessible?' do
     subject { scope.accessible?(accessed_project) }
 
@@ -71,7 +93,7 @@
 
       let(:scope) { described_class.new(target_project) }
 
-      include_context 'with accessible and inaccessible project groups'
+      include_context 'with projects that are with and without groups added in allowlist'
 
       where(:accessed_project, :result) do
         ref(:project_with_target_project_group_in_allowlist)            | true
diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
new file mode 100644
index 0000000000000..bc23ec206a209
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'CiJobTokenScopeAddGroupOrProject', feature_category: :continuous_integration do
+  include GraphqlHelpers
+
+  let_it_be(:project) { create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!) }
+  let(:mutation_response) { graphql_mutation_response(:ci_job_token_scope_add_group_or_project) }
+
+  let(:variables) do
+    {
+      project_path: project.full_path,
+      target_path: target_path.full_path
+    }
+  end
+
+  let(:mutation) do
+    graphql_mutation(:ci_job_token_scope_add_group_or_project, variables) do
+      <<~QL
+        errors
+        ciJobTokenScope {
+          groupsAllowlist {
+            nodes {
+              path
+            }
+          }
+          inboundAllowlist {
+            nodes {
+              path
+            }
+          }
+        }
+      QL
+    end
+  end
+
+  shared_examples 'not authorized' do
+    let_it_be(:current_user) { create(:user) }
+
+    context 'when not a maintainer' do
+      before_all do
+        project.add_developer(current_user)
+      end
+
+      it 'has graphql errors' do
+        post_graphql_mutation(mutation, current_user: current_user)
+
+        expect(graphql_errors).not_to be_empty
+      end
+    end
+  end
+
+  shared_examples 'invalid target' do
+    before do
+      variables[:target_path] = 'unknown/project_or_group'
+    end
+
+    it 'has mutation errors' do
+      post_graphql_mutation(mutation, current_user: current_user)
+
+      expect(mutation_response['errors'])
+      .to contain_exactly(::Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST)
+    end
+  end
+
+  context 'when we add a group' do
+    let_it_be(:target_group) { create(:group, :private) }
+    let(:target_path) { target_group }
+
+    it_behaves_like 'not authorized'
+
+    context 'when authorized' do
+      let_it_be(:current_user) { project.first_owner }
+
+      before_all do
+        target_group.add_developer(current_user)
+      end
+
+      it 'adds the target group to the job token scope' do
+        expect do
+          post_graphql_mutation(mutation, current_user: current_user)
+          expect(response).to have_gitlab_http_status(:success)
+          expect(mutation_response.dig('ciJobTokenScope', 'groupsAllowlist', 'nodes')).not_to be_empty
+        end.to change { Ci::JobToken::GroupScopeLink.count }.by(1)
+      end
+
+      it_behaves_like 'invalid target'
+    end
+  end
+
+  context 'when we add a project' do
+    let_it_be(:target_project) { create(:project) }
+    let(:target_path) { target_project }
+
+    it_behaves_like 'not authorized'
+
+    context 'when authorized' do
+      let_it_be(:current_user) { project.first_owner }
+
+      before_all do
+        target_project.add_developer(current_user)
+      end
+
+      it 'adds the target project to the job token scope' do
+        expect do
+          post_graphql_mutation(mutation, current_user: current_user)
+          expect(response).to have_gitlab_http_status(:success)
+          expect(mutation_response.dig('ciJobTokenScope', 'inboundAllowlist', 'nodes')).not_to be_empty
+        end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1)
+      end
+
+      it_behaves_like 'invalid target'
+    end
+  end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
new file mode 100644
index 0000000000000..525d7c9edb20d
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'CiJobTokenScopeRemoveGroup', feature_category: :continuous_integration do
+  include GraphqlHelpers
+
+  let_it_be(:project) do
+    create(:project,
+      ci_inbound_job_token_scope_enabled: true
+    )
+  end
+
+  let_it_be(:target_group) { create(:group, :private) }
+
+  let_it_be(:link) do
+    create(:ci_job_token_group_scope_link,
+      source_project: project,
+      target_group: target_group)
+  end
+
+  let(:variables) do
+    {
+      project_path: project.full_path,
+      target_group_path: target_group.full_path
+    }
+  end
+
+  let(:mutation) do
+    graphql_mutation(:ci_job_token_scope_remove_group, variables) do
+      <<~QL
+        errors
+        ciJobTokenScope {
+          groupsAllowlist {
+            nodes {
+              path
+            }
+          }
+        }
+      QL
+    end
+  end
+
+  let(:mutation_response) { graphql_mutation_response(:ci_job_token_scope_remove_group) }
+
+  context 'when unauthorized' do
+    let_it_be(:current_user) { create(:user) }
+
+    context 'when not a maintainer' do
+      before_all do
+        project.add_developer(current_user)
+      end
+
+      it 'has graphql errors' do
+        post_graphql_mutation(mutation, current_user: current_user)
+
+        expect(graphql_errors).not_to be_empty
+      end
+    end
+  end
+
+  context 'when authorized' do
+    let_it_be(:current_user) { project.first_owner }
+
+    before_all do
+      target_group.add_guest(current_user)
+    end
+
+    it 'removes the target group from the job token scope' do
+      expect do
+        post_graphql_mutation(mutation, current_user: current_user)
+        expect(response).to have_gitlab_http_status(:success)
+        expect(mutation_response.dig('ciJobTokenScope', 'groupsAllowlist', 'nodes')).to be_empty
+      end.to change { Ci::JobToken::GroupScopeLink.count }.by(-1)
+    end
+
+    context 'when invalid target group is provided' do
+      before do
+        variables[:target_group_path] = 'unknown/project'
+      end
+
+      it 'has mutation errors' do
+        post_graphql_mutation(mutation, current_user: current_user)
+
+        expect(mutation_response['errors'])
+        .to contain_exactly('Target group is not in the job token scope')
+      end
+    end
+  end
+end
diff --git a/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb b/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb
new file mode 100644
index 0000000000000..8ec6961f93eb9
--- /dev/null
+++ b/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobTokenScope::AddGroupOrProjectService, feature_category: :continuous_integration do
+  subject(:service_execute) { described_class.new(source_project, current_user).execute(target) }
+
+  let_it_be(:source_project) { create(:project) }
+  let_it_be(:target_project) { create(:project) }
+  let_it_be(:target_group) { create(:group) }
+  let_it_be(:current_user) { create(:user) }
+  let(:response_success) { ServiceResponse.success }
+
+  describe '#execute' do
+    context 'when group is a target to add' do
+      let(:target) { target_group }
+      let(:add_group_service_double) { instance_double(::Ci::JobTokenScope::AddGroupService) }
+
+      before do
+        allow(::Ci::JobTokenScope::AddGroupService).to receive(:new)
+        .with(source_project, current_user)
+        .and_return(add_group_service_double)
+      end
+
+      it 'calls AddGroupService to add a target' do
+        expect(add_group_service_double)
+          .to receive(:execute).with(target)
+          .and_return(response_success)
+
+        expect(service_execute).to eq(response_success)
+      end
+    end
+
+    context 'when project is a target to add' do
+      let(:target) { target_project }
+      let(:add_project_service_double) { instance_double(::Ci::JobTokenScope::AddProjectService) }
+
+      before do
+        allow(::Ci::JobTokenScope::AddProjectService).to receive(:new)
+        .with(source_project, current_user)
+        .and_return(add_project_service_double)
+      end
+
+      it 'calls AddProjectService to add a target' do
+        expect(add_project_service_double)
+          .to receive(:execute).with(target)
+          .and_return(response_success)
+
+        expect(service_execute).to eq(response_success)
+      end
+    end
+
+    context 'when not found object is a target to add' do
+      let(:target) { nil }
+      let(:expected_error_message) do
+        Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST
+      end
+
+      it 'returns a response error' do
+        response = service_execute
+
+        expect(response).to be_kind_of(ServiceResponse)
+        expect(response).to be_error
+        expect(response.message).to eq(expected_error_message)
+        expect(response.reason).to eq(:not_found)
+      end
+    end
+  end
+end
diff --git a/spec/services/ci/job_token_scope/add_group_service_spec.rb b/spec/services/ci/job_token_scope/add_group_service_spec.rb
new file mode 100644
index 0000000000000..4b2eafb9a3947
--- /dev/null
+++ b/spec/services/ci/job_token_scope/add_group_service_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobTokenScope::AddGroupService, 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!) }
+  let_it_be(:target_group) { create(:group, :private) }
+  let_it_be(:current_user) { create(:user) }
+
+  shared_examples 'adds group' do |_context|
+    it 'adds the group to the scope' do
+      expect do
+        expect(result).to be_success
+      end.to change { Ci::JobToken::GroupScopeLink.count }.by(1)
+    end
+  end
+
+  describe '#execute' do
+    subject(:result) { service.execute(target_group) }
+
+    it_behaves_like 'editable group job token scope' do
+      context 'when user has permissions on source and target groups' do
+        before_all do
+          project.add_maintainer(current_user)
+          target_group.add_developer(current_user)
+        end
+
+        it_behaves_like 'adds group'
+
+        context 'when token scope is disabled' do
+          before do
+            project.ci_cd_settings.update!(job_token_scope_enabled: false)
+          end
+
+          it_behaves_like 'adds group'
+        end
+      end
+
+      context 'when group is already in the allowlist' do
+        before_all do
+          project.add_maintainer(current_user)
+          target_group.add_developer(current_user)
+        end
+
+        before do
+          service.execute(target_group)
+        end
+
+        it_behaves_like 'returns error', 'Target group is already in the job token scope'
+      end
+
+      context 'when create method raises an invalid record exception' do
+        before do
+          allow_next_instance_of(Ci::JobToken::Allowlist) do |link|
+            allow(link)
+              .to receive(:add_group!)
+              .and_raise(ActiveRecord::RecordInvalid)
+          end
+        end
+
+        before_all do
+          project.add_maintainer(current_user)
+          target_group.add_developer(current_user)
+        end
+
+        it_behaves_like 'returns error', 'Record invalid'
+      end
+
+      context 'when has no permissions on a target_group' do
+        before_all do
+          project.add_maintainer(current_user)
+        end
+
+        it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND
+      end
+
+      context 'when has no permissions on a project' do
+        before_all do
+          target_group.add_developer(current_user)
+        end
+
+        it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope'
+      end
+    end
+  end
+end
diff --git a/spec/services/ci/job_token_scope/remove_group_service_spec.rb b/spec/services/ci/job_token_scope/remove_group_service_spec.rb
new file mode 100644
index 0000000000000..25143aa517fa5
--- /dev/null
+++ b/spec/services/ci/job_token_scope/remove_group_service_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobTokenScope::RemoveGroupService, 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!) }
+  let_it_be(:target_group) { create(:group, :private) }
+  let_it_be(:current_user) { create(:user) }
+
+  let_it_be(:link) do
+    create(:ci_job_token_group_scope_link,
+      source_project: project,
+      target_group: target_group)
+  end
+
+  shared_examples 'removes group' do
+    it 'removes the group from the scope' do
+      expect do
+        expect(result).to be_success
+      end.to change { Ci::JobToken::GroupScopeLink.count }.by(-1)
+    end
+  end
+
+  shared_examples 'returns error' do |error|
+    it 'returns an error response', :aggregate_failures do
+      expect(result).to be_error
+      expect(result.message).to eq(error)
+    end
+  end
+
+  describe '#execute' do
+    subject(:result) { service.execute(target_group) }
+
+    context 'when user has permissions on source and target group' do
+      before_all do
+        project.add_maintainer(current_user)
+        target_group.add_developer(current_user)
+      end
+
+      it_behaves_like 'removes group'
+
+      context 'when token scope is disabled' do
+        before do
+          project.ci_cd_settings.update!(job_token_scope_enabled: false)
+        end
+
+        it_behaves_like 'removes group'
+      end
+    end
+
+    context 'when user has no permissions on target_group' do
+      before_all do
+        project.add_maintainer(current_user)
+      end
+
+      it_behaves_like 'removes group'
+    end
+
+    context 'when target group is not in the job token scope' do
+      let_it_be(:target_group) { create(:group, :public) }
+
+      before_all do
+        project.add_maintainer(current_user)
+
+        expect(target_group.id).not_to eq(link.target_group_id)
+      end
+
+      it_behaves_like 'returns error', 'Target group is not in the job token scope'
+    end
+
+    context 'when user has no permissions on source project' do
+      before_all do
+        target_group.add_developer(current_user)
+      end
+
+      it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope'
+    end
+
+    context 'when there is error to delete a link' do
+      before_all do
+        project.add_maintainer(current_user)
+        target_group.add_developer(current_user)
+      end
+
+      before do
+        allow(::Ci::JobToken::GroupScopeLink).to receive(:for_source_and_target).and_return(link)
+        allow(link).to receive(:destroy).and_return(false)
+        allow(link).to receive(:errors).and_return(ActiveModel::Errors.new(link))
+        link.errors.add(:base, 'Custom error message')
+      end
+
+      it_behaves_like 'returns error', 'Custom error message'
+    end
+  end
+end
diff --git a/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb b/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb
new file mode 100644
index 0000000000000..cad345e58b36b
--- /dev/null
+++ b/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb
@@ -0,0 +1,257 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobTokenScope::EditScopeValidations, feature_category: :continuous_integration do
+  using RSpec::Parameterized::TableSyntax
+
+  let(:test_class) do
+    Class.new(::BaseService) do
+      include Ci::JobTokenScope::EditScopeValidations
+    end
+  end
+
+  let_it_be(:source_project) { create(:project) }
+  let_it_be(:target_project) { create(:project) }
+  let_it_be(:target_group) { create(:group) }
+  let_it_be(:current_user) { create(:user) }
+
+  subject(:test_instance) { test_class.new(source_project, current_user) }
+
+  describe '#validate_edit!' do
+    subject(:validate_edit_execution) do
+      test_instance.validate_edit!(source_project, target_project, current_user)
+    end
+
+    before do
+      source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role
+      target_project.send("add_#{target_project_user_role}", current_user) if target_project_user_role
+      source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false))
+      target_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_project_visibility, false))
+    end
+
+    context 'when all permissions are in order' do
+      where(:source_project_visibility,
+        :target_project_visibility,
+        :source_project_user_role,
+        :target_project_user_role) do
+        'PUBLIC' | 'PUBLIC' | :maintainer | :developer
+        'PUBLIC' | 'PUBLIC' | :maintainer | :guest
+        'PRIVATE' | 'PRIVATE' | :maintainer | :developer
+        'PRIVATE' | 'PRIVATE' | :maintainer | :guest
+      end
+
+      with_them do
+        it 'passes the validation' do
+          expect do
+            validate_edit_execution
+          end.not_to raise_error
+        end
+      end
+    end
+
+    context 'when user lacks admin_project permissions for the source project' do
+      where(:source_project_visibility,
+        :target_project_visibility,
+        :source_project_user_role,
+        :target_project_user_role) do
+        'PUBLIC' | 'PUBLIC' | nil | :developer
+        'PRIVATE' | 'PRIVATE' | nil | :developer
+        'PUBLIC' | 'PUBLIC' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :developer | :developer
+        'PUBLIC' | 'PRIVATE' | :developer | :developer
+      end
+
+      with_them do
+        it 'raises an error' do
+          expect do
+            validate_edit_execution
+          end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError,
+            'Insufficient permissions to modify the job token scope')
+        end
+      end
+    end
+
+    context 'when user lacks read_project permissions for the target project' do
+      where(:source_project_visibility,
+        :target_project_visibility,
+        :source_project_user_role,
+        :target_project_user_role) do
+        'PRIVATE' | 'PRIVATE' | :maintainer | nil
+        'PUBLIC' | 'PRIVATE' | :maintainer | nil
+      end
+
+      with_them do
+        it 'raises an error' do
+          expect do
+            validate_edit_execution
+          end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError,
+            Ci::JobTokenScope::EditScopeValidations::TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND)
+        end
+      end
+    end
+  end
+
+  describe '#validate_group_add!' do
+    subject(:validate_group_execution) do
+      test_instance.validate_group_add!(source_project, target_group, current_user)
+    end
+
+    before do
+      source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role
+      target_group.send("add_#{target_group_user_role}", current_user) if target_group_user_role
+      source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false))
+      target_group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_group_visibility, false))
+    end
+
+    context 'when all permissions are in order' do
+      where(:source_project_visibility,
+        :target_group_visibility,
+        :source_project_user_role,
+        :target_group_user_role) do
+        'PUBLIC' | 'PUBLIC' | :maintainer | :developer
+        'PUBLIC' | 'PUBLIC' | :maintainer | :guest
+        'PRIVATE' | 'PRIVATE' | :maintainer | :developer
+        'PRIVATE' | 'PRIVATE' | :maintainer | :guest
+      end
+
+      with_them do
+        it 'passes the validation' do
+          expect do
+            validate_group_execution
+          end.not_to raise_error
+        end
+      end
+    end
+
+    context 'when user lacks admin_project permissions for the source project' do
+      where(:source_project_visibility,
+        :target_group_visibility,
+        :source_project_user_role,
+        :target_group_user_role) do
+        'PUBLIC' | 'PUBLIC' | nil | :developer
+        'PRIVATE' | 'PRIVATE' | nil | :developer
+        'PUBLIC' | 'PUBLIC' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :developer | :developer
+        'PUBLIC' | 'PRIVATE' | :developer | :developer
+      end
+
+      with_them do
+        it 'raises an error' do
+          expect do
+            validate_group_execution
+          end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError,
+            'Insufficient permissions to modify the job token scope')
+        end
+      end
+    end
+
+    context 'when user lacks read_project permissions for the target group' do
+      where(:source_project_visibility,
+        :target_group_visibility,
+        :source_project_user_role,
+        :target_group_user_role) do
+        'PRIVATE' | 'PRIVATE' | :maintainer | nil
+        'PUBLIC' | 'PRIVATE' | :maintainer | nil
+      end
+
+      with_them do
+        it 'raises an error' do
+          expect do
+            validate_group_execution
+          end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError,
+            Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND)
+        end
+      end
+    end
+  end
+
+  describe '#validate_group_remove!' do
+    subject(:validate_group_execution) do
+      test_instance.validate_group_remove!(source_project, current_user)
+    end
+
+    before do
+      source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role
+      target_group.send("add_#{target_group_user_role}", current_user) if target_group_user_role
+      source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false))
+      target_group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_group_visibility, false))
+    end
+
+    context 'when all permissions are in order' do
+      where(:source_project_visibility,
+        :target_group_visibility,
+        :source_project_user_role,
+        :target_group_user_role) do
+        'PUBLIC' | 'PUBLIC' | :maintainer | :developer
+        'PUBLIC' | 'PUBLIC' | :maintainer | :guest
+        'PRIVATE' | 'PRIVATE' | :maintainer | :developer
+        'PRIVATE' | 'PRIVATE' | :maintainer | :guest
+        'PUBLIC' | 'PUBLIC' | :maintainer | nil
+        'PUBLIC' | 'PUBLIC' | :maintainer | nil
+        'PRIVATE' | 'PRIVATE' | :maintainer | nil
+        'PRIVATE' | 'PRIVATE' | :maintainer | nil
+      end
+
+      with_them do
+        it 'passes the validation' do
+          expect do
+            validate_group_execution
+          end.not_to raise_error
+        end
+      end
+    end
+
+    context 'when user lacks admin_project permissions for the source project' do
+      where(:source_project_visibility,
+        :target_group_visibility,
+        :source_project_user_role,
+        :target_group_user_role) do
+        'PUBLIC' | 'PUBLIC' | nil | :developer
+        'PRIVATE' | 'PRIVATE' | nil | :developer
+        'PUBLIC' | 'PUBLIC' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :guest | :developer
+        'PRIVATE' | 'PRIVATE' | :developer | :developer
+        'PUBLIC' | 'PRIVATE' | :developer | :developer
+      end
+
+      with_them do
+        it 'raises an error' do
+          expect do
+            validate_group_execution
+          end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError,
+            'Insufficient permissions to modify the job token scope')
+        end
+      end
+    end
+  end
+
+  describe '#validate_target_exists!' do
+    subject(:validate_target_exists_execution) do
+      test_instance.validate_target_exists!(target)
+    end
+
+    context 'when target is nil' do
+      let_it_be(:target) { nil }
+
+      it 'raises an error' do
+        expect do
+          validate_target_exists_execution
+        end.to raise_error(Ci::JobTokenScope::EditScopeValidations::NotFoundError,
+          Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST)
+      end
+    end
+
+    context 'when target is present' do
+      let_it_be(:target) { target_project }
+
+      it 'raises an error' do
+        expect do
+          validate_target_exists_execution
+        end.not_to raise_error
+      end
+    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 c363ff443baee..865bc6c7c8156 100644
--- a/spec/support/shared_contexts/models/ci/job_token_scope.rb
+++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb
@@ -14,7 +14,7 @@
   include_context 'with inaccessible projects'
 end
 
-RSpec.shared_context 'with accessible and inaccessible project groups' do
+RSpec.shared_context 'with projects that are with and without groups added in allowlist' do
   let_it_be(:project_with_target_project_group_in_allowlist) { create_project_with_group_allowlist(target_project) }
   let_it_be(:project_wo_target_project_group_in_allowlist) { create_project_without_group_allowlist }
 end
diff --git a/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb b/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb
new file mode 100644
index 0000000000000..9d7b4591b7a94
--- /dev/null
+++ b/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'editable group job token scope' do
+  shared_examples 'returns error' do |error|
+    it 'returns an error response', :aggregate_failures do
+      expect(result).to be_error
+      expect(result.message).to eq(error)
+    end
+  end
+
+  context 'when user does not have permissions to edit the job token scope' do
+    it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope'
+  end
+
+  context 'when user has permissions to edit the job token scope' do
+    before do
+      project.add_maintainer(current_user)
+    end
+
+    context 'when target group is not provided' do
+      let(:target_group) { nil }
+
+      it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND
+    end
+
+    context 'when target group is provided' do
+      context 'when user does not have permissions to read the target group' do
+        it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND
+      end
+    end
+  end
+end
-- 
GitLab