From 50d8b24f95c3b0d669b94dd4a66c716e605fd0ee Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 3 Dec 2024 10:11:38 -0600 Subject: [PATCH 01/14] Feat(Q): Add AmazonQ db and config --- ...41120210519_add_amazon_q_to_ai_settings.rb | 38 +++++++++++++++ ...0241121140110_migrate_amazon_q_settings.rb | 47 +++++++++++++++++++ db/schema_migrations/20241120210519 | 1 + db/schema_migrations/20241121140110 | 1 + db/structure.sql | 15 ++++++ ee/app/models/ai/setting.rb | 13 +++++ .../models/gitlab_subscriptions/features.rb | 7 +-- .../wip/amazon_q_integration.yml | 9 ++++ ee/spec/models/ai/setting_spec.rb | 18 +++++++ 9 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20241120210519_add_amazon_q_to_ai_settings.rb create mode 100644 db/migrate/20241121140110_migrate_amazon_q_settings.rb create mode 100644 db/schema_migrations/20241120210519 create mode 100644 db/schema_migrations/20241121140110 create mode 100644 ee/config/feature_flags/wip/amazon_q_integration.yml diff --git a/db/migrate/20241120210519_add_amazon_q_to_ai_settings.rb b/db/migrate/20241120210519_add_amazon_q_to_ai_settings.rb new file mode 100644 index 00000000000000..1b78acf77b1814 --- /dev/null +++ b/db/migrate/20241120210519_add_amazon_q_to_ai_settings.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AddAmazonQToAiSettings < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + + milestone '17.7' + + def up + with_lock_retries do + add_column :ai_settings, :amazon_q_oauth_application_id, :bigint + add_column :ai_settings, :amazon_q_service_account_user_id, :bigint + add_column :ai_settings, :amazon_q_ready, :boolean, default: false, null: false + add_column :ai_settings, :amazon_q_role_arn, :text + end + + add_concurrent_index :ai_settings, :amazon_q_oauth_application_id + add_concurrent_foreign_key :ai_settings, + :oauth_applications, + column: :amazon_q_oauth_application_id, + on_delete: :nullify + add_concurrent_index :ai_settings, :amazon_q_service_account_user_id + add_concurrent_foreign_key :ai_settings, + :users, + column: :amazon_q_service_account_user_id, + on_delete: :nullify + + add_text_limit :ai_settings, :amazon_q_role_arn, 2048 + end + + def down + with_lock_retries do + remove_column :ai_settings, :amazon_q_oauth_application_id, if_exists: true + remove_column :ai_settings, :amazon_q_service_account_user_id, if_exists: true + remove_column :ai_settings, :amazon_q_ready, if_exists: true + remove_column :ai_settings, :amazon_q_role_arn, if_exists: true + end + end +end diff --git a/db/migrate/20241121140110_migrate_amazon_q_settings.rb b/db/migrate/20241121140110_migrate_amazon_q_settings.rb new file mode 100644 index 00000000000000..8ac4a29727ef9a --- /dev/null +++ b/db/migrate/20241121140110_migrate_amazon_q_settings.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# TODO(Q): We can remove this when we merge upstream as this column would never have been created? +class MigrateAmazonQSettings < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + + milestone '17.7' + + def up + Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do + execute <<~SQL + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = 'application_settings'::regclass + AND attname = 'duo' + AND NOT attisdropped + ) THEN + UPDATE ai_settings + SET + amazon_q_oauth_application_id = (SELECT duo->>'duo_amazon_q_oauth_application_id' FROM application_settings)::bigint, + amazon_q_service_account_user_id = (SELECT duo->>'duo_amazon_q_service_account_user_id' FROM application_settings)::bigint, + amazon_q_ready = (SELECT duo->>'duo_amazon_q_ready' FROM application_settings)::boolean, + amazon_q_role_arn = (SELECT duo->>'duo_amazon_q_role_arn' FROM application_settings); + END IF; + END $$; + SQL + rescue # rubocop:disable Style/RescueStandardError -- This will fail outside of the main database as the ai_settings table will be read-only + end + + with_lock_retries { remove_column :application_settings, :duo, if_exists: true } # rubocop:disable Migration/RemoveColumn -- This is only temporary + end + + def down + with_lock_retries do + add_column :application_settings, :duo, :jsonb, default: {}, null: true + end + + add_check_constraint( + :application_settings, + "(jsonb_typeof(duo) = 'object')", + 'check_application_settings_duo_is_hash' + ) + end +end diff --git a/db/schema_migrations/20241120210519 b/db/schema_migrations/20241120210519 new file mode 100644 index 00000000000000..903304ef0e0e43 --- /dev/null +++ b/db/schema_migrations/20241120210519 @@ -0,0 +1 @@ +96e7602127a393034b48697a892e89830fc8fae140f9ace566e14f64ee603384 \ No newline at end of file diff --git a/db/schema_migrations/20241121140110 b/db/schema_migrations/20241121140110 new file mode 100644 index 00000000000000..3138effc15b18e --- /dev/null +++ b/db/schema_migrations/20241121140110 @@ -0,0 +1 @@ +40785e1d6af5d751371315c3173380e3f5e00982ceaee75a7d523116491fc961 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 97d2ac440c2b43..a462b0b104f815 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -6039,7 +6039,12 @@ CREATE TABLE ai_settings ( singleton boolean DEFAULT true NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + amazon_q_oauth_application_id bigint, + amazon_q_service_account_user_id bigint, + amazon_q_ready boolean DEFAULT false NOT NULL, + amazon_q_role_arn text, CONSTRAINT check_3cf9826589 CHECK ((char_length(ai_gateway_url) <= 2048)), + CONSTRAINT check_a02bd8868c CHECK ((char_length(amazon_q_role_arn) <= 2048)), CONSTRAINT check_singleton CHECK ((singleton IS TRUE)) ); @@ -29048,6 +29053,10 @@ CREATE UNIQUE INDEX index_ai_feature_settings_on_feature ON ai_feature_settings CREATE UNIQUE INDEX index_ai_self_hosted_models_on_name ON ai_self_hosted_models USING btree (name); +CREATE INDEX index_ai_settings_on_amazon_q_oauth_application_id ON ai_settings USING btree (amazon_q_oauth_application_id); + +CREATE INDEX index_ai_settings_on_amazon_q_service_account_user_id ON ai_settings USING btree (amazon_q_service_account_user_id); + CREATE UNIQUE INDEX index_ai_settings_on_singleton ON ai_settings USING btree (singleton); CREATE INDEX index_ai_vectorizable_files_on_project_id ON ai_vectorizable_files USING btree (project_id); @@ -35710,6 +35719,9 @@ ALTER TABLE ONLY analytics_dashboards_pointers ALTER TABLE ONLY issues ADD CONSTRAINT fk_05f1e72feb FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE ONLY ai_settings + ADD CONSTRAINT fk_05f695565a FOREIGN KEY (amazon_q_oauth_application_id) REFERENCES oauth_applications(id) ON DELETE SET NULL; + ALTER TABLE ONLY merge_requests ADD CONSTRAINT fk_06067f5644 FOREIGN KEY (latest_merge_request_diff_id) REFERENCES merge_request_diffs(id) ON DELETE SET NULL; @@ -37024,6 +37036,9 @@ ALTER TABLE ONLY external_status_checks_protected_branches ALTER TABLE ONLY dast_profiles_pipelines ADD CONSTRAINT fk_cc206a8c13 FOREIGN KEY (dast_profile_id) REFERENCES dast_profiles(id) ON DELETE CASCADE; +ALTER TABLE ONLY ai_settings + ADD CONSTRAINT fk_cce81e0b9a FOREIGN KEY (amazon_q_service_account_user_id) REFERENCES users(id) ON DELETE SET NULL; + ALTER TABLE ONLY todos ADD CONSTRAINT fk_ccf0373936 FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/ee/app/models/ai/setting.rb b/ee/app/models/ai/setting.rb index a9b8b449bddb38..277962dae7b20b 100644 --- a/ee/app/models/ai/setting.rb +++ b/ee/app/models/ai/setting.rb @@ -5,9 +5,22 @@ class Setting < ApplicationRecord self.table_name = "ai_settings" validates :ai_gateway_url, length: { maximum: 2048 }, allow_nil: true + validates :amazon_q_role_arn, length: { maximum: 2048 }, allow_nil: true + validate :validate_ai_gateway_url validate :validates_singleton + has_one :amazon_q_oauth_application, # rubocop:disable Rails/InverseOf -- This model is defined oustide of the project + class_name: 'Doorkeeper::Application', + foreign_key: 'id', + primary_key: 'amazon_q_oauth_application_id', + dependent: :nullify + has_one :amazon_q_service_account, # rubocop:disable Rails/InverseOf -- I don't think we need this? + class_name: 'User', + foreign_key: 'id', + primary_key: 'amazon_q_service_account_user_id', + dependent: :nullify + def self.instance first || create!(defaults) rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index 5d5dccccaab7a5..f06f350ff24258 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -185,15 +185,16 @@ class Features ].freeze ULTIMATE_FEATURES = %i[ + ai_agents ai_config_chat ai_features - ai_workflows - glab_ask_git_command - ai_agents ai_review_mr + ai_workflows + amazon_q api_discovery api_fuzzing auto_rollback + glab_ask_git_command cluster_receptive_agents cluster_image_scanning external_status_checks diff --git a/ee/config/feature_flags/wip/amazon_q_integration.yml b/ee/config/feature_flags/wip/amazon_q_integration.yml new file mode 100644 index 00000000000000..7125078b38cb37 --- /dev/null +++ b/ee/config/feature_flags/wip/amazon_q_integration.yml @@ -0,0 +1,9 @@ +--- +name: amazon_q_integration +feature_issue_url: +introduced_by_url: +rollout_issue_url: +milestone: '17.5' +group: group::ai framework +type: wip +default_enabled: false diff --git a/ee/spec/models/ai/setting_spec.rb b/ee/spec/models/ai/setting_spec.rb index bf449c35cae9ab..26780c8487043d 100644 --- a/ee/spec/models/ai/setting_spec.rb +++ b/ee/spec/models/ai/setting_spec.rb @@ -3,6 +3,22 @@ require 'spec_helper' RSpec.describe Ai::Setting, feature_category: :ai_abstraction_layer do + describe 'associations' do + it 'has expected associations' do + is_expected.to have_one(:amazon_q_oauth_application) + .class_name('Doorkeeper::Application') + .with_foreign_key('id') + .with_primary_key('amazon_q_oauth_application_id') + .dependent(:nullify) + + is_expected.to have_one(:amazon_q_service_account) + .class_name('User') + .with_foreign_key('id') + .with_primary_key('amazon_q_service_account_user_id') + .dependent(:nullify) + end + end + describe 'validations' do subject(:setting) { described_class.instance } @@ -93,5 +109,7 @@ expect(described_class.count).to eq(1) end end + + it { is_expected.to validate_length_of(:amazon_q_role_arn).is_at_most(2048).allow_nil } end end -- GitLab From 15708a69c0718cfd276725b2931dc5dbce59f30d Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 3 Dec 2024 10:23:58 -0600 Subject: [PATCH 02/14] Feat(Q): Initialize AmazonQ onboarding admin pages --- .../application_settings/general.html.haml | 1 + .../admin/ai/amazon_q_settings_controller.rb | 39 +++++++++++++++++++ .../ee/projects/application_controller.rb | 6 +++ ee/app/policies/ee/global_policy.rb | 8 ++++ .../ai/amazon_q_settings/index.html.haml | 14 +++++++ .../application_settings/_amazon_q.html.haml | 12 ++++++ .../seat_utilization/index.html.haml | 11 ++++++ ee/config/routes/admin.rb | 5 +++ ee/lib/ai/amazon_q.rb | 32 +++++++++++++++ ee/spec/policies/global_policy_spec.rb | 29 ++++++++++++++ 10 files changed, 157 insertions(+) create mode 100644 ee/app/controllers/admin/ai/amazon_q_settings_controller.rb create mode 100644 ee/app/views/admin/ai/amazon_q_settings/index.html.haml create mode 100644 ee/app/views/admin/application_settings/_amazon_q.html.haml create mode 100644 ee/lib/ai/amazon_q.rb diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index ab212e22694d91..bf08a03e84d298 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -109,3 +109,4 @@ = render 'admin/application_settings/slack' = render 'admin/application_settings/security_txt', expanded: expanded_by_default? = render_if_exists 'admin/application_settings/analytics' += render_if_exists 'admin/application_settings/amazon_q' diff --git a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb new file mode 100644 index 00000000000000..16125ca00fbb74 --- /dev/null +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Admin + module Ai + # NOTE: This module is under development and will grow in future MR's. + # See . + class AmazonQSettingsController < Admin::ApplicationController + before_action :authorize_admin_amazon_q! + before_action :expire_current_settings + feature_category :duo_workflow + + def index + setup_view_model + end + + private + + def setup_view_model + @view_model = { + submitUrl: admin_ai_amazon_q_settings_path, + disconnectUrl: disconnect_admin_ai_amazon_q_settings_path, + amazonQSettings: { + ready: ::Ai::Setting.instance.amazon_q_ready, + roleArn: ::Ai::Setting.instance.amazon_q_role_arn, + availability: Gitlab::CurrentSettings.duo_availability + } + } + end + + def authorize_admin_amazon_q! + access_denied!("Amazon Q is not enabled") unless can?(current_user, :admin_amazon_q) + end + + def expire_current_settings + Gitlab::CurrentSettings.expire_current_application_settings + end + end + end +end diff --git a/ee/app/controllers/ee/projects/application_controller.rb b/ee/app/controllers/ee/projects/application_controller.rb index c72edad9118d33..66d77ca3a09846 100644 --- a/ee/app/controllers/ee/projects/application_controller.rb +++ b/ee/app/controllers/ee/projects/application_controller.rb @@ -6,6 +6,12 @@ module ApplicationController extend ActiveSupport::Concern extend ::Gitlab::Utils::Override + prepended do + before_action do + push_frontend_feature_flag(:amazon_q_integration, current_user) + end + end + private override :auth_proc diff --git a/ee/app/policies/ee/global_policy.rb b/ee/app/policies/ee/global_policy.rb index dc2612ac936c50..22e9c52792e80b 100644 --- a/ee/app/policies/ee/global_policy.rb +++ b/ee/app/policies/ee/global_policy.rb @@ -141,6 +141,10 @@ module GlobalPolicy License.feature_available?(:remote_development) end + condition(:amazon_q_feature_available) do + ::Ai::AmazonQ.feature_available? + end + rule { ~anonymous & remote_development_feature_licensed }.policy do enable :access_workspaces_feature end @@ -219,6 +223,10 @@ module GlobalPolicy rule { security_policy_bot }.policy do enable :access_git end + + rule { admin & amazon_q_feature_available }.policy do + enable :admin_amazon_q + end end def duo_chat_free_access_was_cut_off? diff --git a/ee/app/views/admin/ai/amazon_q_settings/index.html.haml b/ee/app/views/admin/ai/amazon_q_settings/index.html.haml new file mode 100644 index 00000000000000..d8e665d3d6e3cb --- /dev/null +++ b/ee/app/views/admin/ai/amazon_q_settings/index.html.haml @@ -0,0 +1,14 @@ +- page_title s_('AmazonQ|Amazon Q Configuration') +- add_to_breadcrumbs _("General"), general_admin_application_settings_path +- breadcrumb_title _("Amazon Q") + +%h2.gl-flex.gl-items-center.gl-gap-3 + = s_('AmazonQ|Configure GitLab Duo with Amazon Q') + = render Pajamas::BadgeComponent.new(s_('AmazonQ|Beta'), variant: :tier) +%p + -# NOTE(Q): View this docs for how to turn off help page redirecting in your local gitlab instance + -# https://docs.gitlab.com/ee/administration/settings/help_page.html#redirect-help-pages + - help_link = link_to('', help_page_path('user/duo_amazon_q/index.md'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AmazonQ|Use GitLab Duo with Amazon Q to create and review merge requests and upgrade Java.%{br}GitLab Duo with Amazon Q is separate from GitLab Duo Pro and Enterprise. %{help_link_start}Learn more%{help_link_end}.'), tag_pair(help_link, :help_link_start, :help_link_end), br: '
'.html_safe) + +#js-amazon-q-settings{ data: { view_model: @view_model.to_json } } diff --git a/ee/app/views/admin/application_settings/_amazon_q.html.haml b/ee/app/views/admin/application_settings/_amazon_q.html.haml new file mode 100644 index 00000000000000..6da51e02cb88b2 --- /dev/null +++ b/ee/app/views/admin/application_settings/_amazon_q.html.haml @@ -0,0 +1,12 @@ +- return unless ::Ai::AmazonQ.feature_available? + += render ::Layouts::SettingsBlockComponent.new(s_('AmazonQ|GitLab Duo with Amazon Q'), + id: 'js-amazon-q-settings', + expanded: expanded_by_default?) do |c| + - c.with_description do + - link = link_to('', help_page_path('user/duo_amazon_q/index.md'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AmazonQ|Use GitLab Duo with Amazon Q to create and review merge requests and upgrade Java. %{link_start}Learn more%{link_end}.'), tag_pair(link, :link_start, :link_end)) + - c.with_body do + .gl-mt-3 + = render Pajamas::ButtonComponent.new(variant: :confirm, href: admin_ai_amazon_q_settings_path) do + = s_('AmazonQ|View configuration setup') diff --git a/ee/app/views/admin/gitlab_duo/seat_utilization/index.html.haml b/ee/app/views/admin/gitlab_duo/seat_utilization/index.html.haml index 4ada61f84ccdef..fb98526d87c1ab 100644 --- a/ee/app/views/admin/gitlab_duo/seat_utilization/index.html.haml +++ b/ee/app/views/admin/gitlab_duo/seat_utilization/index.html.haml @@ -5,4 +5,15 @@ - add_to_breadcrumbs(_('GitLab Duo'), admin_gitlab_duo_path) - duo_pro_url = add_duo_pro_seats_url(@subscription_name) +-# TODO(Q): Add tests. Logic might want to be in helper to make tests easier... +- if ::Ai::AmazonQ.feature_available? && !::Ai::AmazonQ.connected? + = render Pajamas::BannerComponent.new(close_options: { class: '!gl-hidden'}, + svg_path: 'illustrations/tanuki-ai-sm.svg', + button_text: s_('AmazonQ|Get started'), + button_link: admin_ai_amazon_q_settings_path) do |c| + - c.with_title do + = s_('AmazonQ|Configure GitLab Duo with Amazon Q (Beta)') + %p + = s_('AmazonQ|Use Amazon Q to automate workflows, create a merge request from an issue, upgrade Java, and improve your code with AI-powered reviews.') + #js-code-suggestions-page{ data: { add_duo_pro_seats_url: duo_pro_url, subscription_name: @subscription_name, is_bulk_add_on_assignment_enabled: 'true', subscription_start_date: @subscription_start_date, subscription_end_date: @subscription_end_date } } diff --git a/ee/config/routes/admin.rb b/ee/config/routes/admin.rb index a7dce2bdc04c23..8f7e51842ef915 100644 --- a/ee/config/routes/admin.rb +++ b/ee/config/routes/admin.rb @@ -58,6 +58,11 @@ resources :terms_and_conditions, only: [:index, :create] end end + resources :amazon_q_settings, only: [:index, :create] do + collection do + post :disconnect + end + end end # using `only: []` to keep duplicate routes from being created diff --git a/ee/lib/ai/amazon_q.rb b/ee/lib/ai/amazon_q.rb new file mode 100644 index 00000000000000..b23e589e085581 --- /dev/null +++ b/ee/lib/ai/amazon_q.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + # NOTE: This module is under development and will grow in future MR's. + # See . + class << self + def feature_available? + # amazon_q not available on gitlab.com for now + return false if ::Gitlab.org_or_com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- This rule only makes sense if you want to selectively enable behavior. I am not going to add a .com license to disable behavior on .com + + return false unless ::Feature.enabled?(:amazon_q_integration, nil) + + return false unless License.feature_available?(:amazon_q) + + true + end + + def connected? + return false unless feature_available? + + ai_settings.amazon_q_ready + end + + private + + def ai_settings + Ai::Setting.instance + end + end + end +end diff --git a/ee/spec/policies/global_policy_spec.rb b/ee/spec/policies/global_policy_spec.rb index 5d06a6340e8046..4e123d8b7c2f6e 100644 --- a/ee/spec/policies/global_policy_spec.rb +++ b/ee/spec/policies/global_policy_spec.rb @@ -902,4 +902,33 @@ it { is_expected.to be_disallowed(:manage_ai_settings) } end end + + describe "Admin Amazon Q" do + let(:current_user) { admin } + let(:amazon_q_available) { true } + + before do + allow(::Ai::AmazonQ).to receive(:feature_available?).and_return(amazon_q_available) + end + + context 'when admin' do + context 'when amazon q is available', :enable_admin_mode do + it { is_expected.to be_allowed(:admin_amazon_q) } + end + + context 'when admin mode is disabled' do + it { is_expected.to be_disallowed(:admin_amazon_q) } + end + + context 'when amazon q is not available', :enable_admin_mode do + let(:amazon_q_available) { false } + + it { is_expected.to be_disallowed(:admin_amazon_q) } + end + end + + context 'when regular user' do + it { is_expected.to be_disallowed(:manage_ai_settings) } + end + end end -- GitLab From d1daf0094cd8334967f5095c3bd573a08b4d4503 Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 3 Dec 2024 10:35:29 -0600 Subject: [PATCH 03/14] Feat(Q): Add create/update service for AmazonQ --- app/services/service_response.rb | 8 + .../types/q_onbarding_updated.yml | 10 + doc/user/compliance/audit_event_types.md | 1 + .../admin/ai/amazon_q_settings_controller.rb | 46 ++++- ee/app/services/ai/amazon_q/base_service.rb | 59 ++++++ ee/app/services/ai/amazon_q/create_service.rb | 116 +++++++++++ ee/app/services/ai/amazon_q/update_service.rb | 27 +++ .../users/service_accounts/create_service.rb | 3 +- ee/config/cloud_connector/access_data.yml | 40 +++- ee/lib/ai/amazon_q.rb | 37 +++- .../ai/amazon_q/identity_provider_payload.rb | 70 +++++++ ee/lib/gitlab/llm/q_ai/client.rb | 74 +++++++ .../identity_provider_payload_spec.rb | 53 +++++ ee/spec/lib/ai/amazon_q_spec.rb | 190 ++++++++++++++++++ ee/spec/lib/gitlab/llm/q_ai/client_spec.rb | 41 ++++ .../ai/amazon_q_settings_controller_spec.rb | 110 ++++++++++ .../ai/amazon_q/create_service_spec.rb | 184 +++++++++++++++++ .../ai/amazon_q/update_service_spec.rb | 102 ++++++++++ lib/assets/images/bot_avatars/q_avatar.png | Bin 0 -> 16990 bytes lib/gitlab/auth.rb | 2 + 20 files changed, 1163 insertions(+), 10 deletions(-) create mode 100644 config/audit_events/types/q_onbarding_updated.yml create mode 100644 ee/app/services/ai/amazon_q/base_service.rb create mode 100644 ee/app/services/ai/amazon_q/create_service.rb create mode 100644 ee/app/services/ai/amazon_q/update_service.rb create mode 100644 ee/lib/ai/amazon_q/identity_provider_payload.rb create mode 100644 ee/lib/gitlab/llm/q_ai/client.rb create mode 100644 ee/spec/lib/ai/amazon_q/identity_provider_payload_spec.rb create mode 100644 ee/spec/lib/ai/amazon_q_spec.rb create mode 100644 ee/spec/lib/gitlab/llm/q_ai/client_spec.rb create mode 100644 ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb create mode 100644 ee/spec/services/ai/amazon_q/create_service_spec.rb create mode 100644 ee/spec/services/ai/amazon_q/update_service_spec.rb create mode 100644 lib/assets/images/bot_avatars/q_avatar.png diff --git a/app/services/service_response.rb b/app/services/service_response.rb index fa495d164684f7..3db7247accb91c 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -20,6 +20,14 @@ def self.error(message:, payload: {}, http_status: nil, reason: nil) ) end + # This is used to help wrap old service responses that were just hashes + def self.from_legacy_hash(response) + return response if response.is_a?(ServiceResponse) + return ServiceResponse.new(**response) if response.is_a?(Hash) + + raise ArgumentError, "argument must be a ServiceResponse or a Hash" + end + attr_reader :status, :message, :http_status, :payload, :reason def initialize(status:, message: nil, payload: {}, http_status: nil, reason: nil) diff --git a/config/audit_events/types/q_onbarding_updated.yml b/config/audit_events/types/q_onbarding_updated.yml new file mode 100644 index 00000000000000..3cf73a6fc73dae --- /dev/null +++ b/config/audit_events/types/q_onbarding_updated.yml @@ -0,0 +1,10 @@ +--- +name: q_onbarding_updated +description: Amazon Q instance settings changed +introduced_by_issue: https://gitlab.com/gitlab-com/ops-sub-department/aws-gitlab-ai-integration/integration-motion-planning/-/issues/211 +introduced_by_mr: https://gitlab.com/gitlab-com/ops-sub-department/aws-gitlab-ai-integration/gitlab/-/merge_requests/123 +feature_category: ai_framework +milestone: "17.7" +saved_to_database: true +streamed: true +scope: [Instance] diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 9757ad0263ad0e..18f7ef42e4901e 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -49,6 +49,7 @@ Audit event types belong to the following product categories. | Name | Description | Saved to database | Introduced in | Scope | |:------------|:------------|:------------------|:---------|:--------------|:--------------| | [`duo_features_enabled_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145509) | GitLab Duo Features enabled setting on group or project changed | **{check-circle}** Yes | GitLab [16.10](https://gitlab.com/gitlab-org/gitlab/-/issues/442485) | Group, Project | +| [`q_onbarding_updated`](https://gitlab.com/gitlab-com/ops-sub-department/aws-gitlab-ai-integration/gitlab/-/merge_requests/123) | Amazon Q instance settings changed | **{check-circle}** Yes | GitLab [17.7](https://gitlab.com/gitlab-com/ops-sub-department/aws-gitlab-ai-integration/integration-motion-planning/-/issues/211) | Instance | ### Audit events diff --git a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb index 16125ca00fbb74..4750b1d35c2677 100644 --- a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -2,8 +2,6 @@ module Admin module Ai - # NOTE: This module is under development and will grow in future MR's. - # See . class AmazonQSettingsController < Admin::ApplicationController before_action :authorize_admin_amazon_q! before_action :expire_current_settings @@ -13,12 +11,39 @@ def index setup_view_model end + def create + Gitlab::AppLogger.info(message: "Receive create for Amazon Q Settings", params: permitted_params) + + # TODO(Q): What to do with the result?? + service = if ::Ai::AmazonQ.connected? + ::Ai::AmazonQ::UpdateService + else + ::Ai::AmazonQ::CreateService + end + + response = service.new(current_user, permitted_params).execute + + setup_view_model + + message = if response.success? + { notice: s_('AmazonQ|Amazon Q Settings have been saved.') } + else + { alert: response.message || s_("AmazonQ|Something went wrong saving Amazon Q settings.") } + end + + redirect_to( + admin_ai_amazon_q_settings_path, + **message + ) + end + private def setup_view_model @view_model = { submitUrl: admin_ai_amazon_q_settings_path, disconnectUrl: disconnect_admin_ai_amazon_q_settings_path, + identityProviderPayload: identity_provider, amazonQSettings: { ready: ::Ai::Setting.instance.amazon_q_ready, roleArn: ::Ai::Setting.instance.amazon_q_role_arn, @@ -27,6 +52,23 @@ def setup_view_model } end + def identity_provider + return if ::Ai::Setting.instance.amazon_q_ready + + result = ::Ai::AmazonQ::IdentityProviderPayload.new.execute + case result + in { ok: payload } + payload + else + flash[:alert] = s_('AmazonQ|Something went wrong retrieving the identity provider payload.') + {} + end + end + + def permitted_params + params.permit(:role_arn, :availability) + end + def authorize_admin_amazon_q! access_denied!("Amazon Q is not enabled") unless can?(current_user, :admin_amazon_q) end diff --git a/ee/app/services/ai/amazon_q/base_service.rb b/ee/app/services/ai/amazon_q/base_service.rb new file mode 100644 index 00000000000000..4e02567379c791 --- /dev/null +++ b/ee/app/services/ai/amazon_q/base_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class BaseService + include Gitlab::Utils::StrongMemoize + + AVAILABILITY_OPTIONS = %w[default_on default_off never_on].freeze + + def initialize(user, params = {}) + @user = user + @params = params + end + + private + + attr_accessor :user, :params + + def availability_param_error + return ServiceResponse.error(message: 'Missing availability parameter') unless params[:availability].present? + return if AVAILABILITY_OPTIONS.include?(params[:availability]) + + ServiceResponse.error(message: "availability must be one of: #{AVAILABILITY_OPTIONS.join(', ')}") + end + strong_memoize_attr :availability_param_error + + def application_settings + ::Gitlab::CurrentSettings.current_application_settings + end + strong_memoize_attr :application_settings + + def ai_settings + Ai::Setting.instance + end + strong_memoize_attr :ai_settings + + def create_audit_event(audit_availability:, audit_ai_settings:, exclude_columns: []) + message = 'Changed ' + message += "availability to #{application_settings.duo_availability}, " if audit_availability + + if audit_ai_settings + columns = %w[amazon_q_role_arn amazon_q_service_account_user_id amazon_q_oauth_application_id amazon_q_ready] + columns -= exclude_columns + message += columns.map do |column| + "#{column} to #{ai_settings[column].presence || 'null'}, " + end.join + end + + ::Gitlab::Audit::Auditor.audit({ + name: 'q_onbarding_updated', + author: user, + scope: Gitlab::Audit::InstanceScope.new, + target: ai_settings, + message: message[0...-2] + }) + end + end + end +end diff --git a/ee/app/services/ai/amazon_q/create_service.rb b/ee/app/services/ai/amazon_q/create_service.rb new file mode 100644 index 00000000000000..bbb9a25e9a043c --- /dev/null +++ b/ee/app/services/ai/amazon_q/create_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class CreateService < BaseService + def execute + return ServiceResponse.error(message: 'Missing role_arn parameter') unless params[:role_arn].present? + return availability_param_error if availability_param_error + + # TODO(Q): error surfacing and handling + if update_settings + create_audit_event(audit_availability: true, audit_ai_settings: true) + ServiceResponse.success + else + ServiceResponse.error(message: ai_settings.errors.full_messages.to_sentence) + end + end + + private + + def update_settings + return unless ai_settings.update(amazon_q_role_arn: params[:role_arn]) + return unless application_settings.update(duo_availability: params[:availability]) + + create_amazon_q_onboarding + end + + def create_amazon_q_onboarding + # TODO(Q): there should be a retry mechanism in the UI if any of these steps fail + service_account = existing_q_service_account || create_service_account + ensure_service_account_block_status(service_account: service_account) + + return unless find_or_create_oauth_app + return unless ai_settings.update(amazon_q_oauth_application_id: @application.id) + return unless register_oauth_application_with_amazon + + ai_settings.update(amazon_q_ready: true) + end + + def create_service_account + service_account_result = ServiceResponse.from_legacy_hash( + ::Users::ServiceAccounts::CreateService.new( + @user, + { name: 'Amazon Q Service', avatar: Users::Internal.bot_avatar(image: 'q_avatar.png') } + ).execute + ) + return unless service_account_result.success? + + service_account = service_account_result.payload + return unless ai_settings.update(amazon_q_service_account_user_id: service_account.id) + + service_account + end + + def ensure_service_account_block_status(service_account: nil) + if Ai::AmazonQ.should_block_service_account?(availability: params[:availability]) + Ai::AmazonQ.ensure_service_account_blocked!(current_user: user, service_account: service_account) + else + Ai::AmazonQ.ensure_service_account_unblocked!(current_user: user, service_account: service_account) + end + end + + def existing_q_service_account + user_id = ai_settings.amazon_q_service_account_user_id + user_id && User.find_by_id(user_id) + end + + def find_or_create_oauth_app + @application = existing_q_oauth_application + return true if @application + + @application = Doorkeeper::Application.new( + name: 'Amazon Q OAuth', + redirect_uri: oauth_callback_url, + scopes: ::Gitlab::Auth::Q_SCOPES, + trusted: false, + confidential: false + ) + @application.save + end + + def register_oauth_application_with_amazon + client = ::Gitlab::Llm::QAi::Client.new(user, unit_primitive: 'agent_quick_actions') + # Currently the AI Gateway API call is idempotent; it will remove the existing + # application if it already exists. + response = client.perform_create_auth_application( + @application, + @application.secret, + params[:role_arn] + ) + return true if response.success? + + ai_settings.errors.add(:application, + "could not be created by the AI Gateway: Error #{response.code} - #{response.body}") + false + end + + def existing_q_oauth_application + oauth_app_id && oauth_application + end + + def oauth_application + Doorkeeper::Application.find_by_id(oauth_app_id) + end + + def oauth_callback_url + # This value is unused but cannot be nil + Gitlab::Routing.url_helpers.root_url + end + + def oauth_app_id + ai_settings.amazon_q_oauth_application_id + end + end + end +end diff --git a/ee/app/services/ai/amazon_q/update_service.rb b/ee/app/services/ai/amazon_q/update_service.rb new file mode 100644 index 00000000000000..7e7efb7ee7fa43 --- /dev/null +++ b/ee/app/services/ai/amazon_q/update_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class UpdateService < BaseService + def execute + return availability_param_error if availability_param_error + + # TODO(Q): error surfacing and handling + success = application_settings.update(duo_availability: params[:availability]) + return ServiceResponse.error(message: application_settings.errors.full_messages.to_sentence) unless success + + create_audit_event(audit_availability: true, audit_ai_settings: false) + result = + if Ai::AmazonQ.should_block_service_account?(availability: params[:availability]) + Ai::AmazonQ.ensure_service_account_blocked!(current_user: user) + else + Ai::AmazonQ.ensure_service_account_unblocked!(current_user: user) + end + + return result unless result.success? + + ServiceResponse.success + end + end + end +end diff --git a/ee/app/services/users/service_accounts/create_service.rb b/ee/app/services/users/service_accounts/create_service.rb index 82ae44a084ae52..2b7702243df239 100644 --- a/ee/app/services/users/service_accounts/create_service.rb +++ b/ee/app/services/users/service_accounts/create_service.rb @@ -54,7 +54,8 @@ def default_user_params user_type: :service_account, external: true, skip_confirmation: true, # Bot users should always have their emails confirmed. - organization_id: params[:organization_id] + organization_id: params[:organization_id], + avatar: params[:avatar].presence } end diff --git a/ee/config/cloud_connector/access_data.yml b/ee/config/cloud_connector/access_data.yml index 6f8a5713059062..ad849620cac02e 100644 --- a/ee/config/cloud_connector/access_data.yml +++ b/ee/config/cloud_connector/access_data.yml @@ -258,8 +258,38 @@ services: # Cloud connector features (i.e. code_suggestions, duo_chat...) unit_primitives: - explain_code - generate_commit_message - - generate_cube_query - - glab_ask_git_command - - semantic_search_issue - - summarize_issue_discussions - - summarize_merge_request + summarize_comments: + backend: 'gitlab-ai-gateway' + cut_off_date: 2024-10-17 00:00:00 UTC + bundled_with: + duo_enterprise: + unit_primitives: + - summarize_comments + observability_all: + backend: 'gitlab-observability-backend' + bundled_with: + observability: + unit_primitives: + - observability_all + sast: + backend: 'gitlab-security-gateway' + bundled_with: + _irrelevant_: # not checked when cut_off_date is null + unit_primitives: + - security_scans + duo_workflow: + backend: 'gitlab-duo-workflow-service' + bundled_with: + _irrelevant: # not checked when cut_off_date is null + unit_primitives: + - duo_workflow_execute_workflow + - duo_workflow_generate_token + agent_quick_actions: + backend: 'gitlab-ai-gateway' + bundled_with: + _irrelevant: # not checked when cut_off_date is null + unit_primitives: + - event + - amazon_q_create_oauth_app + - amazon_q_create_onboarding_grant_code + - agent_quick_actions diff --git a/ee/lib/ai/amazon_q.rb b/ee/lib/ai/amazon_q.rb index b23e589e085581..c94ec697351961 100644 --- a/ee/lib/ai/amazon_q.rb +++ b/ee/lib/ai/amazon_q.rb @@ -2,8 +2,6 @@ module Ai module AmazonQ - # NOTE: This module is under development and will grow in future MR's. - # See . class << self def feature_available? # amazon_q not available on gitlab.com for now @@ -22,6 +20,41 @@ def connected? ai_settings.amazon_q_ready end + def enabled?(user:, namespace:) + return false unless feature_available? && connected? + return false if ::Gitlab::CurrentSettings.duo_availability == :never_on + + user.can?(:access_duo_features, namespace) + end + + def should_block_service_account?(availability:) + availability == "never_on" + end + + def ensure_service_account_blocked!(current_user:, service_account: nil) + service_account ||= User.find_by_id(ai_settings.amazon_q_service_account_user_id) + + return ServiceResponse.success(message: "Service account not found. Nothing to do.") unless service_account + + if service_account.blocked? + ServiceResponse.success(message: "Service account already blocked. Nothing to do.") + else + ServiceResponse.from_legacy_hash(::Users::BlockService.new(current_user).execute(service_account)) + end + end + + def ensure_service_account_unblocked!(current_user:, service_account: nil) + service_account ||= User.find_by_id(ai_settings.amazon_q_service_account_user_id) + + return ServiceResponse.error(message: "Service account not found.") unless service_account + + if service_account.blocked? + ServiceResponse.from_legacy_hash(::Users::UnblockService.new(current_user).execute(service_account)) + else + ServiceResponse.success(message: "Service account already unblocked. Nothing to do.") + end + end + private def ai_settings diff --git a/ee/lib/ai/amazon_q/identity_provider_payload.rb b/ee/lib/ai/amazon_q/identity_provider_payload.rb new file mode 100644 index 00000000000000..cdf9add4d55832 --- /dev/null +++ b/ee/lib/ai/amazon_q/identity_provider_payload.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class IdentityProviderPayload + def execute + cloud_connector_token_result + .and_then(->(token) { decode_token(token) }) + .and_then(->(token) { instance_uid_from_token(token) }) + .map(->(instance_uid) { build_payload(instance_uid) }) + end + + private + + def cloud_connector_token_result + token = ::CloudConnector::AvailableServices.find_by_name(:agent_quick_actions)&.access_token + return ::Gitlab::Fp::Result.ok(token) if token + + ::Gitlab::Fp::Result.err({ + message: s_('AmazonQ|Active cloud connector token not found.'), + reason: :cc_token_not_found + }) + end + + def decode_token(token) + ::Gitlab::Fp::Result.ok( + JWT.decode(token, false, nil)&.first + ) + rescue JWT::DecodeError => e + Gitlab::AppLogger.error(e) + + ::Gitlab::Fp::Result.err({ + message: s_('AmazonQ|Cloud connector token could not be decoded'), + reason: :cc_token_jwt_decode + }) + end + + def instance_uid_from_token(token) + gitlab_instance_uid = token['gitlab_instance_uid'] + if gitlab_instance_uid + Gitlab::AppLogger.info( + "gitlab_instance_uid found in latest Cloud Connector token. Using gitlab_instance_uid." + ) + + return ::Gitlab::Fp::Result.ok(gitlab_instance_uid) + end + + instance_identifier = token['sub'] + if instance_identifier + Gitlab::AppLogger.info("gitlab_instance_uid not found in latest Cloud Connector token. Using subject.") + + return ::Gitlab::Fp::Result.ok(instance_identifier) + end + + ::Gitlab::Fp::Result.err({ + message: s_('Neither gitlab_instance_uid or sub found on Cloud Connector token'), + reason: :cc_token_no_uid + }) + end + + def build_payload(instance_uid) + { + instance_uid: instance_uid, + aws_provider_url: "https://auth.token.gitlab.com/cc/oidc/#{instance_uid}", + aws_audience: "gitlab-cc-#{instance_uid}" + } + end + end + end +end diff --git a/ee/lib/gitlab/llm/q_ai/client.rb b/ee/lib/gitlab/llm/q_ai/client.rb new file mode 100644 index 00000000000000..3ea4613f258a74 --- /dev/null +++ b/ee/lib/gitlab/llm/q_ai/client.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module QAi + class Client + include ::Gitlab::Llm::Concerns::ExponentialBackoff + include ::Gitlab::Llm::Concerns::EventTracking + include ::Gitlab::Llm::Concerns::AvailableModels + include Langsmith::RunHelpers + + DEFAULT_TIMEOUT = 30.seconds + + def initialize(user, unit_primitive:, tracking_context: {}) + @user = user + @tracking_context = tracking_context + @unit_primitive = unit_primitive + @logger = Gitlab::Llm::Logger.build + end + + def perform_create_auth_application(oauth_app, secret, role_arn) + payload = { + clientId: oauth_app.uid.to_s, + clientSecret: secret, + redirectUrl: oauth_app.redirect_uri, + instanceUrl: Gitlab.config.gitlab.url, + roleArn: role_arn + } + + Gitlab::HTTP.post( + "#{url}/v1/oauth/application", + body: payload.to_json, + headers: request_headers + ) + end + + private + + attr_reader :user, :logger, :tracking_context, :unit_primitive + + def url + Gitlab::AiGateway.url + end + + def service_name + :agent_quick_actions + end + + def service + ::CloudConnector::AvailableServices.find_by_name(service_name) + end + + def api_key + service.access_token(user) + end + + def request_headers + { + "Accept" => "application/json", + 'X-Gitlab-Unit-Primitive' => unit_primitive + }.merge(Gitlab::AiGateway.headers(user: user, service: service)) + end + + def request_body(prompt:, options: {}) + { + prompt: prompt, + max_tokens_to_sample: DEFAULT_MAX_TOKENS, + temperature: DEFAULT_TEMPERATURE + }.merge(options) + end + end + end + end +end diff --git a/ee/spec/lib/ai/amazon_q/identity_provider_payload_spec.rb b/ee/spec/lib/ai/amazon_q/identity_provider_payload_spec.rb new file mode 100644 index 00000000000000..5fe6f124f4aead --- /dev/null +++ b/ee/spec/lib/ai/amazon_q/identity_provider_payload_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::IdentityProviderPayload, feature_category: :ai_agents do + using RSpec::Parameterized::TableSyntax + + let(:signing_key) { OpenSSL::PKey::RSA.new(Rails.application.credentials.openid_connect_signing_key) } + let(:service_data_default) { instance_double(CloudConnector::SelfManaged::AvailableServiceData) } + + let(:token_invalid) { 'NOT_A_REAL_TOKEN' } + let(:token_no_uid) { JWT.encode({ foo: 'bar' }, signing_key, 'RS256', { typ: 'JWT' }) } + let(:token_with_uid) do + JWT.encode({ sub: 'test-subject', gitlab_instance_uid: 'test-gitlab-uid' }, signing_key, 'RS256', { typ: 'JWT' }) + end + + let(:token_self_issued) do + ::Gitlab::CloudConnector::SelfIssuedToken.new( + audience: 'test-audience', + subject: 'test-subject', + scopes: [] + ).encoded + end + + describe '#execute' do + subject(:execution) { described_class.new.execute } + + where(:service_data, :token, :expectation) do + nil | nil | { err: hash_including(reason: :cc_token_not_found) } + ref(:service_data_default) | nil | { err: hash_including(reason: :cc_token_not_found) } + ref(:service_data_default) | ref(:token_invalid) | { err: hash_including(reason: :cc_token_jwt_decode) } + ref(:service_data_default) | ref(:token_no_uid) | { err: hash_including(reason: :cc_token_no_uid) } + ref(:service_data_default) | ref(:token_with_uid) | { ok: { aws_audience: 'gitlab-cc-test-gitlab-uid', + aws_provider_url: 'https://auth.token.gitlab.com/cc/oidc/test-gitlab-uid', + instance_uid: 'test-gitlab-uid' } } + ref(:service_data_default) | ref(:token_self_issued) | { ok: { aws_audience: 'gitlab-cc-test-subject', + aws_provider_url: 'https://auth.token.gitlab.com/cc/oidc/test-subject', + instance_uid: 'test-subject' } } + end + + with_them do + before do + allow(service_data).to receive(:access_token).and_return(token) + + allow(::CloudConnector::AvailableServices).to receive(:find_by_name) + .with(:agent_quick_actions) + .and_return(service_data) + end + + it { expect(execution.to_h).to include(expectation) } + end + end +end diff --git a/ee/spec/lib/ai/amazon_q_spec.rb b/ee/spec/lib/ai/amazon_q_spec.rb new file mode 100644 index 00000000000000..17e686372cb37c --- /dev/null +++ b/ee/spec/lib/ai/amazon_q_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'spec_helper' + +using RSpec::Parameterized::TableSyntax + +RSpec.describe Ai::AmazonQ, feature_category: :ai_abstraction_layer do + let(:application_settings) { ::Gitlab::CurrentSettings.current_application_settings } + + describe '#connected?' do + where(:q_available, :q_ready, :result) do + true | true | true + true | false | false + false | true | false + false | false | false + end + + with_them do + before do + Ai::Setting.instance.update!(amazon_q_ready: q_ready) + allow(described_class).to receive(:feature_available?).and_return(q_available) + end + + it 'returns the expected result' do + expect(described_class.connected?).to be result + end + end + end + + describe '#feature_available?' do + where(:feature_flag_enabled, :amazon_q_license_available, :gitlab_com, :result) do + true | true | true | false + true | false | true | false + false | true | true | false + false | false | true | false + true | true | false | true + true | false | false | false + false | true | false | false + false | false | false | false + end + + with_them do + before do + stub_licensed_features(amazon_q: amazon_q_license_available) + stub_feature_flags(amazon_q_integration: feature_flag_enabled) + allow(::Gitlab).to receive(:org_or_com?).and_return(gitlab_com) + end + + it 'returns the expected result' do + expect(described_class.feature_available?).to eq(result) + end + end + end + + describe '#enabled?' do + context 'with args' do + let(:namespace) { build(:project_namespace) } + let(:user) { build(:user) } + + where(:duo_availability, :user_can_access_duo_features, :result) do + :default_on | true | true + :default_on | false | false + :never_on | true | false + :never_on | false | false + :default_off | true | true + :default_off | false | false + end + + with_them do + before do + allow(described_class).to receive_messages( + feature_available?: true, + connected?: true + ) + allow(::Gitlab::CurrentSettings).to receive_messages( + duo_availability: duo_availability + ) + allow(user).to receive(:can?).with(:access_duo_features, namespace).and_return(user_can_access_duo_features) + end + + it { expect(described_class.enabled?(user: user, namespace: namespace)).to eq(result) } + end + end + end + + describe '#should_block_service_account?' do + where(:availability, :expectation) do + "default_on" | false + "default_off" | false + "never_on" | true + end + + with_them do + it { expect(described_class.should_block_service_account?(availability: availability)).to be(expectation) } + end + end + + describe '#ensure_service_account_blocked!' do + let_it_be(:current_user) { create(:user, :admin) } + let_it_be_with_reload(:service_account_normal) { create(:user, :service_account) } + let_it_be_with_reload(:service_account_blocked) { create(:user, :service_account, :blocked) } + let_it_be(:service_account_not_found) { Struct.new(:id).new(999999) } + + context 'with service_account set in application settings' do + where(:service_account, :expected_service_class, :expected_status, :expected_message) do + ref(:service_account_normal) | ::Users::BlockService | true | nil + ref(:service_account_blocked) | nil | true | "Service account already blocked. Nothing to do." + end + + with_them do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account&.id) + end + + it 'conditionally block the service account', :aggregate_failures do + if expected_service_class + expect_next_instance_of(expected_service_class, current_user) do |instance| + expect(instance).to receive(:execute).with(service_account).and_call_original + end + end + + response = described_class.ensure_service_account_blocked!(current_user: current_user) + + expect(response.success?).to be(expected_status) + expect(response.message).to be(expected_message) + end + end + end + + context 'with service_account set as argument' do + it 'conditionally blocks the given service account', :aggregate_failures do + expect(service_account_normal.blocked?).to be(false) + + response = described_class.ensure_service_account_blocked!( + current_user: current_user, + service_account: service_account_normal + ) + + expect(response.success?).to be(true) + expect(service_account_normal.blocked?).to be(true) + end + end + end + + describe '#ensure_service_account_unblocked!' do + let_it_be(:current_user) { create(:user, :admin) } + let_it_be_with_reload(:service_account_normal) { create(:user, :service_account) } + let_it_be_with_reload(:service_account_blocked) { create(:user, :service_account, :blocked) } + + context 'with service_account set in application settings' do + where(:service_account, :expected_service_class, :expected_status, :expected_message) do + ref(:service_account_normal) | nil | true | "Service account already unblocked. Nothing to do." + ref(:service_account_blocked) | ::Users::UnblockService | true | nil + end + + with_them do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account&.id) + end + + it 'conditionally block the service account', :aggregate_failures do + if expected_service_class + expect_next_instance_of(expected_service_class, current_user) do |instance| + expect(instance).to receive(:execute).with(service_account).and_call_original + end + end + + response = described_class.ensure_service_account_unblocked!(current_user: current_user) + + expect(response.success?).to be(expected_status) + expect(response.message).to be(expected_message) + end + end + end + + context 'with service_account set as argument' do + it 'conditionally blocks the given service account', :aggregate_failures do + expect(service_account_blocked.blocked?).to be(true) + + response = described_class.ensure_service_account_unblocked!( + current_user: current_user, + service_account: service_account_blocked + ) + + expect(response.success?).to be(true) + expect(service_account_blocked.blocked?).to be(false) + end + end + end +end diff --git a/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb b/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb new file mode 100644 index 00000000000000..53f2a54d4c007f --- /dev/null +++ b/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::QAi::Client, feature_category: :ai_agents do + describe '#perform_create_auth_application' do + let_it_be(:user) { create(:user) } + let_it_be(:oauth_app) { create(:doorkeeper_application) } + + let(:cc_token) { 'cc_token' } + let(:response) { 'response' } + let(:role_arn) { 'role_arn' } + let(:secret) { 'secret' } + + subject(:perform_create_auth_application) do + described_class.new(user, unit_primitive: 'agent_quick_actions') + .perform_create_auth_application(oauth_app, secret, role_arn) + end + + before do + cc_servide = double(name: 'name', access_token: cc_token) # rubocop:disable RSpec/VerifiedDoubles -- Not sure what class this should be? + allow(::CloudConnector::AvailableServices).to receive(:find_by_name) + .with(:agent_quick_actions).and_return(cc_servide) + payload = { + clientId: oauth_app.uid.to_s, + clientSecret: secret, + redirectUrl: oauth_app.redirect_uri, + instanceUrl: Gitlab.config.gitlab.url, + roleArn: role_arn + } + + stub_request(:post, "#{Gitlab::AiGateway.url}/v1/oauth/application") + .with(body: payload.to_json) + .to_return(body: response) + end + + it 'makes expected HTTP post request' do + expect(perform_create_auth_application.parsed_response).to eq(response) + end + end +end diff --git a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb new file mode 100644 index 00000000000000..b4d5b41ddf7b7d --- /dev/null +++ b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::Ai::AmazonQSettingsController, :enable_admin_mode, feature_category: :ai_abstraction_layer do + let(:admin) { create(:admin) } + let(:connected) { true } + + let(:actual_view_model) do + Gitlab::Json.parse( + Nokogiri::HTML(response.body).css('#js-amazon-q-settings').first['data-view-model'] + ) + end + + before do + stub_licensed_features(amazon_q: true) + stub_feature_flags(amazon_q_integration: true) + + allow(::Ai::AmazonQ).to receive(:connected?).and_return(connected) + + sign_in(admin) + end + + shared_examples 'returns forbidden when feature is unavailable' do + before do + stub_licensed_features(amazon_q: false) + end + + it 'returns 403' do + perform_request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET #index' do + it_behaves_like 'returns forbidden when feature is unavailable' do + let(:perform_request) { get admin_ai_amazon_q_settings_path } + end + + context 'when there is a problem retreiving the identity provider payload' do + it 'renders alert and empty identityProviderPayload' do + get admin_ai_amazon_q_settings_path + + expect(actual_view_model).to include("identityProviderPayload" => {}) + expect(flash[:alert]).to eq(s_('AmazonQ|Something went wrong retrieving the identity provider payload.')) + end + end + + context 'when there is a valid identity provider payload' do + before do + jwt = JWT.encode({ sub: 'abc123' }, '') + service = instance_double(::CloudConnector::SelfSigned::AvailableServiceData, access_token: jwt) + allow(::CloudConnector::AvailableServices).to receive(:find_by_name).with(:agent_quick_actions) + .and_return(service) + end + + it 'renders the frontend entrypoint with view model' do + get admin_ai_amazon_q_settings_path + + expect(actual_view_model).to eq({ + "amazonQSettings" => { + "availability" => "default_on", + "ready" => false, + "roleArn" => nil + }, + "disconnectUrl" => disconnect_admin_ai_amazon_q_settings_path, + "submitUrl" => admin_ai_amazon_q_settings_path, + "identityProviderPayload" => { + "aws_audience" => "gitlab-cc-abc123", + "aws_provider_url" => "https://auth.token.gitlab.com/cc/oidc/abc123", + "instance_uid" => "abc123" + } + }) + end + end + end + + describe 'POST #create' do + using RSpec::Parameterized::TableSyntax + + let(:params) { { role_arn: 'a', availability: 'always_on' } } + let(:perform_request) { post admin_ai_amazon_q_settings_path, params: params } + + it_behaves_like 'returns forbidden when feature is unavailable' + + # rubocop: disable Layout/LineLength -- Wrapping won't work! + where(:connected, :service, :service_response, :message) do + true | ::Ai::AmazonQ::UpdateService | ServiceResponse.success | { notice: s_('AmazonQ|Amazon Q Settings have been saved.') } + true | ::Ai::AmazonQ::UpdateService | ServiceResponse.error(message: nil) | { alert: s_('AmazonQ|Something went wrong saving Amazon Q settings.') } + false | ::Ai::AmazonQ::CreateService | ServiceResponse.success | { notice: s_('AmazonQ|Amazon Q Settings have been saved.') } + false | ::Ai::AmazonQ::CreateService | ServiceResponse.error(message: 'Doh!') | { alert: 'Doh!' } + end + # rubocop: enable Layout/LineLength + + with_them do + it 'triggers the expected service' do + allow(::Ai::AmazonQ).to receive(:connected?).and_return(connected) + + expect_next_instance_of(service, admin, ActionController::Parameters.new(params).permit!) do |service| + expect(service).to receive(:execute).and_return(service_response) + end + + perform_request + + expect(response).to redirect_to(admin_ai_amazon_q_settings_path) + end + end + end +end diff --git a/ee/spec/services/ai/amazon_q/create_service_spec.rb b/ee/spec/services/ai/amazon_q/create_service_spec.rb new file mode 100644 index 00000000000000..b47b06219927aa --- /dev/null +++ b/ee/spec/services/ai/amazon_q/create_service_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::CreateService, feature_category: :ai_agents do + describe '#execute' do + let_it_be(:user) { create(:admin) } + let_it_be(:service_account) { create(:service_account) } + let_it_be(:doorkeeper_application) { create(:doorkeeper_application) } + + let(:params) { { role_arn: 'a', availability: 'default_off' } } + let(:status) { 200 } + let(:body) { 'success' } + + before do + allow_next_instance_of(::Users::ServiceAccounts::CreateService) do |instance| + allow(instance).to receive(:execute).and_return({ status: :success, payload: service_account }) + end + + stub_request(:post, "#{Gitlab::AiGateway.url}/v1/oauth/application") + .and_return(status: status, body: body) + end + + subject(:instance) { described_class.new(user, params) } + + context 'with missing role_arn param' do + let(:params) { { availability: 'b' } } + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: 'Missing role_arn parameter' + ) + end + end + + context 'with missing availability param' do + let(:params) { { role_arn: 'a' } } + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: 'Missing availability parameter' + ) + end + end + + context 'with invalid availability param' do + let(:params) { { role_arn: 'a', availability: 'z' } } + + it 'does not change duo_availability' do + expect { instance.execute } + .not_to change { ::Gitlab::CurrentSettings.current_application_settings.duo_availability } + end + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: "availability must be one of: default_on, default_off, never_on" + ) + end + end + + context 'when setting availability to never_on' do + let(:params) { { role_arn: 'a', availability: 'never_on' } } + + it 'blocks service account' do + expect { instance.execute }.to change { service_account.blocked? }.from(false).to(true) + end + end + + it 'updates application settings' do + expect { instance.execute } + .to change { Ai::Setting.instance.amazon_q_role_arn }.from(nil).to('a') + .and change { + ::Gitlab::CurrentSettings.current_application_settings.duo_availability + }.from(:default_on).to(:default_off) + end + + it 'creates an audit event' do + expect { instance.execute }.to change { AuditEvent.count }.by(1) + expect(AuditEvent.last.details).to include( + event_name: 'q_onbarding_updated', + custom_message: "Changed availability to default_off, " \ + "amazon_q_role_arn to a, " \ + "amazon_q_service_account_user_id to #{service_account.id}, " \ + "amazon_q_oauth_application_id to #{Doorkeeper::Application.last.id}, " \ + "amazon_q_ready to true" + ) + end + + it 'returns ServiceResponse.success' do + result = instance.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + + context "when q service account does not already exist" do + it 'creates q service account and stores the user id in application settings' do + expect { instance.execute } + .to change { Ai::Setting.instance.amazon_q_service_account_user_id }.from(nil).to(service_account.id) + expect(::Users::ServiceAccounts::CreateService).to have_received(:new) + end + end + + context "when q service account already exists" do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account.id) + end + + it 'does not attempt to create q service account' do + expect { instance.execute }.not_to change { Ai::Setting.instance.amazon_q_service_account_user_id } + expect(::Users::ServiceAccounts::CreateService).not_to have_received(:new) + end + end + + context "when an existing oauth application does not exist" do + it "creates a new oauth application" do + expect_next_instance_of(::Gitlab::Llm::QAi::Client) do |client| + expect(client).to receive(:perform_create_auth_application) + .with( + doorkeeper_application, + doorkeeper_application.secret, + params[:role_arn] + ).and_call_original + end + + expect(Doorkeeper::Application).to receive(:new).with( + { + name: 'Amazon Q OAuth', + redirect_uri: Gitlab::Routing.url_helpers.root_url, + scopes: Gitlab::Auth::Q_SCOPES, + trusted: false, + confidential: false + } + ).and_return(doorkeeper_application) + + expect { instance.execute }.to change { Ai::Setting.instance.amazon_q_oauth_application_id } + .from(nil).to(doorkeeper_application.id) + end + + context 'when AI client returns a 403 error' do + let(:status) { 403 } + let(:body) { '403 Unauthorized' } + + it 'displays a 403 error in the errors' do + expect(instance.execute).to have_attributes( + success?: false, + message: 'Application could not be created by the AI Gateway: Error 403 - 403 Unauthorized' + ) + end + end + end + + context "when an oauth application exists" do + before do + Ai::Setting.instance.update!(amazon_q_oauth_application_id: doorkeeper_application.id) + end + + it "does not create a new oauth application" do + expect(Doorkeeper::Application).not_to receive(:new) + + expect_next_instance_of(::Gitlab::Llm::QAi::Client) do |client| + expect(client).to receive(:perform_create_auth_application) + .with( + doorkeeper_application, + doorkeeper_application.secret, + params[:role_arn] + ).and_call_original + end + + result = nil + expect do + result = instance.execute + end.not_to change { + Ai::Setting.instance.amazon_q_oauth_application_id + } + + expect(result.success?).to be_truthy + end + end + end +end diff --git a/ee/spec/services/ai/amazon_q/update_service_spec.rb b/ee/spec/services/ai/amazon_q/update_service_spec.rb new file mode 100644 index 00000000000000..b0c12e400da5f3 --- /dev/null +++ b/ee/spec/services/ai/amazon_q/update_service_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::UpdateService, feature_category: :ai_agents do + describe '#execute' do + let_it_be(:user) { create(:admin) } + let_it_be_with_reload(:service_account) { create(:service_account) } + + let(:params) { { availability: 'default_off' } } + let(:status) { 200 } + let(:body) { 'success' } + + subject(:instance) { described_class.new(user, params) } + + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account.id) + end + + context 'with missing availability param' do + let(:params) { {} } + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: 'Missing availability parameter' + ) + end + end + + context 'with invalid availability param' do + let(:params) { { availability: 'z' } } + + it 'does not change duo_availability' do + expect { instance.execute } + .not_to change { ::Gitlab::CurrentSettings.current_application_settings.duo_availability } + end + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: "availability must be one of: default_on, default_off, never_on" + ) + end + end + + context 'when the application settings update fails' do + before do + allow(::Gitlab::CurrentSettings).to receive_message_chain(:current_application_settings, :update) + .and_return(false) + allow(::Gitlab::CurrentSettings).to receive_message_chain( + :current_application_settings, :errors, :full_messages, :to_sentence + ).and_return('Invalid value') + end + + it 'returns ServiceResponse.error with expected error message' do + expect(instance.execute).to have_attributes( + success?: false, + message: 'Invalid value' + ) + end + end + + it 'updates application settings' do + expect { instance.execute } + .to change { + ::Gitlab::CurrentSettings.current_application_settings.duo_availability + }.from(:default_on).to(:default_off) + end + + it 'creates an audit event' do + expect { instance.execute }.to change { AuditEvent.count }.by(1) + expect(AuditEvent.last.details).to include( + event_name: 'q_onbarding_updated', + custom_message: 'Changed availability to default_off' + ) + end + + context 'when setting availability to never_on' do + let(:params) { { availability: 'never_on' } } + + it 'blocks service account' do + expect { instance.execute }.to change { service_account.reload.blocked? }.from(false).to(true) + end + end + + context 'when service account blocked' do + it 'unblocks service account' do + ::Users::BlockService.new(user).execute(service_account) + + expect { instance.execute }.to change { service_account.reload.blocked? }.from(true).to(false) + end + end + + it 'returns ServiceResponse.success' do + result = instance.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + end +end diff --git a/lib/assets/images/bot_avatars/q_avatar.png b/lib/assets/images/bot_avatars/q_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..a89b320d2a7df960f68078721e95c447a27d93d6 GIT binary patch literal 16990 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 t(2Lpk>gL`{-)V9@!X(n Date: Tue, 3 Dec 2024 10:42:30 -0600 Subject: [PATCH 04/14] Feat(Q): Add disconnect endpoint --- .../admin/ai/amazon_q_settings_controller.rb | 16 ++++ .../services/ai/amazon_q/destroy_service.rb | 44 +++++++++++ .../ai/amazon_q_settings_controller_spec.rb | 45 +++++++++++ .../ai/amazon_q/destroy_service_spec.rb | 79 +++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 ee/app/services/ai/amazon_q/destroy_service.rb create mode 100644 ee/spec/services/ai/amazon_q/destroy_service_spec.rb diff --git a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb index 4750b1d35c2677..aee28142c7c2c4 100644 --- a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -37,6 +37,22 @@ def create ) end + def disconnect + return head :unprocessable_entity unless ::Ai::AmazonQ.connected? + + response = ::Ai::AmazonQ::DestroyService.new(current_user).execute + + # TODO(Q): If we don't expire the application settings then they stick around across requests. + # Should we handle this differently? + Gitlab::CurrentSettings.expire_current_application_settings + + if response.success? + head :ok + else + render json: { message: response.message }, status: :unprocessable_entity + end + end + private def setup_view_model diff --git a/ee/app/services/ai/amazon_q/destroy_service.rb b/ee/app/services/ai/amazon_q/destroy_service.rb new file mode 100644 index 00000000000000..0cabc932a979c3 --- /dev/null +++ b/ee/app/services/ai/amazon_q/destroy_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class DestroyService < BaseService + def execute + # TODO(Q): We will probably need to make some API calls to effectively clean things up downstream... + result = block_service_account! + + return result unless result.success? + + destroy_oauth_application! + + if ai_settings.update( + amazon_q_oauth_application_id: nil, + amazon_q_ready: false, + amazon_q_role_arn: nil + ) + create_audit_event( + audit_availability: false, + audit_ai_settings: true, + exclude_columns: %w[amazon_q_service_account_user_id] + ) + ServiceResponse.success + else + ServiceResponse.error(message: ai_settings.errors.full_messages.to_sentence) + end + end + + private + + attr_reader :user + + def destroy_oauth_application! + oauth_application = Doorkeeper::Application.find_by_id(ai_settings.amazon_q_oauth_application_id) + oauth_application&.destroy! + end + + def block_service_account! + Ai::AmazonQ.ensure_service_account_blocked!(current_user: user) + end + end + end +end diff --git a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb index b4d5b41ddf7b7d..c11ac6c0c9d522 100644 --- a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb +++ b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb @@ -107,4 +107,49 @@ end end end + + describe 'POST #disconnect' do + let(:perform_request) { post disconnect_admin_ai_amazon_q_settings_path } + + it_behaves_like 'returns forbidden when feature is unavailable' + + context 'when not connected' do + let(:connected) { false } + + it 'returns unprocessable entity response' do + perform_request + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + + context 'when connected' do + let(:service_response) { ServiceResponse.success } + + it 'calls ::Ai::AmazonQ::DestroyService.execute and returns OK response' do + expect_next_instance_of(::Ai::AmazonQ::DestroyService, admin) do |destroy_service| + expect(destroy_service).to receive(:execute).and_return(service_response) + end + + perform_request + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when ::Ai::AmazonQ::DestroyService.execute returns error response' do + let(:service_response) { ServiceResponse.error(message: 'Oops') } + + it 'returns unprocessable entity response with corresponding message' do + expect_next_instance_of(::Ai::AmazonQ::DestroyService, admin) do |destroy_service| + expect(destroy_service).to receive(:execute).and_return(service_response) + end + + perform_request + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ 'message' => 'Oops' }) + end + end + end + end end diff --git a/ee/spec/services/ai/amazon_q/destroy_service_spec.rb b/ee/spec/services/ai/amazon_q/destroy_service_spec.rb new file mode 100644 index 00000000000000..f63dfa390337ba --- /dev/null +++ b/ee/spec/services/ai/amazon_q/destroy_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::DestroyService, feature_category: :ai_agents do + describe '#execute' do + let_it_be(:user) { create(:admin) } + let_it_be(:service_account) { create(:service_account) } + let_it_be(:doorkeeper_application) { create(:doorkeeper_application) } + let_it_be(:role_arn) { SecureRandom.hex } + + before do + Ai::Setting.instance.update!( + amazon_q_service_account_user_id: service_account.id, + amazon_q_oauth_application_id: doorkeeper_application.id, + amazon_q_ready: true, + amazon_q_role_arn: role_arn + ) + end + + subject(:instance) { described_class.new(user) } + + it 'returns ServiceResponse.success' do + result = instance.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + + context 'when the AI settings update fails' do + it 'returns ServiceResponse.error with expected error message' do + ai_settings = Ai::Setting.instance + allow(Ai::Setting).to receive(:instance).and_return(ai_settings) + allow(ai_settings).to receive(:update).and_return(false) + allow(ai_settings).to receive_message_chain( + :errors, :full_messages, :to_sentence + ).and_return('Oh oh!') + + expect(instance.execute).to have_attributes( + success?: false, + message: 'Oh oh!' + ) + end + end + + it 'blocks the service account' do + expect { instance.execute }.to change { service_account.reload.blocked? }.from(false).to(true) + end + + it 'destroys the oauth application' do + instance.execute + + expect { doorkeeper_application.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'updates application settings' do + expect { instance.execute } + .to change { + Ai::Setting.instance.amazon_q_oauth_application_id + }.from(doorkeeper_application.id).to(nil).and change { + Ai::Setting.instance.amazon_q_ready + }.from(true).to(false).and change { + Ai::Setting.instance.amazon_q_role_arn + }.from(role_arn).to(nil).and not_change { + Ai::Setting.instance.amazon_q_service_account_user_id + } + end + + it 'creates an audit event' do + expect { instance.execute }.to change { AuditEvent.count }.by(1) + expect(AuditEvent.last.details).to include( + event_name: 'q_onbarding_updated', + custom_message: 'Changed amazon_q_role_arn to null, ' \ + 'amazon_q_oauth_application_id to null, ' \ + 'amazon_q_ready to null' + ) + end + end +end -- GitLab From 7284cb21c62dcfa905c7f205cc8c65b06d6951b0 Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 3 Dec 2024 10:48:38 -0600 Subject: [PATCH 05/14] Feat(Q): Frontend for AmazonQ admin --- .../amazon_q_settings/components/app.vue | 377 ++++++++++++++++++ .../pages/admin/ai/amazon_q_settings/index.js | 4 + .../amazon_q_settings/components/app_spec.js | 352 ++++++++++++++++ 3 files changed, 733 insertions(+) create mode 100644 ee/app/assets/javascripts/amazon_q_settings/components/app.vue create mode 100644 ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js create mode 100644 ee/spec/frontend/amazon_q_settings/components/app_spec.js diff --git a/ee/app/assets/javascripts/amazon_q_settings/components/app.vue b/ee/app/assets/javascripts/amazon_q_settings/components/app.vue new file mode 100644 index 00000000000000..3bd3d1511b31aa --- /dev/null +++ b/ee/app/assets/javascripts/amazon_q_settings/components/app.vue @@ -0,0 +1,377 @@ + + + diff --git a/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js b/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js new file mode 100644 index 00000000000000..9c57e93e7f71a9 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js @@ -0,0 +1,4 @@ +import SettingsApp from 'ee/amazon_q_settings/components/app.vue'; +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; + +initSimpleApp('#js-amazon-q-settings', SettingsApp); diff --git a/ee/spec/frontend/amazon_q_settings/components/app_spec.js b/ee/spec/frontend/amazon_q_settings/components/app_spec.js new file mode 100644 index 00000000000000..ac2d4f184e9562 --- /dev/null +++ b/ee/spec/frontend/amazon_q_settings/components/app_spec.js @@ -0,0 +1,352 @@ +import { nextTick } from 'vue'; +import { + GlAlert, + GlButton, + GlForm, + GlFormInput, + GlFormInputGroup, + GlFormGroup, + GlFormRadioGroup, + GlFormRadio, + GlSprintf, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import App from 'ee/amazon_q_settings/components/app.vue'; +import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +jest.mock('~/lib/utils/create_and_submit_form'); + +const TEST_SUBMIT_URL = '/foo/submit/url'; +const TEST_DISCONNECT_URL = '/foo/disconnect'; +const TEST_AMAZON_Q_SETTINGS = { + ready: true, + availability: 'default_on', + roleArn: 'aws:role:arn', +}; + +describe('ee/amazon_q_settings/components/app.vue', () => { + let wrapper; + + const createWrapper = (props = {}) => { + wrapper = shallowMount(App, { + propsData: { + submitUrl: TEST_SUBMIT_URL, + disconnectUrl: TEST_DISCONNECT_URL, + identityProviderPayload: { + instance_uid: 'instance-uid', + aws_provider_url: 'https://provider.url', + aws_audience: 'audience', + }, + ...props, + }, + stubs: { + GlFormInputGroup, + GlSprintf, + }, + }); + }; + + const findForm = () => wrapper.findComponent(GlForm); + const findFormGroup = (label) => + findForm() + .findAllComponents(GlFormGroup) + .wrappers.find((x) => x.attributes('label') === label); + + const findStatusFormGroup = () => findFormGroup('Status'); + const findSetupFormGroup = () => findFormGroup('Setup'); + const listItems = () => findSetupFormGroup().findAll('ol li').wrappers; + const listItem = (at) => listItems()[at]; + + // arn helpers ----- + const findArnFormGroup = () => findFormGroup("IAM role's ARN"); + const findArnField = () => findArnFormGroup().findComponent(GlFormInput); + const setArn = (val) => findArnField().vm.$emit('input', val); + + // availability helpers ----- + const findAvailabilityRadioGroup = () => + findFormGroup('Availability').findComponent(GlFormRadioGroup); + const findAvailabilityRadioButtons = () => + findAvailabilityRadioGroup() + .findAllComponents(GlFormRadio) + .wrappers.map((x) => ({ + value: x.attributes('value'), + label: x.text(), + })); + const setAvailability = (val) => findAvailabilityRadioGroup().vm.$emit('input', val); + + // warning helpers ----- + const findAvailabilityWarning = () => findForm().findComponent(GlAlert); + const findSaveWarning = () => findForm().find('[data-testid=amazon-q-save-warning]'); + const findSaveWarningLink = () => findSaveWarning().find('a'); + + // button helpers ----- + const findButton = (text) => + findForm() + .findAllComponents(GlButton) + .wrappers.find((x) => x.text() === text); + const findSubmitButton = () => findButton('Save changes'); + const findDisconnectButton = () => findButton('Disconnect'); + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('does not render status', () => { + expect(findStatusFormGroup()).toBeUndefined(); + }); + + describe('setup', () => { + it('renders setup', () => { + expect(findSetupFormGroup().exists()).toBe(true); + + expect(listItems()).toHaveLength(3); + }); + + it('renders step 1', () => { + const idpStepText = listItem(0).text(); + const idpStepHelpPageLink = listItem(0).findComponent(HelpPageLink); + + expect(idpStepText).toBe( + 'Create an identity provider for this GitLab instance within AWS using the following values. Learn more.', + ); + expect(idpStepHelpPageLink.props()).toEqual({ + anchor: 'create-an-iam-identity-provider', + href: 'user/duo_amazon_q/setup.md', + }); + expect(idpStepHelpPageLink.text()).toEqual('Learn more'); + }); + + it('renders identity provider details with clipboard buttons', () => { + const idpFormFields = listItem(0).findAllComponents(GlFormInputGroup).wrappers; + const idpClipboardButtons = listItem(0).findAllComponents(ClipboardButton).wrappers; + + expect(idpFormFields[0].props('value')).toEqual('instance-uid'); + expect(idpClipboardButtons[0].props('text')).toEqual('instance-uid'); + + expect(idpFormFields[1].props('value')).toEqual('OpenID Connect'); + expect(idpClipboardButtons[1].props('text')).toEqual('OpenID Connect'); + + expect(idpFormFields[2].props('value')).toEqual('https://provider.url'); + expect(idpClipboardButtons[2].props('text')).toEqual('https://provider.url'); + + expect(idpFormFields[3].props('value')).toEqual('audience'); + expect(idpClipboardButtons[3].props('text')).toEqual('audience'); + }); + + it('renders step 2', () => { + const iamStepText = listItem(1).text(); + const iamStepHelpPageLink = listItem(1).findComponent(HelpPageLink); + + expect(iamStepText).toBe( + 'Within your AWS account, create an IAM role for Amazon Q and the relevant identity provider. Learn how to create an IAM role.', + ); + expect(iamStepHelpPageLink.props()).toEqual({ + anchor: 'create-an-iam-role', + href: 'user/duo_amazon_q/setup.md', + }); + expect(iamStepHelpPageLink.text()).toEqual('Learn how to create an IAM role'); + }); + + it('renders step 3', () => { + const arnStepText = listItem(2).text(); + + expect(arnStepText).toEqual("Enter the IAM role's ARN."); + }); + }); + + it('renders arn field', () => { + expect(findArnFormGroup().exists()).toBe(true); + + const input = findArnFormGroup().findComponent(GlFormInput); + + expect(input.attributes()).toMatchObject({ + value: '', + type: 'text', + width: 'lg', + name: 'aws_role', + placeholder: 'arn:aws:iam::account-id:role/role-name', + }); + }); + + it('renders availability field', () => { + expect(findAvailabilityRadioGroup().attributes()).toMatchObject({ + checked: 'default_on', + name: 'availability', + }); + expect(findAvailabilityRadioButtons()).toEqual([ + { + label: 'On by default', + value: 'default_on', + }, + { + label: 'Off by default', + value: 'default_off', + }, + { + label: 'Always off', + value: 'never_on', + }, + ]); + }); + + it('does not render availability warning', () => { + expect(findAvailabilityWarning().exists()).toBe(false); + }); + + it('renders enabled arn', () => { + expect(findArnFormGroup().attributes('disabled')).toBeUndefined(); + }); + + it('renders save button', () => { + expect(findSubmitButton().attributes()).toMatchObject({ + type: 'submit', + variant: 'confirm', + category: 'primary', + }); + }); + + it('does not render disconnect button', () => { + expect(findDisconnectButton()).toBeUndefined(); + }); + + it('renders save acknowledgement', () => { + expect(findSaveWarning().text()).toBe( + 'I understand that by selecting Save changes, GitLab creates a service account for Amazon Q and sends its credentials to AWS. Use of the Amazon Q Developer capabilities as part of GitLab Duo with Amazon Q is governed by the AWS Customer Agreement or other written agreement between you and AWS governing your use of AWS services.', + ); + + expect(findSaveWarningLink().attributes()).toEqual({ + href: 'http://aws.amazon.com/agreement', + rel: 'noopener noreferrer', + target: '_blank', + }); + expect(findSaveWarningLink().text()).toEqual('AWS Customer Agreement'); + }); + + describe('when submitting', () => { + let event; + + beforeEach(async () => { + event = new Event('submit'); + jest.spyOn(event, 'preventDefault'); + + setArn('aws:test:value'); + setAvailability('default_off'); + + await nextTick(); + + findForm().vm.$emit('submit', event); + }); + + it('prevents default', () => { + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('triggers submit form', () => { + expect(createAndSubmitForm).toHaveBeenCalledTimes(1); + expect(createAndSubmitForm).toHaveBeenCalledWith({ + url: TEST_SUBMIT_URL, + data: { + availability: 'default_off', + role_arn: 'aws:test:value', + }, + }); + }); + }); + }); + + describe('when ready', () => { + beforeEach(() => { + createWrapper({ + amazonQSettings: TEST_AMAZON_Q_SETTINGS, + }); + }); + + it('renders status', () => { + expect(findStatusFormGroup().exists()).toBe(true); + expect(findStatusFormGroup().text()).toBe(App.I18N_READY); + }); + + it('does not render setup', () => { + expect(findSetupFormGroup()).toBeUndefined(); + }); + + it('renders disabled arn', () => { + expect(findArnFormGroup().attributes('disabled')).toBeDefined(); + }); + + it('renders disconnect button', () => { + expect(findDisconnectButton().attributes()).toMatchObject({ + variant: 'danger', + category: 'primary', + }); + }); + + it('does not render save acknowledgement', () => { + expect(findSaveWarning().exists()).toBe(false); + }); + + describe('when submitting', () => { + beforeEach(async () => { + setAvailability('default_off'); + + await nextTick(); + + findForm().vm.$emit('submit', new Event('submit')); + }); + + it('triggers submit form', () => { + expect(createAndSubmitForm).toHaveBeenCalledTimes(1); + expect(createAndSubmitForm).toHaveBeenCalledWith({ + url: TEST_SUBMIT_URL, + data: { + availability: 'default_off', + }, + }); + }); + }); + }); + + describe('availability warnings', () => { + it.each` + orig | value | expected + ${'default_off'} | ${'default_off'} | ${''} + ${'default_off'} | ${'never_on'} | ${App.I18N_WARNING_NEVER_ON} + ${'default_off'} | ${'default_on'} | ${''} + ${'never_on'} | ${'never_on'} | ${''} + ${'never_on'} | ${'default_off'} | ${App.I18N_WARNING_OFF_BY_DEFAULT} + ${'never_on'} | ${'default_on'} | ${''} + ${'default_on'} | ${'default_on'} | ${''} + ${'default_on'} | ${'default_off'} | ${App.I18N_WARNING_OFF_BY_DEFAULT} + ${'default_on'} | ${'never_on'} | ${App.I18N_WARNING_NEVER_ON} + `('from $orig to $value', async ({ orig, value, expected }) => { + createWrapper({ + amazonQSettings: { + ...TEST_AMAZON_Q_SETTINGS, + availability: orig, + }, + }); + + expect(findAvailabilityWarning().exists()).toBe(false); + + setAvailability(value); + await nextTick(); + + if (expected) { + expect(findAvailabilityWarning().props()).toMatchObject({ + dismissible: false, + variant: 'warning', + }); + expect(findAvailabilityWarning().text()).toBe(expected); + } else { + expect(findAvailabilityWarning().exists()).toBe(false); + } + }); + }); +}); -- GitLab From 6bcf283fa1899c8151dc9235ba75715d1cc8eec7 Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 3 Dec 2024 10:59:23 -0600 Subject: [PATCH 06/14] Feat(Q): Add Group AmazonQ availability settings --- .../settings/components/amazon_q_settings.vue | 88 ++++++++++++++ .../ai/settings/pages/ai_group_settings.vue | 10 +- ee/app/helpers/ee/groups/settings_helper.rb | 3 +- .../service_account_member_remove_service.rb | 42 +++++++ ee/app/services/ee/groups/update_service.rb | 8 ++ .../components/amazon_q_settings_spec.js | 110 ++++++++++++++++++ .../settings/pages/ai_group_settings_spec.js | 32 +++++ .../helpers/ee/groups/settings_helper_spec.rb | 13 ++- ...vice_account_member_remove_service_spec.rb | 104 +++++++++++++++++ .../services/groups/update_service_spec.rb | 31 +++++ 10 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 ee/app/assets/javascripts/ai/settings/components/amazon_q_settings.vue create mode 100644 ee/app/services/ai/amazon_q/service_account_member_remove_service.rb create mode 100644 ee/spec/frontend/ai/settings/components/amazon_q_settings_spec.js create mode 100644 ee/spec/services/ai/amazon_q/service_account_member_remove_service_spec.rb diff --git a/ee/app/assets/javascripts/ai/settings/components/amazon_q_settings.vue b/ee/app/assets/javascripts/ai/settings/components/amazon_q_settings.vue new file mode 100644 index 00000000000000..930d97a740b3bd --- /dev/null +++ b/ee/app/assets/javascripts/ai/settings/components/amazon_q_settings.vue @@ -0,0 +1,88 @@ + + diff --git a/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue b/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue index b8104c636314e7..299c12b7dc9df3 100644 --- a/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue +++ b/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue @@ -4,6 +4,7 @@ import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; import { createAlert, VARIANT_INFO } from '~/alert'; import { __ } from '~/locale'; import AiCommonSettings from '../components/ai_common_settings.vue'; +import AmazonQSettings from '../components/amazon_q_settings.vue'; const EarlyAccessProgramBanner = () => import('../components/early_access_program_banner.vue'); @@ -12,6 +13,7 @@ export default { components: { AiCommonSettings, EarlyAccessProgramBanner, + AmazonQSettings, }, i18n: { successMessage: __('Group was successfully updated.'), @@ -19,7 +21,10 @@ export default { 'An error occurred while retrieving your settings. Reload the page to try again.', ), }, - inject: ['showEarlyAccessBanner'], + inject: { + showEarlyAccessBanner: { default: false }, + amazonQAvailable: { default: false }, + }, props: { redirectPath: { type: String, @@ -62,7 +67,8 @@ export default { };