diff --git a/app/graphql/mutations/integrations/exclusions/create.rb b/app/graphql/mutations/integrations/exclusions/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..7949b2a267bcfa9d45cc07d8b6298d4317b266d0 --- /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], + 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: result.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..d01d8c6776edf8c7cbb2c94b0d90abbbfedc9cc9 --- /dev/null +++ b/app/graphql/mutations/integrations/exclusions/delete.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Exclusions + class Delete < BaseMutation + graphql_name 'IntegrationExclusionDelete' + include ResolvesIds + + field :exclusions, [::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_ids, + [::Types::GlobalIDType[::Project]], + required: true, + description: 'Id of excluded project.' + + authorize :admin_all_resources + + def resolve(integration_name:, project_ids:) + authorize!(:global) + + projects = Project.id_in(resolve_ids(project_ids)) + + result = ::Integrations::Exclusions::DestroyService.new( + current_user: current_user, + projects: projects, + integration_name: integration_name + ).execute + + { + exclusions: result.payload, + errors: result.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..f5d405b578ade9c7661c69c037d914ea93dca3de --- /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 integration.' + + 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 b4a28f640aa2ac0a9b88943f9ccdb5e2f9f1bae6..0ea7d1a0b5f15373f56d2f3516ee75ee8272a36e 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/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index cfc882bd1f78b4b2289c07261be531ad609e1945..561f34b80461766e173320e7e000fd1e8ef4929e 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 | +| ---- | ---- | ----------- | +| <a id="queryintegrationexclusionsintegrationname"></a>`integrationName` | [`IntegrationType!`](#integrationtype) | Type of integration. | + ### `Query.issue` Find an issue. @@ -5428,6 +5446,54 @@ Input type: `InstanceGoogleCloudLoggingConfigurationUpdateInput` | <a id="mutationinstancegooglecloudloggingconfigurationupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationinstancegooglecloudloggingconfigurationupdateinstancegooglecloudloggingconfiguration"></a>`instanceGoogleCloudLoggingConfiguration` | [`InstanceGoogleCloudLoggingConfigurationType`](#instancegooglecloudloggingconfigurationtype) | configuration updated. | +### `Mutation.integrationExclusionCreate` + +DETAILS: +**Introduced** in GitLab 17.0. +**Status**: Experiment. + +Input type: `IntegrationExclusionCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationintegrationexclusioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationintegrationexclusioncreateintegrationname"></a>`integrationName` | [`IntegrationType!`](#integrationtype) | Type of integration to exclude. | +| <a id="mutationintegrationexclusioncreateprojectids"></a>`projectIds` | [`[ProjectID!]!`](#projectid) | Ids of projects to exclude. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationintegrationexclusioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationintegrationexclusioncreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationintegrationexclusioncreateexclusions"></a>`exclusions` | [`[IntegrationExclusion!]`](#integrationexclusion) | Integration exclusions created by the mutation. | + +### `Mutation.integrationExclusionDelete` + +DETAILS: +**Introduced** in GitLab 17.0. +**Status**: Experiment. + +Input type: `IntegrationExclusionDeleteInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationintegrationexclusiondeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationintegrationexclusiondeleteintegrationname"></a>`integrationName` | [`IntegrationType!`](#integrationtype) | Type of integration. | +| <a id="mutationintegrationexclusiondeleteprojectids"></a>`projectIds` | [`[ProjectID!]!`](#projectid) | Id of excluded project. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationintegrationexclusiondeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationintegrationexclusiondeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationintegrationexclusiondeleteexclusions"></a>`exclusions` | [`[IntegrationExclusion!]`](#integrationexclusion) | Project no longer excluded due to the mutation. | + ### `Mutation.issuableResourceLinkCreate` Input type: `IssuableResourceLinkCreateInput` @@ -12526,6 +12592,29 @@ The edge type for [`InstanceGoogleCloudLoggingConfigurationType`](#instancegoogl | <a id="instancegooglecloudloggingconfigurationtypeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="instancegooglecloudloggingconfigurationtypeedgenode"></a>`node` | [`InstanceGoogleCloudLoggingConfigurationType`](#instancegooglecloudloggingconfigurationtype) | The item at the end of the edge. | +#### `IntegrationExclusionConnection` + +The connection type for [`IntegrationExclusion`](#integrationexclusion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="integrationexclusionconnectionedges"></a>`edges` | [`[IntegrationExclusionEdge]`](#integrationexclusionedge) | A list of edges. | +| <a id="integrationexclusionconnectionnodes"></a>`nodes` | [`[IntegrationExclusion]`](#integrationexclusion) | A list of nodes. | +| <a id="integrationexclusionconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `IntegrationExclusionEdge` + +The edge type for [`IntegrationExclusion`](#integrationexclusion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="integrationexclusionedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="integrationexclusionedgenode"></a>`node` | [`IntegrationExclusion`](#integrationexclusion) | The item at the end of the edge. | + #### `IssuableResourceLinkConnection` The connection type for [`IssuableResourceLink`](#issuableresourcelink). @@ -22949,6 +23038,16 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount). | <a id="instancesecuritydashboardvulnerabilityseveritiescountseverity"></a>`severity` | [`[VulnerabilitySeverity!]`](#vulnerabilityseverity) | Filter vulnerabilities by severity. | | <a id="instancesecuritydashboardvulnerabilityseveritiescountstate"></a>`state` | [`[VulnerabilityState!]`](#vulnerabilitystate) | Filter vulnerabilities by state. | +### `IntegrationExclusion` + +An integration to override the level settings of instance specific integrations. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="integrationexclusionproject"></a>`project` | [`Project`](#project) | Project that has been excluded from the instance specific integration. | + ### `IssuableResourceLink` Describes an issuable resource link for incident issues. @@ -33410,6 +33509,14 @@ Import source. | <a id="importsourcemanifest"></a>`MANIFEST` | Imported from Manifest. | | <a id="importsourcenone"></a>`NONE` | Not imported. | +### `IntegrationType` + +Integration Names. + +| Value | Description | +| ----- | ----------- | +| <a id="integrationtypebeyond_identity"></a>`BEYOND_IDENTITY` | Beyond Identity. | + ### `IssuableResourceLinkType` Issuable resource link type enum. diff --git a/ee/app/graphql/types/google_cloud/artifact_registry/repository_type.rb b/ee/app/graphql/types/google_cloud/artifact_registry/repository_type.rb index bde92488473f16184fb4d2247d963090c40ddd19..d9b936ff27d662fdb106e760c746a88baa43eae6 100644 --- a/ee/app/graphql/types/google_cloud/artifact_registry/repository_type.rb +++ b/ee/app/graphql/types/google_cloud/artifact_registry/repository_type.rb @@ -10,7 +10,7 @@ class RepositoryType < BaseObject include Gitlab::Graphql::Authorize::AuthorizeResource GOOGLE_ARTIFACT_MANAGEMENT_INTEGRATION_ERROR = - "#{Integrations::GoogleCloudPlatform::ArtifactRegistry.title} integration does not exist or inactive".freeze + "#{::Integrations::GoogleCloudPlatform::ArtifactRegistry.title} integration does not exist or inactive".freeze authorize :read_google_cloud_artifact_registry 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/requests/api/graphql/integrations/exclusions_spec.rb b/spec/requests/api/graphql/integrations/exclusions_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b99873bfea92d01d1dfd9373067a57ee54718435 --- /dev/null +++ b/spec/requests/api/graphql/integrations/exclusions_spec.rb @@ -0,0 +1,69 @@ +# 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 + + context 'when the user is not authenticated' do + let(:current_user) { nil } + + 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..2e4dce2e4d08b5360c1f6de90adacc00f9ea2f1f --- /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', + '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 'returns an empty array' do + resolve_mutation + expect(graphql_data['integrationExclusionDelete']['exclusions']).to eq([]) + 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 'enables 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']['exclusions'][0] + expect(exclusion_response['project']['id']).to eq(project.to_global_id.to_s) + 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,