Implement Moderators audit logs (#3449)
This commit is contained in:
parent
d42f8e7cc0
commit
efbb108061
22 changed files with 405 additions and 0 deletions
16
app/controllers/concerns/audit_instrumentation.rb
Normal file
16
app/controllers/concerns/audit_instrumentation.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
21
app/jobs/audit/save_to_persistent_storage_job.rb
Normal file
21
app/jobs/audit/save_to_persistent_storage_job.rb
Normal 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
5
app/models/audit_log.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class AuditLog < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
validates :user_id, presence: true
|
||||
end
|
||||
22
app/services/audit/event/payload.rb
Normal file
22
app/services/audit/event/payload.rb
Normal 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
|
||||
53
app/services/audit/event/util.rb
Normal file
53
app/services/audit/event/util.rb
Normal 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
|
||||
9
app/services/audit/helper.rb
Normal file
9
app/services/audit/helper.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module Audit
|
||||
module Helper
|
||||
NOTIFICATION_SUFFIX = ".audit.log".freeze
|
||||
|
||||
def instrument_name(name)
|
||||
"#{name}#{NOTIFICATION_SUFFIX}"
|
||||
end
|
||||
end
|
||||
end
|
||||
43
app/services/audit/notification.rb
Normal file
43
app/services/audit/notification.rb
Normal 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
|
||||
19
app/services/audit/subscribe.rb
Normal file
19
app/services/audit/subscribe.rb
Normal 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
|
||||
9
config/initializers/audit_events.rb
Normal file
9
config/initializers/audit_events.rb
Normal 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?
|
||||
10
db/migrate/20190708105607_create_audit_logs.rb
Normal file
10
db/migrate/20190708105607_create_audit_logs.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
13
db/schema.rb
13
db/schema.rb
|
|
@ -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"
|
||||
|
|
|
|||
11
spec/factories/activesupport_events.rb
Normal file
11
spec/factories/activesupport_events.rb
Normal 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
|
||||
4
spec/factories/audit_logs.rb
Normal file
4
spec/factories/audit_logs.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FactoryBot.define do
|
||||
factory :audit_log do
|
||||
end
|
||||
end
|
||||
9
spec/models/audit_log_spec.rb
Normal file
9
spec/models/audit_log_spec.rb
Normal 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
|
||||
40
spec/requests/internal/moderator_logs_spec.rb
Normal file
40
spec/requests/internal/moderator_logs_spec.rb
Normal 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
|
||||
16
spec/services/audit/event/util_spec.rb
Normal file
16
spec/services/audit/event/util_spec.rb
Normal 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
|
||||
7
spec/services/audit/helper_spec.rb
Normal file
7
spec/services/audit/helper_spec.rb
Normal 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
|
||||
62
spec/services/audit/notification_spec.rb
Normal file
62
spec/services/audit/notification_spec.rb
Normal 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
|
||||
21
spec/services/audit/subscribe_spec.rb
Normal file
21
spec/services/audit/subscribe_spec.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue