Commit 9dfd75a4 authored by Tan Le's avatar Tan Le

Allow administrator to export audit events to CSV

This change allows administrator to export audit events to CSV file. The
maximum file size is capped to 15Mb since it can fit nicely as an email
attachment and is a common limit across other CSV reports.

We decided not to cap by number of records (i.e 10k audit events) due to
some memory concerns since it requires 10k of records being fetched as
part of the preloading step.
parent 3c96d96d
# 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
AuditEventPresenter.new(self)
end
def target_type
super || details[:target_type]
end
def target_id
details[:target_id]
end
def target_details
super || details[:target_details]
end
def ip_address
super&.to_s || details[:ip_address]
end
def lazy_entity
BatchLoader.for(entity_id)
.batch(
......
......@@ -18,7 +18,7 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
end
def ip_address
audit_event.ip_address&.to_s || details[:ip_address]
audit_event.ip_address
end
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
resource :push_rule, only: [:show, :update]
resource :email, only: [:show, :create]
resources :audit_logs, controller: 'audit_logs', only: [:index]
resources :audit_log_reports, only: [:index], constraints: { format: :csv }
resources :credentials, only: [:index]
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
end
end
context 'filtering by level' do
context 'scoping the results' do
context 'when project level' do
let(:level) { Gitlab::Audit::Levels::Project.new(project: project) }
......
......@@ -202,6 +202,26 @@ RSpec.describe AuditEvent, type: :model do
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
context 'when entity_path exists in both details hash and entity_path column' do
subject(:event) do
......@@ -222,6 +242,26 @@ RSpec.describe AuditEvent, type: :model do
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
it 'returns a presenter' do
expect(subject.present).to be_an_instance_of(AuditEventPresenter)
......
......@@ -124,12 +124,6 @@ RSpec.describe AuditEventPresenter do
it 'survives a round trip from JSON' do
expect(Gitlab::Json.parse(presenter.ip_address.to_json)).to eq(presenter.ip_address)
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
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
ip_address { IPAddr.new '127.0.0.1' }
details do
{
change: 'packges_enabled',
change: 'packages_enabled',
from: true,
to: false,
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