Commit df01bc07 authored by James Lopez's avatar James Lopez Committed by Sean McGivern

Implement server-wide Audit Logging admin screen

parent 0bd4449d
/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props, no-new */
/* global GroupsSelect */
/* global ProjectSelect */
import UsersSelect from './users_select';
import './groups_select';
import './project_select';
class AuditLogs {
constructor() {
this.initFilters();
}
initFilters() {
new ProjectSelect();
new GroupsSelect();
new UsersSelect();
this.initFilterDropdown($('.js-type-filter'), 'event_type', null, () => {
$('.hidden-filter-value').val('');
$('form.filter-form').submit();
});
$('.project-item-select').on('click', () => {
$('form.filter-form').submit();
});
$('form.filter-form').on('submit', function applyFilters(event) {
event.preventDefault();
gl.utils.visitUrl(`${this.action}?${$(this).serialize()}`);
});
}
initFilterDropdown($dropdown, fieldName, searchFields, cb) {
const dropdownOptions = {
fieldName,
selectable: true,
filterable: searchFields ? true : false,
search: { fields: searchFields },
data: $dropdown.data('data'),
clicked: () => $dropdown.closest('form.filter-form').submit(),
};
if (cb) {
dropdownOptions.clicked = cb;
}
$dropdown.glDropdown(dropdownOptions);
}
}
export default AuditLogs;
......@@ -61,6 +61,7 @@ import ShortcutsBlob from './shortcuts_blob';
// EE-only
import ApproversSelect from './approvers_select';
import AuditLogs from './audit_logs';
(function() {
var Dispatcher;
......@@ -394,6 +395,9 @@ import ApproversSelect from './approvers_select';
case 'admin:emails:show':
new AdminEmailSelect();
break;
case 'admin:audit_logs:index':
new AuditLogs();
break;
case 'projects:repository:show':
// Initialize Protected Branch Settings
new gl.ProtectedBranchCreate();
......
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */
import Api from './api';
(function() {
this.ProjectSelect = (function() {
(function () {
this.ProjectSelect = (function () {
function ProjectSelect() {
$('.js-projects-dropdown-toggle').each(function(i, dropdown) {
$('.js-projects-dropdown-toggle').each(function (i, dropdown) {
var $dropdown;
$dropdown = $(dropdown);
return $dropdown.glDropdown({
......@@ -13,16 +13,16 @@ import Api from './api';
search: {
fields: ['name_with_namespace']
},
data: function(term, callback) {
data: function (term, callback) {
var finalCallback, projectsCallback;
var orderBy = $dropdown.data('order-by');
finalCallback = function(projects) {
finalCallback = function (projects) {
return callback(projects);
};
if (this.includeGroups) {
projectsCallback = function(projects) {
projectsCallback = function (projects) {
var groupsCallback;
groupsCallback = function(groups) {
groupsCallback = function (groups) {
var data;
data = groups.concat(projects);
return finalCallback(data);
......@@ -35,22 +35,28 @@ import Api from './api';
if (this.groupId) {
return Api.groupProjects(this.groupId, term, projectsCallback);
} else {
return Api.projects(term, { order_by: orderBy }, projectsCallback);
return Api.projects(term, {
order_by: orderBy
}, projectsCallback);
}
},
url: function(project) {
url: function (project) {
return project.web_url;
},
text: function(project) {
text: function (project) {
return project.name_with_namespace;
}
});
});
$('.ajax-project-select').each(function(i, select) {
$('.ajax-project-select').each(function (i, select) {
var placeholder;
var idAttribute;
this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups');
this.allProjects = $(select).data('allprojects') || false;
this.orderBy = $(select).data('order-by') || 'id';
idAttribute = $(select).data('idattribute') || 'web_url';
placeholder = "Search for project";
if (this.includeGroups) {
placeholder += " or group";
......@@ -58,10 +64,10 @@ import Api from './api';
return $(select).select2({
placeholder: placeholder,
minimumInputLength: 0,
query: (function(_this) {
return function(query) {
query: (function (_this) {
return function (query) {
var finalCallback, projectsCallback;
finalCallback = function(projects) {
finalCallback = function (projects) {
var data;
data = {
results: projects
......@@ -69,9 +75,9 @@ import Api from './api';
return query.callback(data);
};
if (_this.includeGroups) {
projectsCallback = function(projects) {
projectsCallback = function (projects) {
var groupsCallback;
groupsCallback = function(groups) {
groupsCallback = function (groups) {
var data;
data = groups.concat(projects);
return finalCallback(data);
......@@ -84,14 +90,17 @@ import Api from './api';
if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else {
return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback);
return Api.projects(query.term, {
order_by: _this.orderBy,
membership: !_this.allProjects
}, projectsCallback);
}
};
})(this),
id: function(project) {
return project.web_url;
id: function (project) {
return project[idAttribute];
},
text: function(project) {
text: function (project) {
return project.name_with_namespace || project.name;
},
dropdownCssClass: "ajax-project-dropdown"
......
class Admin::AuditLogsController < Admin::ApplicationController
def index
@events = LogFinder.new(audit_logs_params).execute
@entity = case audit_logs_params[:event_type]
when 'User'
User.find_by_id(audit_logs_params[:user_id])
when 'Project'
Project.find_by_id(audit_logs_params[:project_id])
when 'Group'
Namespace.find_by_id(audit_logs_params[:group_id])
else
nil
end
end
def audit_logs_params
params.permit(:page, :event_type, :user_id, :project_id, :group_id)
end
end
class LogFinder
PER_PAGE = 25
ENTITY_COLUMN_TYPES = {
'User' => :user_id,
'Group' => :group_id,
'Project' => :project_id
}.freeze
def initialize(params)
@params = params
end
def execute
AuditEvent.order(id: :desc).where(conditions).page(@params[:page]).per(PER_PAGE)
end
private
def conditions
return nil unless entity_column
{ entity_type: @params[:event_type] }.tap do |hash|
hash[:entity_id] = @params[entity_column] if entity_present?
end
end
def entity_column
@entity_column ||= ENTITY_COLUMN_TYPES[@params[:event_type]]
end
def entity_present?
@params[entity_column] && @params[entity_column] != '0'
end
end
module AuditLogsHelper
def event_type_options
[
{ id: '', text: 'All Events' },
{ id: 'Group', text: 'Group Events' },
{ id: 'Project', text: 'Project Events' },
{ id: 'User', text: 'User Events' }
]
end
def admin_user_dropdown_label(default_label)
if @entity
@entity.name
else
default_label
end
end
def admin_project_dropdown_label(default_label)
if @entity
@entity.name_with_namespace
else
default_label
end
end
def admin_namespace_dropdown_label(default_label)
if @entity
@entity.full_path
else
default_label
end
end
end
......@@ -9,11 +9,15 @@ class AuditEvent < ActiveRecord::Base
after_initialize :initialize_details
def author_name
details[:author_name].blank? ? user&.name : details[:author_name]
end
def initialize_details
self.details = {} if details.nil?
end
def author_name
self.user.try(:name) || details[:author_name]
def present
AuditEventPresenter.new(self)
end
end
class
AuditEventPresenter < Gitlab::View::Presenter::Simple
presents :audit_event
def author_name
audit_event.author_name || '(removed)'
end
def target
audit_event.details[:target_details]
end
def ip_address
audit_event.details[:ip_address]
end
def object
audit_event.details[:entity_path]
end
def date
audit_event.created_at.to_s(:db)
end
def action
Audit::Details.humanize(audit_event.details)
end
end
......@@ -29,7 +29,7 @@ class AuditEventService
target_type: "User",
target_details: user_name
}
when :update
when :update, :override
{
change: "access_level",
from: old_access_level,
......@@ -87,7 +87,8 @@ class AuditEventService
author_id: @author.id,
entity_id: @entity.id,
entity_type: @entity.class.name,
details: @details
details: @details.merge(ip_address: @author.current_sign_in_ip,
entity_path: @entity.full_path)
)
end
end
- @no_container = true
- page_title 'Audit Log'
= render 'admin/background_jobs/head'
%div{ class: container_class }
.todos-filters
.row-content-block.second-block
= form_tag admin_audit_logs_path, method: :get, class: 'filter-form' do
.filter-item.inline
- if params[:event_type].present?
= hidden_field_tag(:event_type, params[:event_type])
- event_type = params[:event_type].presence || 'All'
= dropdown_tag("#{event_type} Events", options: { toggle_class: 'js-type-search js-filter-submit js-type-filter', dropdown_class: 'dropdown-menu-type dropdown-menu-selectable dropdown-menu-action js-filter-submit',
placeholder: 'Search types', data: { field_name: 'event_type', data: event_type_options, default_label: 'All Events' } })
- if params[:event_type] == 'User'
.filter-item.inline
- if params[:user_id].present?
= hidden_field_tag(:user_id, params[:user_id], class:'hidden-filter-value')
= dropdown_tag(admin_user_dropdown_label('User'), options: { toggle_class: 'js-user-search js-filter-submit', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable',
placeholder: 'Search users', data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, field_name: 'user_id' } })
- elsif params[:event_type] == 'Project'
.filter-item.inline
= project_select_tag(:project_id, { class: 'project-item-select hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: admin_project_dropdown_label('Search projects'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', allprojects: 'true'} })
- elsif params[:event_type] == 'Group'
.filter-item.inline
= groups_select_tag(:group_id, { required: true, class: 'group-item-select project-item-select hidden-filter-value', toggle_class: 'js-group-search js-group-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
placeholder: admin_namespace_dropdown_label('Search groups'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', all_available: true} })
- if @events.present?
%table.table
%thead
%tr
%th Author
%th Object
%th Action
%th Target
%th IP Address
%th Date
%tbody
- @events.map(&:present).each do |event|
%tr
%td= event.author_name
%td= event.object
%td= event.action
%td= event.target
%td= event.ip_address
%td= event.date
= paginate @events, theme: 'gitlab'
......@@ -23,3 +23,7 @@
= link_to admin_requests_profiles_path, title: 'Requests Profiles' do
%span
Requests Profiles
= nav_link path: 'audit_logs#index' do
= link_to admin_audit_logs_path, title: 'Audit Log' do
%span
Audit Log
......@@ -5,11 +5,11 @@
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
= nav_link(controller: %w(dashboard admin projects users groups builds runners), html_options: {class: 'home'}) do
= nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
= nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
= nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles audit_logs)) do
= link_to admin_system_info_path, title: 'Monitoring' do
%span
Monitoring
......
---
title: Add server-wide Audit Log admin screen
merge_request: 1852
author:
......@@ -76,6 +76,7 @@ namespace :admin do
## EE-specific
resource :email, only: [:show, :create]
resources :audit_logs, controller: 'audit_logs', only: [:index]
## EE-specific
resource :system_info, controller: 'system_info', only: [:show]
......
......@@ -28,3 +28,21 @@ To view the Audit Events user needs to have enough permissions to view the group
Navigate to Group->Settings->Audit Events to view the Audit Events:
![audit events group](audit_events_group.png)
# Audit Log (Admin only)
> **Notes:**
> [Introduced][ee-2336] in GitLab 9.3.
Server-wide audit logging, available in GitLab Enterprise Edition Premium since 9.3, introduces
the ability to observe user actions across the entire instance of your GitLab Server, making it
easy to understand who changed what and when for audit purposes.
To view the server-wide admin log, visit the Admin Area, select Monitoring and choose Audit Log.
It is possible to filter particular actions by choosing an audit data type from the filter drop-down.
You can further filter by specific group, project or user (for authentication events).
![audit log](audit_log.png)
[ce-23361]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2336
module Audit
class Details
CRUD_ACTIONS = %i[add remove change].freeze
def self.humanize(*args)
new(*args).humanize
end
def initialize(details)
@details = details
end
def humanize
if @details[:with]
"Signed in with #{@details[:with].upcase} authentication"
else
crud_action_text
end
end
private
def crud_action_text
action = @details.slice(*CRUD_ACTIONS)
value = @details.values.first.tr('_', ' ')
case action.keys.first
when :add
"Added #{value}#{@details[:as] ? " as #{@details[:as]}" : ""}"
when :remove
"Removed #{value}"
else
"Changed #{value} from #{@details[:from]} to #{@details[:to]}"
end
end
end
end
FactoryGirl.define do
factory :audit_event, aliases: [:user_audit_event] do
user
type 'SecurityEvent'
entity_type 'User'
entity_id { user.id }
trait :project_event do
entity_type 'Project'
entity_id { create(:empty_project).id }
end
trait :group_event do
entity_type 'Group'
entity_id { create(:group).id }
end
factory :project_audit_event, traits: [:project_event]
factory :group_audit_event, traits: [:group_event]
end
end
require 'spec_helper'
describe 'Admin::AuditLogs', feature: true, js: true do
include Select2Helper
let(:user) { create(:user) }
before do
login_as :admin
end
describe 'user events' do
before do
AuditEventService.new(user, user, with: :ldap).
for_authentication.security_event
visit admin_audit_logs_path
end
it 'filters by user' do
filter_by_type('User Events')
click_button 'User'
wait_for_requests
within '.dropdown-menu-user' do
click_link user.name
end
wait_for_requests
expect(page).to have_content('Signed in with LDAP authentication')
end
end
describe 'group events' do
let(:group_member) { create(:group_member, user: user) }
before do
AuditEventService.new(user, group_member.group, { action: :create }).
for_member(group_member).security_event
visit admin_audit_logs_path
end
it 'filters by group' do
filter_by_type('Group Events')
click_button 'Group'
find('.group-item-select').click
wait_for_requests
find('.select2-results').click
expect(page).to have_content('Added user access as Owner')
end
end
describe 'project events' do
let(:project) { create(:empty_project) }
let(:project_member) { create(:project_member, user: user) }
before do
AuditEventService.new(user, project, { action: :destroy }).
for_member(project_member).security_event
visit admin_audit_logs_path
end
it 'filters by project' do
filter_by_type('Project Events')
click_button 'Project'
find('.project-item-select').click
wait_for_requests
find('.select2-results').click
expect(page).to have_content('Removed user access')
end
end
def filter_by_type(type)
click_button 'Events'
within '.dropdown-menu-type' do
click_link type
end
wait_for_requests
end
end
require 'spec_helper'
describe LogFinder do
let(:user) { create(:user) }
describe '#execute' do
before do
create(:user_audit_event)
create(:project_audit_event)
create(:group_audit_event)
end
it 'finds all the events' do
expect(described_class.new({}).execute.count).to eq(3)
end
context 'filtering by ID' do
it 'finds the right user event' do
expect(described_class.new(event_type: 'User', user_id: 1).
execute.map(&:entity_type)).to all(eq 'User')
end
it 'finds the right project event' do
expect(described_class.new(event_type: 'Project', project_id: 1).
execute.map(&:entity_type)).to all(eq 'Project')
end
it 'finds the right group event' do
expect(described_class.new(event_type: 'Group', group_id: 1).
execute.map(&:entity_type)).to all(eq 'Group')
end
end
context 'filtering by type' do
it 'finds the right user event' do
expect(described_class.new(event_type: 'User').
execute.map(&:entity_type)).to all(eq 'User')
end
it 'finds the right project event' do
expect(described_class.new(event_type: 'Project').
execute.map(&:entity_type)).to all(eq 'Project')
end
it 'finds the right group event' do
expect(described_class.new(event_type: 'Group').
execute.map(&:entity_type)).to all(eq 'Group')
end
it 'finds all the events with no valid even type' do
expect(described_class.new(event_type: '').execute.count).to eq(3)
end
end
end
end
require 'spec_helper'
describe Audit::Details do
let(:user) { create(:user) }
describe '.humanize' do
context 'user' do
let(:login_action) do
{
with: :ldap,
target_id: user.id,
target_type: 'User',
target_details: user.name
}
end
it 'humanizes user login action' do
expect(described_class.humanize(login_action)).to eq('Signed in with LDAP authentication')
end
end
context 'project' do
let(:user_member) { create(:user) }
let(:project) { create(:empty_project) }
let(:member) { create(:project_member, :developer, user: user_member, project: project) }
let(:member_access_action) do
{
add: 'user_access',
as: Gitlab::Access.options_with_owner.key(member.access_level.to_i),
author_name: user.name,
target_id: member.id,
target_type: 'User',
target_details: member.user.name
}
end
it 'humanizes add project member access action' do
expect(described_class.humanize(member_access_action)).to eq('Added user access as Developer')
end
end
context 'group' do
let(:user_member) { create(:user) }
let(:group) { create(:group) }
let(:member) { create(:group_member, group: group, user: user_member) }
let(:member_access_action) do
{
change: 'access_level',
from: 'Guest',
to: member.human_access,
author_name: user.name,
target_id: member.id,
target_type: 'User',
target_details: member.user.name
}
end
it 'humanizes add group member access action' do
expect(described_class.humanize(member_access_action)).to eq('Changed access level from Guest to Owner')
end
end
context 'deploy key' do
let(:removal_action) do
{
remove: 'deploy_key',
author_name: user.name,
target_id: 'key title',
target_type: 'DeployKey',
target_details: 'key title'
}
end
it 'humanizes the removal action' do
expect(described_class.humanize(removal_action)).to eq('Removed deploy key')
end
end
end
end
......@@ -42,4 +42,10 @@ RSpec.describe AuditEvent, type: :model do
end
end
end
describe '#present' do
it 'returns a presenter' do
expect(subject.present).to be_an_instance_of(AuditEventPresenter)
end
end
end
require 'spec_helper'
describe AuditEventPresenter do
let(:details) do
{
author_name: 'author',
ip_address: '127.0.0.1',
target_details: 'target name',
entity_path: 'path',
from: 'a',
to: 'b'
}
end
let(:audit_event) { create(:audit_event, details: details) }
subject(:presenter) do
described_class.new(audit_event)
end
it 'exposes the author name' do
expect(presenter.author_name).to eq(details[:author_name])
end
it 'exposes the target' do
expect(presenter.target).to eq(details[:target_details])
end
it 'exposes the ip address' do
expect(presenter.ip_address).to eq(details[:ip_address])
end
it 'exposes the object' do
expect(presenter.object).to eq(details[:entity_path])
end
it 'exposes the date' do
expect(presenter.date).to eq(audit_event.created_at.to_s(:db))
end
it 'exposes the action' do
expect(presenter.action).to eq('Changed author from a to b')
end
end
require 'spec_helper'
describe AuditEventService, services: true do
let(:project) { create(:project) }
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
let(:project_member) { create(:project_member, user: user) }
let(:service) { described_class.new(user, project, { action: :destroy }) }
......@@ -18,5 +18,15 @@ describe AuditEventService, services: true do
event = service.for_member(project_member).security_event
expect(event[:details][:target_details]).to eq('Deleted User')
end
it 'has the IP address' do
event = service.for_member(project_member).security_event
expect(event[:details][:ip_address]).to eq(user.current_sign_in_ip)
end
it 'has the entity full path' do
event = service.for_member(project_member).security_event
expect(event[:details][:entity_path]).to eq(project.full_path)
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