Commit 29bd2927 authored by Stan Hu's avatar Stan Hu

Merge branch '4550-add-project-download-export-audit-event' into 'master'

Resolve "Add project download/export audit event"

See merge request gitlab-org/gitlab-ee!14775
parents 7ac79ce3 b666fc96
......@@ -86,3 +86,5 @@ class Projects::RepositoriesController < Projects::ApplicationController
render_404
end
end
Projects::RepositoriesController.prepend(EE::Projects::RepositoriesController)
......@@ -73,6 +73,8 @@ From there, you can see the following actions:
- User was added to project and with which [permissions]
- Permission changes of a user assigned to a project
- User was removed from project
- Project export was downloaded
- Project repository was downloaded
### Instance events **(PREMIUM ONLY)**
......
# frozen_string_literal: true
module EE
module Projects
module RepositoriesController
extend ActiveSupport::Concern
prepended do
before_action :log_audit_event, only: [:archive]
end
private
def log_audit_event
AuditEvents::RepositoryDownloadStartedAuditEventService.new(
current_user,
repository.project,
request.remote_ip
).for_project.security_event
end
end
end
end
......@@ -7,6 +7,7 @@ module EE
prepended do
before_action :set_report_approver_rules_feature_flag, only: [:edit]
before_action :log_audit_event, only: [:download_export]
end
override :project_params_attributes
......@@ -85,5 +86,14 @@ module EE
def set_report_approver_rules_feature_flag
push_frontend_feature_flag(:report_approver_rules, default_enabled: false)
end
def log_audit_event
AuditEvents::CustomAuditEventService.new(
current_user,
project,
request.remote_ip,
'Export file download started'
).for_project.security_event
end
end
end
......@@ -5,9 +5,21 @@ module EE
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
# While tracking events that could take place even when
# a user is not logged in, (eg: downloading repo of a public project),
# we set the author_id of such events as -1
UNAUTH_USER_AUTHOR_ID = -1
# Events that are authored by unathenticated users, should be
# shown as authored by `An unauthenticated user` in the UI.
UNAUTH_USER_AUTHOR_NAME = 'An unauthenticated user'.freeze
override :author_name
def author_name
details[:author_name].presence || user&.name
if (author_name = details[:author_name].presence || user&.name)
author_name
elsif authored_by_unauth_user?
UNAUTH_USER_AUTHOR_NAME
end
end
def entity
......@@ -20,5 +32,9 @@ module EE
def present
AuditEventPresenter.new(self)
end
def authored_by_unauth_user?
author_id == UNAUTH_USER_AUTHOR_ID
end
end
end
......@@ -6,9 +6,11 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
def author_name
user = audit_event.user
return unless user
link_to(user.name, user_path(user))
if user
link_to(user.name, user_path(user))
else
audit_event.author_name
end
end
def target
......
......@@ -2,6 +2,7 @@
module EE
module AuditEventService
extend ::Gitlab::Utils::Override
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def for_member(member)
action = @details[:action]
......@@ -99,7 +100,7 @@ module EE
@details[:entity_path] = @entity&.full_path if admin_audit_log_enabled?
SecurityEvent.create(
author_id: @author.respond_to?(:id) ? @author.id : -1,
author_id: @author.respond_to?(:id) ? @author.id : AuditEvent::UNAUTH_USER_AUTHOR_ID,
entity_id: @entity.respond_to?(:id) ? @entity.id : -1,
entity_type: 'User',
details: @details
......@@ -139,6 +140,18 @@ module EE
private
override :base_payload
def base_payload
{
author_id: @author.respond_to?(:id) ? @author.id : AuditEvent::UNAUTH_USER_AUTHOR_ID,
# `@author.respond_to?(:id)` is to support cases where we need to log events
# that could take place even when a user is unathenticated, Eg: downloading a public repo.
# For such events, it is not mandatory that an author is always present.
entity_id: @entity.id,
entity_type: @entity.class.name
}
end
def for_custom_model(model, key_title)
action = @details[:action]
model_class = model.camelize
......
# frozen_string_literal: true
module EE
module AuditEvents
class CustomAuditEventService < ::AuditEventService
def initialize(author, entity, ip_address, custom_message)
super(author, entity, {
action: :custom,
custom_message: custom_message,
ip_address: ip_address
})
end
end
end
end
# frozen_string_literal: true
module EE
module AuditEvents
class RepositoryDownloadStartedAuditEventService < CustomAuditEventService
def initialize(author, entity, ip_address)
super(author, entity, ip_address, 'Repository Download Started')
end
end
end
end
---
title: Add project download & project export audit events
merge_request: 14775
author:
type: added
......@@ -109,6 +109,17 @@ module EE
private
override :send_git_archive
def send_git_archive(repository, **kwargs)
AuditEvents::RepositoryDownloadStartedAuditEventService.new(
current_user,
repository.project,
ip_address
).for_project.security_event
super
end
def private_token
params[::APIGuard::PRIVATE_TOKEN_PARAM] || env[::APIGuard::PRIVATE_TOKEN_HEADER]
end
......
# frozen_string_literal: true
require "spec_helper"
describe Projects::RepositoriesController do
let(:project) { create(:project, :repository) }
describe "GET archive" do
shared_examples 'logs the audit event' do
it 'logs the audit event' do
expect do
get :archive, params: { namespace_id: project.namespace, project_id: project, id: "master" }, format: "zip"
end.to change { SecurityEvent.count }.by(1)
end
end
context 'when unauthenticated', 'for a public project' do
it_behaves_like 'logs the audit event' do
let(:project) { create(:project, :repository, :public) }
end
end
context 'when authenticated', 'as a developer' do
before do
project.add_developer(user)
sign_in(user)
end
it_behaves_like 'logs the audit event' do
let(:user) { create(:user) }
end
end
end
end
......@@ -313,4 +313,24 @@ describe ProjectsController do
end
end
end
describe '#download_export' do
let(:request) { get :download_export, params: { namespace_id: project.namespace, id: project } }
context 'when project export is enabled' do
it 'logs the audit event' do
expect { request }.to change { SecurityEvent.count }.by(1)
end
end
context 'when project export is disabled' do
before do
stub_application_setting(project_export_enabled?: false)
end
it 'does not log an audit event' do
expect { request }.not_to change { SecurityEvent.count }
end
end
end
end
......@@ -41,6 +41,14 @@ RSpec.describe AuditEvent, type: :model do
end
end
end
context 'when authored by an unauthenticated user' do
subject(:event) { described_class.new(author_id: -1) }
it 'returns `An unauthenticated user`' do
expect(subject.author_name).to eq('An unauthenticated user')
end
end
end
describe '#entity' do
......
......@@ -21,14 +21,72 @@ describe AuditEventPresenter do
end
context 'exposes the author' do
it 'shows a link if it exists' do
expect(presenter.author_name).to eq("<a href=\"#{user_path(audit_event.user)}\">#{audit_event.user.name}</a>")
context 'event authored by a user that exists' do
it 'shows a link' do
expect(presenter.author_name).to eq("<a href=\"#{user_path(audit_event.user)}\">#{audit_event.user.name}</a>")
end
end
it 'stores the name if it has been deleted' do
audit_event.user = nil
context 'event authored by a user that no longer exists' do
let(:user) { create(:user) }
let(:audit_event) { create(:audit_event, user: user, details: details) }
expect(presenter.author_name).to be_blank
before do
user.destroy
audit_event.reload
end
context 'when `author_name` is not included in the details' do
let(:details) do
{
author_name: nil,
ip_address: '127.0.0.1',
target_details: 'target name',
entity_path: 'path',
from: 'a',
to: 'b'
}
end
it 'shows a blank author name' do
expect(presenter.author_name).to be_blank
end
end
context 'when `author_name` is included in the details' do
it 'shows the author name as provided in the details' do
expect(presenter.author_name).to eq('author')
end
end
end
context 'event authored by an unauthenticated user' do
before do
audit_event.author_id = -1
end
context 'when `author_name` is not included in details' do
let(:details) do
{
author_name: nil,
ip_address: '127.0.0.1',
target_details: 'target name',
entity_path: 'path',
from: 'a',
to: 'b'
}
end
it 'shows `An unauthenticated user` as the author name' do
expect(presenter.author_name).to eq('An unauthenticated user')
end
end
context 'when `author_name` is included in details' do
it 'shows the author name as provided in the details' do
expect(presenter.author_name).to eq('author')
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe API::Repositories do
let(:project) { create(:project, :repository) }
describe "GET /projects/:id/repository/archive(.:format)?:sha" do
shared_examples 'logs the audit event' do
let(:route) { "/projects/#{project.id}/repository/archive" }
it 'logs the audit event' do
expect do
get api(route, current_user)
end.to change { SecurityEvent.count }.by(1)
end
end
context 'when unauthenticated', 'and project is public' do
it_behaves_like 'logs the audit event' do
let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
end
end
context 'when authenticated', 'as a developer' do
before do
project.add_developer(user)
end
it_behaves_like 'logs the audit event' do
let(:user) { create(:user) }
let(:current_user) { user }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::CustomAuditEventService do
describe '#security_event' do
include_examples 'logs the custom audit event' do
let(:user) { create(:user) }
let(:ip_address) { '127.0.0.1' }
let(:entity) { create(:project) }
let(:entity_type) { 'Project' }
let(:custom_message) { 'Custom Event' }
let(:service) { described_class.new(user, entity, ip_address, custom_message) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EE::AuditEvents::RepositoryDownloadStartedAuditEventService do
describe '#security_event' do
include_examples 'logs the custom audit event' do
let(:user) { create(:user) }
let(:ip_address) { '127.0.0.1' }
let(:entity) { create(:project) }
let(:entity_type) { 'Project' }
let(:custom_message) { 'Repository Download Started' }
let(:service) { described_class.new(user, entity, ip_address) }
end
end
end
......@@ -21,3 +21,31 @@ shared_examples_for 'audit event logging' do
expect { operation }.not_to change(AuditEvent, :count)
end
end
shared_examples_for 'logs the custom audit event' do
let(:logger) { instance_double(Gitlab::AuditJsonLogger) }
before do
stub_licensed_features(audit_events: true)
end
it 'creates an event and logs to a file with the provided details' 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)
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)
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
......@@ -544,6 +544,10 @@ module API
params[:archived]
end
def ip_address
env["action_dispatch.remote_ip"].to_s || request.ip
end
end
end
......
......@@ -27,7 +27,7 @@ module API
end
def get_runner_ip
{ ip_address: env["action_dispatch.remote_ip"].to_s || request.ip }
{ ip_address: ip_address }
end
def current_runner
......
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