diff --git a/app/graphql/mutations/integrations/exclusions/create.rb b/app/graphql/mutations/integrations/exclusions/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..97233f6f0a4d2f5aa4cba652dea66e576c300bae
--- /dev/null
+++ b/app/graphql/mutations/integrations/exclusions/create.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Integrations
+ module Exclusions
+ class Create < BaseMutation
+ graphql_name 'IntegrationExclusionCreate'
+ include ResolvesIds
+
+ field :exclusions, ::Types::Integrations::ExclusionType.connection_type,
+ null: true,
+ description: 'Integration exclusions created by the mutation.'
+
+ argument :integration_name,
+ ::Types::Integrations::IntegrationTypeEnum,
+ required: true,
+ description: 'Type of integration to exclude.'
+
+ argument :project_ids,
+ [::Types::GlobalIDType[::Project]],
+ required: true,
+ description: 'Ids of projects to exclude.'
+
+ authorize :admin_all_resources
+
+ def resolve(integration_name:, project_ids:)
+ authorize!(:global)
+
+ projects = Project.id_in(resolve_ids(project_ids))
+
+ result = ::Integrations::Exclusions::CreateService.new(
+ current_user: current_user,
+ projects: projects,
+ integration_name: integration_name
+ ).execute
+
+ {
+ exclusions: result.payload,
+ errors: []
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/integrations/exclusions/delete.rb b/app/graphql/mutations/integrations/exclusions/delete.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8792ed2bc331ed8d1aac3973e39b0a42afaea9aa
--- /dev/null
+++ b/app/graphql/mutations/integrations/exclusions/delete.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Integrations
+ module Exclusions
+ class Delete < BaseMutation
+ graphql_name 'IntegrationExclusionDelete'
+
+ field :exclusion, ::Types::Integrations::ExclusionType,
+ null: true,
+ description: 'Project no longer excluded due to the mutation.'
+
+ argument :integration_name,
+ ::Types::Integrations::IntegrationTypeEnum,
+ required: true,
+ description: 'Type of integration.'
+
+ argument :project_id,
+ ::Types::GlobalIDType[::Project],
+ required: true,
+ description: 'Id of excluded project.'
+
+ authorize :admin_all_resources
+
+ def resolve(integration_name:, project_id:)
+ authorize!(:global)
+
+ result = ::Integrations::Exclusions::DestroyService.new(
+ current_user: current_user,
+ project_id: project_id.model_id,
+ integration_name: integration_name
+ ).execute
+
+ {
+ exclusion: result.payload,
+ errors: []
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/integrations/exclusions_resolver.rb b/app/graphql/resolvers/integrations/exclusions_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a2cae8b1f0e65d9a74cd406fd816ccd3dcd3a1c5
--- /dev/null
+++ b/app/graphql/resolvers/integrations/exclusions_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Integrations
+ class ExclusionsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ type Types::Integrations::ExclusionType.connection_type, null: true
+
+ argument :integration_name, Types::Integrations::IntegrationTypeEnum,
+ required: true,
+ description: 'Type of description.'
+
+ def resolve(integration_name:)
+ authorize!
+ Integration.integration_name_to_model(integration_name).with_custom_settings.by_active_flag(false)
+ end
+
+ def authorize!
+ raise_resource_not_available_error! unless context[:current_user].can_admin_all_resources?
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/integrations/exclusion_type.rb b/app/graphql/types/integrations/exclusion_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0cd07f74238453f838578280da163bac742cfe79
--- /dev/null
+++ b/app/graphql/types/integrations/exclusion_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module Integrations
+ class ExclusionType < BaseObject
+ graphql_name 'IntegrationExclusion'
+ description 'An integration to override the level settings of instance specific integrations.'
+ authorize :admin_all_resources
+
+ field :project, ::Types::ProjectType,
+ description: 'Project that has been excluded from the instance specific integration.'
+ end
+ end
+end
diff --git a/app/graphql/types/integrations/integration_type_enum.rb b/app/graphql/types/integrations/integration_type_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4e6b8c8005c2e7eb15c42c0b161baa937a1e7d1a
--- /dev/null
+++ b/app/graphql/types/integrations/integration_type_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ module Integrations
+ class IntegrationTypeEnum < BaseEnum
+ graphql_name 'IntegrationType'
+ description 'Integration Names'
+
+ value 'BEYOND_IDENTITY', description: 'Beyond Identity.', value: 'beyond_identity'
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index c821e4ad6ab033f3e1f970b050e87277d457c587..648c3b7c282f2969d71a7d97d795943ecb9d3326 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -63,6 +63,8 @@ class MutationType < BaseObject
mount_mutation Mutations::IncidentManagement::TimelineEvent::Update
mount_mutation Mutations::IncidentManagement::TimelineEvent::Destroy
mount_mutation Mutations::IncidentManagement::TimelineEventTag::Create
+ mount_mutation Mutations::Integrations::Exclusions::Create, alpha: { milestone: '17.0' }
+ mount_mutation Mutations::Integrations::Exclusions::Delete, alpha: { milestone: '17.0' }
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
mount_mutation Mutations::Issues::SetCrmContacts
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 1091ca2a788c38bf55e4061a165f9ed1994a261b..76b5f7a6d05a11ec4527ba7f3bc2678220d1d687 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -222,6 +222,11 @@ class QueryType < ::Types::BaseObject
description: 'Find machine learning models.',
resolver: Resolvers::Ml::ModelDetailResolver
+ field :integration_exclusions, Types::Integrations::ExclusionType.connection_type,
+ null: true,
+ alpha: { milestone: '17.0' },
+ resolver: Resolvers::Integrations::ExclusionsResolver
+
field :work_items_by_reference,
null: true,
alpha: { milestone: '16.7' },
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 68852b0c8f2b9fe79891d9f5b4421c03d4d3f873..9252c8b38cd379dc1b2950896247286408b09ceb 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -691,6 +691,10 @@ def toggle!
active? ? deactivate! : activate!
end
+ def self.exclusions_for_project(project)
+ where(project: project, active: false)
+ end
+
private
# Ancestors sorted by hierarchy depth in bottom-top order.
diff --git a/app/services/integrations/exclusions/create_service.rb b/app/services/integrations/exclusions/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..db6c373175736a6b58d0df8b07a1da3e1c59360d
--- /dev/null
+++ b/app/services/integrations/exclusions/create_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Exclusions
+ class CreateService
+ def initialize(current_user:, integration_name:, projects:)
+ @user = current_user
+ @integration_name = integration_name
+ @projects = projects
+ end
+
+ attr_reader :user, :integration_name, :projects
+
+ def execute
+ return ServiceResponse.error(message: 'not authorized') unless allowed?
+ return ServiceResponse.error(message: 'not instance specific') unless instance_specific_integration?
+
+ integration_type = Integration.integration_name_to_type(integration_name)
+
+ integration_attrs = projects.map do |project|
+ {
+ project_id: project.id,
+ type_new: integration_type,
+ active: false,
+ inherit_from_id: nil
+ }
+ end
+
+ result = Integration.upsert_all(integration_attrs, unique_by: [:project_id, :type_new])
+ ServiceResponse.success(payload: Integration.id_in(result.rows.flatten))
+ end
+
+ private
+
+ def allowed?
+ user.can?(:admin_all_resources)
+ end
+
+ def instance_specific_integration?
+ Integration::INSTANCE_SPECIFIC_INTEGRATION_NAMES.include?(integration_name)
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/exclusions/destroy_service.rb b/app/services/integrations/exclusions/destroy_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9d6de9689215e6e16eb312780951c8c6900b8f0f
--- /dev/null
+++ b/app/services/integrations/exclusions/destroy_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Exclusions
+ class DestroyService < ::BaseService
+ def initialize(current_user:, integration_name:, project_id:)
+ @user = current_user
+ @integration_name = integration_name
+ @project_id = project_id
+ end
+
+ attr_reader :user, :integration_name, :project_id
+
+ def execute
+ return ServiceResponse.error(message: 'not authorized') unless allowed?
+ return ServiceResponse.error(message: 'not instance specific') unless instance_specific_integration?
+
+ integration_class = Integration.integration_name_to_model(integration_name)
+ exclusion = integration_class.exclusions_for_project(project_id).first
+
+ instance_integration = integration_class.for_instance.first
+ return ServiceResponse.success(payload: exclusion&.destroy) unless instance_integration
+
+ ::Integrations::Propagation::BulkUpdateService.new(instance_integration, [exclusion]).execute
+ ServiceResponse.success(payload: exclusion)
+ end
+
+ private
+
+ def allowed?
+ user.can?(:admin_all_resources)
+ end
+
+ def instance_specific_integration?
+ Integration::INSTANCE_SPECIFIC_INTEGRATION_NAMES.include?(integration_name)
+ end
+ end
+ end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index af5601e9db4cf020a7515f58610e8c52f0753e2f..f47b2af628e605dde0a13e99a262bc0d0605bb36 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -477,6 +477,24 @@ Fields related to Instance Security Dashboard.
Returns [`InstanceSecurityDashboard`](#instancesecuritydashboard).
+### `Query.integrationExclusions`
+
+DETAILS:
+**Introduced** in GitLab 17.0.
+**Status**: Experiment.
+
+Returns [`IntegrationExclusionConnection`](#integrationexclusionconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#pagination-arguments):
+`before: String`, `after: String`, `first: Int`, and `last: Int`.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `integrationName` | [`IntegrationType!`](#integrationtype) | Type of description. |
+
### `Query.issue`
Find an issue.
@@ -5360,6 +5378,53 @@ Input type: `InstanceGoogleCloudLoggingConfigurationUpdateInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `instanceGoogleCloudLoggingConfiguration` | [`InstanceGoogleCloudLoggingConfigurationType`](#instancegooglecloudloggingconfigurationtype) | configuration updated. |
+### `Mutation.integrationExclusionCreate`
+
+DETAILS:
+**Introduced** in GitLab 17.0.
+**Status**: Experiment.
+
+Input type: `IntegrationExclusionCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `integrationName` | [`IntegrationType!`](#integrationtype) | Type of integration to exclude. |
+| `projectIds` | [`[ProjectID!]!`](#projectid) | Ids of projects to exclude. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
+### `Mutation.integrationExclusionDelete`
+
+DETAILS:
+**Introduced** in GitLab 17.0.
+**Status**: Experiment.
+
+Input type: `IntegrationExclusionDeleteInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `integrationName` | [`IntegrationType!`](#integrationtype) | Type of integration. |
+| `projectId` | [`ProjectID!`](#projectid) | Id of excluded project. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `exclusion` | [`IntegrationExclusion`](#integrationexclusion) | Project no longer excluded due to the mutation. |
+
### `Mutation.issuableResourceLinkCreate`
Input type: `IssuableResourceLinkCreateInput`
@@ -12440,6 +12505,29 @@ The edge type for [`InstanceGoogleCloudLoggingConfigurationType`](#instancegoogl
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`InstanceGoogleCloudLoggingConfigurationType`](#instancegooglecloudloggingconfigurationtype) | The item at the end of the edge. |
+#### `IntegrationExclusionConnection`
+
+The connection type for [`IntegrationExclusion`](#integrationexclusion).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[IntegrationExclusionEdge]`](#integrationexclusionedge) | A list of edges. |
+| `nodes` | [`[IntegrationExclusion]`](#integrationexclusion) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `IntegrationExclusionEdge`
+
+The edge type for [`IntegrationExclusion`](#integrationexclusion).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`IntegrationExclusion`](#integrationexclusion) | The item at the end of the edge. |
+
#### `IssuableResourceLinkConnection`
The connection type for [`IssuableResourceLink`](#issuableresourcelink).
@@ -22846,6 +22934,16 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount).
| `severity` | [`[VulnerabilitySeverity!]`](#vulnerabilityseverity) | Filter vulnerabilities by severity. |
| `state` | [`[VulnerabilityState!]`](#vulnerabilitystate) | Filter vulnerabilities by state. |
+### `IntegrationExclusion`
+
+An integration to override the level settings of instance specific integrations.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `project` | [`Project`](#project) | Project that has been excluded from the instance specific integration. |
+
### `IssuableResourceLink`
Describes an issuable resource link for incident issues.
@@ -33289,6 +33387,14 @@ Health status of an issue or epic for filtering.
| `needsAttention` | Needs attention. |
| `onTrack` | On track. |
+### `IntegrationType`
+
+Integration Names.
+
+| Value | Description |
+| ----- | ----------- |
+| `BEYOND_IDENTITY` | Beyond Identity. |
+
### `IssuableResourceLinkType`
Issuable resource link type enum.
diff --git a/lib/gitlab/checks/integrations/beyond_identity_check.rb b/lib/gitlab/checks/integrations/beyond_identity_check.rb
index 0dcf7f3446f14b66b98a17a1bd06f56dc5e0d472..8960a550869459e1521d363f0ab5cf1d347703e9 100644
--- a/lib/gitlab/checks/integrations/beyond_identity_check.rb
+++ b/lib/gitlab/checks/integrations/beyond_identity_check.rb
@@ -10,7 +10,7 @@ class BeyondIdentityCheck < ::Gitlab::Checks::BaseBulkChecker
def initialize(integration_check)
@changes_access = integration_check.changes_access
- @integration = ::Integrations::BeyondIdentity.for_instance.first
+ @integration = project.beyond_identity_integration
end
def validate!
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index 0b5739be9a114582e86be12d265c6414b97b0ee6..ec4a1aa15f789c45a3a783e76b735c307a078fc8 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -155,4 +155,14 @@
is_expected.to have_graphql_resolver(Resolvers::Ml::ModelDetailResolver)
end
end
+
+ describe 'integration_exclusions field' do
+ subject { described_class.fields['integrationExclusions'] }
+
+ it 'returns metadata', :aggregate_failures do
+ is_expected.to have_graphql_arguments(:integrationName)
+ is_expected.to have_graphql_type(Types::Integrations::ExclusionType.connection_type)
+ is_expected.to have_graphql_resolver(Resolvers::Integrations::ExclusionsResolver)
+ end
+ end
end
diff --git a/spec/lib/gitlab/checks/integrations/beyond_identity_check_spec.rb b/spec/lib/gitlab/checks/integrations/beyond_identity_check_spec.rb
index 7e3b4b206ff439fcf99b55c7cd2e26b887920b4c..9781f9384f3d38a43d50d624c9fe6d53508f37dc 100644
--- a/spec/lib/gitlab/checks/integrations/beyond_identity_check_spec.rb
+++ b/spec/lib/gitlab/checks/integrations/beyond_identity_check_spec.rb
@@ -2,18 +2,36 @@
require 'spec_helper'
-RSpec.describe Gitlab::Checks::Integrations::BeyondIdentityCheck, feature_category: :source_code_management do
+RSpec.describe Gitlab::Checks::Integrations::BeyondIdentityCheck, :sidekiq_inline, feature_category: :source_code_management do
include_context 'changes access checks context'
let!(:beyond_identity_integration) { create(:beyond_identity_integration) }
+ let(:integration_exclusion) { nil }
let(:integration_check) { Gitlab::Checks::IntegrationsCheck.new(changes_access) }
subject(:check) { described_class.new(integration_check) }
+ before do
+ integration_exclusion
+ ::Integrations::PropagateService.new(beyond_identity_integration).execute
+ end
+
describe '#validate!' do
+ shared_examples_for 'exclusion from the check' do
+ context 'when the project is excluded from the check' do
+ let(:integration_exclusion) do
+ create(:beyond_identity_integration, active: false, project: project, inherit_from_id: nil, instance: false)
+ end
+
+ it 'does not raise an error' do
+ expect { check.validate! }.not_to raise_error
+ end
+ end
+ end
+
context 'when commit without GPG signature' do
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be_with_reload(:project) { create(:project, :repository) }
let_it_be(:oldrev) { '1e292f8fedd741b75372e19097c76d327140c312' }
let_it_be(:newrev) { '7b5160f9bb23a3d58a0accdbe89da13b96b1ece9' }
@@ -27,6 +45,8 @@
.to raise_error(::Gitlab::GitAccess::ForbiddenError, 'Commit is not signed with a GPG signature')
end
+ it_behaves_like 'exclusion from the check'
+
context 'when the push happens from web' do
let(:protocol) { 'web' }
@@ -56,7 +76,7 @@
end
context 'when a commit with GPG signature' do
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be_with_reload(:project) { create(:project, :repository) }
let_it_be(:oldrev) { 'ddd0f15ae83993f5cb66a927a28673882e99100b' }
let_it_be(:newrev) { 'f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373' }
let!(:gpg_key) { create :gpg_key, externally_verified: true }
@@ -66,6 +86,8 @@
project.repository.delete_branch('trailers')
end
+ it_behaves_like 'exclusion from the check'
+
context 'and the signature is unverified' do
it 'is rejected' do
expect { check.validate! }
diff --git a/spec/requests/api/graphql/integrations/exclusions_spec.rb b/spec/requests/api/graphql/integrations/exclusions_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4a785310ebc54fc63f77d20b683c8bb5942cbaf0
--- /dev/null
+++ b/spec/requests/api/graphql/integrations/exclusions_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Querying for integration exclusions', feature_category: :integrations do
+ include GraphqlHelpers
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+ let(:current_user) { admin_user }
+ let(:query) { graphql_query_for('integrationExclusions', args, fields) }
+ let(:args) { { 'integrationName' => :BEYOND_IDENTITY } }
+ let(:fields) do
+ <<~GRAPHQL
+ nodes {
+ project {
+ id
+ }
+ }
+ GRAPHQL
+ end
+
+ context 'when the user is authorized' do
+ let!(:instance_integration) { create(:beyond_identity_integration) }
+ let!(:integration_exclusion) do
+ create(:beyond_identity_integration, active: false, instance: false, project: project2, inherit_from_id: nil)
+ end
+
+ let!(:propagated_integration) do
+ create(:beyond_identity_integration, active: false, instance: false, project: project,
+ inherit_from_id: instance_integration.id)
+ end
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query that returns data'
+
+ it 'returns projects that are custom exclusions' do
+ nodes = graphql_data['integrationExclusions']['nodes']
+ expect(nodes.size).to eq(1)
+ expect(nodes).to include(a_hash_including('project' => { 'id' => project2.to_global_id.to_s }))
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { user }
+
+ it 'responds with an error' do
+ post_graphql(query, current_user: current_user)
+ expect(graphql_errors.first['message']).to eq(
+ Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/integrations/exclusions/create_spec.rb b/spec/requests/api/graphql/mutations/integrations/exclusions/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5d3e19c100dfa63032384f475813a6bc8919671f
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/integrations/exclusions/create_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Integrations::Exclusions::Create, feature_category: :integrations do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+ let(:current_user) { admin_user }
+ let(:mutation) { graphql_mutation(:integration_exclusion_create, args) }
+ let(:args) do
+ {
+ 'integrationName' => 'BEYOND_IDENTITY',
+ 'projectIds' => project_ids
+ }
+ end
+
+ let(:project_ids) { [project.to_global_id.to_s] }
+
+ subject(:resolve_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ context 'when the user is not authorized' do
+ let(:current_user) { user }
+
+ it 'responds with an error' do
+ resolve_mutation
+ expect(graphql_errors.first['message']).to eq(
+ Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+ )
+ end
+ end
+
+ context 'when the user is authorized' do
+ let(:current_user) { admin_user }
+
+ it 'creates inactive integrations for the projects' do
+ expect { resolve_mutation }.to change { Integration.count }.from(0).to(1)
+ end
+
+ context 'when integrations exist for the projects' do
+ let!(:instance_exclusion) { create(:beyond_identity_integration) }
+ let!(:existing_exclusion) do
+ create(:beyond_identity_integration, project: project2, active: false, inherit_from_id: instance_exclusion.id,
+ instance: false)
+ end
+
+ let(:project_ids) { [project, project2].map { |p| p.to_global_id.to_s } }
+
+ it 'updates existing integrations and creates integrations for projects' do
+ expect { resolve_mutation }.to change { Integration.count }.from(2).to(3)
+ existing_exclusion.reload
+ expect(existing_exclusion).not_to be_active
+ expect(existing_exclusion.inherit_from_id).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/integrations/exclusions/delete_spec.rb b/spec/requests/api/graphql/mutations/integrations/exclusions/delete_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1548ac5ed1775c092e65d0a89cd01defbfddc3b7
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/integrations/exclusions/delete_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Integrations::Exclusions::Delete, feature_category: :integrations do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+ let(:current_user) { admin_user }
+ let(:mutation) { graphql_mutation(:integration_exclusion_delete, args) }
+ let(:args) do
+ {
+ 'integrationName' => 'BEYOND_IDENTITY',
+ 'projectId' => project_id
+ }
+ end
+
+ let(:project_id) { project.to_global_id.to_s }
+
+ subject(:resolve_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ context 'when the user is not authorized' do
+ let(:current_user) { user }
+
+ it 'responds with an error' do
+ resolve_mutation
+ expect(graphql_errors.first['message']).to eq(
+ Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+ )
+ end
+ end
+
+ context 'when the user is authorized' do
+ let(:current_user) { admin_user }
+
+ it 'returns nil' do
+ resolve_mutation
+ expect(graphql_data['integrationExclusionDelete']['exclusion']).to be_nil
+ end
+
+ context 'and there are integrations' do
+ let!(:instance_integration) { create(:beyond_identity_integration) }
+ let!(:existing_exclusion) do
+ create(:beyond_identity_integration, project: project, active: false, inherit_from_id: nil,
+ instance: false)
+ end
+
+ it 'the integration for the specified project' do
+ resolve_mutation
+
+ existing_exclusion.reload
+ expect(existing_exclusion).to be_activated
+ expect(existing_exclusion.inherit_from_id).to eq(instance_integration.id)
+ exclusion_response = graphql_data['integrationExclusionDelete']['exclusion']
+ expect(exclusion_response['project']['id']).to eq(project_id)
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/exclusions/create_service_spec.rb b/spec/services/integrations/exclusions/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ae28b18f9694102314c99c8075c3a18fcff78cff
--- /dev/null
+++ b/spec/services/integrations/exclusions/create_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::Exclusions::CreateService, feature_category: :integrations do
+ let(:integration_name) { 'beyond_identity' }
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+ let(:current_user) { admin_user }
+ let_it_be(:project) { create(:project) }
+ let(:service) do
+ described_class.new(current_user: current_user, integration_name: integration_name, projects: [project])
+ end
+
+ describe '#execute', :enable_admin_mode do
+ subject(:execute) { service.execute }
+
+ context 'when the integration is not instance specific' do
+ let(:integration_name) { 'mock_ci' }
+
+ it 'returns an error response' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('not instance specific')
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { user }
+
+ it 'returns an error response' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('not authorized')
+ end
+ end
+
+ context 'when there are existing custom settings' do
+ let!(:existing_integration) do
+ create(:beyond_identity_integration)
+ end
+
+ let!(:existing_integration2) do
+ create(
+ :beyond_identity_integration,
+ active: true,
+ project: project,
+ instance: false,
+ inherit_from_id: existing_integration.id
+ )
+ end
+
+ it 'updates those custom settings' do
+ execute
+ existing_integration2.reload
+ expect(existing_integration2.active).to be_falsey
+ expect(existing_integration2.inherit_from_id).to be_nil
+ end
+ end
+
+ it 'creates custom settings' do
+ expect { execute }.to change { Integration.count }.from(0).to(1)
+ created_integrations = execute.payload
+ expect(created_integrations.first.active).to be_falsey
+ expect(created_integrations.first.inherit_from_id).to be_nil
+ end
+ end
+end
diff --git a/spec/services/integrations/exclusions/destroy_service_spec.rb b/spec/services/integrations/exclusions/destroy_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2d7ae99bc6d443a1937124147fa6f919e3cc0e56
--- /dev/null
+++ b/spec/services/integrations/exclusions/destroy_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::Exclusions::DestroyService, feature_category: :integrations do
+ let(:integration_name) { 'beyond_identity' }
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+ let(:current_user) { admin_user }
+ let_it_be(:project) { create(:project) }
+ let(:service) do
+ described_class.new(current_user: current_user, integration_name: integration_name, project_id: project.id)
+ end
+
+ describe '#execute', :enable_admin_mode do
+ subject(:execute) { service.execute }
+
+ context 'when the integration is not instance specific' do
+ let(:integration_name) { 'mock_ci' }
+
+ it 'returns an error response' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('not instance specific')
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { user }
+
+ it 'returns an error response' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('not authorized')
+ end
+ end
+
+ context 'when there are existing custom settings' do
+ let!(:instance_integration) { create(:beyond_identity_integration) }
+ let!(:exclusion) do
+ create(:beyond_identity_integration, active: false, project: project, instance: false, inherit_from_id: nil)
+ end
+
+ it 'updates the exclusion integration to be active' do
+ expect { execute }.to change { exclusion.reload.active }.from(false).to(true)
+ expect(exclusion.inherit_from_id).to eq(instance_integration.id)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
index 391336526e3f60162df03c4232a289e1899cf69e..cea5ce74f0771c48ca1836e68f44ab76f23a4188 100644
--- a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
+++ b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
@@ -18,6 +18,7 @@
:gitpod_enabled,
:group,
:groups,
+ :integration_exclusions,
:issue,
:issues,
:jobs,