diff --git a/app/models/project.rb b/app/models/project.rb
index 58259816cc9a79d0a6c3613c50833eabe548c7ee..2f1fc9ae5a94ecfd608b1261d19bcfc6e187b91c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -561,6 +561,7 @@ def self.integration_association_name(name)
       delegate :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=
       delegate :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=
       delegate :code_suggestions, :code_suggestions=
+      delegate :duo_features_enabled, :duo_features_enabled=
     end
   end
 
@@ -3246,11 +3247,6 @@ def instance_runner_running_jobs_count
   end
   strong_memoize_attr :instance_runner_running_jobs_count
 
-  def code_suggestions_enabled?
-    code_suggestions && (group.nil? || group.code_suggestions)
-  end
-  strong_memoize_attr :code_suggestions_enabled?
-
   # Overridden in EE
   def allows_multiple_merge_request_assignees?
     false
@@ -3266,6 +3262,11 @@ def on_demand_dast_available?
     false
   end
 
+  # Overridden in EE
+  def code_suggestions_enabled?
+    false
+  end
+
   private
 
   # overridden in EE
diff --git a/db/migrate/20240124212938_add_duo_features_enabled_to_project_settings.rb b/db/migrate/20240124212938_add_duo_features_enabled_to_project_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9dd09e1434aa66d2aab1e868e0d7cfefa0f88f79
--- /dev/null
+++ b/db/migrate/20240124212938_add_duo_features_enabled_to_project_settings.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddDuoFeaturesEnabledToProjectSettings < Gitlab::Database::Migration[2.2]
+  enable_lock_retries!
+
+  milestone '16.9'
+
+  def change
+    add_column :project_settings, :duo_features_enabled, :boolean, default: true, null: false
+  end
+end
diff --git a/db/schema_migrations/20240124212938 b/db/schema_migrations/20240124212938
new file mode 100644
index 0000000000000000000000000000000000000000..32e355dbeef6410546ead8f8ca583abd89f46e1a
--- /dev/null
+++ b/db/schema_migrations/20240124212938
@@ -0,0 +1 @@
+1ab3946da575910f8ae9ab220d1e1da61619b66a9ad09a7c2a90c2abda5056d9
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 1d51178479d5cbbd1d4cc5a462eced08dc94726d..4c3ee5b8cebb1cb14690b16bdf0947b1b6e483b4 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -22750,6 +22750,7 @@ CREATE TABLE project_settings (
     pages_multiple_versions_enabled boolean DEFAULT false NOT NULL,
     allow_merge_without_pipeline boolean DEFAULT false NOT NULL,
     code_suggestions boolean DEFAULT true NOT NULL,
+    duo_features_enabled boolean DEFAULT true NOT NULL,
     CONSTRAINT check_1a30456322 CHECK ((char_length(pages_unique_domain) <= 63)),
     CONSTRAINT check_3a03e7557a CHECK ((char_length(previous_default_branch) <= 4096)),
     CONSTRAINT check_3ca5cbffe6 CHECK ((char_length(issue_branch_template) <= 255)),
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index 3ab5cba92fcab2013577f69d4710139660dbb60b..f8dca4e3d03cbadacfd148c9ee3b52ba489f2c9b 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -1232,12 +1232,31 @@ def on_demand_dast_available?
       ::Gitlab::FIPS.enabled? ? ::Feature.enabled?(:dast_ods_browser_based_scanner, self) : true
     end
 
+    override :code_suggestions_enabled?
+    def code_suggestions_enabled?
+      return super unless ::Gitlab.org_or_com? || ::License.feature_available?(:code_suggestions)
+
+      if gitlab_com_and_feature_enabled? || self_managed_and_past_service_start_date?
+        duo_features_enabled
+      else
+        root_ancestor.code_suggestions
+      end
+    end
+
     def gcp_artifact_registry_enabled?
       ::Feature.enabled?(:gcp_artifact_registry, self) && ::Gitlab::Saas.feature_available?(:google_artifact_registry)
     end
 
     private
 
+    def gitlab_com_and_feature_enabled?
+      ::Gitlab.org_or_com? && ::Feature.enabled?(:purchase_code_suggestions)
+    end
+
+    def self_managed_and_past_service_start_date?
+      ::License.feature_available?(:code_suggestions) && ::CodeSuggestions::SelfManaged::SERVICE_START_DATE.past?
+    end
+
     def latest_ingested_sbom_pipeline_id_redis_key
       "latest_ingested_sbom_pipeline_id/#{id}"
     end
diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb
index f2a4de0fd1e43ee06018c3ac187a3eed5d5485b7..172ad5a49f206ad0d1191e3d3d785e066e265300 100644
--- a/ee/spec/models/ee/project_spec.rb
+++ b/ee/spec/models/ee/project_spec.rb
@@ -4426,5 +4426,84 @@ def stub_default_url_options(host)
 
       it { is_expected.to eq(false) }
     end
+
+    describe '#code_suggestions_enabled?' do
+      let_it_be_with_reload(:project) { create(:project, :in_group) }
+
+      context 'gitlab.com' do
+        where(:duo_features_enabled, :code_suggestions_enabled) do
+          true  | true
+          false | false
+        end
+
+        with_them do
+          context 'purchase_code_suggestions FF is enabled' do
+            before do
+              allow(::Gitlab).to receive(:org_or_com?).and_return(true)
+              project.project_setting.update!(duo_features_enabled: duo_features_enabled)
+            end
+
+            it 'uses the duo_features_enabled project setting value' do
+              expect(project.code_suggestions_enabled?).to eq code_suggestions_enabled
+            end
+          end
+        end
+
+        with_them do
+          context 'purchase_code_suggestions FF is not enabled' do
+            before do
+              allow(::Gitlab).to receive(:org_or_com?).and_return(true)
+              stub_feature_flags(purchase_code_suggestions: false)
+              project.root_ancestor.update!(code_suggestions: duo_features_enabled)
+            end
+
+            it 'uses the legacy code_suggestions setting on the root group' do
+              expect(project.code_suggestions_enabled?).to eq code_suggestions_enabled
+            end
+          end
+        end
+      end
+
+      context 'self-managed' do
+        where(:code_suggestions_license, :duo_features_enabled, :code_suggestions_enabled) do
+          true  | true  |  true
+          true  | false |  false
+          false | true  |  false
+          false | false |  false
+        end
+
+        with_them do
+          context 'after service start date' do
+            before do
+              project.project_setting.update!(duo_features_enabled: duo_features_enabled)
+              allow(::Gitlab).to receive(:org_or_com?).and_return(false)
+              stub_licensed_features(code_suggestions: code_suggestions_license)
+            end
+
+            it 'uses the duo_features_enabled project setting value' do
+              travel_to(::CodeSuggestions::SelfManaged::SERVICE_START_DATE + 1.day) do
+                expect(project.code_suggestions_enabled?).to eq code_suggestions_enabled
+              end
+            end
+          end
+        end
+
+        with_them do
+          context 'before service start date' do
+            before do
+              project.root_ancestor.update!(code_suggestions: duo_features_enabled)
+              allow(::Gitlab).to receive(:org_or_com?).and_return(false)
+              stub_licensed_features(code_suggestions: code_suggestions_license)
+            end
+
+            it 'uses the legacy code_suggestions setting on the root group' do
+              travel_to(::CodeSuggestions::SelfManaged::SERVICE_START_DATE - 1.day) do
+                expect(project.code_suggestions_enabled?).to eq code_suggestions_enabled
+              end
+            end
+          end
+        end
+      end
+    end
   end
 end
diff --git a/ee/spec/models/namespace_setting_spec.rb b/ee/spec/models/namespace_setting_spec.rb
index 445b1ed4e4fd00bdc7fbddc4fc12e366bb5a886b..c59c8d5e0b4f42729135fc58ad127c2afec068dc 100644
--- a/ee/spec/models/namespace_setting_spec.rb
+++ b/ee/spec/models/namespace_setting_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe NamespaceSetting do
+RSpec.describe NamespaceSetting, feature_category: :groups_and_projects, type: :model do
   let(:group) { create(:group) }
   let(:setting) { group.namespace_settings }
 
diff --git a/ee/spec/requests/api/code_suggestions_spec.rb b/ee/spec/requests/api/code_suggestions_spec.rb
index 7c035a4b4975b9f86e8110800631e8236a6bc837..16d16eaa032d1f35bd85cabf9c02435d1ec3ed59 100644
--- a/ee/spec/requests/api/code_suggestions_spec.rb
+++ b/ee/spec/requests/api/code_suggestions_spec.rb
@@ -813,27 +813,20 @@ def get_user(session):
     end
   end
 
-  context 'when checking in project has code suggestions enabled' do
-    let_it_be(:enabled_project) { create(:project, :with_code_suggestions_enabled) }
-    let(:current_user) { authorized_user }
-    let_it_be(:disabled_project) { create(:project, :with_code_suggestions_disabled) }
-    let_it_be(:secret_project) { create(:project, :with_code_suggestions_enabled) }
+  context 'when checking if project has duo features enabled' do
+    let_it_be(:enabled_project) { create(:project, :in_group, :private, :with_duo_features_enabled) }
+    let_it_be(:disabled_project) { create(:project, :in_group, :with_duo_features_disabled) }
 
-    before_all do
-      enabled_project.add_maintainer(authorized_user)
-      disabled_project.add_maintainer(authorized_user)
-    end
+    let(:current_user) { authorized_user }
 
     subject { post api("/code_suggestions/enabled", current_user), params: { project_path: project_path } }
 
-    context 'when not logged in' do
-      let(:current_user) { nil }
-      let(:project_path) { enabled_project.full_path }
-
-      it { is_expected.to eq(401) }
-    end
+    context 'when authorized to view project' do
+      before_all do
+        enabled_project.add_maintainer(authorized_user)
+        disabled_project.add_maintainer(authorized_user)
+      end
 
-    context 'when authorized' do
       context 'when enabled' do
         let(:project_path) { enabled_project.full_path }
 
@@ -845,18 +838,25 @@ def get_user(session):
 
         it { is_expected.to eq(403) }
       end
+    end
 
-      context 'when user cannot access project' do
-        let(:project_path) { secret_project.full_path }
+    context 'when not logged in' do
+      let(:current_user) { nil }
+      let(:project_path) { enabled_project.full_path }
 
-        it { is_expected.to eq(404) }
-      end
+      it { is_expected.to eq(401) }
+    end
 
-      context 'when does not exist' do
-        let(:project_path) { 'not_a_real_project' }
+    context 'when logged in but not authorized to view project' do
+      let(:project_path) { enabled_project.full_path }
 
-        it { is_expected.to eq(404) }
-      end
+      it { is_expected.to eq(404) }
+    end
+
+    context 'when project for project path does not exist' do
+      let(:project_path) { 'not_a_real_project' }
+
+      it { is_expected.to eq(404) }
     end
   end
 end
diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb
index fbde9164a4f5ea04685cc8935dce74ac830dfb6c..14e275a927e3d80ab1aec0029a608393c3823d49 100644
--- a/lib/api/entities/project.rb
+++ b/lib/api/entities/project.rb
@@ -41,7 +41,6 @@ class Project < BasicProjectDetails
         end
       end
 
-      expose :code_suggestions, documentation: { type: 'boolean' }
       expose :packages_enabled, documentation: { type: 'boolean' }
       expose :empty_repo?, as: :empty_repo, documentation: { type: 'boolean' }
       expose :archived?, as: :archived, documentation: { type: 'boolean' }
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index a2848bd025613647183c93009bc44237373121cf..e751a0d740320dd943187153acfbaf4a157c75e2 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -609,15 +609,15 @@
     files { { 'README.md' => 'Hello World' } }
   end
 
-  trait :with_code_suggestions_enabled do
+  trait :with_duo_features_enabled do
     after(:create) do |project|
-      project.project_setting.update!(code_suggestions: true)
+      project.project_setting.update!(duo_features_enabled: true)
     end
   end
 
-  trait :with_code_suggestions_disabled do
+  trait :with_duo_features_disabled do
     after(:create) do |project|
-      project.project_setting.update!(code_suggestions: false)
+      project.project_setting.update!(duo_features_enabled: false)
     end
   end
 end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index e1924a57ad35693fb49de6c04297dddca67b3c85..cae5291fb56d147397d21b7c2c95836fe163ea6f 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -9195,6 +9195,14 @@ def create_hook
     it { is_expected.to be_falsy }
   end
 
+  describe '#code_suggestions_enabled?' do
+    let(:project) { build_stubbed(:project) }
+
+    subject(:code_suggestions_enabled?) { project.code_suggestions_enabled? }
+
+    it { is_expected.to be_falsy }
+  end
+
   private
 
   def finish_job(export_job)
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index ada2a917c316945f857f7f4871a44e94dcf4e6a1..ff1aac2f6f75d4f4a6d93215dcdc50d9b2c660b2 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -180,6 +180,8 @@ project_setting:
     - encrypted_product_analytics_configurator_connection_string
     - encrypted_product_analytics_configurator_connection_string_iv
     - product_analytics_configurator_connection_string
+    - code_suggestions
+    - duo_features_enabled
 
 build_service_desk_setting: # service_desk_setting
   unexposed_attributes: