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