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
evidence&.summary || {}
end
def milestone_list
self.milestones.map {|m| m.title }.sort.join(", ")
end
private
def actual_sha
......
......@@ -11,10 +11,13 @@ module Releases
return error('params is empty', 400) if empty_params?
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)
success(tag: existing_tag, release: release)
success(tag: existing_tag, release: release, milestones_updated: milestones_updated?)
else
error(release.errors.messages || '400 Bad request', 400)
end
......@@ -29,5 +32,11 @@ module Releases
def empty_params?
params.except(:tag).empty?
end
def milestones_updated?
return false unless param_for_milestone_titles_provided?
@previous_milestones.to_set != release.milestones.map(&:title)
end
end
end
......@@ -80,6 +80,9 @@ From there, you can see the following actions:
- Project was archived
- Project was unarchived
- Added/removed/updated protected branches
- Release was added to a project
- Release was updated
- Release milestone associations changed
### 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
expect(security_event.entity_type).to eq(entity_type)
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
.execute
if result[:status] == :success
log_release_created_audit_event(result[:release])
present result[:release], with: Entities::Release, current_user: current_user
else
render_api_error!(result[:message], result[:http_status])
......@@ -91,6 +93,9 @@ module API
.execute
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
else
render_api_error!(result[:message], result[:http_status])
......@@ -147,6 +152,20 @@ module API
def release
@release ||= user_project.releases.find_by_tag(params[:tag])
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
API::Releases.prepend_if_ee('EE::API::Releases')
......@@ -20,5 +20,14 @@ FactoryBot.define do
create(:evidence, release: release)
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
......@@ -181,4 +181,10 @@ RSpec.describe Release do
it { is_expected.to eq(release.evidence.summary) }
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
......@@ -21,6 +21,7 @@ describe Releases::UpdateService do
it 'raises an error' do
result = service.execute
expect(result[:status]).to eq(:error)
expect(result[:milestones_updated]).to be_falsy
end
end
......@@ -50,21 +51,33 @@ describe Releases::UpdateService do
end
context 'when a milestone is passed in' do
let(:new_title) { 'v2.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(:new_milestone) { create(:milestone, project: project, title: new_title) }
let(:service) { described_class.new(new_milestone.project, user, params_with_milestone) }
before do
release.milestones << milestone
end
service.execute
release.reload
context 'a different milestone' do
let(:new_title) { 'v2.0' }
it 'updates the related milestone accordingly' do
result = service.execute
release.reload
expect(release.milestones.first.title).to eq(new_title)
expect(result[:milestones_updated]).to be_truthy
end
end
it 'updates the related milestone accordingly' do
expect(release.milestones.first.title).to eq(new_title)
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
......@@ -76,12 +89,14 @@ describe Releases::UpdateService do
release.milestones << milestone
service.params = params_with_empty_milestone
service.execute
release.reload
end
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(result[:milestones_updated]).to be_truthy
end
end
......@@ -96,14 +111,15 @@ describe Releases::UpdateService do
create(:milestone, project: project, title: new_title_1)
create(:milestone, project: project, title: new_title_2)
release.milestones << milestone
service.execute
release.reload
end
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)
expect(milestone_titles).to match_array([new_title_1, new_title_2])
expect(result[:milestones_updated]).to be_truthy
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