From 0ce1879b8271e01a157ee4b3362cbf454d10a68a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Zaj=C4=85c?= <mzajac@gitlab.com>
Date: Thu, 29 Aug 2024 11:54:13 +0200
Subject: [PATCH] Support finding Sbom::Occurrences via dependency names

Changelog: added
EE: true
---
 doc/api/graphql/reference/index.md            | 34 +++++++++++++++
 ee/app/finders/sbom/aggregations_finder.rb    |  8 ++++
 ee/app/finders/sbom/components_finder.rb      | 25 +++++++++++
 ee/app/finders/sbom/dependencies_finder.rb    |  7 +++
 ee/app/graphql/ee/types/query_type.rb         |  7 +++
 .../resolvers/sbom/component_resolver.rb      | 18 ++++++++
 .../resolvers/sbom/dependencies_resolver.rb   |  6 +++
 ee/app/graphql/types/sbom/component_type.rb   | 18 ++++++++
 ee/app/models/sbom/component.rb               |  4 ++
 ee/app/models/sbom/occurrence.rb              |  8 ++++
 ee/app/policies/sbom/component_policy.rb      |  7 +++
 ...el_dependencies_filtering_by_component.yml |  6 +--
 .../finders/sbom/aggregations_finder_spec.rb  | 28 ++++++++++++
 .../finders/sbom/components_finder_spec.rb    | 43 +++++++++++++++++++
 .../finders/sbom/dependencies_finder_spec.rb  | 14 ++++++
 .../resolvers/sbom/component_resolver_spec.rb | 36 ++++++++++++++++
 ee/spec/graphql/types/query_type_spec.rb      |  3 +-
 ee/spec/models/sbom/component_spec.rb         | 20 +++++++++
 ee/spec/models/sbom/occurrence_spec.rb        |  9 ++++
 .../policies/sbom/component_policy_spec.rb    | 14 ++++++
 20 files changed, 311 insertions(+), 4 deletions(-)
 create mode 100644 ee/app/finders/sbom/components_finder.rb
 create mode 100644 ee/app/graphql/resolvers/sbom/component_resolver.rb
 create mode 100644 ee/app/graphql/types/sbom/component_type.rb
 create mode 100644 ee/app/policies/sbom/component_policy.rb
 create mode 100644 ee/spec/finders/sbom/components_finder_spec.rb
 create mode 100644 ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb
 create mode 100644 ee/spec/policies/sbom/component_policy_spec.rb

diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 9b77b41e0b3dc..3dd8f5f926bdd 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -342,6 +342,22 @@ DETAILS:
 
 Returns [`CloudConnectorStatus`](#cloudconnectorstatus).
 
+### `Query.components`
+
+Find software dependencies by name.
+
+DETAILS:
+**Introduced** in GitLab 17.4.
+**Status**: Experiment.
+
+Returns [`[Component!]`](#component).
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="querycomponentsname"></a>`name` | [`String`](#string) | Entire name or part of the name. |
+
 ### `Query.containerRepository`
 
 Find a container repository.
@@ -19729,6 +19745,17 @@ Compliance violation associated with a merged merge request.
 | <a id="complianceviolationseveritylevel"></a>`severityLevel` | [`ComplianceViolationSeverity!`](#complianceviolationseverity) | Severity of the compliance violation. |
 | <a id="complianceviolationviolatinguser"></a>`violatingUser` | [`UserCore!`](#usercore) | User suspected of causing the compliance violation. |
 
+### `Component`
+
+A software dependency used by a project.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="componentid"></a>`id` | [`SbomComponentID!`](#sbomcomponentid) | ID of the dependency. |
+| <a id="componentname"></a>`name` | [`String!`](#string) | Name of the dependency. |
+
 ### `ComposerMetadata`
 
 Composer metadata.
@@ -29643,6 +29670,7 @@ four standard [pagination arguments](#pagination-arguments):
 
 | Name | Type | Description |
 | ---- | ---- | ----------- |
+| <a id="projectdependenciescomponentids"></a>`componentIds` | [`[SbomComponentID!]`](#sbomcomponentid) | Filter dependencies by component IDs. |
 | <a id="projectdependenciescomponentnames"></a>`componentNames` | [`[String!]`](#string) | Filter dependencies by component names. |
 | <a id="projectdependenciespackagemanagers"></a>`packageManagers` | [`[PackageManager!]`](#packagemanager) | Filter dependencies by package managers. |
 | <a id="projectdependenciessort"></a>`sort` | [`DependencySort`](#dependencysort) | Sort dependencies by given criteria. |
@@ -39356,6 +39384,12 @@ A `RemoteDevelopmentWorkspacesAgentConfigID` is a global ID. It is encoded as a
 
 An example `RemoteDevelopmentWorkspacesAgentConfigID` is: `"gid://gitlab/RemoteDevelopment::WorkspacesAgentConfig/1"`.
 
+### `SbomComponentID`
+
+A `SbomComponentID` is a global ID. It is encoded as a string.
+
+An example `SbomComponentID` is: `"gid://gitlab/Sbom::Component/1"`.
+
 ### `SecurityTrainingProviderID`
 
 A `SecurityTrainingProviderID` is a global ID. It is encoded as a string.
diff --git a/ee/app/finders/sbom/aggregations_finder.rb b/ee/app/finders/sbom/aggregations_finder.rb
index 2f37fbf5db3bf..e1fc603129ed3 100644
--- a/ee/app/finders/sbom/aggregations_finder.rb
+++ b/ee/app/finders/sbom/aggregations_finder.rb
@@ -87,6 +87,7 @@ def inner_occurrences
         .unarchived
 
       relation = filter_by_licences(relation)
+      relation = filter_by_component_ids(relation)
 
       relation
         .order(inner_order)
@@ -100,6 +101,13 @@ def filter_by_licences(relation)
       relation.by_primary_license(params[:licenses])
     end
 
+    def filter_by_component_ids(relation)
+      return relation if Feature.disabled?(:group_level_dependencies_filtering_by_component, namespace)
+      return relation unless params[:component_ids].present?
+
+      relation.filter_by_component_ids(params[:component_ids])
+    end
+
     def inner_order
       evaluator = ->(column) { column_expression(column) }
 
diff --git a/ee/app/finders/sbom/components_finder.rb b/ee/app/finders/sbom/components_finder.rb
new file mode 100644
index 0000000000000..1329d1971065a
--- /dev/null
+++ b/ee/app/finders/sbom/components_finder.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Sbom
+  class ComponentsFinder
+    DEFAULT_MAX_RESULTS = 30
+
+    def initialize(name)
+      @name = name
+    end
+
+    def execute
+      components
+    end
+
+    private
+
+    attr_reader :name
+
+    def components
+      return Sbom::Component.limit(DEFAULT_MAX_RESULTS) unless name
+
+      Sbom::Component.by_name(name).limit(DEFAULT_MAX_RESULTS)
+    end
+  end
+end
diff --git a/ee/app/finders/sbom/dependencies_finder.rb b/ee/app/finders/sbom/dependencies_finder.rb
index ffaf46a32cc46..3897f8516e52f 100644
--- a/ee/app/finders/sbom/dependencies_finder.rb
+++ b/ee/app/finders/sbom/dependencies_finder.rb
@@ -35,6 +35,7 @@ def execute
       filter_by_source_types
       filter_by_package_managers
       filter_by_component_names
+      filter_by_component_ids
       filter_by_licences
       filter_by_visibility
       sort
@@ -64,6 +65,12 @@ def filter_by_component_names
       @collection = @collection.filter_by_component_names(params[:component_names])
     end
 
+    def filter_by_component_ids
+      return if params[:component_ids].blank?
+
+      @collection = @collection.filter_by_component_ids(params[:component_ids])
+    end
+
     def filter_by_licences
       return if params[:licenses].blank?
 
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index bcb237290ab93..b14d55133b89b 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -219,6 +219,13 @@ module QueryType
           alpha: { milestone: '17.4' },
           description: 'Find a project secrets manager.',
           resolver: ::Resolvers::SecretsManagement::ProjectSecretsManagerResolver
+
+        field :components,
+          [::Types::Sbom::ComponentType],
+          null: true,
+          description: 'Find software dependencies by name.',
+          resolver: ::Resolvers::Sbom::ComponentResolver,
+          alpha: { milestone: '17.4' }
       end
 
       def vulnerability(id:)
diff --git a/ee/app/graphql/resolvers/sbom/component_resolver.rb b/ee/app/graphql/resolvers/sbom/component_resolver.rb
new file mode 100644
index 0000000000000..fef4af4f8bdc1
--- /dev/null
+++ b/ee/app/graphql/resolvers/sbom/component_resolver.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module Sbom
+    class ComponentResolver < BaseResolver
+      type [::Types::Sbom::ComponentType], null: true
+
+      description 'Software dependencies, optionally filtered by name'
+
+      argument :name, ::GraphQL::Types::String, required: false,
+        description: 'Entire name or part of the name.'
+
+      def resolve(name: nil)
+        ::Sbom::ComponentsFinder.new(name).execute
+      end
+    end
+  end
+end
diff --git a/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb b/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb
index cf5cc1f5f88b6..aefe4e2ed5413 100644
--- a/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb
+++ b/ee/app/graphql/resolvers/sbom/dependencies_resolver.rb
@@ -32,11 +32,17 @@ class DependenciesResolver < BaseResolver
         required: false,
         description: 'Filter dependencies by component names.'
 
+      argument :component_ids, [Types::GlobalIDType[::Sbom::Component]],
+        required: false,
+        description: 'Filter dependencies by component IDs.'
+
       argument :source_types, [Types::Sbom::SourceTypeEnum],
         required: false,
         default_value: ::Sbom::Source::DEFAULT_SOURCES.keys.map(&:to_s) + ['nil_source'],
         description: 'Filter dependencies by source type.'
 
+      validates mutually_exclusive: [:component_names, :component_ids]
+
       alias_method :project, :object
 
       def resolve_with_lookahead(**args)
diff --git a/ee/app/graphql/types/sbom/component_type.rb b/ee/app/graphql/types/sbom/component_type.rb
new file mode 100644
index 0000000000000..33c13a9630889
--- /dev/null
+++ b/ee/app/graphql/types/sbom/component_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+  module Sbom
+    class ComponentType < BaseObject
+      graphql_name 'Component'
+      description 'A software dependency used by a project'
+
+      authorize :read_component
+
+      field :id, ::Types::GlobalIDType[::Sbom::Component],
+        null: false, description: 'ID of the dependency.'
+
+      field :name, GraphQL::Types::String,
+        null: false, description: 'Name of the dependency.'
+    end
+  end
+end
diff --git a/ee/app/models/sbom/component.rb b/ee/app/models/sbom/component.rb
index fc74ef4b4fd6d..3667f9e68e525 100644
--- a/ee/app/models/sbom/component.rb
+++ b/ee/app/models/sbom/component.rb
@@ -20,5 +20,9 @@ class Component < ::Gitlab::Database::SecApplicationRecord
     scope :by_unique_attributes, ->(name, purl_type, component_type, organization_id) do
       where(name: name, purl_type: purl_type, component_type: component_type, organization_id: organization_id)
     end
+
+    scope :by_name, ->(name) do
+      where(Sbom::Component.arel_table[:name].matches("%#{name}%"))
+    end
   end
 end
diff --git a/ee/app/models/sbom/occurrence.rb b/ee/app/models/sbom/occurrence.rb
index 827c78f6861ab..352cec6828261 100644
--- a/ee/app/models/sbom/occurrence.rb
+++ b/ee/app/models/sbom/occurrence.rb
@@ -116,6 +116,14 @@ class Occurrence < Gitlab::Database::SecApplicationRecord
       left_outer_joins(:source).where(sbom_sources: { source_type: source_types })
     end
 
+    scope :filter_by_component_names, ->(component_names) do
+      where(component_name: component_names)
+    end
+
+    scope :filter_by_component_ids, ->(component_ids) do
+      where(component_id: component_ids)
+    end
+
     scope :filter_by_search_with_component_and_group, ->(search, component_id, group) do
       relation = includes(project: :namespace)
         .where(component_version_id: component_id, project: group.all_projects)
diff --git a/ee/app/policies/sbom/component_policy.rb b/ee/app/policies/sbom/component_policy.rb
new file mode 100644
index 0000000000000..ff2ebb4258cc1
--- /dev/null
+++ b/ee/app/policies/sbom/component_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Sbom
+  class ComponentPolicy < BasePolicy
+    rule { default }.enable :read_component
+  end
+end
diff --git a/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml
index c23416e76f440..37a3a037cde28 100644
--- a/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml
+++ b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml
@@ -1,9 +1,9 @@
 ---
 name: group_level_dependencies_filtering_by_component
-feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442406
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148257
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454305
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161932
 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454589
-milestone: '16.11'
+milestone: '16.4'
 group: group::threat insights
 type: wip
 default_enabled: false
diff --git a/ee/spec/finders/sbom/aggregations_finder_spec.rb b/ee/spec/finders/sbom/aggregations_finder_spec.rb
index c16a996ab81ae..b3ae5ccedaf0f 100644
--- a/ee/spec/finders/sbom/aggregations_finder_spec.rb
+++ b/ee/spec/finders/sbom/aggregations_finder_spec.rb
@@ -191,6 +191,34 @@ def occurrence(name:, severity:, traits: [])
       end
     end
 
+    describe 'filtering by component IDs' do
+      let_it_be(:component_1) { create(:sbom_component) }
+      let_it_be(:component_2) { create(:sbom_component) }
+      let_it_be(:occurrence_1) do
+        create(:sbom_occurrence, component_id: component_1.id, project: target_projects.first)
+      end
+
+      let_it_be(:occurrence_2) { create(:sbom_occurrence, component_id: component_2.id, project: target_projects.last) }
+
+      let(:params) { { component_ids: [component_1.id, component_2.id] } }
+
+      context 'when feature flag is enabled' do
+        it 'returns only matching Sbom::Occurrences' do
+          expect(execute.to_a).to match_array([occurrence_1, occurrence_2])
+        end
+      end
+
+      context 'when feature flag is disabled' do
+        before do
+          stub_feature_flags(group_level_dependencies_filtering_by_component: false)
+        end
+
+        it 'returns the original relation' do
+          expect(execute.to_a).to match_array(target_occurrences + [occurrence_1, occurrence_2])
+        end
+      end
+    end
+
     describe 'filtering by license' do
       using RSpec::Parameterized::TableSyntax
 
diff --git a/ee/spec/finders/sbom/components_finder_spec.rb b/ee/spec/finders/sbom/components_finder_spec.rb
new file mode 100644
index 0000000000000..5617362a35644
--- /dev/null
+++ b/ee/spec/finders/sbom/components_finder_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sbom::ComponentsFinder, feature_category: :vulnerability_management do
+  let(:finder) { described_class.new(query) }
+  let_it_be(:component_1) { create(:sbom_component, name: "activerecord") }
+  let_it_be(:component_2) { create(:sbom_component, name: "activejob") }
+  let_it_be(:component_3) { create(:sbom_component, name: "activestorage") }
+  let_it_be(:component_4) { create(:sbom_component, name: "activesupport") }
+
+  describe '#execute' do
+    subject(:find) { finder.execute }
+
+    context 'when given no query string' do
+      let(:query) { nil }
+
+      it "returns all Sbom::Components" do
+        expect(find).to match_array([component_1, component_2, component_3, component_4])
+      end
+
+      context 'when there is more than maximum limit Sbom::Components' do
+        before do
+          stub_const("#{described_class}::DEFAULT_MAX_RESULTS", 3)
+          create_list(:sbom_component, described_class::DEFAULT_MAX_RESULTS)
+        end
+
+        it 'does not return more than Sbom::Component::DEFAULT_MAX_RESULTS results' do
+          expect(Sbom::Component.count).to be > described_class::DEFAULT_MAX_RESULTS
+          expect(find.length).to be <= described_class::DEFAULT_MAX_RESULTS
+        end
+      end
+    end
+
+    context 'when given a query string' do
+      let(:query) { "actives" }
+
+      it "returns all matching Sbom::Components" do
+        expect(find).to match_array([component_3, component_4])
+      end
+    end
+  end
+end
diff --git a/ee/spec/finders/sbom/dependencies_finder_spec.rb b/ee/spec/finders/sbom/dependencies_finder_spec.rb
index 8778ede2a045f..eb2e6f1a454d7 100644
--- a/ee/spec/finders/sbom/dependencies_finder_spec.rb
+++ b/ee/spec/finders/sbom/dependencies_finder_spec.rb
@@ -170,6 +170,20 @@
         end
       end
 
+      context 'when filtered by component IDs' do
+        let_it_be(:params) do
+          {
+            component_ids: [occurrence_1.component_id]
+          }
+        end
+
+        it 'returns only records corresponding to the filter' do
+          component_ids = dependencies.map(&:component_id)
+
+          expect(component_ids).to eq([occurrence_1.component_id])
+        end
+      end
+
       context 'when filtered by license' do
         let_it_be(:params) do
           {
diff --git a/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb b/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb
new file mode 100644
index 0000000000000..747f014ccd3ee
--- /dev/null
+++ b/ee/spec/graphql/resolvers/sbom/component_resolver_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Sbom::ComponentResolver, feature_category: :vulnerability_management do
+  include GraphqlHelpers
+
+  let_it_be(:component_1) { create(:sbom_component, name: "activestorage") }
+  let_it_be(:component_2) { create(:sbom_component, name: "activesupport") }
+  let_it_be(:component_3) { create(:sbom_component, name: "log4j") }
+
+  describe '#resolve' do
+    subject { resolve_components(args: { name: name }) }
+
+    context 'when not given a query string' do
+      let(:name) { nil }
+
+      it { is_expected.to match_array([component_1, component_2, component_3]) }
+    end
+
+    context 'when given a query string' do
+      let(:name) { "actives" }
+
+      it { is_expected.to match_array([component_1, component_2]) }
+    end
+  end
+
+  def resolve_components(args: {})
+    resolve(
+      described_class,
+      obj: nil,
+      args: args,
+      ctx: {}
+    )
+  end
+end
diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb
index 0d55a3d622fd0..3c02309176004 100644
--- a/ee/spec/graphql/types/query_type_spec.rb
+++ b/ee/spec/graphql/types/query_type_spec.rb
@@ -48,7 +48,8 @@
       :self_managed_users_queued_for_role_promotion,
       :ai_self_hosted_models,
       :cloud_connector_status,
-      :project_secrets_manager
+      :project_secrets_manager,
+      :components
     ]
 
     all_expected_fields = expected_foss_fields + expected_ee_fields
diff --git a/ee/spec/models/sbom/component_spec.rb b/ee/spec/models/sbom/component_spec.rb
index 9402dcb04aab3..78536693fe330 100644
--- a/ee/spec/models/sbom/component_spec.rb
+++ b/ee/spec/models/sbom/component_spec.rb
@@ -57,6 +57,26 @@
     end
   end
 
+  describe '.by_name' do
+    let_it_be(:component_1) do
+      create(:sbom_component, name: 'activesupport')
+    end
+
+    let_it_be(:component_2) do
+      create(:sbom_component, name: 'activestorage')
+    end
+
+    let_it_be(:non_matching_component) do
+      create(:sbom_component, name: 'log4j')
+    end
+
+    subject(:results) { described_class.by_name('actives') }
+
+    it 'returns only the matching components' do
+      expect(results).to match_array([component_1, component_2])
+    end
+  end
+
   context 'with loose foreign key on sbom_components.organization_id' do
     it_behaves_like 'cleanup by a loose foreign key' do
       let_it_be(:parent) { create(:organization) }
diff --git a/ee/spec/models/sbom/occurrence_spec.rb b/ee/spec/models/sbom/occurrence_spec.rb
index f64b97d6b3f15..02a4bd9f6bb67 100644
--- a/ee/spec/models/sbom/occurrence_spec.rb
+++ b/ee/spec/models/sbom/occurrence_spec.rb
@@ -298,6 +298,15 @@
     end
   end
 
+  describe '.filter_by_component_ids' do
+    let_it_be(:occurrence_1) { create(:sbom_occurrence) }
+    let_it_be(:occurrence_2) { create(:sbom_occurrence) }
+
+    it 'returns records filtered by component IDs' do
+      expect(described_class.filter_by_component_ids([occurrence_1.component_id])).to eq([occurrence_1])
+    end
+  end
+
   describe '.filter_by_source_types' do
     let_it_be(:container_scanning_occurrence) { create(:sbom_occurrence, :os_occurrence) }
     let_it_be(:dependency_scanning_occurrence) { create(:sbom_occurrence) }
diff --git a/ee/spec/policies/sbom/component_policy_spec.rb b/ee/spec/policies/sbom/component_policy_spec.rb
new file mode 100644
index 0000000000000..9d7fce571a724
--- /dev/null
+++ b/ee/spec/policies/sbom/component_policy_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sbom::ComponentPolicy, feature_category: :vulnerability_management do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:sbom_component) { create(:sbom_component) }
+
+  subject { described_class.new(user, sbom_component) }
+
+  describe "reading Sbom::Components present in GitLab" do
+    it { is_expected.to be_allowed(:read_component) }
+  end
+end
-- 
GitLab