From 5672df16c7c110c18d1c4f07c0702557ab5b616e Mon Sep 17 00:00:00 2001
From: Michael Becker <mbecker@gitlab.com>
Date: Wed, 12 Feb 2025 15:34:28 +0700
Subject: [PATCH] Add a `/disabled_filters` route to the dependencies
 controllers

Context
-------------------------

We have some filters that we disable when certain conditions
are met, for example if a group has too many vulnerabilities.

Currently, if you try filtering with a disabled filter, you
will get a "too many vulnerabilities" error response. We would
like to give a tool-tip in the UI to explain the filter is
disabled ahead of any error response. We have done that for a
few filters by injecting booleans into the page on load.

As we continue adding filters that need to be disabled in certain
circumstances, we are looking for a way frontend could reliably know
what filters are disabled for a given `vulnerable`. We achieve this
via a graphql extension.

Future work
-------------------------

rather than calculating these disabled filters every time, we could
have frontend request specific "filter hints" and only build the array
if requested

This change
-------------------------

1. Adds a `disabled_filters` array to the graphql response if the
   vulnerability resolver is invoked
    - behind a de-risk FF
2. DRYs up the code that disables the identifier search

epic: https://gitlab.com/groups/gitlab-org/-/epics/13340
related to: https://gitlab.com/gitlab-org/gitlab/-/issues/517915
resolves: https://gitlab.com/gitlab-org/gitlab/-/work_items/517925
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180973
Changelog: changed
EE: truewe have some filters that we disable when certain conditions
are met, for example if a group has too many vulnerabilities.
---
 .../security/group_identifier_search.rb       | 26 ++++++++
 .../identifier_search_resolver.rb             | 12 ++--
 .../resolvers/vulnerabilities_resolver.rb     | 27 +++++---
 .../resolvers/vulnerability_filterable.rb     | 14 +---
 ...disabled_vulnerability_graphql_filters.yml |  9 +++
 ee/spec/graphql/api/vulnerabilities_spec.rb   | 66 ++++++++++++++++++-
 .../vulnerabilities_resolver_spec.rb          |  2 +-
 ...rability_severities_count_resolver_spec.rb |  2 +-
 8 files changed, 125 insertions(+), 33 deletions(-)
 create mode 100644 ee/app/controllers/concerns/security/group_identifier_search.rb
 create mode 100644 ee/config/feature_flags/gitlab_com_derisk/return_disabled_vulnerability_graphql_filters.yml

diff --git a/ee/app/controllers/concerns/security/group_identifier_search.rb b/ee/app/controllers/concerns/security/group_identifier_search.rb
new file mode 100644
index 00000000000000..3189b05157a53b
--- /dev/null
+++ b/ee/app/controllers/concerns/security/group_identifier_search.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Security
+  # rubocop:disable Search/NamespacedClass -- not that kind of search
+  module GroupIdentifierSearch
+    MAX_VULNERABILITY_COUNT_GROUP_SUPPORT = 20_000
+
+    private
+
+    def search_by_identifier_allowed?(vulnerable:)
+      return true if vulnerable.is_a?(::Project)
+      return false unless vulnerable.is_a?(::Group)
+
+      vulnerability_count = ::Security::ProjectStatistics.sum_vulnerability_count_for_group(vulnerable)
+      vulnerability_count <= MAX_VULNERABILITY_COUNT_GROUP_SUPPORT
+    end
+
+    def search_by_identifier_allowed!(vulnerable:)
+      return if search_by_identifier_allowed?(vulnerable: vulnerable)
+
+      raise ::Gitlab::Graphql::Errors::ArgumentError,
+        "#{vulnerable.type} has more than 20k vulnerabilities."
+    end
+  end
+  # rubocop:enable Search/NamespacedClass
+end
diff --git a/ee/app/graphql/resolvers/vulnerabilities/identifier_search_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities/identifier_search_resolver.rb
index ce7217b65fdefc..e64517a97b6b26 100644
--- a/ee/app/graphql/resolvers/vulnerabilities/identifier_search_resolver.rb
+++ b/ee/app/graphql/resolvers/vulnerabilities/identifier_search_resolver.rb
@@ -4,8 +4,7 @@ module Resolvers
   module Vulnerabilities
     class IdentifierSearchResolver < BaseResolver
       include Gitlab::Graphql::Authorize::AuthorizeResource
-
-      MAX_VULNERABILITY_COUNT_GROUP_SUPPORT = 20_000
+      include ::Security::GroupIdentifierSearch
 
       authorize :read_security_resource
 
@@ -23,7 +22,7 @@ def resolve(**args)
 
         validate_args(args)
 
-        group_allowed_for_search?
+        search_by_identifier_allowed!(vulnerable: object)
 
         if object.is_a?(::Project)
           ::Vulnerabilities::Identifier.search_identifier_name(
@@ -54,13 +53,10 @@ def authorize!
           raise_resource_not_available_error!
       end
 
-      def group_allowed_for_search?
+      def group_allowed_for_search!
         return unless object.is_a?(Group)
 
-        vulnerability_count = ::Security::ProjectStatistics.sum_vulnerability_count_for_group(object)
-        allowed = vulnerability_count <= MAX_VULNERABILITY_COUNT_GROUP_SUPPORT
-
-        raise ::Gitlab::Graphql::Errors::ArgumentError, 'Group has more than 20k vulnerabilities.' unless allowed
+        search_by_identifier_allowed?(vulnerable: object)
       end
     end
   end
diff --git a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
index b6b1c854606580..73103a646e8e21 100644
--- a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
+++ b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb
@@ -100,6 +100,11 @@ def resolve_with_lookahead(**args)
 
       args[:scanner_id] = resolve_gids(args[:scanner_id], ::Vulnerabilities::Scanner) if args[:scanner_id]
 
+      if Feature.enabled?(:return_disabled_vulnerability_graphql_filters, vulnerable_to_actor)
+        disabled_filters = context.response_extensions["disabled_filters"] ||= []
+        disabled_filters << :identifier_name unless search_by_identifier_allowed?(vulnerable: vulnerable)
+      end
+
       vulnerabilities(args)
         .with_findings_scanner_and_identifiers
     end
@@ -129,16 +134,18 @@ def vulnerabilities(params)
     end
 
     def resolve_with_duo_filtering_enabled?
-      actor = case vulnerable
-              when ::InstanceSecurityDashboard
-                current_user
-              when Project
-                vulnerable.group
-              else
-                vulnerable
-              end
-
-      Feature.enabled?(:vulnerability_report_vr_filter, actor)
+      Feature.enabled?(:vulnerability_report_vr_filter, vulnerable_to_actor)
+    end
+
+    def vulnerable_to_actor
+      case vulnerable
+      when ::InstanceSecurityDashboard
+        current_user
+      when Project
+        vulnerable.group
+      else
+        vulnerable
+      end
     end
 
     def after_severity
diff --git a/ee/app/graphql/resolvers/vulnerability_filterable.rb b/ee/app/graphql/resolvers/vulnerability_filterable.rb
index 4e8e50d5ee2f07..eca6da8e43730e 100644
--- a/ee/app/graphql/resolvers/vulnerability_filterable.rb
+++ b/ee/app/graphql/resolvers/vulnerability_filterable.rb
@@ -3,23 +3,13 @@
 module Resolvers
   module VulnerabilityFilterable
     extend ActiveSupport::Concern
-
-    MAX_VULNERABILITY_COUNT_GROUP_SUPPORT = 20_000
+    include ::Security::GroupIdentifierSearch
 
     private
 
     def validate_filters(filters)
       validate_owasp_top_ten(filters) if filters[:owasp_top_10].present?
-      validate_filter_by_identifier_name if filters[:identifier_name].present?
-    end
-
-    def validate_filter_by_identifier_name
-      return unless object.is_a?(Group)
-
-      vulnerability_count = ::Security::ProjectStatistics.sum_vulnerability_count_for_group(object)
-      allowed = vulnerability_count <= MAX_VULNERABILITY_COUNT_GROUP_SUPPORT
-
-      raise ::Gitlab::Graphql::Errors::ArgumentError, 'Group has more than 20k vulnerabilities.' unless allowed
+      search_by_identifier_allowed!(vulnerable: object) if filters[:identifier_name].present?
     end
 
     def validate_owasp_top_ten(filters)
diff --git a/ee/config/feature_flags/gitlab_com_derisk/return_disabled_vulnerability_graphql_filters.yml b/ee/config/feature_flags/gitlab_com_derisk/return_disabled_vulnerability_graphql_filters.yml
new file mode 100644
index 00000000000000..2ee4d829eb0b38
--- /dev/null
+++ b/ee/config/feature_flags/gitlab_com_derisk/return_disabled_vulnerability_graphql_filters.yml
@@ -0,0 +1,9 @@
+---
+name: return_disabled_vulnerability_graphql_filters
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/work_items/517925
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180973
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/work_items/520703
+milestone: '17.10'
+group: group::security insights
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/ee/spec/graphql/api/vulnerabilities_spec.rb b/ee/spec/graphql/api/vulnerabilities_spec.rb
index acccca1e873f7e..85c56956cceee4 100644
--- a/ee/spec/graphql/api/vulnerabilities_spec.rb
+++ b/ee/spec/graphql/api/vulnerabilities_spec.rb
@@ -2,7 +2,8 @@
 
 require 'spec_helper'
 
-RSpec.describe 'vulnerabilities' do
+RSpec.describe 'vulnerabilities', feature_category: :vulnerability_management do
+  let_it_be(:group) { create(:group) }
   let_it_be(:project) { create(:project) }
   let_it_be(:user) { project.first_owner }
   let_it_be(:vulnerability_1) { create(:vulnerability, project: project) }
@@ -28,6 +29,7 @@
   end
 
   let(:vulnerabilities) { execute.dig('data', 'project', 'vulnerabilities') }
+  let(:extensions) { execute['extensions'] }
 
   subject(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json }
 
@@ -40,6 +42,68 @@
     create(:vulnerabilities_finding, vulnerability: vulnerability_1, project: project)
   end
 
+  describe 'extensions' do
+    context 'when the query could use vulnerability identifier filtering' do
+      let(:query) do
+        %(
+          query {
+          #{vulnerable.class.name.downcase}(fullPath: "#{vulnerable.full_path}") {
+            name
+            vulnerabilities { nodes { id } } }})
+      end
+
+      before do
+        test_vulnerability_limit = 1
+        stub_const(
+          '::Security::GroupIdentifierSearch::MAX_VULNERABILITY_COUNT_GROUP_SUPPORT',
+          test_vulnerability_limit
+        )
+        allow(::Security::ProjectStatistics).to receive(:sum_vulnerability_count_for_group)
+                                                  .with(group).and_return(test_vulnerability_limit + 1)
+      end
+
+      context 'when vulnerable is project' do
+        let(:vulnerable) { project }
+
+        it { expect(extensions["disabled_filters"]).to eq [] }
+
+        context 'when the return_disabled_vulnerability_graphql_filters FF is disabled' do
+          before do
+            stub_feature_flags(return_disabled_vulnerability_graphql_filters: false)
+          end
+
+          it "does not calculate disabled vulnerability filters" do
+            expect(extensions).to be_nil
+          end
+        end
+      end
+
+      context 'when vulnerable is group' do
+        let(:vulnerable) { group }
+
+        context 'when the return_disabled_vulnerability_graphql_filters FF is disabled' do
+          before do
+            stub_feature_flags(return_disabled_vulnerability_graphql_filters: false)
+          end
+
+          it "does not calculate disabled vulnerability filters" do
+            expect(extensions).to be_nil
+          end
+        end
+
+        it { expect(extensions["disabled_filters"]).to match_array %w[identifier_name] }
+      end
+
+      context 'when the vulnerability type is not in the query' do
+        let(:query) { %(query { group(fullPath: "#{group.full_path}") { name } }) }
+
+        it "does not calculate disabled vulnerability filters" do
+          expect(extensions).to be_nil
+        end
+      end
+    end
+  end
+
   describe 'nodes' do
     let(:nodes) { vulnerabilities['nodes'] }
     let(:vulnerability_ids) { nodes.map { |node| node['id'] } }
diff --git a/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb
index 88f261bfbbafd9..6dbb1ae552baf7 100644
--- a/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb
@@ -399,7 +399,7 @@
 
           before do
             stub_const(
-              'Resolvers::VulnerabilityFilterable::MAX_VULNERABILITY_COUNT_GROUP_SUPPORT',
+              '::Security::GroupIdentifierSearch::MAX_VULNERABILITY_COUNT_GROUP_SUPPORT',
               max
             )
 
diff --git a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
index f45ac4f7bcd5ac..dab9651f348257 100644
--- a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
@@ -312,7 +312,7 @@
 
             before do
               stub_const(
-                'Resolvers::VulnerabilityFilterable::MAX_VULNERABILITY_COUNT_GROUP_SUPPORT', max
+                '::Security::GroupIdentifierSearch::MAX_VULNERABILITY_COUNT_GROUP_SUPPORT', max
               )
               allow(::Security::ProjectStatistics).to receive(:sum_vulnerability_count_for_group)
                                                         .with(group).and_return(group.vulnerabilities.count)
-- 
GitLab