diff --git a/ee/app/models/ai/instance_accessible_entity_rule.rb b/ee/app/models/ai/instance_accessible_entity_rule.rb new file mode 100644 index 0000000000000000000000000000000000000000..9eea42823f327ed57c4aedbe43f2e2205938d5af --- /dev/null +++ b/ee/app/models/ai/instance_accessible_entity_rule.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ai + class InstanceAccessibleEntityRule < ApplicationRecord + include AccessibleEntityRuleable + + self.table_name = 'ai_instance_accessible_entity_rules' + + belongs_to :through_namespace, + class_name: 'Namespace', + inverse_of: :accessible_instance_ai_entities_through_namespace + end +end diff --git a/ee/app/models/concerns/ai/accessible_entity_ruleable.rb b/ee/app/models/concerns/ai/accessible_entity_ruleable.rb new file mode 100644 index 0000000000000000000000000000000000000000..be67fab0641139512b9dd84c4d5073e0196f5cb1 --- /dev/null +++ b/ee/app/models/concerns/ai/accessible_entity_ruleable.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ai + module AccessibleEntityRuleable + extend ActiveSupport::Concern + + included do + include BulkInsertSafe + + validates :through_namespace_id, :accessible_entity, presence: true + validates :accessible_entity, length: { maximum: 255 }, uniqueness: { scope: [:through_namespace_id] } + + validate :accessible_entity_exists + + def access_entities + %w[duo_classic duo_agents duo_flows] + end + end + + private + + def accessible_entity_exists + return if access_entities.include?(accessible_entity) + + errors.add(:accessible_entity, "#{accessible_entity} is not a valid access entity") + end + end +end diff --git a/ee/app/models/ee/namespace.rb b/ee/app/models/ee/namespace.rb index 14efb9757a01302f3a6ac6ac940109668e6f9ce6..5ec17a311c455cf442d8ef2dab29b7d96f1582e2 100644 --- a/ee/app/models/ee/namespace.rb +++ b/ee/app/models/ee/namespace.rb @@ -67,6 +67,11 @@ module Namespace has_many :enabled_foundational_flow_records, class_name: 'Ai::Catalog::EnabledFoundationalFlow', foreign_key: :namespace_id, inverse_of: :namespace + # Instance-level accessible entity rules, will only have values on SM + has_many :accessible_instance_ai_entities_through_namespace, + class_name: 'Ai::InstanceAccessibleEntityRule', + inverse_of: :through_namespace, foreign_key: 'through_namespace_id' + accepts_nested_attributes_for :gitlab_subscription, update_only: true accepts_nested_attributes_for :namespace_limit accepts_nested_attributes_for :ai_settings, update_only: true diff --git a/ee/spec/models/ai/instance_accessible_entity_rule_spec.rb b/ee/spec/models/ai/instance_accessible_entity_rule_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..559d3b7190894827ce4dacbdb62c8dfa78b7c321 --- /dev/null +++ b/ee/spec/models/ai/instance_accessible_entity_rule_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ai::InstanceAccessibleEntityRule, feature_category: :ai_abstraction_layer do + let_it_be(:through_namespace) { create(:group) } + + include_examples 'accessible entity ruleable' + + describe 'associations' do + it { is_expected.to belong_to(:through_namespace).inverse_of(:accessible_instance_ai_entities_through_namespace) } + end + + describe 'bulk insert' do + let_it_be(:namespace1) { create(:group) } + let_it_be(:namespace2) { create(:group) } + + it 'bulk inserts multiple records' do + records = [ + build(:ai_instance_accessible_entity_rules, + :duo_classic, + through_namespace: namespace1 + ), + build(:ai_instance_accessible_entity_rules, + :duo_agents, + through_namespace: namespace2 + ) + ] + + expect { described_class.bulk_insert!(records) }.to change { described_class.count }.by(2) + end + end +end diff --git a/ee/spec/models/ee/namespace_spec.rb b/ee/spec/models/ee/namespace_spec.rb index 78e9ac95929c3a5485f633a2bd96d08243534725..67b9b59e0ae29975bbef780508fc085f94c5dac2 100644 --- a/ee/spec/models/ee/namespace_spec.rb +++ b/ee/spec/models/ee/namespace_spec.rb @@ -35,6 +35,7 @@ it { is_expected.to have_many(:custom_statuses).class_name('WorkItems::Statuses::Custom::Status') } it { is_expected.to have_many(:converted_statuses).class_name('WorkItems::Statuses::Custom::Status') } it { is_expected.to have_many(:enabled_foundational_flow_records).class_name('Ai::Catalog::EnabledFoundationalFlow') } + it { is_expected.to have_many(:accessible_instance_ai_entities_through_namespace).class_name('Ai::InstanceAccessibleEntityRule') } it { is_expected.to delegate_method(:trial?).to(:gitlab_subscription) } it { is_expected.to delegate_method(:trial_ends_on).to(:gitlab_subscription) } diff --git a/ee/spec/support/shared_examples/models/ai/accessible_entity_ruleable_shared_examples.rb b/ee/spec/support/shared_examples/models/ai/accessible_entity_ruleable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..fcfa9beec8aea159ca694ddebd97cfefcf369085 --- /dev/null +++ b/ee/spec/support/shared_examples/models/ai/accessible_entity_ruleable_shared_examples.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'accessible entity ruleable' do + describe 'validations' do + subject { described_class.new } + + it { is_expected.to validate_presence_of(:through_namespace_id) } + it { is_expected.to validate_presence_of(:accessible_entity) } + it { is_expected.to validate_length_of(:accessible_entity).is_at_most(255) } + + describe 'accessible_entity_exists validation' do + context 'with valid access entity' do + %w[duo_classic duo_agents duo_flows].each do |entity| + it "accepts #{entity}" do + record = described_class.new( + through_namespace_id: through_namespace.id, accessible_entity: entity + ) + expect(record.errors[:accessible_entity]).to be_empty + end + end + end + + context 'with invalid access entity' do + it 'adds error' do + record = described_class.new( + through_namespace_id: through_namespace.id, accessible_entity: 'invalid_entity' + ) + expect(record).not_to be_valid + expect(record.errors[:accessible_entity]).to include('invalid_entity is not a valid access entity') + end + end + end + end + + describe 'BulkInsertSafe inclusion' do + it 'includes BulkInsertSafe' do + expect(described_class.included_modules).to include(BulkInsertSafe) + end + end +end