diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 82c0011b5444d6252a2c5cf314da6a14722de0d6..47b9837cd0199a8d8fcfb05e0855f7cf789527e6 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2428,6 +2428,33 @@ Input type: `AiCatalogFlowUpdateInput` | `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | | `item` | [`AiCatalogItem`](#aicatalogitem) | Flow that was updated. | +### `Mutation.aiCatalogItemConsumerCreate` + +{{< details >}} +**Introduced** in GitLab 18.3. +**Status**: Experiment. +{{< /details >}} + +Input type: `AiCatalogItemConsumerCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `enabled` | [`Boolean`](#boolean) | Whether to enable the item. | +| `itemId` | [`AiCatalogItemID!`](#aicatalogitemid) | Item to configure. | +| `locked` | [`Boolean`](#boolean) | Whether to lock the item configuration (groups only). | +| `target` | [`ItemConsumerTargetInput!`](#itemconsumertargetinput) | Target in which the catalog item is configured. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | +| `itemConsumer` | [`AiCatalogItemConsumer`](#aicatalogitemconsumer) | Item configuration created. | + ### `Mutation.aiDuoWorkflowCreate` {{< details >}} @@ -52626,6 +52653,15 @@ Labels for the Node Pool of a GKE cluster. | `projectId` | [`ProjectID`](#projectid) | Filter compliance requirement statuses by project. | | `requirementId` | [`ComplianceManagementComplianceFrameworkComplianceRequirementID`](#compliancemanagementcomplianceframeworkcompliancerequirementid) | Filter compliance requirement statuses by compliance requirement. | +### `ItemConsumerTargetInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `groupId` | [`GroupID`](#groupid) | Group in which to configure the item. | +| `projectId` | [`ProjectID`](#projectid) | Project in which to configure the item. | + ### `JiraUsersMappingInputType` #### Arguments diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index f003458d119f2e596c13d4a2b9ea1992352bf142..45f12f974c0f220c4462c9d5b6e1c313a5a30ed6 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -16,6 +16,7 @@ def self.authorization_scopes mount_mutation ::Mutations::Ai::Catalog::Flow::Create, experiment: { milestone: '18.3' } mount_mutation ::Mutations::Ai::Catalog::Flow::Delete, experiment: { milestone: '18.3' } mount_mutation ::Mutations::Ai::Catalog::Flow::Update, experiment: { milestone: '18.3' } + mount_mutation ::Mutations::Ai::Catalog::ItemConsumer::Create, experiment: { milestone: '18.3' } mount_mutation ::Mutations::Ci::Catalog::VerifiedNamespace::Create mount_mutation ::Mutations::Ci::ProjectSubscriptions::Create mount_mutation ::Mutations::Ci::ProjectSubscriptions::Delete diff --git a/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb b/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..242339e2d64bc6aa52dc843f8c171a6dc7e11c75 --- /dev/null +++ b/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Mutations + module Ai + module Catalog + module ItemConsumer + class Create < BaseMutation + graphql_name 'AiCatalogItemConsumerCreate' + + field :item_consumer, + ::Types::Ai::Catalog::ItemConsumerType, + null: true, + description: 'Item configuration created.' + + argument :item_id, ::Types::GlobalIDType[::Ai::Catalog::Item], + required: true, + description: 'Item to configure.' + + argument :enabled, GraphQL::Types::Boolean, + required: false, + description: 'Whether to enable the item.' + + argument :locked, GraphQL::Types::Boolean, + required: false, + description: 'Whether to lock the item configuration (groups only).' + + argument :target, Types::Ai::Catalog::ItemConsumerTargetInputType, + required: true, + description: 'Target in which the catalog item is configured.' + + authorize :admin_ai_catalog_item_consumer + + def resolve(item_id:, target:, enabled: true, locked: true) + group_id = target[:group_id] + group = group_id ? authorized_find!(id: group_id) : nil + project_id = target[:project_id] + project = project_id ? authorized_find!(id: project_id) : nil + item = GitlabSchema.find_by_gid(item_id).sync + + raise_resource_not_available_error! unless item.flow? && allowed?(item) + + result = ::Ai::Catalog::ItemConsumers::CreateService.new( + container: group || project, + current_user: current_user, + params: { + item: item, + enabled: enabled, + locked: locked + } + ).execute + + { item_consumer: result.payload&.dig(:item_consumer), errors: result.errors } + end + + private + + def allowed?(item) + Ability.allowed?(current_user, :read_ai_catalog_item, item) + end + end + end + end + end +end diff --git a/ee/app/graphql/types/ai/catalog/item_consumer_target_input_type.rb b/ee/app/graphql/types/ai/catalog/item_consumer_target_input_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f2ea9ad1320a52f5e0f83f869ddce55b41bfe17 --- /dev/null +++ b/ee/app/graphql/types/ai/catalog/item_consumer_target_input_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ai + module Catalog + class ItemConsumerTargetInputType < BaseInputObject + graphql_name 'ItemConsumerTargetInput' + + one_of + + argument :group_id, ::Types::GlobalIDType[::Group], + required: false, + description: 'Group in which to configure the item.' + + argument :project_id, ::Types::GlobalIDType[::Project], + required: false, + description: 'Project in which to configure the item.' + end + end + end +end diff --git a/ee/app/policies/ai/catalog/item_consumer_policy.rb b/ee/app/policies/ai/catalog/item_consumer_policy.rb index 039e4dac96a54b32624f93a5c437b05b1219ade3..cdb3219b66aac94757954a0b3bf592285aa2e11a 100644 --- a/ee/app/policies/ai/catalog/item_consumer_policy.rb +++ b/ee/app/policies/ai/catalog/item_consumer_policy.rb @@ -4,6 +4,7 @@ module Ai module Catalog class ItemConsumerPolicy < ::BasePolicy delegate { @subject.project } + delegate { @subject.group } end end end diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index 87b687545930a8805e4f4f4a4bee127d6be5f155..5eae8b841a1f90c97b9b17223cc8632f1798d2a7 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -327,6 +327,10 @@ module GroupPolicy ).allowed? end + condition(:ai_catalog_enabled, scope: :user) do + ::Feature.enabled?(:global_ai_catalog, @user) + end + rule { user_banned_from_namespace }.prevent_all rule { public_group | logged_in_viewable }.policy do @@ -364,6 +368,7 @@ module GroupPolicy enable :read_runner_usage enable :admin_push_rules enable :admin_security_testing + enable :admin_ai_catalog_item_consumer end rule { (admin | maintainer) & group_analytics_dashboards_available & ~has_parent }.policy do @@ -606,6 +611,7 @@ module GroupPolicy enable :read_group_audit_events enable :read_vulnerability_statistics enable :read_security_inventory + enable :read_ai_catalog_item_consumer end rule { security_orchestration_policies_enabled & can?(:developer_access) }.policy do @@ -1066,6 +1072,11 @@ module GroupPolicy subject.namespace_settings&.duo_features_enabled? end + rule { ~ai_catalog_enabled }.policy do + prevent :admin_ai_catalog_item_consumer + prevent :read_ai_catalog_item_consumer + end + rule { can?(:admin_group) & group_model_selection_enabled }.enable :admin_group_model_selection end diff --git a/ee/app/services/ai/catalog/item_consumers/create_service.rb b/ee/app/services/ai/catalog/item_consumers/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e86c7f53c05556438d81a5fa2f089b33852f5c39 --- /dev/null +++ b/ee/app/services/ai/catalog/item_consumers/create_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Ai + module Catalog + module ItemConsumers + class CreateService < ::BaseContainerService + def execute + return error_no_permissions unless allowed? + return error('Catalog item is not a flow') unless item.flow? + + params.merge!(project: project, group: group) + item_consumer = ::Ai::Catalog::ItemConsumer.create(params) + return ServiceResponse.success(payload: { item_consumer: item_consumer }) if item_consumer.save + + error_creating(item_consumer) + end + + private + + def item + params[:item] + end + + def error_creating(item_consumer) + error(item_consumer.errors.full_messages.presence || 'Failed to create item consumer') + end + + def allowed? + Ability.allowed?(current_user, :admin_ai_catalog_item_consumer, container) && + Ability.allowed?(current_user, :read_ai_catalog_item, item) + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + + def error_no_permissions + error('Item does not exist, or you have insufficient permissions') + end + end + end + end +end diff --git a/ee/spec/graphql/mutations/ai/catalog/item_consumer/create_spec.rb b/ee/spec/graphql/mutations/ai/catalog/item_consumer/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..77dea4eb389fdf98a21ae5eaa6e3aa3b070d8f3c --- /dev/null +++ b/ee/spec/graphql/mutations/ai/catalog/item_consumer/create_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ai::Catalog::ItemConsumer::Create, feature_category: :workflow_catalog do + include GraphqlHelpers + + subject(:mutation) { described_class } + + it { is_expected.to have_graphql_name('AiCatalogItemConsumerCreate') } + + it { expect(described_class).to require_graphql_authorizations(:admin_ai_catalog_item_consumer) } + + it { is_expected.to have_graphql_fields(:item_consumer, :errors, :client_mutation_id) } + + it { is_expected.to have_graphql_arguments(:item_id, :target, :enabled, :locked, :client_mutation_id) } +end diff --git a/ee/spec/policies/ai/catalog/item_consumer_policy_spec.rb b/ee/spec/policies/ai/catalog/item_consumer_policy_spec.rb index b55e9b6161afa19ffcaa1811f932c0efc72b8a63..d5697a71e93b3d965025574ca91adf5c3875529b 100644 --- a/ee/spec/policies/ai/catalog/item_consumer_policy_spec.rb +++ b/ee/spec/policies/ai/catalog/item_consumer_policy_spec.rb @@ -5,12 +5,23 @@ RSpec.describe Ai::Catalog::ItemConsumerPolicy, feature_category: :duo_chat do subject(:policy) { described_class.new(nil, item_consumer) } - let_it_be(:item_consumer) { build(:ai_catalog_item_consumer, project: build(:project)) } + context 'when item consumer belongs to a project' do + let(:item_consumer) { build_stubbed(:ai_catalog_item_consumer, project: build_stubbed(:project)) } - it 'delegates to ProjectPolicy' do - delegations = policy.delegated_policies + it 'delegates to ProjectPolicy' do + delegations = policy.delegated_policies - expect(delegations.size).to eq(1) - expect(delegations.each_value.first).to be_instance_of(::ProjectPolicy) + expect(delegations.values).to include(an_instance_of(::ProjectPolicy)) + end + end + + context 'when item consumer belongs to a group' do + let(:item_consumer) { build_stubbed(:ai_catalog_item_consumer, group: build_stubbed(:group)) } + + it 'delegates to ProjectPolicy' do + delegations = policy.delegated_policies + + expect(delegations.values).to include(an_instance_of(::GroupPolicy)) + end end end diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb index 1250b6cf28896339a61add0fd8cc1c17ef170ca9..7dc8e196a3f63ef65bc301adb0e8c4a39c329e10 100644 --- a/ee/spec/policies/group_policy_spec.rb +++ b/ee/spec/policies/group_policy_spec.rb @@ -5182,4 +5182,36 @@ def create_member_role(member, abilities = member_role_abilities) end end end + + describe 'AI catalog abilities' do + let(:current_user) { maintainer } + + context 'with global_ai_catalog feature flag enabled' do + context 'when maintainer' do + it { is_expected.to be_allowed(:admin_ai_catalog_item_consumer) } + end + + context 'when developer' do + let(:current_user) { developer } + + it { is_expected.to be_disallowed(:admin_ai_catalog_item_consumer) } + it { is_expected.to be_allowed(:read_ai_catalog_item_consumer) } + end + + context 'when reporter' do + let(:current_user) { reporter } + + it { is_expected.to be_disallowed(:read_ai_catalog_item_consumer) } + end + end + + context 'when global_ai_catalog feature flag is disabled' do + before do + stub_feature_flags(global_ai_catalog: false) + end + + it { is_expected.to be_disallowed(:admin_ai_catalog_item_consumer) } + it { is_expected.to be_disallowed(:read_ai_catalog_item_consumer) } + end + end end diff --git a/ee/spec/requests/api/graphql/mutations/ai/catalog/item_consumer/create_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/item_consumer/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fad4500c07516a1744a4241015805ad4040d2a68 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/ai/catalog/item_consumer/create_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ai::Catalog::ItemConsumer::Create, feature_category: :workflow_catalog do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:consumer_group) { create(:group, maintainers: user) } + let_it_be(:consumer_project) { create(:project, group: consumer_group) } + + let_it_be(:item_project) { create(:project, developers: user) } + let_it_be(:item) { create(:ai_catalog_item, item_type: :flow, project: item_project) } + + let(:current_user) { user } + let(:mutation) { graphql_mutation(:ai_catalog_item_consumer_create, params) } + let(:target) { { project_id: consumer_project.to_global_id } } + let(:params) do + { + target: target, + item_id: item.to_global_id + } + end + + subject(:execute) { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'an authorization failure' do + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create a catalog item consumer' do + expect { execute }.not_to change { Ai::Catalog::ItemConsumer.count } + end + end + + context 'when user is not authorized to create a consumer item in the consumer project' do + let(:current_user) do + create(:user).tap do |user| + consumer_project.add_developer(user) + item_project.add_developer(user) + end + end + + it_behaves_like 'an authorization failure' + end + + context 'when user is not authorized to read the catalog item' do + let(:current_user) do + create(:user).tap do |user| + consumer_project.add_maintainer(user) + item_project.add_reporter(user) + end + end + + it_behaves_like 'an authorization failure' + end + + context 'when target argument is provided neither group_id or project_id' do + let(:target) { {} } + + it_behaves_like 'an invalid argument to the mutation', argument_name: :target + end + + context 'when target is provided both group_id or project_id are provided' do + let(:target) { super().merge({ group_id: consumer_group.to_global_id }) } + + it_behaves_like 'an invalid argument to the mutation', argument_name: :target + end + + context 'when the item is not a flow' do + let(:item) { create(:ai_catalog_item, item_type: :agent, project: item_project) } + + it_behaves_like 'an authorization failure' + end + + context 'when global_ai_catalog feature flag is disabled' do + before do + stub_feature_flags(global_ai_catalog: false) + end + + it_behaves_like 'an authorization failure' + end + + it 'creates a catalog item consumer with expected data' do + execute + + expect(graphql_data_at(:ai_catalog_item_consumer_create, :item_consumer)).to match a_hash_including( + 'item' => a_hash_including('id' => item.to_global_id.to_s), + 'project' => a_hash_including('id' => consumer_project.to_global_id.to_s), + 'enabled' => true, + 'locked' => true + ) + end + + context 'with a group_id' do + let(:params) do + { item_id: item.to_global_id, target: { group_id: consumer_group.to_global_id }, enabled: false, locked: false } + end + + it 'creates a catalog item consumer with expected data' do + execute + + expect(graphql_data_at(:ai_catalog_item_consumer_create, :item_consumer)).to match a_hash_including( + 'item' => a_hash_including('id' => item.to_global_id.to_s), + 'group' => a_hash_including('id' => consumer_group.to_global_id.to_s), + 'enabled' => false, + 'locked' => false + ) + end + end +end diff --git a/ee/spec/services/ai/catalog/item_consumers/create_service_spec.rb b/ee/spec/services/ai/catalog/item_consumers/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d30068b98bd73b60026cdc87a160bb7c8716509 --- /dev/null +++ b/ee/spec/services/ai/catalog/item_consumers/create_service_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::Catalog::ItemConsumers::CreateService, feature_category: :workflow_catalog do + let_it_be(:user) { create(:user) } + let_it_be(:consumer_group) { create(:group, maintainers: user) } + let_it_be(:consumer_project) { create(:project, group: consumer_group) } + + let_it_be(:item_project) { create(:project, developers: user) } + let_it_be(:item) { create(:ai_catalog_item, item_type: :flow, project: item_project) } + + let(:container) { consumer_project } + let(:params) do + { + item: item, + enabled: true, + locked: true + } + end + + subject(:execute) { described_class.new(container: container, current_user: user, params: params).execute } + + shared_examples 'a failure' do |message| + it 'does not create a catalog item consumer' do + expect { execute }.not_to change { Ai::Catalog::ItemConsumer.count } + end + + it 'returns failure response with expected message' do + response = execute + + expect(response).to be_error + expect(response.message).to contain_exactly(message) + end + end + + it 'creates a catalog item consumer with expected data' do + execute + + expect(Ai::Catalog::ItemConsumer.last).to have_attributes( + project: consumer_project, + group: nil, + item: item, + enabled: true, + locked: true + ) + end + + context 'when the consumer is a group' do + let(:container) { consumer_group } + + it 'creates a catalog item consumer with expected data' do + execute + + expect(Ai::Catalog::ItemConsumer.last).to have_attributes( + project: nil, + group: consumer_group, + item: item, + enabled: true, + locked: true + ) + end + end + + context 'when user is not authorized to create a consumer item in the consumer project' do + let(:user) do + create(:user).tap do |user| + consumer_project.add_developer(user) + item_project.add_developer(user) + end + end + + it_behaves_like 'a failure', 'Item does not exist, or you have insufficient permissions' + end + + context 'when user is not authorized to read the catalog item' do + let(:user) do + create(:user).tap do |user| + consumer_project.add_maintainer(user) + item_project.add_reporter(user) + end + end + + it_behaves_like 'a failure', 'Item does not exist, or you have insufficient permissions' + end + + context 'when the item is not a flow' do + let(:item) { create(:ai_catalog_item, item_type: :agent, project: item_project) } + + it_behaves_like 'a failure', 'Catalog item is not a flow' + end + + context 'when global_ai_catalog feature flag is disabled' do + before do + stub_feature_flags(global_ai_catalog: false) + end + + it_behaves_like 'a failure', 'Item does not exist, or you have insufficient permissions' + end +end