diff --git a/doc/api/vulnerability_exports.md b/doc/api/vulnerability_exports.md index f53a0ca08a3778b92f742107bac1cf10a6b2405e..2c9ac5d65ebf9a3273392afe16b6eeae9597e336 100644 --- a/doc/api/vulnerability_exports.md +++ b/doc/api/vulnerability_exports.md @@ -42,7 +42,7 @@ POST /security/projects/:id/vulnerability_exports curl --header POST "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/security/projects/1/vulnerability_exports ``` -The created vulnerability export will be automatically deleted after 1 hour. +The created vulnerability export is automatically deleted after 1 hour. Example response: @@ -51,6 +51,53 @@ Example response: "id": 2, "created_at": "2020-03-30T09:35:38.746Z", "project_id": 1, + "group_id": null, + "format": "csv", + "status": "created", + "started_at": null, + "finished_at": null, + "_links": { + "self": "https://gitlab.example.com/api/v4/security/vulnerability_exports/2", + "download": "https://gitlab.example.com/api/v4/security/vulnerability_exports/2/download" + } +} +``` + +## Create a group-level vulnerability export + +Creates a new vulnerability export for a group. + +Vulnerability export permissions inherit permissions from their group. If a group is +private and a user isn't a member of the group to which the vulnerability +belongs, requests to that group return a `404 Not Found` status code. +Vulnerability exports can be only accessed by the export's author. + +If an authenticated user doesn't have permission to +[create a new vulnerability](../user/permissions.md#group-members-permissions), +this request results in a `403` status code. + +```plaintext +POST /security/groups/:id/vulnerability_exports +``` + +| Attribute | Type | Required | Description | +| ------------------- | ----------------- | ---------- | -----------------------------------------------------------------------------------------------------------------------------| +| `id` | integer or string | yes | The ID or [URL-encoded path](README.md#namespaced-path-encoding) of the group which the authenticated user is a member of | + +```shell +curl --header POST "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/security/groups/1/vulnerability_exports +``` + +The created vulnerability export is automatically deleted after 1 hour. + +Example response: + +```json +{ + "id": 2, + "created_at": "2020-03-30T09:35:38.746Z", + "project_id": null, + "group_id": 1, "format": "csv", "status": "created", "started_at": null, @@ -83,6 +130,7 @@ Example response: "id": 2, "created_at": "2020-03-30T09:35:38.746Z", "project_id": null, + "group_id": null, "format": "csv", "status": "created", "started_at": null, @@ -119,6 +167,7 @@ Example response: "id": 2, "created_at": "2020-03-30T09:35:38.746Z", "project_id": 1, + "group_id": null, "format": "csv", "status": "finished", "started_at": "2020-03-30T09:36:54.469Z", diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index f235bcb972b72af97f70febca09e9952f7f0351e..3b9658ba1f26ad5d0176c2a799868918e7f8b66a 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -202,6 +202,8 @@ module GroupPolicy rule { security_dashboard_enabled & developer }.enable :read_group_security_dashboard + rule { can?(:read_group_security_dashboard) }.enable :create_vulnerability_export + rule { admin | owner }.policy do enable :read_group_compliance_dashboard enable :read_group_credentials_inventory diff --git a/ee/changelogs/unreleased/213013_group_level_security_reports_api.yml b/ee/changelogs/unreleased/213013_group_level_security_reports_api.yml new file mode 100644 index 0000000000000000000000000000000000000000..d7e3ccae5beeadbbfae15bc72e384aa9a6f9f9a4 --- /dev/null +++ b/ee/changelogs/unreleased/213013_group_level_security_reports_api.yml @@ -0,0 +1,5 @@ +--- +title: Introduce a new API endpoint to generate group-level vulnerability exports +merge_request: 31889 +author: +type: added diff --git a/ee/lib/api/vulnerability_exports.rb b/ee/lib/api/vulnerability_exports.rb index f9d49508dc7589dac00f5420504e3be3e8848d54..3a388ead87a0486a5c02c3706d188604676e9cb5 100644 --- a/ee/lib/api/vulnerability_exports.rb +++ b/ee/lib/api/vulnerability_exports.rb @@ -38,7 +38,7 @@ def process_create_request_for(exportable) default: ::Vulnerabilities::Export.formats.each_key.first, values: ::Vulnerabilities::Export.formats.keys end - desc 'Generate an export of project vulnerability findings' do + desc 'Generate a project-level export' do success EE::API::Entities::VulnerabilityExport end @@ -53,6 +53,28 @@ def process_create_request_for(exportable) end end + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + params do + requires :id, type: String, desc: 'The ID of a group' + optional :export_format, type: String, desc: 'The format of export to be generated', + default: ::Vulnerabilities::Export.formats.each_key.first, + values: ::Vulnerabilities::Export.formats.keys + end + desc 'Generate a group-level export' do + success EE::API::Entities::VulnerabilityExport + end + + before do + not_found! unless Feature.enabled?(:first_class_vulnerabilities, user_group, default_enabled: true) + end + + post ':id/vulnerability_exports' do + authorize! :create_vulnerability_export, user_group + + process_create_request_for(user_group) + end + end + namespace do before do not_found! unless Feature.enabled?(:first_class_vulnerabilities, default_enabled: true) @@ -63,7 +85,7 @@ def process_create_request_for(exportable) default: ::Vulnerabilities::Export.formats.each_key.first, values: ::Vulnerabilities::Export.formats.keys end - desc 'Generate an instance level export' do + desc 'Generate an instance-level export' do success EE::API::Entities::VulnerabilityExport end post 'vulnerability_exports' do @@ -73,7 +95,7 @@ def process_create_request_for(exportable) end end - desc 'Get single project vulnerability export' do + desc 'Get a single vulnerability export' do success EE::API::Entities::VulnerabilityExport end get 'vulnerability_exports/:id' do @@ -88,7 +110,7 @@ def process_create_request_for(exportable) with: EE::API::Entities::VulnerabilityExport end - desc 'Download single project vulnerability export' + desc 'Download a single vulnerability export' get 'vulnerability_exports/:id/download' do authorize! :read_vulnerability_export, vulnerability_export diff --git a/ee/lib/ee/api/entities/vulnerability_export.rb b/ee/lib/ee/api/entities/vulnerability_export.rb index 84489755eef15851a194561cec5583da4feefa25..b6d7fd86fbd1b64e86ef0e3ff44d7922c4431f7b 100644 --- a/ee/lib/ee/api/entities/vulnerability_export.rb +++ b/ee/lib/ee/api/entities/vulnerability_export.rb @@ -9,6 +9,7 @@ class VulnerabilityExport < Grape::Entity expose :id expose :created_at expose :project_id + expose :group_id expose :format expose :status expose :started_at diff --git a/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_export.json b/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_export.json index 6f74f38a6739ec2182fb2455dc7898295c051c72..0531ac45d32c3fe492010af7aa1d179133a2c3b7 100644 --- a/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_export.json +++ b/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_export.json @@ -4,6 +4,7 @@ "id", "created_at", "project_id", + "group_id", "format", "status", "started_at", @@ -13,7 +14,8 @@ "properties" : { "id": { "type": "integer" }, "created_at": { "type": "date" }, - "project_id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, "format": { "type": "string", "enum": ["csv"] diff --git a/ee/spec/lib/ee/api/entities/vulnerability_export_spec.rb b/ee/spec/lib/ee/api/entities/vulnerability_export_spec.rb index 5de3be3a49316f6e1a2b9591d930990b1a5ae0ad..9aa2ac99db6f307faccc9212316caa84e4b5a135 100644 --- a/ee/spec/lib/ee/api/entities/vulnerability_export_spec.rb +++ b/ee/spec/lib/ee/api/entities/vulnerability_export_spec.rb @@ -14,6 +14,7 @@ expect(subject[:id]).to eq(vulnerability_export.id) expect(subject[:created_at]).to eq(vulnerability_export.created_at) expect(subject[:project_id]).to eq(vulnerability_export.project_id) + expect(subject[:group_id]).to eq(vulnerability_export.group_id) expect(subject[:format]).to eq(vulnerability_export.format) expect(subject[:status]).to eq(vulnerability_export.status) expect(subject[:started_at]).to eq(vulnerability_export.started_at) diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb index d3834c75ddc5ed8e05ec453297db2a1d46f35f48..26a1a4935a388e6d1cd34fb72331d83e5ff95c19 100644 --- a/ee/spec/policies/group_policy_spec.rb +++ b/ee/spec/policies/group_policy_spec.rb @@ -630,7 +630,9 @@ end end - describe 'read_group_security_dashboard' do + describe 'read_group_security_dashboard & create_vulnerability_export' do + let(:abilities) { %i(read_group_security_dashboard create_vulnerability_export) } + before do stub_licensed_features(security_dashboard: true) end @@ -638,57 +640,57 @@ context 'with admin' do let(:current_user) { admin } - it { is_expected.to be_allowed(:read_group_security_dashboard) } + it { is_expected.to be_allowed(*abilities) } end context 'with owner' do let(:current_user) { owner } - it { is_expected.to be_allowed(:read_group_security_dashboard) } + it { is_expected.to be_allowed(*abilities) } end context 'with maintainer' do let(:current_user) { maintainer } - it { is_expected.to be_allowed(:read_group_security_dashboard) } + it { is_expected.to be_allowed(*abilities) } end context 'with developer' do let(:current_user) { developer } - it { is_expected.to be_allowed(:read_group_security_dashboard) } + it { is_expected.to be_allowed(*abilities) } context 'when security dashboard features is not available' do before do stub_licensed_features(security_dashboard: false) end - it { is_expected.to be_disallowed(:read_group_security_dashboard) } + it { is_expected.to be_disallowed(*abilities) } end end context 'with reporter' do let(:current_user) { reporter } - it { is_expected.to be_disallowed(:read_group_security_dashboard) } + it { is_expected.to be_disallowed(*abilities) } end context 'with guest' do let(:current_user) { guest } - it { is_expected.to be_disallowed(:read_group_security_dashboard) } + it { is_expected.to be_disallowed(*abilities) } end context 'with non member' do let(:current_user) { create(:user) } - it { is_expected.to be_disallowed(:read_group_security_dashboard) } + it { is_expected.to be_disallowed(*abilities) } end context 'with anonymous' do let(:current_user) { nil } - it { is_expected.to be_disallowed(:read_group_security_dashboard) } + it { is_expected.to be_disallowed(*abilities) } end end diff --git a/ee/spec/requests/api/vulnerability_exports_spec.rb b/ee/spec/requests/api/vulnerability_exports_spec.rb index e4949012e8945056ecdb40f03168c640f4c630f9..b8a70353faf5efb438bf83d254670007458c40a7 100644 --- a/ee/spec/requests/api/vulnerability_exports_spec.rb +++ b/ee/spec/requests/api/vulnerability_exports_spec.rb @@ -71,6 +71,70 @@ end end + describe 'POST /security/groups/:id/vulnerability_exports' do + let_it_be(:group) { create(:group) } + + let(:format) { 'csv' } + let(:request_path) { "/security/groups/#{group.id}/vulnerability_exports" } + + subject(:create_vulnerability_export) { post api(request_path, user), params: { export_format: format } } + + context 'when the request does not fulfill the requirements' do + let(:format) { 'exif' } + + it 'responds with bad_request' do + create_vulnerability_export + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('error' => 'export_format does not have a valid value') + end + end + + context 'when the request fulfills the requirements' do + context 'when the user is not authorized to take the action' do + it 'responds with 403 forbidden' do + create_vulnerability_export + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when the user is authorized to take the action' do + let(:mock_service_object) { instance_double(VulnerabilityExports::CreateService, execute: vulnerability_export) } + + before do + allow(VulnerabilityExports::CreateService).to receive(:new).and_return(mock_service_object) + group.add_developer(user) + end + + context 'when the export creation succeeds' do + let(:vulnerability_export) { create(:vulnerability_export) } + + it 'returns information about new vulnerability export' do + create_vulnerability_export + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/vulnerability_export', dir: 'ee') + end + end + + context 'when the export creation fails' do + let(:errors) { instance_double(ActiveModel::Errors, any?: true, messages: ['foo']) } + let(:vulnerability_export) { instance_double(Vulnerabilities::Export, persisted?: false, errors: errors) } + + it 'returns the error message' do + create_vulnerability_export + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('message' => ['foo']) + end + end + end + end + + it_behaves_like 'forbids access to vulnerability API endpoint in case of disabled features' + end + describe 'POST /security/vulnerability_exports' do let(:format) { 'csv' } let(:request_path) { "/security/vulnerability_exports" }