diff --git a/ee/app/controllers/groups/dependencies_controller.rb b/ee/app/controllers/groups/dependencies_controller.rb index f3f99528c114c9dcd14623dea6a47e67ee500114..8e2f3df90e2cb814f7bdc4f118f660613006ff64 100644 --- a/ee/app/controllers/groups/dependencies_controller.rb +++ b/ee/app/controllers/groups/dependencies_controller.rb @@ -47,9 +47,10 @@ def collect_dependencies end def serialized_dependencies - DependencyListSerializer.new( - project: nil, - user: current_user).with_pagination(request, response).represent(collect_dependencies) + DependencyListSerializer + .new(project: nil, group: group, user: current_user) + .with_pagination(request, response) + .represent(collect_dependencies) end def render_not_authorized diff --git a/ee/app/serializers/dependency_entity.rb b/ee/app/serializers/dependency_entity.rb index 6e68a1b7d742521f1f4bc146cc9fefa6fd6b1372..85904264aeaf3177f311c0d5f261bb10055f1b48 100644 --- a/ee/app/serializers/dependency_entity.rb +++ b/ee/app/serializers/dependency_entity.rb @@ -18,6 +18,14 @@ class VulnerabilityEntity < Grape::Entity class LicenseEntity < Grape::Entity expose :name, :url + + def name + object[:name] || object["name"] + end + + def url + object[:url] || object["url"] + end end class ProjectEntity < Grape::Entity @@ -28,8 +36,9 @@ class ProjectEntity < Grape::Entity expose :location, using: LocationEntity expose :vulnerabilities, using: VulnerabilityEntity, if: ->(_) { can_read_vulnerabilities? } expose :licenses, using: LicenseEntity, if: ->(_) { can_read_licenses? } - expose :project, using: ProjectEntity, if: ->(_) { !has_project? } - expose :project_count, :occurrence_count, :component_id, if: ->(_) { !has_project? } + expose :project, using: ProjectEntity, if: ->(_) { group? } + expose :project_count, :occurrence_count, if: ->(_) { group_counts? } + expose :component_id, if: ->(_) { group? } private @@ -38,10 +47,21 @@ def can_read_vulnerabilities? end def can_read_licenses? - can?(request.user, :read_licenses, request.project) + (group? && Feature.enabled?(:group_level_licenses, group)) || + can?(request.user, :read_licenses, request.project) + end + + def group + request.respond_to?(:group) ? request.group : nil + end + + def group? + group.present? end - def has_project? - !request.project.nil? + def group_counts? + group? && + object.respond_to?(:project_count) && + object.respond_to?(:occurrence_count) end end diff --git a/ee/config/feature_flags/development/group_level_licenses.yml b/ee/config/feature_flags/development/group_level_licenses.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac3d3dd0ce1a5bdf4a737b0b7cd94870f94cf310 --- /dev/null +++ b/ee/config/feature_flags/development/group_level_licenses.yml @@ -0,0 +1,8 @@ +--- +name: group_level_licenses +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/130238 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/422978 +milestone: '16.4' +type: development +group: group::threat insights +default_enabled: false diff --git a/ee/spec/factories/sbom/occurrences.rb b/ee/spec/factories/sbom/occurrences.rb index ccd2e48a445ed0776767ab608c1d76486d067782..5c7ddff4a299c082b0cdca930eb5aeec3255bb58 100644 --- a/ee/spec/factories/sbom/occurrences.rb +++ b/ee/spec/factories/sbom/occurrences.rb @@ -13,6 +13,32 @@ packager_name { 'npm' } end + trait :bundler do + packager_name { 'bundler' } + end + + trait :npm do + packager_name { 'npm' } + end + + trait :apache_2 do + after(:build) do |occurrence| + occurrence.licenses.push({ + 'name' => 'Apache-2.0', + 'url' => 'https://spdx.org/licenses/Apache-2.0.html' + }) + end + end + + trait :mit do + after(:build) do |occurrence| + occurrence.licenses.push({ + 'name' => 'MIT', + 'url' => 'https://spdx.org/licenses/MIT.html' + }) + end + end + after(:build) do |occurrence| occurrence.uuid = Sbom::OccurrenceUUID.generate( project_id: occurrence.project.id, diff --git a/ee/spec/requests/groups/dependencies_controller_spec.rb b/ee/spec/requests/groups/dependencies_controller_spec.rb index 78cce786576621e0494dc3404236b566abc6bb90..4582fc7fcc2ca225e1f6d0e9293eae52214d4e4c 100644 --- a/ee/spec/requests/groups/dependencies_controller_spec.rb +++ b/ee/spec/requests/groups/dependencies_controller_spec.rb @@ -126,8 +126,8 @@ context 'with existing dependencies' do let_it_be(:project) { create(:project, group: group) } - let_it_be(:sbom_occurrence_npm) { create(:sbom_occurrence, project: project, packager_name: 'npm') } - let_it_be(:sbom_occurrence_bundler) { create(:sbom_occurrence, project: project, packager_name: 'bundler') } + let_it_be(:sbom_occurrence_npm) { create(:sbom_occurrence, :mit, :npm, project: project) } + let_it_be(:sbom_occurrence_bundler) { create(:sbom_occurrence, :apache_2, :bundler, project: project) } let(:expected_response) do { @@ -140,6 +140,12 @@ 'name' => sbom_occurrence_npm.name, 'packager' => sbom_occurrence_npm.packager, 'version' => sbom_occurrence_npm.version, + 'licenses' => [ + { + 'name' => 'MIT', + 'url' => 'https://spdx.org/licenses/MIT.html' + } + ], 'occurrence_count' => 1, 'project_count' => 1, "project" => { "full_path" => project.full_path, "name" => project.name }, @@ -150,6 +156,12 @@ 'name' => sbom_occurrence_bundler.name, 'packager' => sbom_occurrence_bundler.packager, 'version' => sbom_occurrence_bundler.version, + 'licenses' => [ + { + 'name' => 'Apache-2.0', + 'url' => 'https://spdx.org/licenses/Apache-2.0.html' + } + ], 'occurrence_count' => 1, 'project_count' => 1, "project" => { "full_path" => project.full_path, "name" => project.name }, @@ -159,6 +171,12 @@ } end + it 'returns the expected response' do + subject + + expect(json_response).to eq(expected_response) + end + it 'includes pagination headers in the response' do subject diff --git a/ee/spec/serializers/dependency_entity_spec.rb b/ee/spec/serializers/dependency_entity_spec.rb index d54692f29597a476ce2c7a7d04f20e23a6e01d57..169e7faf54bbce8a2202c16465386d9336d2a681 100644 --- a/ee/spec/serializers/dependency_entity_spec.rb +++ b/ee/spec/serializers/dependency_entity_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe DependencyEntity do +RSpec.describe DependencyEntity, feature_category: :dependency_management do describe '#as_json' do subject { described_class.represent(dependency, request: request).as_json } @@ -49,10 +49,12 @@ end context 'with project' do + let(:project) { create(:project, :repository, :private, :in_group) } let(:dependency) { build(:dependency, project: project) } before do allow(request).to receive(:project).and_return(nil) + allow(request).to receive(:group).and_return(project.group) end it 'includes project name and full_path' do @@ -70,6 +72,7 @@ context 'when all required features are unavailable' do before do + stub_feature_flags(group_level_licenses: false) project.add_developer(user) end @@ -89,5 +92,36 @@ expect(location[:path]).to eq('package_file.lock') end end + + context 'with an Sbom::Occurrence' do + subject { described_class.represent(sbom_occurrence, request: request).as_json } + + let(:project) { create(:project, :repository, :private, :in_group) } + let(:sbom_occurrence) { create(:sbom_occurrence, :mit, :bundler, project: project) } + + before do + allow(request).to receive(:group).and_return(project.group) + end + + it 'renders the proper representation' do + expect(subject.as_json).to eq({ + "name" => sbom_occurrence.name, + "packager" => sbom_occurrence.packager, + "project" => { + "name" => project.name, + "full_path" => project.full_path + }, + "version" => sbom_occurrence.version, + "licenses" => sbom_occurrence.licenses, + "component_id" => sbom_occurrence.component_id, + "location" => { + "ancestors" => nil, + "blob_path" => sbom_occurrence.location[:blob_path], + "path" => sbom_occurrence.location[:path], + "top_level" => sbom_occurrence.location[:top_level] + } + }) + end + end end end