Implement Moderators audit logs (#3449)

This commit is contained in:
Branko Arnaudovski 2019-10-23 16:30:45 +02:00 committed by Mac Siri
parent d42f8e7cc0
commit efbb108061
22 changed files with 405 additions and 0 deletions

View file

@ -0,0 +1,16 @@
module AuditInstrumentation
extend ActiveSupport::Concern
included do
def notify(listener, user, slug)
Audit::Notification.notify(listener) do |payload|
payload.user_id = user.id
payload.roles = user.roles.pluck(:name)
payload.slug = slug
if block_given?
payload.data = yield
end
end
end
end
end

View file

@ -1,4 +1,5 @@
class Internal::TagsController < Internal::ApplicationController
include AuditInstrumentation
layout "internal"
def index
@ -23,6 +24,8 @@ class Internal::TagsController < Internal::ApplicationController
add_moderator if @add_user_id
remove_moderator if @remove_user_id
@tag.update!(tag_params)
notify(:internal, current_user, __method__) { tag_params.dup }
redirect_to "/internal/tags/#{params[:id]}"
end

View file

@ -0,0 +1,21 @@
module Audit
class SaveToPersistentStorageJob < ApplicationJob
queue_as :audit_logs
def perform(event_string)
Audit::Event::Util.deserialize(event_string).
then { |event| build_params(event) }.
then { |params| AuditLog.create!(params) }
end
def build_params(event)
{
user_id: event.payload[:user_id],
roles: event.payload[:roles],
slug: event.payload[:slug],
category: event.name,
data: event.payload[:data]
}
end
end
end

5
app/models/audit_log.rb Normal file
View file

@ -0,0 +1,5 @@
class AuditLog < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
end

View file

@ -0,0 +1,22 @@
module Audit
module Event
class Payload
##
# Definition of Event payload.
#
# New instance object is used as block parameter in Audit::Notification.notify method.
attr_accessor :user_id, :roles, :slug, :data
##
# Use the initializer to define default values for the payload.
def initialize
@roles = []
@slug = :undefined
@data = {}
yield(self)
end
end
end
end

View file

@ -0,0 +1,53 @@
module Audit
module Event
class Util
class << self
##
# These class methods are only used to serialize the object send toward
# ActiveJob. Up until Rails 6, Rails cannot serialize Time objects,
# therefore we need custom serialization.
#
# Additional method is used as Warning, which helps to notify when it is
# save to remove this custom serialization for ActiveSupport::Notifications::Event object
# more at: https://edgeapi.rubyonrails.org/classes/ActiveJob/Serializers/ObjectSerializer.html
# and https://edgeapi.rubyonrails.org/classes/ActiveJob/SerializationError.html
# for supported class instances
def deserialize(string)
obsolete_usage_warn
transform_values(string).
then { |obj| ActiveSupport::Notifications::Event.new(*obj) }
end
def serialize(event)
obsolete_usage_warn
ActiveSupport::JSON.encode event
end
private
def obsolete_usage_warn
warn_message = <<-WARN.strip_heredoc
This Util class becomes obsolete from Rails 6.
Rails 6 adds out of the box, support for Active Job message serialization
for class like Time.
You can save delete this Util class and remove the usage from
Audit::Notification.listen method, or in any other places
WARN
Rails.logger.warn(warn_message) if Rails.version.match?(/\A6.\d.\w+/)
end
def transform_values(string)
ActiveSupport::JSON.decode(string).deep_symbolize_keys.tap do |h|
h[:time] = Time.zone.iso8601(h[:time])
h[:end] = Time.zone.iso8601(h[:end])
end.values_at(:name, :time, :end, :transaction_id, :payload)
end
end
end
end
end

View file

@ -0,0 +1,9 @@
module Audit
module Helper
NOTIFICATION_SUFFIX = ".audit.log".freeze
def instrument_name(name)
"#{name}#{NOTIFICATION_SUFFIX}"
end
end
end

View file

@ -0,0 +1,43 @@
module Audit
class Notification
##
# Main class for wrapping ActiveSupport Instrumentation API.
#
# This class represent main entry point for receiving and notifying custom
# events, implemented according to
# https://guides.rubyonrails.org/active_support_instrumentation.html#creating-custom-events
class << self
include Audit::Helper
##
# Audit::Notification.notify method, receives listener name, which is registered through
# Audit::Notification.listen and the event payload, passed as a block.
#
# Object of Audit::Event::Payload is send to the block as argument. This way,
# the payload object follows the rules defined in Audit::Event::Payload
#
# Example:
# Audit::Notification.notify('listener_name') do |payload|
# payload.user_id = current_user.id
# payload.roles = current_user.roles.pluck(:name)
# end
def notify(listener, &block)
return unless block_given?
ActiveSupport::Notifications.instrument(instrument_name(listener), Audit::Event::Payload.new(&block))
end
##
# Audit::Notification.listen receives Events sent from ActiveSupport Instrumentation API.
# Then, this event is serialized and send to background job.
def listen(*args)
ActiveSupport::Notifications::Event.new(*args).
then { |event| Audit::Event::Util.serialize(event) }.
then { |event_job| Audit::SaveToPersistentStorageJob.perform_later(event_job) }
end
end
end
end

View file

@ -0,0 +1,19 @@
module Audit
class Subscribe
class << self
include Audit::Helper
def listen(*listeners)
listeners.each do |listener|
ActiveSupport::Notifications.subscribe(instrument_name(listener)) do |*args|
Audit::Notification.listen(*args)
end
end
end
def forget(*listeners)
listeners.each { |listener| ActiveSupport::Notifications.unsubscribe(instrument_name(listener)) }
end
end
end
end

View file

@ -0,0 +1,9 @@
##
# Custom Audit Instrumentation
#
# Put here all custom listener names, for later usage in Audit Instrumentation
#
# Example:
# Audit::Subscribe.listen :admin, :quest_user
Audit::Subscribe.listen(:moderator, :internal) unless Rails.env.test?

View file

@ -0,0 +1,10 @@
class CreateAuditLogs < ActiveRecord::Migration[5.2]
def change
create_table :audit_logs do |t|
t.references :user, foreign_key: true
t.string :roles, array: true
t.timestamps
end
end
end

View file

@ -0,0 +1,6 @@
class AddAdditionalColumnsToAuditLog < ActiveRecord::Migration[5.2]
def change
add_column(:audit_logs, :slug, :string)
add_column(:audit_logs, :category, :string)
end
end

View file

@ -0,0 +1,6 @@
class AddJsonbDataColumnToAuditLogs < ActiveRecord::Migration[5.2]
def change
add_column :audit_logs, :data, :jsonb, null: false, default: {}
add_index :audit_logs, :data, using: :gin
end
end

View file

@ -145,6 +145,18 @@ ActiveRecord::Schema.define(version: 2019_09_18_104106) do
t.index ["user_id"], name: "index_articles_on_user_id"
end
create_table "audit_logs", force: :cascade do |t|
t.string "category"
t.datetime "created_at", null: false
t.jsonb "data", default: {}, null: false
t.string "roles", array: true
t.string "slug"
t.datetime "updated_at", null: false
t.bigint "user_id"
t.index ["data"], name: "index_audit_logs_on_data", using: :gin
t.index ["user_id"], name: "index_audit_logs_on_user_id"
end
create_table "backup_data", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "instance_id", null: false
@ -1178,6 +1190,7 @@ ActiveRecord::Schema.define(version: 2019_09_18_104106) do
t.index ["user_id"], name: "index_webhook_endpoints_on_user_id"
end
add_foreign_key "audit_logs", "users"
add_foreign_key "badge_achievements", "badges"
add_foreign_key "badge_achievements", "users"
add_foreign_key "chat_channel_memberships", "chat_channels"

View file

@ -0,0 +1,11 @@
FactoryBot.define do
factory :activesupport_event, class: ActiveSupport::Notifications::Event do
name { "audit.log" }
time { Timecop.freeze(Time.zone.now) }
ending { time + 10.seconds }
transaction_id { Faker::Crypto.md5 }
payload { {} }
initialize_with { new(name, time, ending, transaction_id, payload) }
end
end

View file

@ -0,0 +1,4 @@
FactoryBot.define do
factory :audit_log do
end
end

View file

@ -0,0 +1,9 @@
require "rails_helper"
RSpec.describe AuditLog, type: :model do
let(:user) { create(:user) }
let(:audit_log) { create(:audit_log, user_id: user.id) }
it { is_expected.to belong_to(:user) }
it { is_expected.to validate_presence_of(:user_id) }
end

View file

@ -0,0 +1,40 @@
require "rails_helper"
RSpec.describe "/internal/tags", type: :request do
let(:super_admin) { create(:user, :super_admin) }
let(:tag_moderator) { create(:user) }
let!(:tag) { create(:tag) }
let(:listener) { :internal }
before do
sign_in super_admin
Audit::Subscribe.listen listener
end
after do
Audit::Subscribe.forget listener
end
def update_params(tag_moderator_id)
{
tag: {
tag_moderator_id: tag_moderator_id
}
}
end
describe "POST /internal/tag/:id" do
it "creates entry for #update action" do
allow(AssignTagModerator).to receive(:add_tag_moderators)
perform_enqueued_jobs do
put "/internal/tags/#{tag.id}", params: update_params(tag_moderator.id.to_s)
log = AuditLog.where(user_id: super_admin.id, slug: :update)
expected = update_params(tag_moderator.id.to_s)[:tag]
expect(log.first.data.symbolize_keys).to eq expected
expect(log.count).to eq(1)
end
end
end
end

View file

@ -0,0 +1,16 @@
require "rails_helper"
RSpec.describe Audit::Event::Util, type: :service do
let(:utils) { described_class }
let!(:event) { build(:activesupport_event) }
describe "Serialization" do
it "evaluates to same object" do
compare_to = utils.deserialize(utils.serialize(event))
expect(event.class).to eq(compare_to.class)
expect(event.time.iso8601.in_time_zone).to eq(compare_to.time.iso8601)
expect(event.end.iso8601.in_time_zone).to eq(compare_to.end.iso8601)
end
end
end

View file

@ -0,0 +1,7 @@
require "rails_helper"
RSpec.describe Audit::Helper, type: :service do
it "has a notification suffix" do
expect(Audit::Helper::NOTIFICATION_SUFFIX).not_to be nil
end
end

View file

@ -0,0 +1,62 @@
require "rails_helper"
RSpec.describe Audit::Notification, type: :service do
let!(:listener) { Faker::Alphanumeric.alpha(10) }
let(:user) { build(:user, :admin) }
let(:queue_name) { Audit::SaveToPersistentStorageJob.queue_name }
let(:job_class) { Audit::SaveToPersistentStorageJob }
before do
Audit::Subscribe.listen listener
end
after do
Audit::Subscribe.forget listener
end
def notify
described_class.notify(listener) do |payload|
payload.user_id = user.id
payload.roles = user.roles.pluck(:name)
end
end
describe "Publishing and receiving events" do
context "when payload is missing" do
it "event is not created" do
allow(described_class).to receive(:listen)
described_class.notify(listener)
expect(described_class).not_to have_received(:listen)
end
end
context "when payload is present" do
it "receives an event" do
allow(described_class).to receive(:listen)
notify
expect(described_class).to have_received(:listen)
end
end
end
describe "Queueing job for saving an event" do
it "can enqueue job on dedicated queue name" do
expect { notify }.to have_enqueued_job(job_class).on_queue(queue_name.to_s)
end
end
describe "Saving to database" do
it "creates an AuditLog record" do
user.save
perform_enqueued_jobs do
notify
end
event_record = AuditLog.find_by(user_id: user.id)
expect(event_record.user).to eq(user)
expect(event_record.roles).to eq(user.roles.pluck(:name))
end
end
end

View file

@ -0,0 +1,21 @@
require "rails_helper"
RSpec.describe Audit::Subscribe, type: :service do
let(:notifications) { ActiveSupport::Notifications }
let(:listeners) { %i[moderator visitor] }
let(:listener_suffix) { Audit::Helper::NOTIFICATION_SUFFIX }
before do
listeners.each do |listener|
allow(notifications).to receive(:subscribe).with([listener, listener_suffix].join)
end
end
it "can subscribe to custom listeners" do
described_class.listen(*listeners)
listeners.each do |listener|
expect(notifications).to have_received(:subscribe).with([listener, listener_suffix].join)
end
end
end