Commit 5e638388 authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch 'tancnle/export-audit-events-csv' into 'master'

Export audit events to CSV

See merge request gitlab-org/gitlab!31191
parents cc86d001 9dfd75a4
# frozen_string_literal: true
class Admin::AuditLogReportsController < Admin::ApplicationController
before_action :validate_audit_log_reports_available!
def index
csv_data = AuditEvents::ExportCsvService.new(audit_log_reports_params).csv_data
respond_to do |format|
format.csv do
send_data(
csv_data,
type: 'text/csv; charset=utf-8; header=present',
filename: csv_filename
)
end
end
end
private
def validate_audit_log_reports_available!
render_404 unless Feature.enabled?(:audit_log_export_csv) &&
License.feature_available?(:admin_audit_log)
end
def csv_filename
"audit-events-#{Time.current.to_i}.csv"
end
def audit_log_reports_params
params.permit(:entity_type, :entity_id, :created_before, :created_after, :author_id)
end
end
...@@ -28,10 +28,22 @@ module EE ...@@ -28,10 +28,22 @@ module EE
AuditEventPresenter.new(self) AuditEventPresenter.new(self)
end end
def target_type
super || details[:target_type]
end
def target_id
details[:target_id]
end
def target_details def target_details
super || details[:target_details] super || details[:target_details]
end end
def ip_address
super&.to_s || details[:ip_address]
end
def lazy_entity def lazy_entity
BatchLoader.for(entity_id) BatchLoader.for(entity_id)
.batch( .batch(
......
...@@ -18,7 +18,7 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple ...@@ -18,7 +18,7 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
end end
def ip_address def ip_address
audit_event.ip_address&.to_s || details[:ip_address] audit_event.ip_address
end end
def details def details
......
# frozen_string_literal: true
module AuditEvents
class ExportCsvService
TARGET_FILESIZE = 15.megabytes
def initialize(params = {})
@params = params
end
def csv_data
csv_builder.render(TARGET_FILESIZE)
end
private
def csv_builder
@csv_builder ||= CsvBuilder.new(data, header_to_value_hash)
end
def data
events = AuditLogFinder.new(finder_params).execute
Gitlab::Audit::Events::Preloader.preload!(events)
end
def finder_params
{
level: Gitlab::Audit::Levels::Instance.new,
params: @params
}
end
def header_to_value_hash
{
'ID' => 'id',
'Author ID' => 'author_id',
'Author Name' => 'author_name',
'Entity ID' => 'entity_id',
'Entity Type' => 'entity_type',
'Entity Path' => 'entity_path',
'Target ID' => 'target_id',
'Target Type' => 'target_type',
'Target Details' => 'target_details',
'Action' => -> (event) { Audit::Details.humanize(event.details) },
'IP Address' => 'ip_address',
'Created At (UTC)' => -> (event) { event.created_at.utc.iso8601 }
}
end
end
end
...@@ -20,6 +20,7 @@ namespace :admin do ...@@ -20,6 +20,7 @@ namespace :admin do
resource :push_rule, only: [:show, :update] resource :push_rule, only: [:show, :update]
resource :email, only: [:show, :create] resource :email, only: [:show, :create]
resources :audit_logs, controller: 'audit_logs', only: [:index] resources :audit_logs, controller: 'audit_logs', only: [:index]
resources :audit_log_reports, only: [:index], constraints: { format: :csv }
resources :credentials, only: [:index] resources :credentials, only: [:index]
resource :license, only: [:show, :new, :create, :destroy] do resource :license, only: [:show, :new, :create, :destroy] do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Admin::AuditLogReportsController do
describe 'GET index' do
let(:csv_data) do
<<~CSV
ID,Author Name,Action,Entity Type,Target Details
18,タンさん,Added new project,Project,gitlab-org/awesome-rails
19,\"Ru'by McRüb\"\"Face\",!@#$%^&*()`~ new project,Project,¯\\_(ツ)_/¯
20,sǝʇʎq ƃuᴉpoɔǝp,",./;'[]\-= old project",Project,¯\\_(ツ)_/¯
CSV
end
let(:params) do
{
entity_type: 'Project',
entity_id: '789',
created_before: '2020-09-01',
created_after: '2020-08-01',
author_id: '67'
}
end
let(:export_csv_service) { instance_spy(AuditEvents::ExportCsvService, csv_data: csv_data) }
subject { get :index, params: params, as: :csv }
context 'when user has access' do
let_it_be(:admin) { create(:admin) }
before do
sign_in(admin)
end
context 'when licensed and feature flag is enabled' do
before do
stub_feature_flags(audit_log_export_csv: true)
stub_licensed_features(admin_audit_log: true)
allow(AuditEvents::ExportCsvService).to receive(:new).and_return(export_csv_service)
end
it 'invokes CSV export service with correct arguments' do
subject
expect(AuditEvents::ExportCsvService).to have_received(:new)
.with(ActionController::Parameters.new(params).permit!)
end
it 'returns success status with correct headers', :aggregate_failures do
freeze_time do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8')
expect(response.headers['Content-Disposition'])
.to include("filename=\"audit-events-#{Time.current.to_i}.csv\"")
end
end
it 'returns a csv file in response', :aggregate_failures do
subject
expect(csv_response).to eq([
["ID", "Author Name", "Action", "Entity Type", "Target Details"],
["18", "タンさん", "Added new project", "Project", "gitlab-org/awesome-rails"],
["19", "Ru'by McRüb\"Face", "!@#$%^&*()`~ new project", "Project", \\_(ツ)_/¯"],
["20", "sǝʇʎq ƃuᴉpoɔǝp", ",./;'[]\-= old project", "Project", \\_(ツ)_/¯"]
])
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(audit_log_export_csv: false)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
end
context 'when unlicensed' do
before do
stub_licensed_features(admin_audit_log: false)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
end
end
context 'when user does not have access' do
let_it_be(:user) { create(:user) }
before do
sign_in(user)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
end
end
end
...@@ -28,7 +28,7 @@ RSpec.describe AuditLogFinder do ...@@ -28,7 +28,7 @@ RSpec.describe AuditLogFinder do
end end
end end
context 'filtering by level' do context 'scoping the results' do
context 'when project level' do context 'when project level' do
let(:level) { Gitlab::Audit::Levels::Project.new(project: project) } let(:level) { Gitlab::Audit::Levels::Project.new(project: project) }
......
...@@ -202,6 +202,26 @@ RSpec.describe AuditEvent, type: :model do ...@@ -202,6 +202,26 @@ RSpec.describe AuditEvent, type: :model do
end end
end end
describe '#ip_address' do
context 'when ip_address exists in both details hash and ip_address column' do
subject(:event) do
described_class.new(ip_address: '10.2.1.1', details: { ip_address: '192.168.0.1' })
end
it 'returns the value from ip_address column' do
expect(event.ip_address).to eq('10.2.1.1')
end
end
context 'when ip_address exists in details hash but not in ip_address column' do
subject(:event) { described_class.new(details: { ip_address: '192.168.0.1' }) }
it 'returns the value from details hash' do
expect(event.ip_address).to eq('192.168.0.1')
end
end
end
describe '#entity_path' do describe '#entity_path' do
context 'when entity_path exists in both details hash and entity_path column' do context 'when entity_path exists in both details hash and entity_path column' do
subject(:event) do subject(:event) do
...@@ -222,6 +242,26 @@ RSpec.describe AuditEvent, type: :model do ...@@ -222,6 +242,26 @@ RSpec.describe AuditEvent, type: :model do
end end
end end
describe '#target_type' do
context 'when target_type exists in both details hash and target_type column' do
subject(:event) do
described_class.new(target_type: 'Group', details: { target_type: 'Project' })
end
it 'returns the value from target_type column' do
expect(event.target_type).to eq('Group')
end
end
context 'when target_type exists in details hash but not in target_type column' do
subject(:event) { described_class.new(details: { target_type: 'Project' }) }
it 'returns the value from details hash' do
expect(event.target_type).to eq('Project')
end
end
end
describe '#present' do describe '#present' do
it 'returns a presenter' do it 'returns a presenter' do
expect(subject.present).to be_an_instance_of(AuditEventPresenter) expect(subject.present).to be_an_instance_of(AuditEventPresenter)
......
...@@ -124,12 +124,6 @@ RSpec.describe AuditEventPresenter do ...@@ -124,12 +124,6 @@ RSpec.describe AuditEventPresenter do
it 'survives a round trip from JSON' do it 'survives a round trip from JSON' do
expect(Gitlab::Json.parse(presenter.ip_address.to_json)).to eq(presenter.ip_address) expect(Gitlab::Json.parse(presenter.ip_address.to_json)).to eq(presenter.ip_address)
end end
it 'falls back to the details hash' do
audit_event.update(ip_address: nil)
expect(presenter.ip_address).to eq('127.0.0.1')
end
end end
context 'exposes the object' do context 'exposes the object' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe AuditEvents::ExportCsvService do
let_it_be(:author) { create(:user, name: "Ru'by McRüb\"Face") }
let_it_be(:audit_event) do
create(:project_audit_event,
entity_id: 678,
entity_type: 'Project',
entity_path: 'gitlab-org/awesome-rails',
target_details: "special package ¯\\_(ツ)_/¯",
author_id: author.id,
ip_address: IPAddr.new('192.168.0.1'),
details: {
custom_message: "Removed package ,./;'[]\-=",
target_id: 3, target_type: 'Package'
},
created_at: Time.zone.parse('2020-02-20T12:00:00Z'))
end
let(:params) do
{
entity_type: 'Project',
entity_id: 678,
created_before: '2020-03-01',
created_after: '2020-01-01',
author_id: author.id
}
end
subject { described_class.new(params) }
it 'invokes the CSV builder with correct limit' do
csv_builder = instance_spy(CsvBuilder)
allow(CsvBuilder).to receive(:new).and_return(csv_builder)
subject.csv_data
expect(csv_builder).to have_received(:render).with(15.megabytes)
end
it 'includes the appropriate headers' do
expect(csv.headers).to eq([
'ID', 'Author ID', 'Author Name',
'Entity ID', 'Entity Type', 'Entity Path',
'Target ID', 'Target Type', 'Target Details',
'Action', 'IP Address', 'Created At (UTC)'
])
end
context 'data verification' do
specify 'ID' do
expect(csv[0]['ID']).to eq(audit_event.id.to_s)
end
specify 'Author ID' do
expect(csv[0]['Author ID']).to eq(author.id.to_s)
end
specify 'Author Name' do
expect(csv[0]['Author Name']).to eq("Ru'by McRüb\"Face")
end
specify 'Entity ID' do
expect(csv[0]['Entity ID']).to eq('678')
end
specify 'Entity Type' do
expect(csv[0]['Entity Type']).to eq('Project')
end
specify 'Entity Path' do
expect(csv[0]['Entity Path']).to eq('gitlab-org/awesome-rails')
end
specify 'Target ID' do
expect(csv[0]['Target ID']).to eq('3')
end
specify 'Target Type' do
expect(csv[0]['Target Type']).to eq('Package')
end
specify 'Target Details' do
expect(csv[0]['Target Details']).to eq("special package ¯\\_(ツ)_/¯")
end
specify 'Action' do
expect(csv[0]['Action']).to eq("Removed package ,./;'[]\-=")
end
specify 'IP Address' do
expect(csv[0]['IP Address']).to eq('192.168.0.1')
end
specify 'Created At (UTC)' do
expect(csv[0]['Created At (UTC)']).to eq('2020-02-20T12:00:00Z')
end
end
def csv
CSV.parse(subject.csv_data, headers: true)
end
end
...@@ -36,7 +36,7 @@ FactoryBot.define do ...@@ -36,7 +36,7 @@ FactoryBot.define do
ip_address { IPAddr.new '127.0.0.1' } ip_address { IPAddr.new '127.0.0.1' }
details do details do
{ {
change: 'packges_enabled', change: 'packages_enabled',
from: true, from: true,
to: false, to: false,
author_name: user.name, author_name: user.name,
......
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