diff --git a/app/services/service_response.rb b/app/services/service_response.rb index fa495d164684f78df2ed2cf33660357960258729..3db7247accb91ca3a63b1e607f6eee5847cb8f39 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 0000000000000000000000000000000000000000..b4353ee0c5ae51e223da5aa75249ce9f650f2db0 --- /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-org/gitlab/-/issues/508250 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175501 +feature_category: ai_framework +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 fb1f1479292fa43a7c5001d6c9a38c5314b80188..a976d4a99818d5212c84061ca3ae60704f5b4cfb 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-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/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb index 11fe50ca3df4682bcc77026bacbc5510eaed6bfb..c8525a13e1921778db319badc9d3a6178913eacb 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 0000000000000000000000000000000000000000..4e02567379c791221729b521d232ea55caee30be --- /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 0000000000000000000000000000000000000000..3055c5a20b0bd734491fce28ef958b94c89a4c5b --- /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 0000000000000000000000000000000000000000..1f5f9ce0afd3dc473f6dea520a8ff22859d161cc --- /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 82ae44a084ae5208daa6f299880f389ad5f6071e..2b7702243df239180e37dd85d2dbccaff7fc37f8 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 de071e2b6cdab7c5e9402fde4b0962a6309ac814..2e0b2d1d2951f9549913587d4be00ad0b0e78610 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 65c78eda346f594ab9b2f20ae447f3ae8a29b730..9cb7591b381ad089181bc97f6dffd3b3f3609af2 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 ||= ai_settings.amazon_q_service_account_user + + 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 ||= ai_settings.amazon_q_service_account_user + + 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 0000000000000000000000000000000000000000..69f87247f3febfed4a6dc2cc566b5e8c554b9d6d --- /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 99478bc780b65d8caf680a6dcef55e385cc45b41..efef3acde9408fe43acd1dc701f8daab8c3b59fc 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 0000000000000000000000000000000000000000..f03b1cb112d85adc05b94d971144ca0e78378e77 --- /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 e70eb1c73b57543c0f064b4e3ce6f98966664d3a..f759e0dc53d7a3090b1825771bde44ea48504afe 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 0000000000000000000000000000000000000000..c43fdd669ffd952a845ec9b7d0f8cb1c4fa28eb6 --- /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 0000000000000000000000000000000000000000..b0c12e400da5f3d9302d36d9970ca4de8628b8ee --- /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 Binary files /dev/null and b/lib/assets/images/bot_avatars/q_avatar.png differ diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 38a4a38fb132b93a365c39c2732503c98795fe49..4ac005f598b33b4a8373bf011d2e6898dafed55e 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -60,6 +60,8 @@ module Auth ADMIN_MODE_SCOPE = :admin_mode ADMIN_SCOPES = [SUDO_SCOPE, ADMIN_MODE_SCOPE, READ_SERVICE_PING_SCOPE].freeze + Q_SCOPES = [API_SCOPE, REPOSITORY_SCOPES].flatten.freeze + # Default scopes for OAuth applications that don't define their own DEFAULT_SCOPES = [API_SCOPE].freeze diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f8008bed9c337bc016c53c2be1e7bb1a30b85c90..2579d8cff878be1279da89c69f9072853a7d3add 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5907,6 +5907,9 @@ msgstr "" msgid "AmazonQ|Amazon Q Configuration" msgstr "" +msgid "AmazonQ|Amazon Q Settings have been saved." +msgstr "" + msgid "AmazonQ|Amazon Q will be turned off by default, but still be available to any groups or projects that have previously enabled it." msgstr "" @@ -5991,6 +5994,9 @@ msgstr "" msgid "AmazonQ|Something went wrong retrieving the identity provider payload." msgstr "" +msgid "AmazonQ|Something went wrong saving Amazon Q settings." +msgstr "" + msgid "AmazonQ|Status" msgstr "" diff --git a/spec/services/service_response_spec.rb b/spec/services/service_response_spec.rb index 0b7e71106b4fa99ffb1eebcb2b1e0b3263a97575..eea1d36ff942060cbaf5613bdcf889385299a17e 100644 --- a/spec/services/service_response_spec.rb +++ b/spec/services/service_response_spec.rb @@ -75,6 +75,36 @@ end end + describe '.from_legacy_hash' do + it 'with a ServiceResponse, returns the argument' do + response = described_class.success + + expect(described_class.from_legacy_hash(response)).to be(response) + end + + it 'with a Hash, builds a new ServiceResponse' do + hash = { + status: :error, + message: 'lorem ipsum', + payload: { foo: 123 } + } + + result = described_class.from_legacy_hash(hash) + + expect(result.class).to be(described_class) + expect(result.success?).to be(false) + expect(result.payload).to match(a_hash_including({ foo: 123 })) + expect(result.status).to be(:error) + expect(result.message).to be('lorem ipsum') + expect(result.http_status).to be_nil + expect(result.reason).to be_nil + end + + it 'throws if argument not expected type' do + expect { described_class.from_legacy_hash(123) }.to raise_error(ArgumentError) + end + end + describe '#success?' do it 'returns true for a successful response' do expect(described_class.success.success?).to eq(true)