Commit 99adf78c authored by mo khan's avatar mo khan

Add endpoint to create a software license policy

* Add a software license policy creation endpoint
* Create a software license policy via license id
* Create software license policy via spdx identifier
* Apply the correct id to a license for known licenses
* Render contents of page based on ability
parent 1aeb21f9
......@@ -4,6 +4,11 @@ module Projects
module Security
class LicensesController < Projects::ApplicationController
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
respond_to do |format|
......@@ -19,6 +24,18 @@ module Projects
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
def serializer
......@@ -29,6 +46,14 @@ module Projects
def pageable(items)
::Gitlab::ItemsCollection.new(items)
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
......@@ -10,16 +10,7 @@ module SCA
def policies
strong_memoize(:policies) do
configured_policies = project.software_license_policies.index_by { |policy| policy.software_license.canonical_id }
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)
new_policies.merge(known_policies).sort.map(&:last)
end
end
......@@ -31,10 +22,30 @@ module SCA
end
end
def report_for(policy)
build_policy(license_scan_report[policy.software_license.canonical_id], policy)
end
private
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
strong_memoize(:pipeline) do
project.all_pipelines.latest_successful_for_ref(project.default_branch)
......
......@@ -10,19 +10,7 @@ module SCA
@url = reported_license&.url
@dependencies = reported_license&.dependencies || []
@spdx_identifier = software_policy&.spdx_identifier || reported_license.id
@classification = classify(software_policy)
end
private
def classify(policy)
if policy&.approved?
'allowed'
elsif policy&.blacklisted?
'denied'
else
'unclassified'
end
@classification = software_policy&.approval_status || 'unclassified'
end
end
end
......@@ -9,7 +9,9 @@ class SoftwareLicense < ApplicationRecord
validates :spdx_identifier, length: { maximum: 255 }
scope :by_name, -> (names) { where(name: names) }
scope :by_spdx, -> (spdx_identifier) { where(spdx_identifier: spdx_identifier) }
scope :ordered, -> { order(:name) }
scope :spdx, -> { where.not(spdx_identifier: nil) }
scope :unknown, -> { where(spdx_identifier: nil) }
scope :grouped_by_name, -> { group(:name) }
......
......@@ -33,6 +33,7 @@ class SoftwareLicensePolicy < ApplicationRecord
scope :for_project, -> (project) { where(project: project) }
scope :with_license, -> { joins(:software_license) }
scope :including_license, -> { includes(:software_license) }
scope :unreachable_limit, -> { limit(1_000) }
scope :with_license_by_name, -> (license_name) do
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
end
resources :subscriptions, only: [:create, :destroy]
resources :licenses, only: [:index, :create], controller: 'security/licenses'
end
# End of the /-/ scope.
......
......@@ -5,7 +5,7 @@ module Gitlab
module Reports
module LicenseScanning
class Report
delegate :empty?, :fetch, to: :found_licenses
delegate :empty?, :fetch, :[], to: :found_licenses
attr_accessor :version
def initialize(version: '1.0')
......
......@@ -70,13 +70,16 @@ describe Projects::Security::LicensesController do
end
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!(:pipeline) { create(:ee_ci_pipeline, :with_license_management_report, project: project) }
let!(:mit) { create(:software_license, :mit) }
let!(:mit_policy) { create(:software_license_policy, :denied, software_license: mit, project: project) }
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_it_be(:mit) { create(:software_license, :mit) }
let_it_be(: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
pipeline.job_artifacts.license_management.last.update!(file: raw_report)
get :index, params: { namespace_id: project.namespace, project_id: project }, format: :json
end
......@@ -97,7 +100,7 @@ describe Projects::Security::LicensesController do
"spdx_identifier" => "MIT",
"name" => mit.name,
"url" => "http://spdx.org/licenses/MIT.json",
"classification" => "denied"
"classification" => "blacklisted"
})
expect(json_response.dig("licenses", 2)).to include({
......@@ -146,4 +149,122 @@ describe Projects::Security::LicensesController do
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
......@@ -24,7 +24,7 @@ RSpec.describe SCA::LicenseCompliance do
expect(subject.policies[0]&.id).to eq(mit_policy.id)
expect(subject.policies[0]&.name).to eq(mit.name)
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")
end
end
......@@ -77,8 +77,9 @@ RSpec.describe SCA::LicenseCompliance do
it { expect(subject.policies.map(&:spdx_identifier)).to contain_exactly('BSD-3-Clause', 'MIT', nil) }
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(: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-v2.json"), "application/json") }
......@@ -100,12 +101,51 @@ RSpec.describe SCA::LicenseCompliance do
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://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[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("allowed")
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")
......
......@@ -52,11 +52,11 @@ RSpec.describe SCA::LicensePolicy do
let(:denied_policy) { build(:software_license_policy, :denied, software_license: software_license) }
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
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
context "when a software_policy is NOT provided" do
......
......@@ -39,8 +39,16 @@ describe SoftwareLicense do
describe 'scopes' do
subject { described_class }
let!(:mit) { create(:software_license, :mit, spdx_identifier: 'MIT') }
let!(:apache_2) { create(:software_license, :apache_2_0, spdx_identifier: nil) }
let_it_be(:mit) { create(:software_license, :mit, spdx_identifier: 'MIT') }
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
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