Commit 8fca2bea authored by Sean Carroll's avatar Sean Carroll

Capture Release actions in the audit log page

Closes https://gitlab.com/gitlab-org/gitlab/issues/32807

See merge request https://gitlab.com/gitlab-org/gitlab/merge_requests/22167
parent 9e71803b
...@@ -81,6 +81,10 @@ class Release < ApplicationRecord ...@@ -81,6 +81,10 @@ class Release < ApplicationRecord
evidence&.summary || {} evidence&.summary || {}
end end
def milestone_list
self.milestones.map {|m| m.title }.sort.join(", ")
end
private private
def actual_sha def actual_sha
......
...@@ -11,10 +11,13 @@ module Releases ...@@ -11,10 +11,13 @@ module Releases
return error('params is empty', 400) if empty_params? return error('params is empty', 400) if empty_params?
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
params[:milestones] = milestones if param_for_milestone_titles_provided? if param_for_milestone_titles_provided?
@previous_milestones = release.milestones.map(&:title)
params[:milestones] = milestones
end
if release.update(params) if release.update(params)
success(tag: existing_tag, release: release) success(tag: existing_tag, release: release, milestones_updated: milestones_updated?)
else else
error(release.errors.messages || '400 Bad request', 400) error(release.errors.messages || '400 Bad request', 400)
end end
...@@ -29,5 +32,11 @@ module Releases ...@@ -29,5 +32,11 @@ module Releases
def empty_params? def empty_params?
params.except(:tag).empty? params.except(:tag).empty?
end end
def milestones_updated?
return false unless param_for_milestone_titles_provided?
@previous_milestones.to_set != release.milestones.map(&:title)
end
end end
end end
...@@ -80,6 +80,9 @@ From there, you can see the following actions: ...@@ -80,6 +80,9 @@ From there, you can see the following actions:
- Project was archived - Project was archived
- Project was unarchived - Project was unarchived
- Added/removed/updated protected branches - Added/removed/updated protected branches
- Release was added to a project
- Release was updated
- Release milestone associations changed
### Instance events **(PREMIUM ONLY)** ### Instance events **(PREMIUM ONLY)**
......
# frozen_string_literal: true
module EE
module AuditEvents
class ReleaseArtifactsDownloadedAuditEventService < ReleaseAuditEventService
def message
'Repository External Resource Download Started'
end
end
end
end
# frozen_string_literal: true
module EE
module AuditEvents
class ReleaseAssociateMilestoneAuditEventService < ReleaseAuditEventService
def message
milestones = @release.milestone_list
milestones = "[none]" if milestones.blank?
"Milestones associated with release changed to #{milestones}"
end
end
end
end
# frozen_string_literal: true
module EE
module AuditEvents
class ReleaseAuditEventService < ::AuditEventService
attr_reader :release
def initialize(author, entity, ip_address, release)
@release = release
super(author, entity, {
action: :custom,
custom_message: message,
ip_address: ip_address,
target_id: release.id,
target_type: release.class.name,
target_details: release.name
})
end
def message
nil
end
end
end
end
# frozen_string_literal: true
module EE
module AuditEvents
class ReleaseCreatedAuditEventService < ReleaseAuditEventService
def message
simple_message = "Created Release #{release.tag}"
milestone_count = release.milestones.count
if milestone_count > 0
"#{simple_message} with #{'Milestone'.pluralize(milestone_count)} #{release.milestone_list}"
else
simple_message
end
end
end
end
end
# frozen_string_literal: true
module EE
module AuditEvents
class ReleaseUpdatedAuditEventService < ReleaseAuditEventService
def message
"Updated Release #{release.tag}"
end
end
end
end
---
title: Capture Release actions in the audit log page
merge_request: 22167
author:
type: added
# frozen_string_literal: true
module EE
module API
module Releases
extend ActiveSupport::Concern
prepended do
helpers do
extend ::Gitlab::Utils::Override
override :log_release_created_audit_event
def log_release_created_audit_event(release)
EE::AuditEvents::ReleaseCreatedAuditEventService.new(
current_user,
user_project,
request.ip,
release
).security_event
end
override :log_release_updated_audit_event
def log_release_updated_audit_event
EE::AuditEvents::ReleaseUpdatedAuditEventService.new(
current_user,
user_project,
request.ip,
release
).security_event
end
override :log_release_milestones_updated_audit_event
def log_release_milestones_updated_audit_event
EE::AuditEvents::ReleaseAssociateMilestoneAuditEventService.new(
current_user,
user_project,
request.ip,
release
).security_event
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::Releases do
let(:project) { create(:project, :repository, :private) }
let(:maintainer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:commit) { create(:commit, project: project) }
before do
project.add_maintainer(maintainer)
project.add_reporter(reporter)
project.add_guest(guest)
project.repository.add_tag(maintainer, 'v0.1', commit.id)
project.repository.add_tag(maintainer, 'v0.2', commit.id)
end
describe 'POST /projects/:id/releases' do
let(:params) do
{
name: 'New release',
tag_name: 'v0.1',
description: 'Super nice release'
}
end
context 'updates the audit log' do
subject { AuditEvent.last.details }
it 'without milestone' do
expect do
post api("/projects/#{project.id}/releases", maintainer), params: params
end.to change { AuditEvent.count }.by(1)
release = project.releases.last
expect(subject[:custom_message]).to eq("Created Release #{release.tag}")
expect(subject[:target_type]).to eq('Release')
expect(subject[:target_id]).to eq(release.id)
expect(subject[:target_details]).to eq(release.name)
end
context 'with milestone' do
let!(:milestone) { create(:milestone, project: project, title: 'v1.0') }
it do
expect do
post api("/projects/#{project.id}/releases", maintainer), params: params.merge(milestones: ['v1.0'])
end.to change { AuditEvent.count }.by(1)
release = project.releases.last
expect(subject[:custom_message]).to eq("Created Release v0.1 with Milestone v1.0")
expect(subject[:target_type]).to eq('Release')
expect(subject[:target_id]).to eq(release.id)
expect(subject[:target_details]).to eq(release.name)
end
end
end
end
describe 'PUT /projects/:id/releases/:tag_name' do
let(:params) { { description: 'Best release ever!' } }
let!(:release) do
create(:release,
project: project,
tag: 'v0.1',
name: 'New release',
released_at: '2018-03-01T22:00:00Z',
description: 'Super nice release')
end
it 'updates the audit log when a release is updated' do
params = { name: 'A new name', description: 'a new description' }
expect do
put api("/projects/#{project.id}/releases/v0.1", maintainer), params: params
end.to change { AuditEvent.count }.by(1)
release = project.releases.last
expect(AuditEvent.last.details[:custom_message]).to eq("Updated Release #{release.tag}")
end
shared_examples 'update with milestones' do
it do
expect do
put api("/projects/#{project.id}/releases/v0.1", maintainer), params: params.to_json, headers: { 'CONTENT_TYPE' => 'application/json' }
end.to change { AuditEvent.count }.by(2)
release = project.releases.last
expect(AuditEvent.first.details[:custom_message]).to eq("Updated Release #{release.tag}")
expect(AuditEvent.second.details[:custom_message]).to eq(milestone_message)
end
end
context 'with milestones' do
context 'no existing milestones' do
let!(:milestone) { create(:milestone, project: project, title: 'v1.0') }
context 'add single milestone' do
let(:params) { { milestones: ['v1.0'] } }
let(:milestone_message) { "Milestones associated with release changed to v1.0" }
it_behaves_like 'update with milestones'
end
context 'add multiple milestones' do
let!(:milestone2) { create(:milestone, project: project, title: 'v2.0') }
let(:params) { { milestones: ['v1.0', 'v2.0'] } }
let(:milestone_message) { "Milestones associated with release changed to v1.0, v2.0" }
it_behaves_like 'update with milestones'
end
end
context 'existing milestone' do
let!(:existing_milestone) { create(:milestone, project: project, title: 'v0.1') }
let!(:milestone) { create(:milestone, project: project, title: 'v1.0') }
before do
release.milestones << existing_milestone
end
context 'add milestone' do
let(:params) { { milestones: ['v0.1', 'v1.0'] } }
let(:milestone_message) { "Milestones associated with release changed to v0.1, v1.0" }
it_behaves_like 'update with milestones'
end
context 'replace milestone' do
let(:params) { { milestones: ['v1.0'] } }
let(:milestone_message) { "Milestones associated with release changed to v1.0" }
it_behaves_like 'update with milestones'
end
context 'remove all milestones' do
let(:params) { { milestones: [] } }
let(:milestone_message) { "Milestones associated with release changed to [none]" }
it_behaves_like 'update with milestones'
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::ReleaseArtifactsDownloadedAuditEventService do
describe '#security_event' do
include_examples 'logs the release audit event' do
let(:release) { create(:release, project: entity) }
let(:custom_message) { 'Repository External Resource Download Started' }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::ReleaseAssociateMilestoneAuditEventService do
describe '#security_event' do
context 'with no milestones' do
include_examples 'logs the release audit event' do
let(:release) { create(:release, project: entity) }
let(:custom_message) { "Milestones associated with release changed to [none]" }
end
end
context "with one milestone" do
include_examples 'logs the release audit event' do
let(:release) { create(:release, :with_milestones, milestones_count: 1, project: entity) }
let(:custom_message) { "Milestones associated with release changed to #{Milestone.first.title}" }
end
end
context "with multiple milestones" do
include_examples 'logs the release audit event' do
let(:release) { create(:release, :with_milestones, milestones_count: 2, project: entity) }
let(:custom_message) { "Milestones associated with release changed to #{Milestone.first.title}, #{Milestone.second.title}" }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::ReleaseCreatedAuditEventService do
describe '#security_event' do
context 'with no milestones' do
include_examples 'logs the release audit event' do
let(:release) { create(:release, project: entity) }
let(:custom_message) { "Created Release #{release.tag}" }
end
end
context "with one milestone" do
include_examples 'logs the release audit event' do
let(:release) { create(:release, :with_milestones, milestones_count: 1, project: entity) }
let(:custom_message) { "Created Release #{release.tag} with Milestone #{Milestone.first.title}" }
end
end
context "with multiple milestones" do
include_examples 'logs the release audit event' do
let(:release) { create(:release, :with_milestones, milestones_count: 2, project: entity) }
let(:custom_message) { "Created Release #{release.tag} with Milestones #{Milestone.first.title}, #{Milestone.second.title}" }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::ReleaseUpdatedAuditEventService do
describe '#security_event' do
include_examples 'logs the release audit event' do
let(:release) { create(:release, project: entity) }
let(:custom_message) { "Updated Release #{release.tag}" }
end
end
end
...@@ -51,3 +51,48 @@ shared_examples_for 'logs the custom audit event' do ...@@ -51,3 +51,48 @@ shared_examples_for 'logs the custom audit event' do
expect(security_event.entity_type).to eq(entity_type) expect(security_event.entity_type).to eq(entity_type)
end end
end end
shared_examples_for 'logs the release audit event' do
let(:logger) { instance_double(Gitlab::AuditJsonLogger) }
let(:user) { create(:user) }
let(:ip_address) { '127.0.0.1' }
let(:entity) { create(:project) }
let(:target_details) { release.name }
let(:target_id) { release.id }
let(:target_type) { 'Release' }
let(:entity_type) { 'Project' }
let(:service) { described_class.new(user, entity, ip_address, release) }
before do
stub_licensed_features(audit_events: true)
end
it 'logs the event to file' do
expect(service).to receive(:file_logger).and_return(logger)
expect(logger).to receive(:info).with(author_id: user.id,
entity_id: entity.id,
entity_type: entity_type,
action: :custom,
ip_address: ip_address,
custom_message: custom_message,
target_details: target_details,
target_id: target_id,
target_type: target_type)
expect { service.security_event }.to change(SecurityEvent, :count).by(1)
security_event = SecurityEvent.last
expect(security_event.details).to eq(custom_message: custom_message,
ip_address: ip_address,
action: :custom,
target_details: target_details,
target_id: target_id,
target_type: target_type)
expect(security_event.author_id).to eq(user.id)
expect(security_event.entity_id).to eq(entity.id)
expect(security_event.entity_type).to eq(entity_type)
end
end
...@@ -66,6 +66,8 @@ module API ...@@ -66,6 +66,8 @@ module API
.execute .execute
if result[:status] == :success if result[:status] == :success
log_release_created_audit_event(result[:release])
present result[:release], with: Entities::Release, current_user: current_user present result[:release], with: Entities::Release, current_user: current_user
else else
render_api_error!(result[:message], result[:http_status]) render_api_error!(result[:message], result[:http_status])
...@@ -91,6 +93,9 @@ module API ...@@ -91,6 +93,9 @@ module API
.execute .execute
if result[:status] == :success if result[:status] == :success
log_release_updated_audit_event
log_release_milestones_updated_audit_event if result[:milestones_updated]
present result[:release], with: Entities::Release, current_user: current_user present result[:release], with: Entities::Release, current_user: current_user
else else
render_api_error!(result[:message], result[:http_status]) render_api_error!(result[:message], result[:http_status])
...@@ -147,6 +152,20 @@ module API ...@@ -147,6 +152,20 @@ module API
def release def release
@release ||= user_project.releases.find_by_tag(params[:tag]) @release ||= user_project.releases.find_by_tag(params[:tag])
end end
def log_release_created_audit_event(release)
# This is a separate method so that EE can extend its behaviour
end
def log_release_updated_audit_event
# This is a separate method so that EE can extend its behaviour
end
def log_release_milestones_updated_audit_event
# This is a separate method so that EE can extend its behaviour
end
end end
end end
end end
API::Releases.prepend_if_ee('EE::API::Releases')
...@@ -20,5 +20,14 @@ FactoryBot.define do ...@@ -20,5 +20,14 @@ FactoryBot.define do
create(:evidence, release: release) create(:evidence, release: release)
end end
end end
trait :with_milestones do
transient do
milestones_count { 2 }
end
after(:create) do |release, evaluator|
create_list(:milestone, evaluator.milestones_count, project: evaluator.project, releases: [release])
end
end
end end
end end
...@@ -181,4 +181,10 @@ RSpec.describe Release do ...@@ -181,4 +181,10 @@ RSpec.describe Release do
it { is_expected.to eq(release.evidence.summary) } it { is_expected.to eq(release.evidence.summary) }
end end
end end
describe '#milestone_list' do
let(:release) { create(:release, :with_milestones) }
it { expect(release.milestone_list).to eq(release.milestones.map {|m| m.title }.sort.join(", "))}
end
end end
...@@ -21,6 +21,7 @@ describe Releases::UpdateService do ...@@ -21,6 +21,7 @@ describe Releases::UpdateService do
it 'raises an error' do it 'raises an error' do
result = service.execute result = service.execute
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:milestones_updated]).to be_falsy
end end
end end
...@@ -50,21 +51,33 @@ describe Releases::UpdateService do ...@@ -50,21 +51,33 @@ describe Releases::UpdateService do
end end
context 'when a milestone is passed in' do context 'when a milestone is passed in' do
let(:new_title) { 'v2.0' }
let(:milestone) { create(:milestone, project: project, title: 'v1.0') } let(:milestone) { create(:milestone, project: project, title: 'v1.0') }
let(:new_milestone) { create(:milestone, project: project, title: new_title) }
let(:params_with_milestone) { params.merge!({ milestones: [new_title] }) } let(:params_with_milestone) { params.merge!({ milestones: [new_title] }) }
let(:new_milestone) { create(:milestone, project: project, title: new_title) }
let(:service) { described_class.new(new_milestone.project, user, params_with_milestone) } let(:service) { described_class.new(new_milestone.project, user, params_with_milestone) }
before do before do
release.milestones << milestone release.milestones << milestone
service.execute
release.reload
end end
context 'a different milestone' do
let(:new_title) { 'v2.0' }
it 'updates the related milestone accordingly' do it 'updates the related milestone accordingly' do
result = service.execute
release.reload
expect(release.milestones.first.title).to eq(new_title) expect(release.milestones.first.title).to eq(new_title)
expect(result[:milestones_updated]).to be_truthy
end
end
context 'an identical milestone' do
let(:new_title) { 'v1.0' }
it "raises an error" do
expect { service.execute }.to raise_error(ActiveRecord::RecordInvalid)
end
end end
end end
...@@ -76,12 +89,14 @@ describe Releases::UpdateService do ...@@ -76,12 +89,14 @@ describe Releases::UpdateService do
release.milestones << milestone release.milestones << milestone
service.params = params_with_empty_milestone service.params = params_with_empty_milestone
service.execute
release.reload
end end
it 'removes the old milestone and does not associate any new milestone' do it 'removes the old milestone and does not associate any new milestone' do
result = service.execute
release.reload
expect(release.milestones).not_to be_present expect(release.milestones).not_to be_present
expect(result[:milestones_updated]).to be_truthy
end end
end end
...@@ -96,14 +111,15 @@ describe Releases::UpdateService do ...@@ -96,14 +111,15 @@ describe Releases::UpdateService do
create(:milestone, project: project, title: new_title_1) create(:milestone, project: project, title: new_title_1)
create(:milestone, project: project, title: new_title_2) create(:milestone, project: project, title: new_title_2)
release.milestones << milestone release.milestones << milestone
service.execute
release.reload
end end
it 'removes the old milestone and update the release with the new ones' do it 'removes the old milestone and update the release with the new ones' do
result = service.execute
release.reload
milestone_titles = release.milestones.map(&:title) milestone_titles = release.milestones.map(&:title)
expect(milestone_titles).to match_array([new_title_1, new_title_2]) expect(milestone_titles).to match_array([new_title_1, new_title_2])
expect(result[:milestones_updated]).to be_truthy
end end
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