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