From 6d25f55d13ffacd3c6bfea1a99c3e3122802d36d Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Wed, 11 Dec 2024 21:58:10 -0600 Subject: [PATCH 1/2] 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 | 32 +++ 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 | 26 +++ .../users/service_accounts/create_service.rb | 3 +- ee/config/routes/admin.rb | 2 +- ee/lib/ai/amazon_q.rb | 37 +++- ee/lib/gitlab/llm/q_ai/client.rb | 53 +++++ ee/spec/lib/ai/amazon_q_spec.rb | 105 ++++++++++ ee/spec/lib/gitlab/llm/q_ai/client_spec.rb | 46 +++++ .../ai/amazon_q_settings_controller_spec.rb | 30 +++ .../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 + locale/gitlab.pot | 6 + spec/services/service_response_spec.rb | 30 +++ 20 files changed, 848 insertions(+), 4 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/gitlab/llm/q_ai/client.rb create mode 100644 ee/spec/lib/gitlab/llm/q_ai/client_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 fb1f1479292fa4..a87959c2954145 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 11fe50ca3df468..c8525a13e19217 100644 --- a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -6,11 +6,35 @@ class AmazonQSettingsController < Admin::ApplicationController feature_category :ai_abstraction_layer before_action :check_can_admin_amazon_q + before_action :expire_current_settings def index setup_view_model end + def create + Gitlab::AppLogger.debug(message: "Receive create for Amazon Q Settings", params: permitted_params) + + service = if ::Ai::Setting.instance.amazon_q_ready + ::Ai::AmazonQ::UpdateService + else + ::Ai::AmazonQ::CreateService + end + + response = service.new(current_user, permitted_params).execute + + message = if response.success? + { notice: s_('AmazonQ|Amazon Q Settings have been saved.') } + else + { alert: response.message.presence || 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 @@ -45,6 +69,14 @@ def identity_provider def check_can_admin_amazon_q render_404 unless ::Ai::AmazonQ.feature_available? end + + def expire_current_settings + Gitlab::CurrentSettings.expire_current_application_settings + end + + def permitted_params + params.permit(:role_arn, :availability) + end end end 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..3055c5a20b0bd7 --- /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 + + 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 + + attr_accessor :user, :params + + 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 + 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 + [::Gitlab::Auth::DYNAMIC_USER], + trusted: false, + confidential: false + ) + @application.save + end + + def register_oauth_application_with_amazon + client = ::Gitlab::Llm::QAi::Client.new(user) + # 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..1f5f9ce0afd3dc --- /dev/null +++ b/ee/app/services/ai/amazon_q/update_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class UpdateService < BaseService + def execute + return availability_param_error if availability_param_error + + 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/routes/admin.rb b/ee/config/routes/admin.rb index de071e2b6cdab7..2e0b2d1d2951f9 100644 --- a/ee/config/routes/admin.rb +++ b/ee/config/routes/admin.rb @@ -59,7 +59,7 @@ end end - resources :amazon_q_settings, only: [:index] + resources :amazon_q_settings, only: [:index, :create] 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 index 65c78eda346f59..5b532e4347e124 100644 --- a/ee/lib/ai/amazon_q.rb +++ b/ee/lib/ai/amazon_q.rb @@ -2,7 +2,6 @@ module Ai module AmazonQ - # NOTE: This module is under development. See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174614 class << self def feature_available? ::Feature.enabled?(:amazon_q_integration, nil) && License.feature_available?(:amazon_q) @@ -11,7 +10,41 @@ def feature_available? def connected? return false unless feature_available? - Ai::Setting.instance.amazon_q_ready + ai_settings.amazon_q_ready + 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 + Ai::Setting.instance 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..69f87247f3febf --- /dev/null +++ b/ee/lib/gitlab/llm/q_ai/client.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module QAi + class Client + def initialize(user) + @user = user + end + + def perform_create_auth_application(oauth_app, secret, role_arn) + payload = { + client_id: oauth_app.uid.to_s, + client_secret: secret, + redirect_url: oauth_app.redirect_uri, + instance_url: Gitlab.config.gitlab.url, + role_arn: role_arn + } + + Gitlab::HTTP.post( + "#{url}/v1/amazon_q/oauth/application", + body: payload.to_json, + headers: request_headers + ) + end + + private + + attr_reader :user + + def url + Gitlab::AiGateway.url + end + + def service_name + :amazon_q_integration + end + + def service + ::CloudConnector::AvailableServices.find_by_name(service_name) + end + + def request_headers + { + "Accept" => "application/json", + # Note: In this case, the service is the same as the unit primitive name + 'X-Gitlab-Unit-Primitive' => service_name.to_s + }.merge(Gitlab::AiGateway.headers(user: user, service: service)) + end + end + end + end +end diff --git a/ee/spec/lib/ai/amazon_q_spec.rb b/ee/spec/lib/ai/amazon_q_spec.rb index 99478bc780b65d..efef3acde9408f 100644 --- a/ee/spec/lib/ai/amazon_q_spec.rb +++ b/ee/spec/lib/ai/amazon_q_spec.rb @@ -46,4 +46,109 @@ 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..f03b1cb112d85a --- /dev/null +++ b/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb @@ -0,0 +1,46 @@ +# 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(:service_data) { instance_double(CloudConnector::SelfManaged::AvailableServiceData) } + + 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) + .perform_create_auth_application(oauth_app, secret, role_arn) + end + + before do + payload = { + client_id: oauth_app.uid.to_s, + client_secret: secret, + redirect_url: oauth_app.redirect_uri, + instance_url: Gitlab.config.gitlab.url, + role_arn: role_arn + } + + stub_request(:post, "#{Gitlab::AiGateway.url}/v1/amazon_q/oauth/application") + .with(body: payload.to_json) + .to_return(body: response) + end + + it 'makes expected HTTP post request' do + expect(service_data).to receive_messages( + name: 'amazon_q_integration', + access_token: 'cc_token' + ) + expect(::CloudConnector::AvailableServices).to receive(:find_by_name) + .with(:amazon_q_integration).and_return(service_data) + + 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 index e70eb1c73b5754..f759e0dc53d7a3 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 @@ -101,4 +101,34 @@ 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 404 when feature is unavailable' + + # rubocop: disable Layout/LineLength -- Wrapping won't work! + where(:amazon_q_ready, :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 + 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..41a4e9e321a126 --- /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/amazon_q/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: [:api, :read_repository, :write_repository, :"user:*"], + 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: Fri, 13 Dec 2024 01:13:39 -0600 Subject: [PATCH 2/2] Polish from code review - Update audit event q_onboarding_updated - Update use of " in spec - Update User.find to just use active record association --- config/audit_events/types/q_onbarding_updated.yml | 6 +++--- doc/user/compliance/audit_event_types.md | 2 +- ee/lib/ai/amazon_q.rb | 4 ++-- ee/spec/services/ai/amazon_q/create_service_spec.rb | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/config/audit_events/types/q_onbarding_updated.yml b/config/audit_events/types/q_onbarding_updated.yml index 3cf73a6fc73dae..b4353ee0c5ae51 100644 --- a/config/audit_events/types/q_onbarding_updated.yml +++ b/config/audit_events/types/q_onbarding_updated.yml @@ -1,10 +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 +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/508250 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175501 feature_category: ai_framework -milestone: "17.7" +milestone: '17.8' 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 a87959c2954145..a976d4a99818d5 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -49,7 +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 | +| [`q_onbarding_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175501) | Amazon Q instance settings changed | **{check-circle}** Yes | GitLab [17.8](https://gitlab.com/gitlab-org/gitlab/-/issues/508250) | Instance | ### Audit events diff --git a/ee/lib/ai/amazon_q.rb b/ee/lib/ai/amazon_q.rb index 5b532e4347e124..9cb7591b381ad0 100644 --- a/ee/lib/ai/amazon_q.rb +++ b/ee/lib/ai/amazon_q.rb @@ -18,7 +18,7 @@ def should_block_service_account?(availability:) 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) + service_account ||= ai_settings.amazon_q_service_account_user return ServiceResponse.success(message: "Service account not found. Nothing to do.") unless service_account @@ -30,7 +30,7 @@ def ensure_service_account_blocked!(current_user:, service_account: nil) 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) + service_account ||= ai_settings.amazon_q_service_account_user return ServiceResponse.error(message: "Service account not found.") unless service_account diff --git a/ee/spec/services/ai/amazon_q/create_service_spec.rb b/ee/spec/services/ai/amazon_q/create_service_spec.rb index 41a4e9e321a126..c43fdd669ffd95 100644 --- a/ee/spec/services/ai/amazon_q/create_service_spec.rb +++ b/ee/spec/services/ai/amazon_q/create_service_spec.rb @@ -96,7 +96,7 @@ expect(result.success?).to be(true) end - context "when q service account does not already exist" do + 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) @@ -104,7 +104,7 @@ end end - context "when q service account already exists" do + context 'when q service account already exists' do before do Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account.id) end @@ -115,8 +115,8 @@ end end - context "when an existing oauth application does not exist" do - it "creates a new oauth application" do + 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( @@ -153,12 +153,12 @@ end end - context "when an oauth application exists" do + 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 + 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| -- GitLab