From 69cb2d20fc2a501ace8172af4aebfc478da317d1 Mon Sep 17 00:00:00 2001 From: Igor Drozdov <idrozdov@gitlab.com> Date: Mon, 13 Nov 2023 18:31:26 +0100 Subject: [PATCH] Introduce BeyondIdentity integration to verify GPG keys BeyondIdentity is an instance level integration. When it's activated, only the GPG keys that were issued by BeyondIdentity service are accepted. Changelog: added --- .../admin/application_settings_controller.rb | 4 +- .../types/projects/service_type_enum.rb | 2 +- app/models/integration.rb | 46 ++++++++--- app/models/integrations/beyond_identity.rb | 54 +++++++++++++ app/models/project.rb | 6 +- app/services/gpg_keys/create_service.rb | 16 +++- .../gpg_keys/validate_integrations_service.rb | 33 ++++++++ ...53239_instances_beyond_identity_active.yml | 21 +++++ ...oups_inheriting_beyond_identity_active.yml | 21 +++++ ...17153519_groups_beyond_identity_active.yml | 21 +++++ ...ects_inheriting_beyond_identity_active.yml | 21 +++++ ...153527_projects_beyond_identity_active.yml | 21 +++++ db/docs/integrations.yml | 1 + .../project/integrations/beyond_identity.md | 35 ++++++++ lib/gitlab/beyond_identity/client.rb | 41 ++++++++++ locale/gitlab.pot | 15 ++++ .../settings/integrations_controller_spec.rb | 8 +- spec/factories/integrations.rb | 7 ++ .../instance_integrations_spec.rb | 6 +- .../lib/gitlab/beyond_identity/client_spec.rb | 81 +++++++++++++++++++ spec/lib/gitlab/import_export/all_models.yml | 1 + spec/models/integration_spec.rb | 41 +++++++--- .../integrations/beyond_identity_spec.rb | 62 ++++++++++++++ spec/models/project_spec.rb | 15 ++++ spec/requests/api/integrations_spec.rb | 2 +- spec/services/gpg_keys/create_service_spec.rb | 14 ++++ .../validate_integrations_service_spec.rb | 60 ++++++++++++++ 27 files changed, 622 insertions(+), 33 deletions(-) create mode 100644 app/models/integrations/beyond_identity.rb create mode 100644 app/services/gpg_keys/validate_integrations_service.rb create mode 100644 config/metrics/counts_all/20240117153239_instances_beyond_identity_active.yml create mode 100644 config/metrics/counts_all/20240117153516_groups_inheriting_beyond_identity_active.yml create mode 100644 config/metrics/counts_all/20240117153519_groups_beyond_identity_active.yml create mode 100644 config/metrics/counts_all/20240117153523_projects_inheriting_beyond_identity_active.yml create mode 100644 config/metrics/counts_all/20240117153527_projects_beyond_identity_active.yml create mode 100644 doc/user/project/integrations/beyond_identity.md create mode 100644 lib/gitlab/beyond_identity/client.rb create mode 100644 spec/lib/gitlab/beyond_identity/client_spec.rb create mode 100644 spec/models/integrations/beyond_identity_spec.rb create mode 100644 spec/services/gpg_keys/validate_integrations_service_spec.rb diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index cd099173718fd..7bd1ce5666987 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -53,7 +53,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def integrations return not_found unless instance_level_integrations? - @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_instance).sort_by(&:title) + @integrations = Integration.find_or_initialize_all_non_project_specific( + Integration.for_instance, include_instance_specific: true + ).sort_by(&:title) end def update diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb index fd88fa957e7b3..7a8863c7d6722 100644 --- a/app/graphql/types/projects/service_type_enum.rb +++ b/app/graphql/types/projects/service_type_enum.rb @@ -17,7 +17,7 @@ def type_description(name, type) # This prepend must stay here because the dynamic block below depends on it. prepend_mod # rubocop: disable Cop/InjectEnterpriseEditionModule - ::Integration.available_integration_names(include_dev: false).each do |name| + ::Integration.available_integration_names(include_instance_specific: false, include_dev: false).each do |name| type = "#{name.camelize}Service" domain_value = Integration.integration_name_to_type(name) value type.underscore.upcase, value: domain_value, description: type_description(name, type) diff --git a/app/models/integration.rb b/app/models/integration.rb index 8ebf24b1663b5..aa02db949bf4c 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -26,6 +26,10 @@ class Integration < ApplicationRecord unify_circuit webex_teams youtrack zentao ].freeze + INSTANCE_SPECIFIC_INTEGRATION_NAMES = %w[ + beyond_identity + ].freeze + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677 PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ apple_app_store gitlab_slack_application google_play jenkins @@ -124,9 +128,14 @@ def properties=(props) scope :with_default_settings, -> { where.not(inherit_from_id: nil) } scope :with_custom_settings, -> { where(inherit_from_id: nil) } scope :for_group, ->(group) { - where(group_id: group, type: available_integration_types(include_project_specific: false)) - } - scope :for_instance, -> { where(instance: true, type: available_integration_types(include_project_specific: false)) } + types = available_integration_types(include_project_specific: false, include_instance_specific: false) + where(group_id: group, type: types) + } + + scope :for_instance, -> { + types = available_integration_types(include_project_specific: false, include_instance_specific: true) + where(instance: true, type: types) + } scope :push_hooks, -> { where(push_events: true, active: true) } scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) } @@ -270,17 +279,18 @@ def self.event_description(event) end def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil) - return unless name.in?(available_integration_names(include_project_specific: false)) + return unless name.in?(available_integration_names(include_project_specific: false, + include_instance_specific: instance)) integration_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id) end - def self.find_or_initialize_all_non_project_specific(scope) - scope + build_nonexistent_integrations_for(scope) + def self.find_or_initialize_all_non_project_specific(scope, include_instance_specific: false) + scope + build_nonexistent_integrations_for(scope, include_instance_specific: include_instance_specific) end - def self.build_nonexistent_integrations_for(scope) - nonexistent_integration_types_for(scope).map do |type| + def self.build_nonexistent_integrations_for(...) + nonexistent_integration_types_for(...).map do |type| integration_type_to_model(type).new end end @@ -288,19 +298,25 @@ def self.build_nonexistent_integrations_for(scope) # Returns a list of integration types that do not exist in the given scope. # Example: ["AsanaService", ...] - def self.nonexistent_integration_types_for(scope) + def self.nonexistent_integration_types_for(scope, include_instance_specific: false) # Using #map instead of #pluck to save one query count. This is because # ActiveRecord loaded the object here, so we don't need to query again later. - available_integration_types(include_project_specific: false) - scope.map(&:type) + available_integration_types( + include_project_specific: false, + include_instance_specific: include_instance_specific + ) - scope.map(&:type) end private_class_method :nonexistent_integration_types_for # Returns a list of available integration names. # Example: ["asana", ...] - def self.available_integration_names(include_project_specific: true, include_dev: true) + def self.available_integration_names( + include_project_specific: true, include_dev: true, include_instance_specific: true + ) names = integration_names names += project_specific_integration_names if include_project_specific names += dev_integration_names if include_dev + names += instance_specific_integration_names if include_instance_specific names.sort_by(&:downcase) end @@ -309,6 +325,10 @@ def self.integration_names INTEGRATION_NAMES end + def self.instance_specific_integration_names + INSTANCE_SPECIFIC_INTEGRATION_NAMES + end + def self.dev_integration_names return [] unless Gitlab.dev_or_test_env? @@ -323,8 +343,8 @@ def self.project_specific_integration_names # Returns a list of available integration types. # Example: ["Integrations::Asana", ...] - def self.available_integration_types(include_project_specific: true, include_dev: true) - available_integration_names(include_project_specific: include_project_specific, include_dev: include_dev).map do + def self.available_integration_types(...) + available_integration_names(...).map do integration_name_to_type(_1) end end diff --git a/app/models/integrations/beyond_identity.rb b/app/models/integrations/beyond_identity.rb new file mode 100644 index 0000000000000..b170c50d0dafd --- /dev/null +++ b/app/models/integrations/beyond_identity.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Integrations + class BeyondIdentity < Integration + validates :token, presence: true, if: :activated? + + field :token, + type: :password, + title: 'API token', + help: -> { + s_('BeyondIdentityService|API Token. User must have access to `git-commit-signing` endpoint.') + }, + non_empty_password_title: -> { s_('ProjectService|Enter new API token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API token.') }, + description: -> { + s_('BeyondIdentityService|API Token. User must have access to `git-commit-signing` endpoint.') + }, + required: true + + def self.title + 'Beyond Identity' + end + + def self.description + s_('BeyondIdentity|Verify that GPG keys are authorized by Beyond Identity Authenticator.') + end + + def self.help + docs_link = ActionController::Base.helpers.link_to( + _('Learn more'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/beyond_identity'), + target: '_blank', rel: 'noopener noreferrer') + + format(_('Verify that GPG keys are authorized by Beyond Identity Authenticator. %{docs_link}').html_safe, # rubocop:disable Rails/OutputSafety -- It is fine to call html_safe here + docs_link: docs_link.html_safe) # rubocop:disable Rails/OutputSafety -- It is fine to call html_safe here + end + + def self.to_param + 'beyond_identity' + end + + def self.supported_events + %w[] + end + + def inheritable? + false + end + + def execute(params) + ::Gitlab::BeyondIdentity::Client.new(self).execute(params) + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 8ad463f151187..f541e0003cba2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -200,6 +200,7 @@ def self.integration_association_name(name) has_one :asana_integration, class_name: 'Integrations::Asana' has_one :assembla_integration, class_name: 'Integrations::Assembla' has_one :bamboo_integration, class_name: 'Integrations::Bamboo' + has_one :beyond_identity_integration, class_name: 'Integrations::BeyondIdentity' has_one :bugzilla_integration, class_name: 'Integrations::Bugzilla' has_one :buildkite_integration, class_name: 'Integrations::Buildkite' has_one :campfire_integration, class_name: 'Integrations::Campfire' @@ -1725,7 +1726,7 @@ def external_wiki def find_or_initialize_integrations Integration - .available_integration_names + .available_integration_names(include_instance_specific: false) .difference(disabled_integrations) .map { find_or_initialize_integration(_1) } .sort_by(&:title) @@ -1742,7 +1743,8 @@ def disabled_integrations end def find_or_initialize_integration(name) - return if disabled_integrations.include?(name) || Integration.available_integration_names.exclude?(name) + return if disabled_integrations.include?(name) + return if Integration.available_integration_names(include_instance_specific: false).exclude?(name) find_integration(integrations, name) || build_from_instance(name) || build_integration(name) end diff --git a/app/services/gpg_keys/create_service.rb b/app/services/gpg_keys/create_service.rb index ab8b12732d775..c061c92df3ed5 100644 --- a/app/services/gpg_keys/create_service.rb +++ b/app/services/gpg_keys/create_service.rb @@ -3,15 +3,25 @@ module GpgKeys class CreateService < Keys::BaseService def execute - key = create(params) + key = user.gpg_keys.build(params) + + return key unless validate(key) + + create(key) + notification_service.new_gpg_key(key) if key.persisted? key end private - def create(params) - user.gpg_keys.create(params) + def validate(key) + GpgKeys::ValidateIntegrationsService.new(key).execute + end + + def create(key) + key.save + key end end end diff --git a/app/services/gpg_keys/validate_integrations_service.rb b/app/services/gpg_keys/validate_integrations_service.rb new file mode 100644 index 0000000000000..f593eb6925aba --- /dev/null +++ b/app/services/gpg_keys/validate_integrations_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module GpgKeys + class ValidateIntegrationsService < Keys::BaseService + ValidationError = Class.new(StandardError) + + def initialize(key) + @key = key + end + + def execute + return false unless key.valid? + + validate_beyond_identity! + + key.errors.empty? + end + + private + + attr_reader :key + + def validate_beyond_identity! + integration = Integrations::BeyondIdentity.for_instance.first + + return unless integration&.activated? + + integration.execute({ key_id: key.primary_keyid, committer_email: key.user.email }) + rescue ::Gitlab::BeyondIdentity::Client::Error => e + key.errors.add(:base, "BeyondIdentity: #{e.message}") + end + end +end diff --git a/config/metrics/counts_all/20240117153239_instances_beyond_identity_active.yml b/config/metrics/counts_all/20240117153239_instances_beyond_identity_active.yml new file mode 100644 index 0000000000000..e1040d2a0cbca --- /dev/null +++ b/config/metrics/counts_all/20240117153239_instances_beyond_identity_active.yml @@ -0,0 +1,21 @@ +--- +key_path: counts.instances_beyond_identity_active +description: Count of projects with active integrations for BeyondIdentity +product_section: dev +product_stage: create +product_group: source_code +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136754 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20240117153516_groups_inheriting_beyond_identity_active.yml b/config/metrics/counts_all/20240117153516_groups_inheriting_beyond_identity_active.yml new file mode 100644 index 0000000000000..5d68a2ed46666 --- /dev/null +++ b/config/metrics/counts_all/20240117153516_groups_inheriting_beyond_identity_active.yml @@ -0,0 +1,21 @@ +--- +key_path: counts.groups_inheriting_beyond_identity_active +description: Count of projects with active integrations for BeyondIdentity +product_section: dev +product_stage: create +product_group: source_code +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136754 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20240117153519_groups_beyond_identity_active.yml b/config/metrics/counts_all/20240117153519_groups_beyond_identity_active.yml new file mode 100644 index 0000000000000..ca4db5076977e --- /dev/null +++ b/config/metrics/counts_all/20240117153519_groups_beyond_identity_active.yml @@ -0,0 +1,21 @@ +--- +key_path: counts.groups_beyond_identity_active +description: Count of projects with active integrations for BeyondIdentity +product_section: dev +product_stage: create +product_group: source_code +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136754 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20240117153523_projects_inheriting_beyond_identity_active.yml b/config/metrics/counts_all/20240117153523_projects_inheriting_beyond_identity_active.yml new file mode 100644 index 0000000000000..0dffad590e40c --- /dev/null +++ b/config/metrics/counts_all/20240117153523_projects_inheriting_beyond_identity_active.yml @@ -0,0 +1,21 @@ +--- +key_path: counts.projects_inheriting_beyond_identity_active +description: Count of projects with active integrations for BeyondIdentity +product_section: dev +product_stage: create +product_group: source_code +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136754 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20240117153527_projects_beyond_identity_active.yml b/config/metrics/counts_all/20240117153527_projects_beyond_identity_active.yml new file mode 100644 index 0000000000000..260213e30705d --- /dev/null +++ b/config/metrics/counts_all/20240117153527_projects_beyond_identity_active.yml @@ -0,0 +1,21 @@ +--- +key_path: counts.projects_beyond_identity_active +description: Count of projects with active integrations for BeyondIdentity +product_section: dev +product_stage: create +product_group: source_code +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136754 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/db/docs/integrations.yml b/db/docs/integrations.yml index 037dea949d0ac..415f0593cb110 100644 --- a/db/docs/integrations.yml +++ b/db/docs/integrations.yml @@ -13,6 +13,7 @@ classes: - Integrations::BaseSlackNotification - Integrations::BaseSlashCommands - Integrations::BaseThirdPartyWiki +- Integrations::BeyondIdentity - Integrations::Bugzilla - Integrations::Buildkite - Integrations::Campfire diff --git a/doc/user/project/integrations/beyond_identity.md b/doc/user/project/integrations/beyond_identity.md new file mode 100644 index 0000000000000..9f955ea38111f --- /dev/null +++ b/doc/user/project/integrations/beyond_identity.md @@ -0,0 +1,35 @@ +--- +stage: Create +group: Source Code +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Beyond Identity **(FREE ALL)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/431433) in GitLab 16.9. + +Configure GitLab to verify GPG keys issued by [Beyond Identity](https://www.beyondidentity.com/) +added to a user profile. + +## Set up the Beyond Identity integration for your instance + +Prerequisites: + +- You must have administrator access to the GitLab instance. +- The email address used in the GitLab profile must be the same as the email assigned to the key in the Beyond Identity Authenticator. +- You must have a Beyond Identity API token. You can request it from their Sales Engineer. + +To enable the Beyond Identity integration for your instance: + +1. Sign in to GitLab as an administrator. +1. On the left sidebar, at the bottom, select **Admin Area**. +1. Select **Settings > Integrations**. +1. Select **Beyond Identity**. +1. Under **Enable integration**, select the **Active** checkbox. +1. In **API token**, paste the API token you received from Beyond Identity. +1. Select **Save changes**. + +The Beyond Identity integration for your instance is now enabled. +When a user adds a GPG key to their profile, the key is verified. +If the key wasn't issued by the Beyond Identity Authenticator or the email used in their GitLab +profile is different from the email assigned to the key in the Beyond Identity Authenticator, it's rejected. diff --git a/lib/gitlab/beyond_identity/client.rb b/lib/gitlab/beyond_identity/client.rb new file mode 100644 index 0000000000000..c9e47eb830587 --- /dev/null +++ b/lib/gitlab/beyond_identity/client.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module BeyondIdentity + class Client + API_URL = "https://api.byndid.com/key-mgmt/v0/gpg/key/authorization/git-commit-signing" + + Error = Class.new(StandardError) + + attr_reader :integration + + def initialize(integration) + raise Error, 'integration is not activated' unless integration.activated? + + @integration = integration + end + + def execute(params) + options = { headers: headers, query: params } + response = Gitlab::HTTP.get(API_URL, options) + body = Gitlab::Json.parse(response.body) || {} + + raise Error, body.dig('error', 'message') unless response.success? + raise Error, "authorization denied: #{body['message']}" unless body['authorized'] + + body + rescue JSON::ParserError + raise Error, 'invalid response format' + end + + private + + def headers + { + 'Content-Type': 'application/json', + Authorization: "Bearer #{integration.token}" + } + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a47526e038e96..83bcc2ed86585 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7745,6 +7745,12 @@ msgstr "" msgid "BetaBadge|What's Beta?" msgstr "" +msgid "BeyondIdentityService|API Token. User must have access to `git-commit-signing` endpoint." +msgstr "" + +msgid "BeyondIdentity|Verify that GPG keys are authorized by Beyond Identity Authenticator." +msgstr "" + msgid "Bi-weekly code coverage" msgstr "" @@ -38225,6 +38231,9 @@ msgstr "" msgid "ProjectService|Enter new API key" msgstr "" +msgid "ProjectService|Enter new API token" +msgstr "" + msgid "ProjectService|Enter new password" msgstr "" @@ -38243,6 +38252,9 @@ msgstr "" msgid "ProjectService|Leave blank to use your current API key." msgstr "" +msgid "ProjectService|Leave blank to use your current API token." +msgstr "" + msgid "ProjectService|Leave blank to use your current password" msgstr "" @@ -53958,6 +53970,9 @@ msgstr "" msgid "Verify code" msgstr "" +msgid "Verify that GPG keys are authorized by Beyond Identity Authenticator. %{docs_link}" +msgstr "" + msgid "Version" msgstr "" diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb index e21010b76f73b..73a9820bdaf28 100644 --- a/spec/controllers/groups/settings/integrations_controller_spec.rb +++ b/spec/controllers/groups/settings/integrations_controller_spec.rb @@ -55,7 +55,9 @@ get :edit, params: { group_id: group, - id: Integration.available_integration_names(include_project_specific: false).sample + id: Integration.available_integration_names( + include_project_specific: false, include_instance_specific: false + ).sample } expect(response).to have_gitlab_http_status(:not_found) @@ -67,7 +69,9 @@ group.add_owner(user) end - Integration.available_integration_names(include_project_specific: false).each do |integration_name| + Integration.available_integration_names( + include_project_specific: false, include_instance_specific: false + ).each do |integration_name| context integration_name do it 'successfully displays the template' do get :edit, params: { group_id: group, id: integration_name } diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 1d698e1b4d855..7c52907cefd5d 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -292,6 +292,13 @@ active { true } end + factory :beyond_identity_integration, class: 'Integrations::BeyondIdentity' do + type { 'Integrations::BeyondIdentity' } + active { true } + instance { true } + token { 'api-token' } + end + factory :assembla_integration, class: 'Integrations::Assembla' do project token { 'secrettoken' } diff --git a/spec/features/admin/integrations/instance_integrations_spec.rb b/spec/features/admin/integrations/instance_integrations_spec.rb index d963aa700ebfc..5f2ce1d041184 100644 --- a/spec/features/admin/integrations/instance_integrations_spec.rb +++ b/spec/features/admin/integrations/instance_integrations_spec.rb @@ -10,7 +10,11 @@ end it_behaves_like 'integration settings form' do - let(:integrations) { Integration.find_or_initialize_all_non_project_specific(Integration.for_instance) } + let(:integrations) do + Integration.find_or_initialize_all_non_project_specific( + Integration.for_instance, include_instance_specific: true + ) + end def navigate_to_integration(integration) visit_instance_integration(integration.title) diff --git a/spec/lib/gitlab/beyond_identity/client_spec.rb b/spec/lib/gitlab/beyond_identity/client_spec.rb new file mode 100644 index 0000000000000..250db1bbb238a --- /dev/null +++ b/spec/lib/gitlab/beyond_identity/client_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::BeyondIdentity::Client, feature_category: :source_code_management do + let_it_be_with_reload(:integration) { create(:beyond_identity_integration) } + + let(:stubbed_response) do + { 'authorized' => true }.to_json + end + + let(:params) { { key_id: 'key-id', committer_email: 'email@example.com' } } + let(:status) { 200 } + + let!(:request) do + stub_request(:get, ::Gitlab::BeyondIdentity::Client::API_URL).with( + query: params, + headers: { 'Content-Type' => 'application/json', Authorization: "Bearer #{integration.token}" } + ).to_return( + status: status, + body: stubbed_response + ) + end + + subject(:client) { described_class.new(integration) } + + context 'when integration is not activated' do + it 'raises a config error' do + integration.active = false + + expect do + client.execute(params) + end.to raise_error(::Gitlab::BeyondIdentity::Client::Error).with_message( + 'integration is not activated' + ) + + expect(request).not_to have_been_requested + end + end + + it 'executes successfully' do + expect(client.execute(params)).to eq({ 'authorized' => true }) + expect(request).to have_been_requested + end + + context 'with invalid response' do + let(:stubbed_response) { 'invalid' } + + it 'executes successfully' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('invalid response format') + end + end + + context 'with an error response' do + let(:stubbed_response) do + { 'error' => { 'message' => 'gpg_key is invalid' } }.to_json + end + + let(:status) { 400 } + + it 'returns an error' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('gpg_key is invalid') + end + end + + context 'when key is unauthorized' do + let(:stubbed_response) do + { 'unauthorized' => false, 'message' => 'key is unauthorized' }.to_json + end + + it 'returns an error' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('authorization denied: key is unauthorized') + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 6d5c17176dcc8..2d0112a046871 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -585,6 +585,7 @@ project: - prometheus_integration - assembla_integration - asana_integration +- beyond_identity_integration - slack_integration - microsoft_teams_integration - mattermost_integration diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index 3a96de4efe275..8e7729b1468e5 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -284,14 +284,29 @@ describe '.find_or_initialize_all_non_project_specific' do shared_examples 'integration instances' do - it 'returns the available integration instances' do - expect(described_class.find_or_initialize_all_non_project_specific(described_class.for_instance).map(&:to_param)) - .to match_array(described_class.available_integration_names(include_project_specific: false)) - end + [false, true].each do |include_instance_specific| + context "with include_instance_specific value equal to #{include_instance_specific}" do + it 'returns the available integration instances' do + integrations = described_class.find_or_initialize_all_non_project_specific( + described_class.for_instance, include_instance_specific: include_instance_specific + ).map(&:to_param) + + expect(integrations).to match_array( + described_class.available_integration_names( + include_project_specific: false, + include_instance_specific: include_instance_specific) + ) + end - it 'does not create integration instances' do - expect { described_class.find_or_initialize_all_non_project_specific(described_class.for_instance) } - .not_to change(described_class, :count) + it 'does not create integration instances' do + expect do + described_class.find_or_initialize_all_non_project_specific( + described_class.for_instance, + include_instance_specific: include_instance_specific + ) + end.not_to change(described_class, :count) + end + end end end @@ -990,6 +1005,7 @@ def fields allow(described_class).to receive(:integration_names).and_return(%w[foo]) allow(described_class).to receive(:project_specific_integration_names).and_return(['bar']) allow(described_class).to receive(:dev_integration_names).and_return(['baz']) + allow(described_class).to receive(:instance_specific_integration_names).and_return(['instance-specific']) end it { is_expected.to include('foo', 'bar', 'baz') } @@ -997,16 +1013,23 @@ def fields context 'when `include_project_specific` is false' do subject { described_class.available_integration_names(include_project_specific: false) } - it { is_expected.to include('foo', 'baz') } + it { is_expected.to include('foo', 'baz', 'instance-specific') } it { is_expected.not_to include('bar') } end context 'when `include_dev` is false' do subject { described_class.available_integration_names(include_dev: false) } - it { is_expected.to include('foo', 'bar') } + it { is_expected.to include('foo', 'bar', 'instance-specific') } it { is_expected.not_to include('baz') } end + + context 'when `include_instance_specific` is false' do + subject { described_class.available_integration_names(include_instance_specific: false) } + + it { is_expected.to include('foo', 'baz', 'bar') } + it { is_expected.not_to include('instance-specific') } + end end describe '.project_specific_integration_names' do diff --git a/spec/models/integrations/beyond_identity_spec.rb b/spec/models/integrations/beyond_identity_spec.rb new file mode 100644 index 0000000000000..29d428b5a03cf --- /dev/null +++ b/spec/models/integrations/beyond_identity_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::BeyondIdentity, feature_category: :integrations do + subject(:integration) { create(:beyond_identity_integration) } + + describe 'validations' do + context 'when inactive' do + before do + integration.active = false + end + + it { is_expected.not_to validate_presence_of(:token) } + end + + context 'when active' do + it { is_expected.to validate_presence_of(:token) } + end + end + + describe 'attributes' do + it 'configures attributes' do + is_expected.not_to be_inheritable + expect(integration.supported_events).to be_blank + expect(integration.to_param).to eq('beyond_identity') + expect(integration.title).to eq('Beyond Identity') + + expect(integration.description).to eq( + 'Verify that GPG keys are authorized by Beyond Identity Authenticator.' + ) + + expect(integration.help).to include( + 'Verify that GPG keys are authorized by Beyond Identity Authenticator.' + ) + end + end + + describe '.api_fields' do + it 'returns api fields' do + expect(described_class.api_fields).to eq([{ + required: true, + name: :token, + type: String, + desc: 'API Token. User must have access to `git-commit-signing` endpoint.' + }]) + end + end + + describe '#execute' do + it 'performs a request to beyond identity service' do + params = { key_id: 'key-id', committer_email: 'email' } + response = 'response' + + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with(params).and_return(response) + end + + expect(integration.execute(params)).to eq(response) + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 1743c9bd89d27..07ec10ab517fd 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -89,6 +89,7 @@ it { is_expected.to have_one(:external_wiki_integration) } it { is_expected.to have_one(:confluence_integration) } it { is_expected.to have_one(:gitlab_slack_application_integration) } + it { is_expected.to have_one(:beyond_identity_integration) } it { is_expected.to have_one(:project_feature) } it { is_expected.to have_one(:project_repository) } it { is_expected.to have_one(:container_expiration_policy) } @@ -6619,6 +6620,14 @@ def has_external_wiki ] end end + + context 'with instance specific integration' do + it 'does not contain instance specific integrations' do + expect(subject.find_or_initialize_integrations).not_to include( + have_attributes(title: 'Beyond Identity') + ) + end + end end describe '#disabled_integrations' do @@ -6695,6 +6704,12 @@ def has_external_wiki expect(subject.find_or_initialize_integration('prometheus').api_url).to be_nil end end + + context 'with instance specific integrations' do + it 'does not create an instance specific integration' do + expect(subject.find_or_initialize_integration('beyond_identity')).to be_nil + end + end end describe '.for_group' do diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb index 4696be0704516..36667022fc562 100644 --- a/spec/requests/api/integrations_spec.rb +++ b/spec/requests/api/integrations_spec.rb @@ -52,7 +52,7 @@ Integrations::Zentao.to_param ] - names = Integration.available_integration_names + names = Integration.available_integration_names(include_instance_specific: false) names.reject { |name| name.in?(unavailable_integration_names) } end diff --git a/spec/services/gpg_keys/create_service_spec.rb b/spec/services/gpg_keys/create_service_spec.rb index d603ce951ecd1..30fe52f732b10 100644 --- a/spec/services/gpg_keys/create_service_spec.rb +++ b/spec/services/gpg_keys/create_service_spec.rb @@ -30,4 +30,18 @@ expect(gpg_key.subkeys.count).to eq(2) end end + + context 'invalid key' do + let(:params) { {} } + + it 'returns an invalid key' do + expect_next_instance_of(GpgKeys::ValidateIntegrationsService) do |instance| + expect(instance).to receive(:execute) + end + + gpg_key = subject.execute + + expect(gpg_key).not_to be_persisted + end + end end diff --git a/spec/services/gpg_keys/validate_integrations_service_spec.rb b/spec/services/gpg_keys/validate_integrations_service_spec.rb new file mode 100644 index 0000000000000..41ca95f1b6920 --- /dev/null +++ b/spec/services/gpg_keys/validate_integrations_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GpgKeys::ValidateIntegrationsService, feature_category: :source_code_management do + let_it_be(:user) { create(:user) } + + let(:gpg_key) { build(:gpg_key, user: user) } + + subject(:service) { described_class.new(gpg_key) } + + it 'returns true' do + expect(service.execute).to eq(true) + end + + context 'when key is invalid' do + it 'returns false' do + gpg_key.key = '' + + expect(service.execute).to eq(false) + end + end + + context 'when BeyondIdentity integration is not activated' do + let_it_be(:integration) { create(:beyond_identity_integration, active: false) } + + it 'return false' do + expect(::Gitlab::BeyondIdentity::Client).not_to receive(:new) + + expect(service.execute).to eq(true) + end + end + + context 'when BeyondIdentity integration is activated' do + let_it_be(:integration) { create(:beyond_identity_integration) } + + it 'returns true on successful check' do + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with( + { key_id: 'CCFBE19F00AC8B1D', committer_email: user.email } + ) + end + + expect(service.execute).to eq(true) + end + + it 'returns false and sets an error on unsuccessful check' do + error = 'service error' + + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with( + { key_id: 'CCFBE19F00AC8B1D', committer_email: user.email } + ).and_raise(::Gitlab::BeyondIdentity::Client::Error.new(error)) + end + + expect(service.execute).to eq(false) + expect(gpg_key.errors.full_messages).to eq(['BeyondIdentity: service error']) + end + end +end -- GitLab