From 90257b370c31648a9629eb352338ef7dfb4a2ff7 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Tue, 7 Oct 2025 22:51:45 +0800 Subject: [PATCH 01/12] Add duo_default_namespace reader and validation --- config/locales/en.yml | 6 ++ ee/app/models/ee/user.rb | 1 + ee/app/models/ee/user_preference.rb | 34 ++++++++ ee/spec/models/user_preference_spec.rb | 106 ++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index a91962d5f92bed..96318d1cdb4082 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -55,6 +55,12 @@ en: location: 'Location' organization: 'Organization' website_url: 'Website url' + errors: + models: + user_preference: + attributes: + duo_default_namespace_id: + invalid: "specified does not allow you to execute a workflow." views: pagination: previous: "Prev" diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb index 004b456a17c28c..68507ed6a4da05 100644 --- a/ee/app/models/ee/user.rb +++ b/ee/app/models/ee/user.rb @@ -66,6 +66,7 @@ module User to: :user_detail, allow_nil: true delegate :enabled_zoekt?, :enabled_zoekt, :enabled_zoekt=, + :duo_default_namespace_id, :duo_default_namespace_id=, to: :user_preference delegate :policy_advanced_editor?, :policy_advanced_editor, :policy_advanced_editor=, diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index a4ecacf366a43d..e009ce27689d9e 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -5,7 +5,10 @@ module UserPreference extend ActiveSupport::Concern prepended do + extend ::Gitlab::Utils::Override + belongs_to :default_duo_add_on_assignment, class_name: 'GitlabSubscriptions::UserAddOnAssignment', optional: true + belongs_to :duo_default_namespace, class_name: 'Namespace', optional: true validates :roadmap_epics_state, allow_nil: true, inclusion: { in: ::Epic.available_states.values, message: "%{value} is not a valid epic state id" @@ -14,11 +17,24 @@ module UserPreference validates :epic_notes_filter, inclusion: { in: ::UserPreference::NOTES_FILTERS.values }, presence: true validate :check_seat_for_default_duo_assigment, if: :default_duo_add_on_assignment_id_changed? + validate :validate_duo_default_namespace_id, if: :duo_default_namespace_id_changed? + + def duo_default_namespace_candidates + if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) + distinct_eligible_duo_add_on_assignments.map(&:namespace) + else + namespaces = user.authorized_groups.top_level.to_a + namespaces.append(user.namespace) if user.namespace + namespaces + end + end validates :policy_advanced_editor, allow_nil: false, inclusion: { in: [true, false] } attribute :policy_advanced_editor, default: false + # EE:SaaS - namespace with seats for SAAS purpose only + # See https://gitlab.com/gitlab-org/gitlab/-/issues/557584 def eligible_duo_add_on_assignments assignable_enum_value = ::GitlabSubscriptions::AddOn.names.values_at( *::GitlabSubscriptions::AddOn::SEAT_ASSIGNABLE_DUO_ADD_ONS @@ -60,6 +76,24 @@ def get_default_duo_namespace assignments.first.add_on_purchase.namespace end + + override :duo_default_namespace + def duo_default_namespace + namespace = super + namespace if namespace && user.can?(:read_namespace, namespace) + end + + private + + def validate_duo_default_namespace_id + return unless duo_default_namespace_id + + valid = duo_default_namespace_candidates.any? { |n| n.id == duo_default_namespace_id } + + return if valid + + errors.add(:duo_default_namespace_id) + end end end end diff --git a/ee/spec/models/user_preference_spec.rb b/ee/spec/models/user_preference_spec.rb index c2b2c26fc40b0c..7d5f2f75e89be3 100644 --- a/ee/spec/models/user_preference_spec.rb +++ b/ee/spec/models/user_preference_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe UserPreference do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :with_namespace) } let(:user_preference) { create(:user_preference, user: user) } @@ -39,6 +39,12 @@ .class_name('GitlabSubscriptions::UserAddOnAssignment') .optional end + + it 'belongs to duo_default_namespace optionally' do + is_expected.to belong_to(:duo_default_namespace) + .class_name('Namespace') + .optional + end end describe 'roadmap_epics_state' do @@ -232,6 +238,46 @@ end end + describe '#duo_default_namespace_candidates', feature_category: :ai_abstraction_layer do + context 'when SaaS', :saas do + before do + stub_saas_features(duo_chat_on_saas: true) + end + + context 'when user has eligible duo add-on assignments' do + include_context 'with multiple user add-on assignments' + + it 'returns distinct eligible duo add-on assignments' do + result = user_preference.duo_default_namespace_candidates + + expect(result.count).to eq(2) + end + end + + context 'when user has no eligible duo add-on assignments' do + it 'is empty' do + result = user_preference.duo_default_namespace_candidates + + expect(result).to be_empty + end + end + end + + context 'when Self-Managed' do + let_it_be(:top_level_group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: top_level_group) } + + it 'returns top level authorized groups and user namespace' do + top_level_group.add_maintainer(user) + subgroup.add_maintainer(user) + + result = user_preference.duo_default_namespace_candidates + + expect(result).to eq([top_level_group, user.namespace]) + end + end + end + describe '#get_default_duo_namespace', :saas do context 'when there are multiple eligible duo add-on assignments' do include_context 'with multiple user add-on assignments' @@ -308,4 +354,62 @@ end end end + + describe '#duo_default_namespace' do + let_it_be(:namespace) { create(:group, :private) } + + it 'returns the namespace when accessible' do + namespace.add_developer(user) + + user_preference.duo_default_namespace = namespace + + expect(user_preference.duo_default_namespace).to eq(namespace) + end + + it 'returns nil when not accessible' do + user_preference.duo_default_namespace_id = namespace.id + + expect(user_preference.duo_default_namespace).to be_nil + end + + it 'returns nil when duo_default_namespace is nil' do + user_preference.duo_default_namespace = nil + + expect(user_preference.duo_default_namespace).to be_nil + end + end + + describe '#validate_duo_default_namespace_id' do + let_it_be(:namespace) { create(:group, :private) } + + def expect_valid(valid) + expect(user_preference.valid?).to eq(valid) + expect(user_preference.errors.added?(:duo_default_namespace_id, :invalid)).to be true unless valid + end + + it 'is valid when duo_default_namespace_id is nil' do + user_preference.duo_default_namespace_id = nil + + expect_valid(true) + end + + it 'is valid when user has read access to the namespace' do + namespace.add_developer(user) + user_preference.duo_default_namespace_id = namespace.id + + expect_valid(true) + end + + it 'adds error when user does not have read access to the namespace' do + user_preference.duo_default_namespace_id = namespace.id + + expect_valid(false) + end + + it 'adds error when duo_default_namespace is set directly instead of duo_default_namespace_id' do + user_preference.duo_default_namespace = namespace + + expect_valid(false) + end + end end -- GitLab From 5dfa04105607e095615acd71d1b17fe5b164a1b9 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Tue, 14 Oct 2025 11:06:27 +0800 Subject: [PATCH 02/12] Assign assignment_id instead in SaaS as it is the single source of truth due to need to nullify assignment_id when seat became absent. Allow finding assignment based on namespace --- ee/app/models/ee/user_preference.rb | 14 ++++++++ ee/spec/models/user_preference_spec.rb | 50 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index e009ce27689d9e..95aac06e029d3a 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -83,6 +83,14 @@ def duo_default_namespace namespace if namespace && user.can?(:read_namespace, namespace) end + def duo_default_namespace_id=(namespace_id) + if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) + self.default_duo_add_on_assignment_id = find_user_add_on_assignment_id_for_namespace_id(namespace_id) + else + super + end + end + private def validate_duo_default_namespace_id @@ -94,6 +102,12 @@ def validate_duo_default_namespace_id errors.add(:duo_default_namespace_id) end + + def find_user_add_on_assignment_id_for_namespace_id(namespace_id) + return if namespace_id.blank? + + eligible_duo_add_on_assignments.where(add_on_purchase: { namespace_id: namespace_id }).pick(:id) + end end end end diff --git a/ee/spec/models/user_preference_spec.rb b/ee/spec/models/user_preference_spec.rb index 7d5f2f75e89be3..c29f74ba4072ee 100644 --- a/ee/spec/models/user_preference_spec.rb +++ b/ee/spec/models/user_preference_spec.rb @@ -379,6 +379,56 @@ end end + describe '#duo_default_namespace_id=' do + let_it_be(:namespace) { create(:group, :private) } + + def expect_assignment_id_and_namespace_id(assignment_id, namespace_id) + expect(user_preference).to have_attributes( + default_duo_add_on_assignment_id: assignment_id, + duo_default_namespace_id: namespace_id + ) + end + + context 'when SaaS feature is available', :saas do + include_context 'with multiple user add-on assignments' + + before do + stub_saas_features(duo_chat_on_saas: true) + end + + it 'sets default_duo_add_on_assignment_id' do + assignment = user_assignments.first + user_preference.duo_default_namespace_id = assignment.add_on_purchase.namespace_id + + expect_assignment_id_and_namespace_id(assignment.id, nil) + + user_preference.duo_default_namespace_id = nil + + expect_assignment_id_and_namespace_id(nil, nil) + end + + it 'sets default_duo_add_on_assignment_id to nil when no assignment exists for namespace' do + namespace = create(:namespace) + user_preference.duo_default_namespace_id = namespace.id + + expect_assignment_id_and_namespace_id(nil, nil) + end + end + + context 'when self-managed' do + it 'sets duo_default_namespace_id as is' do + namespace.add_reporter(user) + user_preference.duo_default_namespace_id = namespace.id + + expect_assignment_id_and_namespace_id(nil, namespace.id) + + user_preference.duo_default_namespace_id = nil + + expect_assignment_id_and_namespace_id(nil, nil) + end + end + end + describe '#validate_duo_default_namespace_id' do let_it_be(:namespace) { create(:group, :private) } -- GitLab From be0c9355bb7e4b7d1965198e505d24190691ea6f Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Mon, 20 Oct 2025 15:01:55 +0800 Subject: [PATCH 03/12] Make candidate to include Duo Core --- ee/app/models/concerns/ai/user_authorizable.rb | 11 ++++++----- ee/app/models/ee/user_preference.rb | 2 +- ee/spec/models/user_preference_spec.rb | 11 +++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ee/app/models/concerns/ai/user_authorizable.rb b/ee/app/models/concerns/ai/user_authorizable.rb index f11d5d37b953f4..64ee6448ed36f8 100644 --- a/ee/app/models/concerns/ai/user_authorizable.rb +++ b/ee/app/models/concerns/ai/user_authorizable.rb @@ -41,6 +41,12 @@ def duo_available_namespace_ids end end + # EE:SaaS + def groups_with_duo_core_enabled + Namespace.id_in(billable_gitlab_duo_pro_root_group_ids) + .namespace_settings_with_duo_core_features_enabled + end + # Returns namespace IDs where user has Duo Core # access through namespace-level settings. # We currently provide an alternative pathway to Duo Core features beyond add-on purchase @@ -228,11 +234,6 @@ def denied_response Response.new(allowed?: false, namespace_ids: [], authorized_by_duo_core: false) end - def groups_with_duo_core_enabled - Namespace.id_in(billable_gitlab_duo_pro_root_group_ids) - .namespace_settings_with_duo_core_features_enabled - end - def duo_core_add_on? duo_core_add_on_purchase.present? end diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 95aac06e029d3a..5f42925308361e 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -21,7 +21,7 @@ module UserPreference def duo_default_namespace_candidates if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) - distinct_eligible_duo_add_on_assignments.map(&:namespace) + user.groups_with_duo_core_enabled else namespaces = user.authorized_groups.top_level.to_a namespaces.append(user.namespace) if user.namespace diff --git a/ee/spec/models/user_preference_spec.rb b/ee/spec/models/user_preference_spec.rb index c29f74ba4072ee..b8646ae19c353f 100644 --- a/ee/spec/models/user_preference_spec.rb +++ b/ee/spec/models/user_preference_spec.rb @@ -244,13 +244,16 @@ stub_saas_features(duo_chat_on_saas: true) end - context 'when user has eligible duo add-on assignments' do - include_context 'with multiple user add-on assignments' + let_it_be(:group) { create(:group) } + + context 'when user has access to namespaces with Duo Core enabled' do + it 'returns eligible namespaces' do + group.add_reporter(user) + group.update!(duo_core_features_enabled: true) - it 'returns distinct eligible duo add-on assignments' do result = user_preference.duo_default_namespace_candidates - expect(result.count).to eq(2) + expect(result.count).to eq(1) end end -- GitLab From 1bb1961806ebfa5426c7248ca695be4bd684f44f Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Mon, 20 Oct 2025 19:37:18 +0800 Subject: [PATCH 04/12] Make duo_default_namespace work with assignments returning nil if no assignment exists, which simulates SQL on delete nullify behavior. duo_default_namespace_id= to always persist namespace_id, as it will not be the SSOT. --- ee/app/models/ee/user_preference.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 5f42925308361e..3f55705be84e26 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -78,17 +78,24 @@ def get_default_duo_namespace end override :duo_default_namespace - def duo_default_namespace - namespace = super - namespace if namespace && user.can?(:read_namespace, namespace) + def duo_default_namespace(duo_pro_and_above: false) + namespace = super() + + return unless namespace + + if duo_pro_and_above + namespace if distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace.id).exists? + else + namespace if duo_default_namespace_candidates.include?(namespace) # rubocop:disable Style/IfInsideElse -- readability + end end def duo_default_namespace_id=(namespace_id) if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) self.default_duo_add_on_assignment_id = find_user_add_on_assignment_id_for_namespace_id(namespace_id) - else - super end + + super end private -- GitLab From 63519c7888591a8d98c02cc10fe3a8601bf28bce Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 12:34:48 +0800 Subject: [PATCH 05/12] Rewrite duo_default_namespace and handle Duo Core --- ee/app/models/ee/user_preference.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 3f55705be84e26..cffb88f602939f 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -78,15 +78,19 @@ def get_default_duo_namespace end override :duo_default_namespace - def duo_default_namespace(duo_pro_and_above: false) - namespace = super() + def duo_default_namespace + namespace = super return unless namespace - if duo_pro_and_above - namespace if distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace.id).exists? - else - namespace if duo_default_namespace_candidates.include?(namespace) # rubocop:disable Style/IfInsideElse -- readability + purchases = GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).by_namespace(namespace) + + if purchases.empty? + nil + elsif purchases.any? { |p| p.add_on_name == "duo_core" } + namespace + elsif distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace).exists? # rubocop:disable Lint/DuplicateBranch -- readability + namespace # Duo assignable add-on (e.g. Duo Pro) exists for user end end -- GitLab From ee0df01a8ee8b056372622174019b8fc872caf27 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 15:48:55 +0800 Subject: [PATCH 06/12] Merge old default namesapce method into the new so the method can fallback to use deprecated add-on assignment field --- ee/app/models/ee/user_preference.rb | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index cffb88f602939f..5d9152004dd671 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -81,18 +81,29 @@ def get_default_duo_namespace def duo_default_namespace namespace = super - return unless namespace + if namespace + purchases = GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).by_namespace(namespace) + + if purchases.empty? + return + elsif purchases.any? { |p| p.add_on_name == "duo_core" } + return namespace + elsif distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace).exists? # rubocop:disable Lint/DuplicateBranch -- readability + return namespace # Duo assignable add-on (e.g. Duo Pro) exists for user + end + end - purchases = GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).by_namespace(namespace) + # Fallback to deprecated add-on assignment field + return default_duo_add_on_assignment.namespace if default_duo_add_on_assignment.present? - if purchases.empty? - nil - elsif purchases.any? { |p| p.add_on_name == "duo_core" } - namespace - elsif distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace).exists? # rubocop:disable Lint/DuplicateBranch -- readability - namespace # Duo assignable add-on (e.g. Duo Pro) exists for user - end + # Fallback if only a single namespace is present in add-on assignments + assignments = distinct_eligible_duo_add_on_assignments.limit(2).to_a + + return if assignments.size != 1 + + assignments.first.add_on_purchase.namespace end + alias_method :get_default_duo_namespace, :duo_default_namespace def duo_default_namespace_id=(namespace_id) if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) -- GitLab From 8aa809f0cc0da953ec7a10a8b070da986f181dfa Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 16:17:40 +0800 Subject: [PATCH 07/12] Update duo_default_namespace_id= --- ee/app/models/ee/user_preference.rb | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 5d9152004dd671..1ea8ba2553d29e 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -106,10 +106,8 @@ def duo_default_namespace alias_method :get_default_duo_namespace, :duo_default_namespace def duo_default_namespace_id=(namespace_id) - if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) - self.default_duo_add_on_assignment_id = find_user_add_on_assignment_id_for_namespace_id(namespace_id) - end - + # Prevent fallback to assignment id in future reads + self.default_duo_add_on_assignment_id = nil if namespace_id.nil? super end @@ -124,12 +122,6 @@ def validate_duo_default_namespace_id errors.add(:duo_default_namespace_id) end - - def find_user_add_on_assignment_id_for_namespace_id(namespace_id) - return if namespace_id.blank? - - eligible_duo_add_on_assignments.where(add_on_purchase: { namespace_id: namespace_id }).pick(:id) - end end end end -- GitLab From 719c5340e6653f25143a9e7980c0134225f68997 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 16:22:04 +0800 Subject: [PATCH 08/12] Revert "Make candidate to include Duo Core" This reverts commit be0c9355bb7e4b7d1965198e505d24190691ea6f. --- ee/app/models/concerns/ai/user_authorizable.rb | 11 +++++------ ee/app/models/ee/user_preference.rb | 2 +- ee/spec/models/user_preference_spec.rb | 11 ++++------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/ee/app/models/concerns/ai/user_authorizable.rb b/ee/app/models/concerns/ai/user_authorizable.rb index 64ee6448ed36f8..f11d5d37b953f4 100644 --- a/ee/app/models/concerns/ai/user_authorizable.rb +++ b/ee/app/models/concerns/ai/user_authorizable.rb @@ -41,12 +41,6 @@ def duo_available_namespace_ids end end - # EE:SaaS - def groups_with_duo_core_enabled - Namespace.id_in(billable_gitlab_duo_pro_root_group_ids) - .namespace_settings_with_duo_core_features_enabled - end - # Returns namespace IDs where user has Duo Core # access through namespace-level settings. # We currently provide an alternative pathway to Duo Core features beyond add-on purchase @@ -234,6 +228,11 @@ def denied_response Response.new(allowed?: false, namespace_ids: [], authorized_by_duo_core: false) end + def groups_with_duo_core_enabled + Namespace.id_in(billable_gitlab_duo_pro_root_group_ids) + .namespace_settings_with_duo_core_features_enabled + end + def duo_core_add_on? duo_core_add_on_purchase.present? end diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 1ea8ba2553d29e..7069bea74e4931 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -21,7 +21,7 @@ module UserPreference def duo_default_namespace_candidates if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) - user.groups_with_duo_core_enabled + distinct_eligible_duo_add_on_assignments.map(&:namespace) else namespaces = user.authorized_groups.top_level.to_a namespaces.append(user.namespace) if user.namespace diff --git a/ee/spec/models/user_preference_spec.rb b/ee/spec/models/user_preference_spec.rb index b8646ae19c353f..c29f74ba4072ee 100644 --- a/ee/spec/models/user_preference_spec.rb +++ b/ee/spec/models/user_preference_spec.rb @@ -244,16 +244,13 @@ stub_saas_features(duo_chat_on_saas: true) end - let_it_be(:group) { create(:group) } - - context 'when user has access to namespaces with Duo Core enabled' do - it 'returns eligible namespaces' do - group.add_reporter(user) - group.update!(duo_core_features_enabled: true) + context 'when user has eligible duo add-on assignments' do + include_context 'with multiple user add-on assignments' + it 'returns distinct eligible duo add-on assignments' do result = user_preference.duo_default_namespace_candidates - expect(result.count).to eq(1) + expect(result.count).to eq(2) end end -- GitLab From f219b9c11ef4e40506559540976f95e526f4a407 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 18:58:36 +0800 Subject: [PATCH 09/12] Attempt to update candidate query --- ee/app/models/ee/user_preference.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 7069bea74e4931..61811f31b96e3b 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -21,7 +21,13 @@ module UserPreference def duo_default_namespace_candidates if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) - distinct_eligible_duo_add_on_assignments.map(&:namespace) + add_on_assignment_namespace_ids = distinct_eligible_duo_add_on_assignments.pluck(:namespace_id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- not used in IN queries + + GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).map do |purchase| + if purchase.add_on_name == "duo_core" || add_on_assignment_namespace_ids.include?(purchase.namespace_id) + purchase.namespace + end + end else namespaces = user.authorized_groups.top_level.to_a namespaces.append(user.namespace) if user.namespace -- GitLab From e282e6472e0ac184b791f4cb7c1471959d3c8775 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 21:37:05 +0800 Subject: [PATCH 10/12] Convert to DB left join query --- ee/app/models/ee/user_preference.rb | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 61811f31b96e3b..5c4ea0ab565211 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -21,13 +21,21 @@ module UserPreference def duo_default_namespace_candidates if ::Gitlab::Saas.feature_available?(:gitlab_duo_saas_only) - add_on_assignment_namespace_ids = distinct_eligible_duo_add_on_assignments.pluck(:namespace_id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- not used in IN queries - - GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).map do |purchase| - if purchase.add_on_name == "duo_core" || add_on_assignment_namespace_ids.include?(purchase.namespace_id) - purchase.namespace - end - end + duo_core_id = GitlabSubscriptions::AddOn.find_by(name: :duo_core).id + duo_assignable_ids = GitlabSubscriptions::AddOn.where(name: ::GitlabSubscriptions::AddOn::SEAT_ASSIGNABLE_DUO_ADD_ONS).pluck(:id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- size is < 5 and relatively constant + + GitlabSubscriptions::AddOnPurchase + .for_duo_add_ons + .active.for_user(user) + .left_outer_joins(:assigned_users) + .where(<<~SQL, duo_core_id, duo_assignable_ids) + subscription_add_on_id = ? OR + (subscription_add_on_id IN (?) AND + subscription_user_add_on_assignments.id IS NOT NULL) + SQL + .includes(:namespace) + .map(&:namespace) + .uniq else namespaces = user.authorized_groups.top_level.to_a namespaces.append(user.namespace) if user.namespace -- GitLab From f566f8f9c0e8c93e701bf61f002d8b0cf8045042 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 21:54:21 +0800 Subject: [PATCH 11/12] Simplify duo_default_namespace --- ee/app/models/ee/user_preference.rb | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 5c4ea0ab565211..648c4cc2a9ed30 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -96,15 +96,7 @@ def duo_default_namespace namespace = super if namespace - purchases = GitlabSubscriptions::AddOnPurchase.for_duo_add_ons.active.for_user(user).by_namespace(namespace) - - if purchases.empty? - return - elsif purchases.any? { |p| p.add_on_name == "duo_core" } - return namespace - elsif distinct_eligible_duo_add_on_assignments.where(namespace_id: namespace).exists? # rubocop:disable Lint/DuplicateBranch -- readability - return namespace # Duo assignable add-on (e.g. Duo Pro) exists for user - end + return duo_default_namespace_candidates.include?(namespace) ? namespace : nil end # Fallback to deprecated add-on assignment field -- GitLab From b85f27d81369b30129838460284354ef92942a44 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 22 Oct 2025 21:56:28 +0800 Subject: [PATCH 12/12] Refactor --- ee/app/models/ee/user_preference.rb | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/ee/app/models/ee/user_preference.rb b/ee/app/models/ee/user_preference.rb index 648c4cc2a9ed30..e3c6f108a24aa4 100644 --- a/ee/app/models/ee/user_preference.rb +++ b/ee/app/models/ee/user_preference.rb @@ -96,20 +96,11 @@ def duo_default_namespace namespace = super if namespace - return duo_default_namespace_candidates.include?(namespace) ? namespace : nil + duo_default_namespace_candidates.include?(namespace) ? namespace : nil + else # Fallback to deprecated add-on assignment approach + get_default_duo_namespace end - - # Fallback to deprecated add-on assignment field - return default_duo_add_on_assignment.namespace if default_duo_add_on_assignment.present? - - # Fallback if only a single namespace is present in add-on assignments - assignments = distinct_eligible_duo_add_on_assignments.limit(2).to_a - - return if assignments.size != 1 - - assignments.first.add_on_purchase.namespace end - alias_method :get_default_duo_namespace, :duo_default_namespace def duo_default_namespace_id=(namespace_id) # Prevent fallback to assignment id in future reads -- GitLab