diff --git a/ee/app/controllers/groups/dependencies_controller.rb b/ee/app/controllers/groups/dependencies_controller.rb index 872a61708792ceea1dd4bb5955e5c7c4843c1873..a9012a0b6c12ba8ddbd4877dbf163b3da0d84973 100644 --- a/ee/app/controllers/groups/dependencies_controller.rb +++ b/ee/app/controllers/groups/dependencies_controller.rb @@ -4,7 +4,12 @@ module Groups class DependenciesController < Groups::ApplicationController include GovernUsageGroupTracking + before_action only: :index do + push_frontend_feature_flag(:group_level_dependencies_filtering, group) + end + before_action :authorize_read_dependency_list! + before_action :validate_project_ids_limit!, only: :index feature_category :dependency_management urgency :low @@ -12,10 +17,7 @@ class DependenciesController < Groups::ApplicationController # More details on https://gitlab.com/gitlab-org/gitlab/-/issues/411257#note_1508315283 GROUP_COUNT_LIMIT = 600 - - before_action only: :index do - push_frontend_feature_flag(:group_level_dependencies_filtering, group) - end + PROJECT_IDS_LIMIT = 10 def index respond_to do |format| @@ -56,6 +58,15 @@ def authorize_read_dependency_list! render_not_authorized end + def validate_project_ids_limit! + return unless params.fetch(:project_ids, []).size > PROJECT_IDS_LIMIT + + render_error( + :unprocessable_entity, + format(_('A maximum of %{limit} projects can be searched for at one time.'), limit: PROJECT_IDS_LIMIT) + ) + end + def dependencies_finder ::Sbom::DependenciesFinder.new(group, params: dependencies_finder_params) end @@ -69,7 +80,8 @@ def dependencies_finder_params :sort_by, component_names: [], licenses: [], - package_managers: [] + package_managers: [], + project_ids: [] ) else params.permit(:page, :per_page, :sort, :sort_by) @@ -93,6 +105,14 @@ def render_not_authorized end end + def render_error(status, message) + respond_to do |format| + format.json do + render json: { message: message }, status: status + end + end + end + def set_enable_project_search @enable_project_search = filtering_allowed? end diff --git a/ee/app/finders/sbom/dependencies_finder.rb b/ee/app/finders/sbom/dependencies_finder.rb index 82dc1b1e1292ad8d9a212014d04c179e10ae7565..bd78d1d7c3870c6ea48c730d1ae00bf1f6545a4d 100644 --- a/ee/app/finders/sbom/dependencies_finder.rb +++ b/ee/app/finders/sbom/dependencies_finder.rb @@ -2,6 +2,8 @@ module Sbom class DependenciesFinder + include Gitlab::Utils::StrongMemoize + def initialize(project_or_group, params: {}) @project_or_group = project_or_group @params = params @@ -27,13 +29,10 @@ def execute attr_reader :project_or_group, :params def filtered_collection - collection = project_or_group.sbom_occurrences - + collection = occurrences collection = filter_by_package_managers(collection) if params[:package_managers].present? - collection = filter_by_component_names(collection) if params[:component_names].present? collection = collection.by_licenses(params[:licenses]) if params[:licenses].present? - collection end @@ -48,5 +47,22 @@ def filter_by_component_names(sbom_occurrences) def sort_direction params[:sort]&.downcase == 'desc' ? 'desc' : 'asc' end + + def occurrences + return project_or_group.sbom_occurrences if params[:project_ids].blank? || project? + + Sbom::Occurrence.by_project_ids(project_ids_in_group_hierarchy) + end + + def project_ids_in_group_hierarchy + Project + .id_in(params[:project_ids]) + .for_group_and_its_subgroups(project_or_group) + .select(:id) + end + + def project? + project_or_group.is_a?(::Project) + end end end diff --git a/ee/app/models/sbom/occurrence.rb b/ee/app/models/sbom/occurrence.rb index 9a801603a02044f8598bf89b793d1de8e98098b7..e6e8ac558d720fe4002f6f3a638285ab6951aa7c 100644 --- a/ee/app/models/sbom/occurrence.rb +++ b/ee/app/models/sbom/occurrence.rb @@ -57,6 +57,8 @@ class Occurrence < ApplicationRecord where(query_parts.join(' OR '), licenses: Array(licenses)) end + scope :by_project_ids, ->(project_ids) { where(project_id: project_ids) } + scope :filter_by_package_managers, ->(package_managers) do where(package_manager: package_managers) end diff --git a/ee/spec/finders/sbom/dependencies_finder_spec.rb b/ee/spec/finders/sbom/dependencies_finder_spec.rb index 116da6542f08f73df9006e4e26b384ea85065eab..9fbbc5b75d2659397d9da57bb4e7a230d518b6db 100644 --- a/ee/spec/finders/sbom/dependencies_finder_spec.rb +++ b/ee/spec/finders/sbom/dependencies_finder_spec.rb @@ -6,32 +6,31 @@ let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:project) { create(:project, group: subgroup) } - let_it_be(:component_1) { create(:sbom_component, name: 'component-1') } - let_it_be(:component_2) { create(:sbom_component, name: 'component-2') } - let_it_be(:component_3) { create(:sbom_component, name: 'component-3') } - let_it_be(:component_version_1) { create(:sbom_component_version, component: component_1) } - let_it_be(:component_version_2) { create(:sbom_component_version, component: component_2) } - let_it_be(:component_version_3) { create(:sbom_component_version, component: component_3) } let_it_be(:occurrence_1) do - create(:sbom_occurrence, :mit, component_version: component_version_1, packager_name: 'nuget', project: project) + create(:sbom_occurrence, :mit, packager_name: 'nuget', project: project) end let_it_be(:occurrence_2) do - create(:sbom_occurrence, :apache_2, component_version: component_version_2, packager_name: 'npm', project: project) + create(:sbom_occurrence, :apache_2, packager_name: 'npm', project: project) end let_it_be(:occurrence_3) do - create(:sbom_occurrence, :mpl_2, component_version: component_version_3, source: nil, project: project) + create(:sbom_occurrence, :mpl_2, source: nil, project: project) + end + + before do + stub_licensed_features(dependency_scanning: true) end shared_examples 'filter and sorting' do + subject(:dependencies) { described_class.new(project_or_group, params: params).execute } + context 'without params' do let_it_be(:params) { {} } it 'returns the dependencies associated with the project ordered by id' do - expect(dependencies.first.id).to eq(occurrence_1.id) - expect(dependencies.last.id).to eq(occurrence_3.id) + expect(dependencies.map(&:id)).to be_sorted end end @@ -45,8 +44,7 @@ end it 'returns array of data properly sorted' do - expect(dependencies.first.name).to eq('component-1') - expect(dependencies.last.name).to eq('component-3') + expect(dependencies.map(&:name)).to be_sorted end end @@ -59,8 +57,7 @@ end it 'returns array of data properly sorted' do - expect(dependencies.first.name).to eq('component-3') - expect(dependencies.last.name).to eq('component-1') + expect(dependencies.map(&:name)).to be_sorted(verse: :desc) end end @@ -98,9 +95,9 @@ let_it_be(:params) { { sort: 'asc', sort_by: 'license' } } it 'returns sorted results' do - expect(dependencies[0].licenses[0]["spdx_identifier"]).to eq('Apache-2.0') - expect(dependencies[1].licenses[0]["spdx_identifier"]).to eq('MIT') - expect(dependencies[2].licenses[0]["spdx_identifier"]).to eq('MPL-2.0') + spdx_ids = dependencies.map { |dependency| dependency.licenses.first['spdx_identifier'] } + + expect(spdx_ids).to be_sorted end end @@ -108,9 +105,9 @@ let_it_be(:params) { { sort: 'desc', sort_by: 'license' } } it 'returns sorted results' do - expect(dependencies[0].licenses[0]["spdx_identifier"]).to eq('MPL-2.0') - expect(dependencies[1].licenses[0]["spdx_identifier"]).to eq('MIT') - expect(dependencies[2].licenses[0]["spdx_identifier"]).to eq('Apache-2.0') + spdx_ids = dependencies.map { |dependency| dependency.licenses.first['spdx_identifier'] } + + expect(spdx_ids).to be_sorted(verse: :desc) end end @@ -163,28 +160,62 @@ end it 'returns the dependencies associated with the project ordered by id' do - expect(dependencies.first.id).to eq(occurrence_1.id) - expect(dependencies.last.id).to eq(occurrence_3.id) + expect(dependencies.map(&:id)).to be_sorted end end end end + shared_examples 'group with project_id filters' do + context 'when filtering by project_id' do + let_it_be(:authorized_project) { create(:project, group: subgroup) } + let_it_be(:occurrence_from_authorized_project) do + create(:sbom_occurrence, project: authorized_project) + end + + let_it_be(:unauthorized_project) { create(:project) } + let_it_be(:occurrence_from_unauthorized_project) do + create(:sbom_occurrence, project: unauthorized_project) + end + + let_it_be(:params) { { project_ids: [authorized_project, unauthorized_project].map(&:id) } } + + it 'returns records for authorized projects only' do + expect(dependencies.map(&:id)).to match_array([occurrence_from_authorized_project.id]) + end + end + end + context 'with project' do - subject(:dependencies) { described_class.new(project, params: params).execute } + let(:project_or_group) { project } include_examples 'filter and sorting' + + context 'when filtering by project_id' do + let_it_be(:other_project) { create(:project, group: group) } + let_it_be(:occurrence_from_other_project) do + create(:sbom_occurrence, project: other_project) + end + + let_it_be(:params) { { project_ids: [other_project.id] } } + + it 'ignores the project_id param' do + expect(dependencies).to match_array([occurrence_1, occurrence_2, occurrence_3]) + end + end end context 'with group' do - subject(:dependencies) { described_class.new(group, params: params).execute } + let(:project_or_group) { group } include_examples 'filter and sorting' + include_examples 'group with project_id filters' end context 'with subgroup' do - subject(:dependencies) { described_class.new(subgroup, params: params).execute } + let(:project_or_group) { subgroup } include_examples 'filter and sorting' + include_examples 'group with project_id filters' end end diff --git a/ee/spec/models/sbom/occurrence_spec.rb b/ee/spec/models/sbom/occurrence_spec.rb index c563a451ef36cbffe2a007048046cbd92023d09f..99291f8ecea024691158a15289c1d43bfd64debe 100644 --- a/ee/spec/models/sbom/occurrence_spec.rb +++ b/ee/spec/models/sbom/occurrence_spec.rb @@ -369,6 +369,15 @@ end end + describe '.by_project_ids' do + let_it_be(:occurrence_1) { create(:sbom_occurrence) } + let_it_be(:occurrence_2) { create(:sbom_occurrence) } + + it 'returns records filtered by project_id' do + expect(described_class.by_project_ids(occurrence_1.project)).to eq([occurrence_1]) + end + end + describe '.filter_by_package_managers' do let_it_be(:occurrence_nuget) { create(:sbom_occurrence, packager_name: 'nuget') } let_it_be(:occurrence_npm) { create(:sbom_occurrence, packager_name: 'npm') } diff --git a/ee/spec/requests/groups/dependencies_controller_spec.rb b/ee/spec/requests/groups/dependencies_controller_spec.rb index 3c9deedcabcda23b26725a36e9f5e54f8467f17f..361ad05813b9f19cf6ac2ba901d1f25b5a16d974 100644 --- a/ee/spec/requests/groups/dependencies_controller_spec.rb +++ b/ee/spec/requests/groups/dependencies_controller_spec.rb @@ -16,7 +16,7 @@ context 'when security dashboard feature is enabled' do before do - stub_licensed_features(security_dashboard: true) + stub_licensed_features(security_dashboard: true, dependency_scanning: true) end context 'and user is allowed to access group level dependencies' do @@ -81,7 +81,7 @@ context 'when security dashboard feature is enabled' do before do - stub_licensed_features(security_dashboard: true) + stub_licensed_features(security_dashboard: true, dependency_scanning: true) end context 'and user is allowed to access group level dependencies' do @@ -338,6 +338,36 @@ expect(json_response['dependencies'].pluck('name')).to eq([sbom_occurrence_bundler.name]) end end + + context 'when filtered by projects' do + let_it_be(:other_project) { create(:project, group: group) } + let_it_be(:occurrence_from_other_project) { create(:sbom_occurrence, project: other_project) } + + let(:params) { { project_ids: [other_project.id] } } + + it 'returns a filtered list' do + subject + + expect(json_response['dependencies'].count).to eq(1) + expect(json_response['dependencies'].pluck('name')).to eq([occurrence_from_other_project.name]) + end + + context 'when trying to search for too many projects' do + let(:params) { { project_ids: (1..11).to_a } } + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq( + format( + _('A maximum of %{limit} projects can be searched for at one time.'), + limit: described_class::PROJECT_IDS_LIMIT + ) + ) + end + end + end end end end @@ -370,7 +400,7 @@ context 'when security dashboard feature is enabled' do before do - stub_licensed_features(security_dashboard: true) + stub_licensed_features(security_dashboard: true, dependency_scanning: true) end context 'and user is allowed to access group level dependencies' do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c658795361cfc5d2cb665d4fc1792197f47217ca..34e7472b837ee13c82861ff2a2259f3bcdec6ed7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1813,6 +1813,9 @@ msgstr "" msgid "A management, operational, or technical control (that is, safeguard or countermeasure) employed by an organization that provides equivalent or comparable protection for an information system." msgstr "" +msgid "A maximum of %{limit} projects can be searched for at one time." +msgstr "" + msgid "A member of the abuse team will review your report as soon as possible." msgstr ""