From fd3f5e717973c08a59a775db53498cfa4035e811 Mon Sep 17 00:00:00 2001 From: Eduardo Bonet Date: Wed, 17 Dec 2025 23:25:38 +0100 Subject: [PATCH 1/3] Adds Ai::InstanceAccessibleEntityRule model Adds associations and validations related to for ai_instance_accessible_entity_rules table. Extracts some of the logic into a concern as it will be used later by the SaaS counterpart at namespace level EE: true --- .../ai/instance_accessible_entity_rule.rb | 13 ++++++ .../concerns/ai/accessible_entity_ruleable.rb | 28 +++++++++++++ ee/app/models/ee/namespace.rb | 5 +++ .../instance_accessible_entity_rule_spec.rb | 33 +++++++++++++++ ee/spec/models/ee/namespace_spec.rb | 1 + ...essible_entity_ruleable_shared_examples.rb | 40 +++++++++++++++++++ 6 files changed, 120 insertions(+) create mode 100644 ee/app/models/ai/instance_accessible_entity_rule.rb create mode 100644 ee/app/models/concerns/ai/accessible_entity_ruleable.rb create mode 100644 ee/spec/models/ai/instance_accessible_entity_rule_spec.rb create mode 100644 ee/spec/support/shared_examples/models/ai/accessible_entity_ruleable_shared_examples.rb 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 00000000000000..9eea42823f327e --- /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 00000000000000..be67fab0641139 --- /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 0342de54d8adb1..4a62a8775ea310 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 00000000000000..559d3b71908948 --- /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 40dff5223151bb..835295d8096036 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 00000000000000..fcfa9beec8aea1 --- /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 -- GitLab From ff8bedf713e21d245bb005210bbfa49fe09d170b Mon Sep 17 00:00:00 2001 From: Eduardo Bonet Date: Thu, 18 Dec 2025 13:38:54 +0100 Subject: [PATCH 2/3] Renames to Ai::FeatureAccessRule --- ..._entity_rule.rb => feature_access_rule.rb} | 6 ++-- .../concerns/ai/accessible_entity_ruleable.rb | 28 ------------------- .../concerns/ai/feature_access_ruleable.rb | 18 ++++++++++++ ee/app/models/ee/namespace.rb | 4 +-- .../ai_instance_accessible_entity_rules.rb | 2 +- ...le_spec.rb => feature_access_rule_spec.rb} | 4 +-- ee/spec/models/ee/namespace_spec.rb | 2 +- ...essible_entity_ruleable_shared_examples.rb | 2 +- 8 files changed, 28 insertions(+), 38 deletions(-) rename ee/app/models/ai/{instance_accessible_entity_rule.rb => feature_access_rule.rb} (52%) delete mode 100644 ee/app/models/concerns/ai/accessible_entity_ruleable.rb create mode 100644 ee/app/models/concerns/ai/feature_access_ruleable.rb rename ee/spec/models/ai/{instance_accessible_entity_rule_spec.rb => feature_access_rule_spec.rb} (84%) diff --git a/ee/app/models/ai/instance_accessible_entity_rule.rb b/ee/app/models/ai/feature_access_rule.rb similarity index 52% rename from ee/app/models/ai/instance_accessible_entity_rule.rb rename to ee/app/models/ai/feature_access_rule.rb index 9eea42823f327e..64ea26234f9467 100644 --- a/ee/app/models/ai/instance_accessible_entity_rule.rb +++ b/ee/app/models/ai/feature_access_rule.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module Ai - class InstanceAccessibleEntityRule < ApplicationRecord - include AccessibleEntityRuleable + class FeatureAccessRule < ApplicationRecord + include FeatureAccessRuleable self.table_name = 'ai_instance_accessible_entity_rules' belongs_to :through_namespace, class_name: 'Namespace', - inverse_of: :accessible_instance_ai_entities_through_namespace + inverse_of: :accessible_ai_features_on_instance end end diff --git a/ee/app/models/concerns/ai/accessible_entity_ruleable.rb b/ee/app/models/concerns/ai/accessible_entity_ruleable.rb deleted file mode 100644 index be67fab0641139..00000000000000 --- a/ee/app/models/concerns/ai/accessible_entity_ruleable.rb +++ /dev/null @@ -1,28 +0,0 @@ -# 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/concerns/ai/feature_access_ruleable.rb b/ee/app/models/concerns/ai/feature_access_ruleable.rb new file mode 100644 index 00000000000000..458d3848d8c031 --- /dev/null +++ b/ee/app/models/concerns/ai/feature_access_ruleable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ai + module FeatureAccessRuleable + 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] }, + inclusion: { in: %w[duo_classic duo_agents duo_flows] } + + alias_attribute :feature, :accessible_entity + end + end +end diff --git a/ee/app/models/ee/namespace.rb b/ee/app/models/ee/namespace.rb index 4a62a8775ea310..d510ba2061cd85 100644 --- a/ee/app/models/ee/namespace.rb +++ b/ee/app/models/ee/namespace.rb @@ -68,8 +68,8 @@ module Namespace 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', + has_many :accessible_ai_features_on_instance, + class_name: 'Ai::FeatureAccessRule', inverse_of: :through_namespace, foreign_key: 'through_namespace_id' accepts_nested_attributes_for :gitlab_subscription, update_only: true diff --git a/ee/spec/factories/ai_instance_accessible_entity_rules.rb b/ee/spec/factories/ai_instance_accessible_entity_rules.rb index b1e66b21dc0f41..ca482833ec50df 100644 --- a/ee/spec/factories/ai_instance_accessible_entity_rules.rb +++ b/ee/spec/factories/ai_instance_accessible_entity_rules.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ai_instance_accessible_entity_rules, class: 'Ai::InstanceAccessibleEntityRule' do + factory :ai_instance_accessible_entity_rules, class: 'Ai::FeatureAccessRule' do association :through_namespace, factory: :namespace accessible_entity { 'duo_classic' } created_at { Time.zone.now } diff --git a/ee/spec/models/ai/instance_accessible_entity_rule_spec.rb b/ee/spec/models/ai/feature_access_rule_spec.rb similarity index 84% rename from ee/spec/models/ai/instance_accessible_entity_rule_spec.rb rename to ee/spec/models/ai/feature_access_rule_spec.rb index 559d3b71908948..9d00c297d7c9d7 100644 --- a/ee/spec/models/ai/instance_accessible_entity_rule_spec.rb +++ b/ee/spec/models/ai/feature_access_rule_spec.rb @@ -2,13 +2,13 @@ require 'spec_helper' -RSpec.describe ::Ai::InstanceAccessibleEntityRule, feature_category: :ai_abstraction_layer do +RSpec.describe ::Ai::FeatureAccessRule, 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) } + it { is_expected.to belong_to(:through_namespace).inverse_of(:accessible_ai_features_on_instance) } end describe 'bulk insert' do diff --git a/ee/spec/models/ee/namespace_spec.rb b/ee/spec/models/ee/namespace_spec.rb index 835295d8096036..0617c386145014 100644 --- a/ee/spec/models/ee/namespace_spec.rb +++ b/ee/spec/models/ee/namespace_spec.rb @@ -35,7 +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 have_many(:accessible_ai_features_on_instance).class_name('Ai::FeatureAccessRule') } 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 index fcfa9beec8aea1..1c7d61cb0dd45d 100644 --- 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 @@ -26,7 +26,7 @@ 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') + expect(record.errors[:accessible_entity]).to include('is not included in the list') end end end -- GitLab From 6ab2e56542c2b5a49c7b061bd1b163a32ada8acc Mon Sep 17 00:00:00 2001 From: Eduardo Bonet Date: Thu, 18 Dec 2025 14:21:37 +0100 Subject: [PATCH 3/3] Updates db doc file --- db/docs/ai_instance_accessible_entity_rules.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/docs/ai_instance_accessible_entity_rules.yml b/db/docs/ai_instance_accessible_entity_rules.yml index acbe7a5220c290..474050b33cd880 100644 --- a/db/docs/ai_instance_accessible_entity_rules.yml +++ b/db/docs/ai_instance_accessible_entity_rules.yml @@ -1,7 +1,7 @@ --- table_name: ai_instance_accessible_entity_rules classes: -- Ai::InstanceAccessibleEntityRule +- Ai::FeatureAccessRule feature_categories: - ai_abstraction_layer description: Stores instance-level AI accessible entity rules that define which AI -- GitLab