Commit 8a606030 authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch '121-retrieve-audit-events-via-api-mvc' into 'master'

Resolve "Retrieve audit events via API: MVC"

See merge request gitlab-org/gitlab!15698
parents 996e31c2 667bec36
# frozen_string_literal: true
class AuditEvent < ApplicationRecord
include CreatedAtFilterable
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
......@@ -9,6 +11,9 @@ class AuditEvent < ApplicationRecord
validates :entity_id, presence: true
validates :entity_type, presence: true
scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
after_initialize :initialize_details
def initialize_details
......@@ -18,6 +23,10 @@ class AuditEvent < ApplicationRecord
def author_name
self.user.name
end
def formatted_details
details.merge(details.slice(:from, :to).transform_values(&:to_s))
end
end
AuditEvent.prepend_if_ee('EE::AuditEvent')
......@@ -104,6 +104,7 @@ The following API resources are available outside of project and group contexts
| Resource | Available endpoints |
|:--------------------------------------------------|:------------------------------------------------------------------------|
| [Applications](applications.md) | `/applications` |
| [Audit Events](audit_events.md) **(PREMIUM ONLY)** | `/audit_events` |
| [Avatar](avatar.md) | `/avatar` |
| [Broadcast messages](broadcast_messages.md) | `/broadcast_messages` |
| [Code snippets](snippets.md) | `/snippets` |
......
# Audit Events API **(PREMIUM ONLY)**
The Audit Events API allows you to retrieve [instance audit events](../administration/audit_events.md#instance-events-premium-only).
To retrieve audit events using the API, you must [authenticate yourself](README.html#authentication) as an Administrator.
## Retrieve all instance audit events
```
GET /audit_events
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `created_after` | string | no | Return audit events created on or after the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ |
| `created_before` | string | no | Return audit events created on or before the given time. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ |
| `entity_type` | string | no | Return audit events for the given entity type. Valid values are: `User`, `Group`, or `Project`. |
| `entity_id` | boolean | no | Return audit events for the given entity ID. Requires `entity_type` attribute to be present. |
By default, `GET` requests return 20 results at a time because the API results
are paginated.
Read more on [pagination](README.md#pagination).
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/audit_events
```
Example response:
```json
[
{
"id": 1,
"author_id": 1,
"entity_id": 6,
"entity_type": "Project",
"details": {
"custom_message": "Project archived",
"author_name": "Administrator",
"target_id": "flightjs/flight",
"target_type": "Project",
"target_details": "flightjs/flight",
"ip_address": "127.0.0.1",
"entity_path": "flightjs/flight"
},
"created_at": "2019-08-30T07:00:41.885Z"
},
{
"id": 2,
"author_id": 1,
"entity_id": 60,
"entity_type": "Group",
"details": {
"add": "group",
"author_name": "Administrator",
"target_id": "flightjs",
"target_type": "Group",
"target_details": "flightjs",
"ip_address": "127.0.0.1",
"entity_path": "flightjs"
},
"created_at": "2019-08-27T18:36:44.162Z"
},
{
"id": 3,
"author_id": 51,
"entity_id": 51,
"entity_type": "User",
"details": {
"change": "email address",
"from": "hello@flightjs.com",
"to": "maintainer@flightjs.com",
"author_name": "Andreas",
"target_id": 51,
"target_type": "User",
"target_details": "Andreas",
"ip_address": null,
"entity_path": "Andreas"
},
"created_at": "2019-08-22T16:34:25.639Z"
}
]
```
## Retrieve single instance audit event
```
GET /audit_events/:id
```
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/audit_events/1
```
Example response:
```json
{
"id": 1,
"author_id": 1,
"entity_id": 6,
"entity_type": "Project",
"details": {
"custom_message": "Project archived",
"author_name": "Administrator",
"target_id": "flightjs/flight",
"target_type": "Project",
"target_details": "flightjs/flight",
"ip_address": "127.0.0.1",
"entity_path": "flightjs/flight"
},
"created_at": "2019-08-30T07:00:41.885Z"
}
```
......@@ -15,7 +15,7 @@ class AuditLogs {
groupsSelect();
new UsersSelect();
this.initFilterDropdown($('.js-type-filter'), 'event_type', null, () => {
this.initFilterDropdown($('.js-type-filter'), 'entity_type', null, () => {
$('.hidden-filter-value').val('');
$('form.filter-form').submit();
});
......
......@@ -2,23 +2,24 @@
class Admin::AuditLogsController < Admin::ApplicationController
before_action :check_license_admin_audit_log_available!
PER_PAGE = 25
def index
@events = LogFinder.new(audit_logs_params).execute
@entity = case audit_logs_params[:event_type]
@events = LogFinder.new(audit_logs_params).execute.page(params[:page]).per(PER_PAGE)
@entity = case audit_logs_params[:entity_type]
when 'User'
User.find_by_id(audit_logs_params[:user_id])
User.find_by_id(audit_logs_params[:entity_id])
when 'Project'
Project.find_by_id(audit_logs_params[:project_id])
Project.find_by_id(audit_logs_params[:entity_id])
when 'Group'
Namespace.find_by_id(audit_logs_params[:group_id])
Namespace.find_by_id(audit_logs_params[:entity_id])
else
nil
end
end
def audit_logs_params
params.permit(:page, :event_type, :user_id, :project_id, :group_id)
params.permit(:entity_type, :entity_id)
end
def check_license_admin_audit_log_available!
......
# frozen_string_literal: true
class LogFinder
PER_PAGE = 25
ENTITY_COLUMN_TYPES = {
'User' => :user_id,
'Group' => :group_id,
'Project' => :project_id
}.freeze
include CreatedAtFilter
VALID_ENTITY_TYPES = %w[Project User Group].freeze
def initialize(params)
@params = params
end
# rubocop: disable CodeReuse/ActiveRecord
def execute
AuditEvent.order(id: :desc).where(conditions).page(@params[:page]).per(PER_PAGE)
audit_events = AuditEvent.order(id: :desc) # rubocop: disable CodeReuse/ActiveRecord
audit_events = by_entity(audit_events)
audit_events = by_created_at(audit_events)
audit_events
end
# rubocop: enable CodeReuse/ActiveRecord
private
def conditions
return unless entity_column
attr_reader :params
def by_entity(audit_events)
return audit_events unless valid_entity_type?
{ entity_type: @params[:event_type] }.tap do |hash|
hash[:entity_id] = @params[entity_column] if entity_present?
audit_events = audit_events.by_entity_type(params[:entity_type])
if params[:entity_id].present? && params[:entity_id] != '0'
audit_events = audit_events.by_entity_id(params[:entity_id])
end
end
def entity_column
@entity_column ||= ENTITY_COLUMN_TYPES[@params[:event_type]]
audit_events
end
def entity_present?
@params[entity_column] && @params[entity_column] != '0'
def valid_entity_type?
VALID_ENTITY_TYPES.include? params[:entity_type]
end
end
# frozen_string_literal: true
module AuditLogsHelper
def event_type_options
def entity_type_options
[
{ id: '', text: 'All Events' },
{ id: 'Group', text: 'Group Events' },
......
......@@ -4,24 +4,24 @@
.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'
- if params[:entity_type].present?
= hidden_field_tag(:entity_type, params[:entity_type])
- entity_type = params[:entity_type].presence || 'All'
= dropdown_tag("#{entity_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: 'entity_type', data: entity_type_options, default_label: 'All Events' } })
- if params[:entity_type] == 'User'
.filter-item.inline
- if params[:user_id].present?
= hidden_field_tag(:user_id, params[:user_id], class:'hidden-filter-value')
- if params[:entity_id].present?
= hidden_field_tag(:entity_id, params[:entity_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'
placeholder: 'Search users', data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, field_name: 'entity_id' } })
- elsif params[:entity_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',
= project_select_tag(:entity_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', all_projects: 'true', simple_filter: true } })
- elsif params[:event_type] == 'Group'
- elsif params[:entity_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',
= groups_select_tag(:entity_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?
......
---
title: Add Audit Event API
merge_request: 15698
author:
type: added
# frozen_string_literal: true
module API
class AuditEvents < ::Grape::API
include ::API::PaginationParams
before do
authenticated_as_admin!
forbidden! unless ::License.feature_available?(:admin_audit_log)
end
resources :audit_events do
desc 'Get the list of audit events' do
success EE::API::Entities::AuditEvent
end
params do
optional :entity_type, type: String, desc: 'Return list of audit events for the specified entity type', values: LogFinder::VALID_ENTITY_TYPES
optional :entity_id, type: Integer
given :entity_id do
requires :entity_type, type: String
end
optional :created_after, type: DateTime, desc: 'Return audit events created after the specified time'
optional :created_before, type: DateTime, desc: 'Return audit events created before the specified time'
use :pagination
end
get do
audit_events = LogFinder.new(params).execute
present paginate(audit_events), with: EE::API::Entities::AuditEvent
end
desc 'Get single audit event' do
success EE::API::Entities::AuditEvent
end
params do
requires :id, type: Integer, desc: 'The ID of audit event'
end
get ':id' do
audit_event = AuditEvent.find_by_id(params[:id])
not_found!('Audit Event') unless audit_event
present audit_event, with: EE::API::Entities::AuditEvent
end
end
end
end
......@@ -11,6 +11,7 @@ module EE
mount ::EE::API::Boards
mount ::EE::API::GroupBoards
mount ::API::AuditEvents
mount ::API::ProjectApprovalRules
mount ::API::ProjectApprovalSettings
mount ::API::Unleash
......
......@@ -265,6 +265,17 @@ module EE
end
end
class AuditEvent < Grape::Entity
expose :id
expose :author_id
expose :entity_id
expose :entity_type
expose :details do |audit_event|
audit_event.formatted_details
end
expose :created_at
end
class Epic < Grape::Entity
can_admin_epic = ->(epic, opts) { Ability.allowed?(opts[:user], :admin_epic, epic) }
......
......@@ -126,7 +126,7 @@ module EE
override :send_git_archive
def send_git_archive(repository, **kwargs)
AuditEvents::RepositoryDownloadStartedAuditEventService.new(
EE::AuditEvents::RepositoryDownloadStartedAuditEventService.new(
current_user,
repository.project,
ip_address
......
FactoryBot.define do
factory :audit_event, aliases: [:user_audit_event] do
factory :audit_event, class: 'SecurityEvent', aliases: [:user_audit_event] do
user
type 'SecurityEvent'
entity_type 'User'
entity_id { user.id }
details do
{
change: 'email address',
from: 'admin@gitlab.com',
to: 'maintainer@gitlab.com',
author_name: user.name,
target_id: user.id,
target_type: 'User',
target_details: user.name,
ip_address: '127.0.0.1',
entity_path: user.username
}
end
trait :project_event do
entity_type 'Project'
entity_id { create(:project).id }
details do
{
add: 'user_access',
as: 'Developer',
author_name: user.name,
target_id: user.id,
target_type: 'User',
target_details: user.name,
ip_address: '127.0.0.1',
entity_path: 'gitlab.org/gitlab-ce'
}
end
end
trait :group_event do
entity_type 'Group'
entity_id { create(:group).id }
details do
{
change: 'project_creation_level',
from: nil,
to: 'Developers + Maintainers',
author_name: 'Administrator',
target_id: 1,
target_type: 'Group',
target_details: "gitlab-org",
ip_address: '127.0.0.1',
entity_path: "gitlab-org"
}
end
end
factory :project_audit_event, traits: [:project_event]
......
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
set(:user_audit_event) { create(:user_audit_event, created_at: 3.days.ago) }
set(:project_audit_event) { create(:project_audit_event, created_at: 2.days.ago) }
set(:group_audit_event) { create(:group_audit_event, created_at: 1.day.ago) }
subject { described_class.new(params).execute }
context 'no filtering' do
let(:params) { {} }
it 'finds all the events' do
expect(described_class.new({}).execute.count).to eq(3)
it 'finds all the events' do
expect(subject.count).to eq(3)
end
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')
context 'no entity_type provided' do
let(:params) { { entity_id: 1 } }
it 'ignores entity_id and returns all events' do
expect(subject.count).to eq(3)
end
end
shared_examples 'finds the right event' do
it 'finds the right event' do
expect(subject.count).to eq(1)
entity = subject.first
expect(entity.entity_type).to eq(entity_type)
expect(entity.id).to eq(audit_event.id)
end
end
context 'User Event' do
let(:params) { { entity_type: 'User', entity_id: user_audit_event.entity_id } }
it_behaves_like 'finds the right event' do
let(:entity_type) { 'User' }
let(:audit_event) { user_audit_event }
end
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')
context 'Project Event' do
let(:params) { { entity_type: 'Project', entity_id: project_audit_event.entity_id } }
it_behaves_like 'finds the right event' do
let(:entity_type) { 'Project' }
let(:audit_event) { project_audit_event }
end
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')
context 'Group Event' do
let(:params) { { entity_type: 'Group', entity_id: group_audit_event.entity_id } }
it_behaves_like 'finds the right event' do
let(:entity_type) { 'Group' }
let(:audit_event) { group_audit_event }
end
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')
let(:entity_types) { subject.map(&:entity_type) }
context 'User Event' do
let(:params) { { entity_type: 'User' } }
it 'finds the right user event' do
expect(entity_types).to all(eq 'User')
end
end
context 'Project Event' do
let(:params) { { entity_type: 'Project' } }
it 'finds the right project event' do
expect(entity_types).to all(eq 'Project')
end
end
context 'Group Event' do
let(:params) { { entity_type: 'Group' } }
it 'finds the right group event' do
expect(entity_types).to all(eq 'Group')
end
end
context 'invalid entity types' do
context 'blank entity_type' do
let(:params) { { entity_type: '' } }
it 'finds all the events with blank entity_type' do
expect(subject.count).to eq(3)
end
end
context 'invalid entity_type' do
let(:params) { { entity_type: 'Invalid Entity Type' } }
it 'finds all the events with invalid entity_type' do
expect(subject.count).to eq(3)
end
end
end
end
context 'filtering by created_at' do
context 'through created_after' do
let(:params) { { created_after: group_audit_event.created_at } }
it 'finds the right project event' do
expect(described_class.new(event_type: 'Project')
.execute.map(&:entity_type)).to all(eq 'Project')
it 'returns events created on or after the given date' do
expect(subject).to contain_exactly(group_audit_event)
end
end
it 'finds the right group event' do
expect(described_class.new(event_type: 'Group')
.execute.map(&:entity_type)).to all(eq 'Group')
context 'through created_before' do
let(:params) { { created_before: user_audit_event.created_at } }
it 'returns events created on or before the given date' do
expect(subject).to contain_exactly(user_audit_event)
end
end
it 'finds all the events with no valid even type' do
expect(described_class.new(event_type: '').execute.count).to eq(3)
context 'through created_after and created_before' do
let(:params) { { created_after: user_audit_event.created_at, created_before: project_audit_event.created_at } }
it 'returns events created between the given dates' do
expect(subject).to contain_exactly(user_audit_event, project_audit_event)
end
end
end
end
......
......@@ -78,4 +78,13 @@ RSpec.describe AuditEvent, type: :model do
expect(subject.present).to be_an_instance_of(AuditEventPresenter)
end
end
describe '#formatted_details' do
subject(:event) { create(:group_audit_event, details: { change: 'membership_lock', from: false, to: true, ip_address: '127.0.0.1' })}
it 'converts value of `to` and `from` in `details` to string' do
expect(event.formatted_details[:to]).to eq('true')
expect(event.formatted_details[:from]).to eq('false')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::AuditEvents do
describe 'GET /audit_events' do
let(:url) { "/audit_events" }
context 'when authenticated, as a user' do
let(:user) { create(:user) }
it_behaves_like '403 response' do
let(:request) { get api(url, user) }
end
end
context 'when authenticated, as an admin' do
let(:admin) { create(:admin) }
context 'audit events feature is not available' do
it_behaves_like '403 response' do
let(:request) { get api(url, admin) }
end
end
context 'audit events feature is available' do
set(:user_audit_event) { create(:user_audit_event, created_at: Date.new(2000, 1, 10)) }
set(:project_audit_event) { create(:project_audit_event, created_at: Date.new(2000, 1, 15)) }
set(:group_audit_event) { create(:group_audit_event, created_at: Date.new(2000, 1, 20)) }
before do
stub_licensed_features(admin_audit_log: true)
end
it 'returns 200 response' do
get api(url, admin)
expect(response).to have_gitlab_http_status(200)
end
it 'includes the correct pagination headers' do
audit_events_counts = AuditEvent.count
get api(url, admin)
expect(response).to include_pagination_headers
expect(response.headers['X-Total']).to eq(audit_events_counts.to_s)
expect(response.headers['X-Page']).to eq('1')
end
context 'parameters' do
context 'entity_type parameter' do
it "returns audit events of the provided entity type" do
get api(url, admin), params: { entity_type: 'User' }
expect(json_response.size).to eq 1
expect(json_response.first["id"]).to eq(user_audit_event.id)
end
end
context 'entity_id parameter' do
context 'requires entity_type parameter to be present' do
it_behaves_like '400 response' do
let(:request) { get api(url, admin), params: { entity_id: 1 } }
end
end
it 'returns audit_events of the provided entity id' do
get api(url, admin), params: { entity_type: 'User', entity_id: user_audit_event.entity_id }
expect(json_response.size).to eq 1
expect(json_response.first["id"]).to eq(user_audit_event.id)
end
end
context 'created_before parameter' do
it "returns audit events created before the given parameter" do
created_before = '2000-01-20T00:00:00.060Z'
get api(url, admin), params: { created_before: created_before }
expect(json_response.size).to eq 3
expect(json_response.first["id"]).to eq(group_audit_event.id)
expect(json_response.last["id"]).to eq(user_audit_event.id)
end
end
context 'created_after parameter' do
it "returns audit events created after the given parameter" do
created_after = '2000-01-12T00:00:00.060Z'
get api(url, admin), params: { created_after: created_after }
expect(json_response.size).to eq 2
expect(json_response.first["id"]).to eq(group_audit_event.id)
expect(json_response.last["id"]).to eq(project_audit_event.id)
end
end
end
context 'attributes' do
it 'exposes the right attributes' do
get api(url, admin), params: { entity_type: 'User' }
response = json_response.first
details = response['details']
expect(response["id"]).to eq(user_audit_event.id)
expect(response["author_id"]).to eq(user_audit_event.user.id)
expect(response["entity_id"]).to eq(user_audit_event.user.id)
expect(response["entity_type"]).to eq('User')
expect(Time.parse(response["created_at"])).to be_like_time(user_audit_event.created_at)
expect(details).to eq user_audit_event.formatted_details.with_indifferent_access
end
end
end
end
end
describe 'GET /audit_events/:id' do
set(:user_audit_event) { create(:user_audit_event, created_at: Date.new(2000, 1, 10)) }
let(:url) { "/audit_events/#{user_audit_event.id}" }
context 'when authenticated, as a user' do
let(:user) { create(:user) }
it_behaves_like '403 response' do
let(:request) { get api(url, user) }
end
end
context 'when authenticated, as an admin' do
let(:admin) { create(:admin) }
context 'audit events feature is not available' do
it_behaves_like '403 response' do
let(:request) { get api(url, admin) }
end
end
context 'audit events feature is available' do
before do
stub_licensed_features(admin_audit_log: true)
end
context 'audit event exists' do
it 'returns 200 response' do
get api(url, admin)
expect(response).to have_gitlab_http_status(200)
end
context 'attributes' do
it 'exposes the right attributes' do
get api(url, admin)
details = json_response['details']
expect(json_response["id"]).to eq(user_audit_event.id)
expect(json_response["author_id"]).to eq(user_audit_event.user.id)
expect(json_response["entity_id"]).to eq(user_audit_event.user.id)
expect(json_response["entity_type"]).to eq('User')
expect(Time.parse(json_response["created_at"])).to be_like_time(user_audit_event.created_at)
expect(details).to eq user_audit_event.formatted_details.with_indifferent_access
end
end
end
context 'audit event does not exist' do
it_behaves_like '404 response' do
let(:url) { "/audit_events/10001" }
let(:request) { get api(url, admin) }
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