Commit 89d29997 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '34825-create-software-license-policy' into 'master'

Provide the API to create a new software license policy

See merge request gitlab-org/gitlab!19598
parents 058e3853 99adf78c
...@@ -4,6 +4,11 @@ module Projects ...@@ -4,6 +4,11 @@ module Projects
module Security module Security
class LicensesController < Projects::ApplicationController class LicensesController < Projects::ApplicationController
before_action :authorize_read_licenses_list! before_action :authorize_read_licenses_list!
before_action :authorize_admin_software_license_policy!, only: [:create]
before_action do
push_frontend_feature_flag(:licenses_list)
end
def index def index
respond_to do |format| respond_to do |format|
...@@ -19,6 +24,18 @@ module Projects ...@@ -19,6 +24,18 @@ module Projects
end end
end end
def create
result = ::Projects::Licenses::CreatePolicyService
.new(project, current_user, software_license_policy_params)
.execute
if result[:status] == :success
render json: LicenseEntity.represent(result[:software_license_policy]), status: :created
else
render_error_for(result)
end
end
private private
def serializer def serializer
...@@ -29,6 +46,14 @@ module Projects ...@@ -29,6 +46,14 @@ module Projects
def pageable(items) def pageable(items)
::Gitlab::ItemsCollection.new(items) ::Gitlab::ItemsCollection.new(items)
end end
def software_license_policy_params
params.require(:software_license_policy).permit(:software_license_id, :spdx_identifier, :classification)
end
def render_error_for(result)
render json: { errors: result[:message].as_json }, status: result.fetch(:http_status, :unprocessable_entity)
end
end end
end end
end end
...@@ -10,16 +10,7 @@ module SCA ...@@ -10,16 +10,7 @@ module SCA
def policies def policies
strong_memoize(:policies) do strong_memoize(:policies) do
configured_policies = project.software_license_policies.index_by { |policy| policy.software_license.canonical_id } new_policies.merge(known_policies).sort.map(&:last)
detected_licenses = license_scan_report.licenses.map do |reported_license|
policy = configured_policies[reported_license.canonical_id]
configured_policies.delete(reported_license.canonical_id) if policy
build_policy(reported_license, policy)
end
undetected_licenses = configured_policies.map do |id, policy|
build_policy(license_scan_report.fetch(id, nil), policy)
end
(detected_licenses + undetected_licenses).sort_by(&:name)
end end
end end
...@@ -31,10 +22,30 @@ module SCA ...@@ -31,10 +22,30 @@ module SCA
end end
end end
def report_for(policy)
build_policy(license_scan_report[policy.software_license.canonical_id], policy)
end
private private
attr_reader :project attr_reader :project
def known_policies
strong_memoize(:known_policies) do
project.software_license_policies.including_license.unreachable_limit.map do |policy|
[policy.software_license.canonical_id, report_for(policy)]
end.to_h
end
end
def new_policies
license_scan_report.licenses.map do |reported_license|
next if known_policies[reported_license.canonical_id]
[reported_license.canonical_id, build_policy(reported_license, nil)]
end.compact.to_h
end
def pipeline def pipeline
strong_memoize(:pipeline) do strong_memoize(:pipeline) do
project.all_pipelines.latest_successful_for_ref(project.default_branch) project.all_pipelines.latest_successful_for_ref(project.default_branch)
......
...@@ -10,19 +10,7 @@ module SCA ...@@ -10,19 +10,7 @@ module SCA
@url = reported_license&.url @url = reported_license&.url
@dependencies = reported_license&.dependencies || [] @dependencies = reported_license&.dependencies || []
@spdx_identifier = software_policy&.spdx_identifier || reported_license.id @spdx_identifier = software_policy&.spdx_identifier || reported_license.id
@classification = classify(software_policy) @classification = software_policy&.approval_status || 'unclassified'
end
private
def classify(policy)
if policy&.approved?
'allowed'
elsif policy&.blacklisted?
'denied'
else
'unclassified'
end
end end
end end
end end
...@@ -9,7 +9,9 @@ class SoftwareLicense < ApplicationRecord ...@@ -9,7 +9,9 @@ class SoftwareLicense < ApplicationRecord
validates :spdx_identifier, length: { maximum: 255 } validates :spdx_identifier, length: { maximum: 255 }
scope :by_name, -> (names) { where(name: names) } scope :by_name, -> (names) { where(name: names) }
scope :by_spdx, -> (spdx_identifier) { where(spdx_identifier: spdx_identifier) }
scope :ordered, -> { order(:name) } scope :ordered, -> { order(:name) }
scope :spdx, -> { where.not(spdx_identifier: nil) }
scope :unknown, -> { where(spdx_identifier: nil) } scope :unknown, -> { where(spdx_identifier: nil) }
scope :grouped_by_name, -> { group(:name) } scope :grouped_by_name, -> { group(:name) }
......
...@@ -33,6 +33,7 @@ class SoftwareLicensePolicy < ApplicationRecord ...@@ -33,6 +33,7 @@ class SoftwareLicensePolicy < ApplicationRecord
scope :for_project, -> (project) { where(project: project) } scope :for_project, -> (project) { where(project: project) }
scope :with_license, -> { joins(:software_license) } scope :with_license, -> { joins(:software_license) }
scope :including_license, -> { includes(:software_license) } scope :including_license, -> { includes(:software_license) }
scope :unreachable_limit, -> { limit(1_000) }
scope :with_license_by_name, -> (license_name) do scope :with_license_by_name, -> (license_name) do
with_license.where(SoftwareLicense.arel_table[:name].lower.in(Array(license_name).map(&:downcase))) with_license.where(SoftwareLicense.arel_table[:name].lower.in(Array(license_name).map(&:downcase)))
......
# frozen_string_literal: true
module Projects
module Licenses
class CreatePolicyService < ::BaseService
def execute
policy = create_policy(find_software_license, params[:classification])
success(software_license_policy: license_compliance.report_for(policy))
rescue ActiveRecord::RecordInvalid => exception
error(exception.record.errors, :unprocessable_entity)
end
private
def license_compliance
@license_compliance ||= ::SCA::LicenseCompliance.new(project)
end
def create_policy(software_license, classification)
raise error_for(:approval_status, :invalid) unless known?(classification)
policy = project.software_license_policies.create!(software_license: software_license, approval_status: classification)
RefreshLicenseComplianceChecksWorker.perform_async(project.id)
policy
end
def find_software_license
SoftwareLicense.id_in(params[:software_license_id]).or(SoftwareLicense.by_spdx(params[:spdx_identifier])).first
end
def known?(classification)
SoftwareLicensePolicy.approval_statuses.key?(classification)
end
def error_for(attribute, error)
ActiveRecord::RecordInvalid.new(build_error_for(attribute, error))
end
def build_error_for(attribute, error)
SoftwareLicensePolicy.new { |policy| policy.errors.add(attribute, error) }
end
end
end
end
...@@ -60,6 +60,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -60,6 +60,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
resources :subscriptions, only: [:create, :destroy] resources :subscriptions, only: [:create, :destroy]
resources :licenses, only: [:index, :create], controller: 'security/licenses'
end end
# End of the /-/ scope. # End of the /-/ scope.
......
...@@ -5,7 +5,7 @@ module Gitlab ...@@ -5,7 +5,7 @@ module Gitlab
module Reports module Reports
module LicenseScanning module LicenseScanning
class Report class Report
delegate :empty?, :fetch, to: :found_licenses delegate :empty?, :fetch, :[], to: :found_licenses
attr_accessor :version attr_accessor :version
def initialize(version: '1.0') def initialize(version: '1.0')
......
...@@ -70,13 +70,16 @@ describe Projects::Security::LicensesController do ...@@ -70,13 +70,16 @@ describe Projects::Security::LicensesController do
end end
context "when software policies are applied to some of the most recently detected licenses" do context "when software policies are applied to some of the most recently detected licenses" do
let!(:raw_report) { fixture_file_upload(Rails.root.join('ee/spec/fixtures/security_reports/gl-license-management-report-v2.json'), 'application/json') } let_it_be(:raw_report) { fixture_file_upload(Rails.root.join('ee/spec/fixtures/security_reports/gl-license-management-report-v2.json'), 'application/json') }
let!(:pipeline) { create(:ee_ci_pipeline, :with_license_management_report, project: project) } let_it_be(:mit) { create(:software_license, :mit) }
let!(:mit) { create(:software_license, :mit) } let_it_be(:mit_policy) { create(:software_license_policy, :denied, software_license: mit, project: project) }
let!(:mit_policy) { create(:software_license_policy, :denied, software_license: mit, project: project) } let_it_be(:pipeline) do
create(:ee_ci_pipeline, :with_license_management_report, project: project).tap do |pipeline|
pipeline.job_artifacts.license_management.last.update!(file: raw_report)
end
end
before do before do
pipeline.job_artifacts.license_management.last.update!(file: raw_report)
get :index, params: { namespace_id: project.namespace, project_id: project }, format: :json get :index, params: { namespace_id: project.namespace, project_id: project }, format: :json
end end
...@@ -97,7 +100,7 @@ describe Projects::Security::LicensesController do ...@@ -97,7 +100,7 @@ describe Projects::Security::LicensesController do
"spdx_identifier" => "MIT", "spdx_identifier" => "MIT",
"name" => mit.name, "name" => mit.name,
"url" => "http://spdx.org/licenses/MIT.json", "url" => "http://spdx.org/licenses/MIT.json",
"classification" => "denied" "classification" => "blacklisted"
}) })
expect(json_response.dig("licenses", 2)).to include({ expect(json_response.dig("licenses", 2)).to include({
...@@ -146,4 +149,122 @@ describe Projects::Security::LicensesController do ...@@ -146,4 +149,122 @@ describe Projects::Security::LicensesController do
end end
end end
end end
describe "POST #create" do
let(:project) { create(:project, :repository, :private) }
let(:mit_license) { create(:software_license, :mit) }
let(:default_params) do
{
namespace_id: project.namespace,
project_id: project,
software_license_policy: {
software_license_id: mit_license.id,
classification: 'approved'
}
}
end
context "when authenticated" do
let(:current_user) { create(:user) }
before do
stub_licensed_features(licenses_list: true, license_management: true)
sign_in(current_user)
end
context "when the current user is not a member of the project" do
before do
post :create, xhr: true, params: default_params
end
it { expect(response).to have_http_status(:not_found) }
end
context "when the current user is a member of the project but not authorized to create policies" do
before do
project.add_guest(current_user)
post :create, xhr: true, params: default_params
end
it { expect(response).to have_http_status(:not_found) }
end
context "when authorized as a maintainer" do
let(:json) { json_response.with_indifferent_access }
before do
project.add_maintainer(current_user)
end
context "when creating a policy for a software license by the software license database id" do
before do
post :create, xhr: true, params: default_params.merge({
software_license_policy: {
software_license_id: mit_license.id,
classification: 'blacklisted'
}
})
end
it { expect(response).to have_http_status(:created) }
it 'creates a new policy' do
expect(project.reload.software_license_policies.blacklisted.count).to be(1)
expect(project.reload.software_license_policies.blacklisted.last.software_license).to eq(mit_license)
end
it 'returns the proper JSON response' do
expect(json[:id]).to be_present
expect(json[:spdx_identifier]).to eq(mit_license.spdx_identifier)
expect(json[:classification]).to eq('blacklisted')
expect(json[:name]).to eq(mit_license.name)
expect(json[:url]).to be_nil
expect(json[:components]).to be_empty
end
end
context "when creating a policy for a software license by the software license SPDX identifier" do
before do
post :create, xhr: true, params: default_params.merge({
software_license_policy: {
spdx_identifier: mit_license.spdx_identifier,
classification: 'approved'
}
})
end
it { expect(response).to have_http_status(:created) }
it 'creates a new policy' do
expect(project.reload.software_license_policies.approved.count).to be(1)
expect(project.reload.software_license_policies.approved.last.software_license).to eq(mit_license)
end
it 'returns the proper JSON response' do
expect(json[:id]).to be_present
expect(json[:spdx_identifier]).to eq(mit_license.spdx_identifier)
expect(json[:classification]).to eq('approved')
expect(json[:name]).to eq(mit_license.name)
expect(json[:url]).to be_nil
expect(json[:components]).to be_empty
end
end
context "when the parameters are invalid" do
before do
post :create, xhr: true, params: default_params.merge({
software_license_policy: {
spdx_identifier: nil,
classification: 'approved'
}
})
end
it { expect(response).to have_http_status(:unprocessable_entity) }
it { expect(json).to eq({ 'errors' => { "software_license" => ["can't be blank"] } }) }
end
end
end
end
end end
...@@ -24,7 +24,7 @@ RSpec.describe SCA::LicenseCompliance do ...@@ -24,7 +24,7 @@ RSpec.describe SCA::LicenseCompliance do
expect(subject.policies[0]&.id).to eq(mit_policy.id) expect(subject.policies[0]&.id).to eq(mit_policy.id)
expect(subject.policies[0]&.name).to eq(mit.name) expect(subject.policies[0]&.name).to eq(mit.name)
expect(subject.policies[0]&.url).to be_nil expect(subject.policies[0]&.url).to be_nil
expect(subject.policies[0]&.classification).to eq("denied") expect(subject.policies[0]&.classification).to eq("blacklisted")
expect(subject.policies[0]&.spdx_identifier).to eq("MIT") expect(subject.policies[0]&.spdx_identifier).to eq("MIT")
end end
end end
...@@ -77,8 +77,9 @@ RSpec.describe SCA::LicenseCompliance do ...@@ -77,8 +77,9 @@ RSpec.describe SCA::LicenseCompliance do
it { expect(subject.policies.map(&:spdx_identifier)).to contain_exactly('BSD-3-Clause', 'MIT', nil) } it { expect(subject.policies.map(&:spdx_identifier)).to contain_exactly('BSD-3-Clause', 'MIT', nil) }
end end
context "when a pipeline has successfully produced a license scan report" do context "when a pipeline has successfully produced a v2.0 license scan report" do
let(:builds) { [license_scan_build] } let(:builds) { [license_scan_build] }
let(:license_scan_build) { create(:ci_build, :success, job_artifacts: [license_scan_artifact]) } let(:license_scan_build) { create(:ci_build, :success, job_artifacts: [license_scan_artifact]) }
let(:license_scan_artifact) { create(:ci_job_artifact, file_type: :license_management, file_format: :raw) } let(:license_scan_artifact) { create(:ci_job_artifact, file_type: :license_management, file_format: :raw) }
let(:license_scan_file) { fixture_file_upload(Rails.root.join("ee/spec/fixtures/security_reports/gl-license-management-report-v2.json"), "application/json") } let(:license_scan_file) { fixture_file_upload(Rails.root.join("ee/spec/fixtures/security_reports/gl-license-management-report-v2.json"), "application/json") }
...@@ -100,12 +101,51 @@ RSpec.describe SCA::LicenseCompliance do ...@@ -100,12 +101,51 @@ RSpec.describe SCA::LicenseCompliance do
expect(subject.policies[1]&.id).to eq(mit_policy.id) expect(subject.policies[1]&.id).to eq(mit_policy.id)
expect(subject.policies[1]&.name).to eq(mit.name) expect(subject.policies[1]&.name).to eq(mit.name)
expect(subject.policies[1]&.url).to eq("http://spdx.org/licenses/MIT.json") expect(subject.policies[1]&.url).to eq("http://spdx.org/licenses/MIT.json")
expect(subject.policies[1]&.classification).to eq("denied") expect(subject.policies[1]&.classification).to eq("blacklisted")
expect(subject.policies[1]&.spdx_identifier).to eq("MIT")
expect(subject.policies[2]&.id).to eq(other_license_policy.id)
expect(subject.policies[2]&.name).to eq(other_license.name)
expect(subject.policies[2]&.url).to be_blank
expect(subject.policies[2]&.classification).to eq("approved")
expect(subject.policies[2]&.spdx_identifier).to eq(other_license.spdx_identifier)
expect(subject.policies[3]&.id).to be_nil
expect(subject.policies[3]&.name).to eq("unknown")
expect(subject.policies[3]&.url).to be_blank
expect(subject.policies[3]&.classification).to eq("unclassified")
expect(subject.policies[3]&.spdx_identifier).to be_nil
end
end
context "when a pipeline has successfully produced a v1.1 license scan report" do
let(:builds) { [license_scan_build] }
let(:license_scan_build) { create(:ci_build, :success, job_artifacts: [license_scan_artifact]) }
let(:license_scan_artifact) { create(:ci_job_artifact, file_type: :license_management, file_format: :raw) }
let(:license_scan_file) { fixture_file_upload(Rails.root.join("ee/spec/fixtures/security_reports/gl-license-management-report-v1.1.json"), "application/json") }
it 'adds an entry for each detected license and each policy' do
mit = create(:software_license, :mit)
mit_policy = create(:software_license_policy, :denied, software_license: mit, project: project)
other_license = create(:software_license, spdx_identifier: "Other-Id")
other_license_policy = create(:software_license_policy, :allowed, software_license: other_license, project: project)
license_scan_artifact.update!(file: license_scan_file)
expect(subject.policies.count).to eq(4)
expect(subject.policies[0]&.id).to be_nil
expect(subject.policies[0]&.name).to eq("BSD")
expect(subject.policies[0]&.url).to eq("http://spdx.org/licenses/BSD-4-Clause.json")
expect(subject.policies[0]&.classification).to eq("unclassified")
expect(subject.policies[0]&.spdx_identifier).to eq("BSD-4-Clause")
expect(subject.policies[1]&.id).to eq(mit_policy.id)
expect(subject.policies[1]&.name).to eq(mit.name)
expect(subject.policies[1]&.url).to eq("http://opensource.org/licenses/mit-license")
expect(subject.policies[1]&.classification).to eq("blacklisted")
expect(subject.policies[1]&.spdx_identifier).to eq("MIT") expect(subject.policies[1]&.spdx_identifier).to eq("MIT")
expect(subject.policies[2]&.id).to eq(other_license_policy.id) expect(subject.policies[2]&.id).to eq(other_license_policy.id)
expect(subject.policies[2]&.name).to eq(other_license.name) expect(subject.policies[2]&.name).to eq(other_license.name)
expect(subject.policies[2]&.url).to be_blank expect(subject.policies[2]&.url).to be_blank
expect(subject.policies[2]&.classification).to eq("allowed") expect(subject.policies[2]&.classification).to eq("approved")
expect(subject.policies[2]&.spdx_identifier).to eq(other_license.spdx_identifier) expect(subject.policies[2]&.spdx_identifier).to eq(other_license.spdx_identifier)
expect(subject.policies[3]&.id).to be_nil expect(subject.policies[3]&.id).to be_nil
expect(subject.policies[3]&.name).to eq("unknown") expect(subject.policies[3]&.name).to eq("unknown")
......
...@@ -52,11 +52,11 @@ RSpec.describe SCA::LicensePolicy do ...@@ -52,11 +52,11 @@ RSpec.describe SCA::LicensePolicy do
let(:denied_policy) { build(:software_license_policy, :denied, software_license: software_license) } let(:denied_policy) { build(:software_license_policy, :denied, software_license: software_license) }
context "when a allowed software_policy is provided" do context "when a allowed software_policy is provided" do
it { expect(described_class.new(license, allowed_policy).classification).to eq("allowed") } it { expect(described_class.new(license, allowed_policy).classification).to eq("approved") }
end end
context "when a denied software_policy is provided" do context "when a denied software_policy is provided" do
it { expect(described_class.new(license, denied_policy).classification).to eq("denied") } it { expect(described_class.new(license, denied_policy).classification).to eq("blacklisted") }
end end
context "when a software_policy is NOT provided" do context "when a software_policy is NOT provided" do
......
...@@ -39,8 +39,16 @@ describe SoftwareLicense do ...@@ -39,8 +39,16 @@ describe SoftwareLicense do
describe 'scopes' do describe 'scopes' do
subject { described_class } subject { described_class }
let!(:mit) { create(:software_license, :mit, spdx_identifier: 'MIT') } let_it_be(:mit) { create(:software_license, :mit, spdx_identifier: 'MIT') }
let!(:apache_2) { create(:software_license, :apache_2_0, spdx_identifier: nil) } let_it_be(:apache_2) { create(:software_license, :apache_2_0, spdx_identifier: nil) }
describe '.by_spdx' do
it { expect(subject.by_spdx(mit.spdx_identifier)).to contain_exactly(mit) }
end
describe '.spdx' do
it { expect(subject.spdx).to contain_exactly(mit) }
end
describe '.by_name' do describe '.by_name' do
it { expect(subject.by_name(mit.name)).to contain_exactly(mit) } it { expect(subject.by_name(mit.name)).to contain_exactly(mit) }
......
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Licenses::CreatePolicyService do
subject { described_class.new(project, user, params) }
let_it_be(:project) { create(:project, :repository, :private) }
let_it_be(:user) { create(:user) }
describe "#execute" do
let_it_be(:mit_license) { create(:software_license, :mit) }
before do
allow(RefreshLicenseComplianceChecksWorker).to receive(:perform_async)
end
context "when creating a policy for a software license by the software license database id" do
let(:params) do
{
software_license_id: mit_license.id,
classification: 'approved'
}
end
it 'creates a new policy' do
result = subject.execute
expect(result[:status]).to eq(:success)
expect(result[:software_license_policy]).to be_present
expect(result[:software_license_policy].id).to be_present
expect(result[:software_license_policy].spdx_identifier).to eq(mit_license.spdx_identifier)
expect(result[:software_license_policy].classification).to eq('approved')
expect(result[:software_license_policy].name).to eq(mit_license.name)
expect(result[:software_license_policy].url).to be_nil
expect(result[:software_license_policy].dependencies).to be_empty
expect(RefreshLicenseComplianceChecksWorker).to have_received(:perform_async).with(project.id)
end
end
context "when creating a policy for a software license by the software license SPDX identifier" do
let(:params) do
{
spdx_identifier: mit_license.spdx_identifier,
classification: 'blacklisted'
}
end
it 'creates a new policy' do
result = subject.execute
expect(result[:status]).to eq(:success)
expect(result[:software_license_policy]).to be_present
expect(result[:software_license_policy].id).to be_present
expect(result[:software_license_policy].spdx_identifier).to eq(mit_license.spdx_identifier)
expect(result[:software_license_policy].classification).to eq('blacklisted')
expect(result[:software_license_policy].name).to eq(mit_license.name)
expect(result[:software_license_policy].url).to be_nil
expect(result[:software_license_policy].dependencies).to be_empty
expect(RefreshLicenseComplianceChecksWorker).to have_received(:perform_async).with(project.id)
end
end
context "when the software license is not specified" do
let(:params) do
{
spdx_identifier: nil,
classification: 'blacklisted'
}
end
it 'returns an error' do
result = subject.execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to be_instance_of(ActiveModel::Errors)
expect(result[:http_status]).to eq(:unprocessable_entity)
expect(RefreshLicenseComplianceChecksWorker).not_to have_received(:perform_async)
end
end
context "when the classification is invalid" do
let(:params) do
{
spdx_identifier: mit_license.spdx_identifier,
classification: 'invalid'
}
end
it 'returns an error' do
result = subject.execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to be_instance_of(ActiveModel::Errors)
expect(result[:http_status]).to eq(:unprocessable_entity)
expect(RefreshLicenseComplianceChecksWorker).not_to have_received(:perform_async)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment