[deploy] Moderator and admin actions log (#6797)

* Add audit logging for negative reactions

This change saves requests that create negative reactions to the audit
log. We need to be monitoring admin and moderator actions throughout the
app to increase visiblity on how our tools are being used and identify
abuse.

Additionally, this will help east concerns about giving elevated users
more powerful tools.

* Add a basic UI for moderator audit logs

* Log experience level ratings

* Log tag adjustments

* UI tweaks for moderator actions log

* Add logging to internal reactions controller

* Add logging for admin actions on users and articles

* Add searching to the moderator actions table

* Move audit instrumentation out of concern

We are able to use a PORO here instead of using a Rails concern. Moving
this also makes syntax to audit something quite a bit more explicit and
obvious to future developers

* Change moderator logs pagination page length

* Move auditing to after action filters

* Add moderator actions to the internal navbar

* Add request spec for internal moderator actions

* Use request params to populate AuditLog slug
This commit is contained in:
Jacob Herrington 2020-03-26 16:59:24 -05:00 committed by GitHub
parent 7dd5a2eabf
commit 38d56542eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 233 additions and 82 deletions

View file

@ -1,16 +0,0 @@
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,6 +1,10 @@
class Internal::ArticlesController < Internal::ApplicationController
layout "internal"
after_action only: [:update] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def index
@pending_buffer_updates = BufferUpdate.where(status: "pending").includes(:article)

View file

@ -0,0 +1,12 @@
class Internal::ModeratorActionsController < Internal::ApplicationController
layout "internal"
def index
@q = AuditLog.
includes(:user).
where(category: "moderator.audit.log").
order(created_at: :desc).
ransack(params[:q])
@moderator_actions = @q.result.page(params[:page] || 1).per(25)
end
end

View file

@ -1,4 +1,8 @@
class Internal::ReactionsController < Internal::ApplicationController
after_action only: [:update] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def update
@reaction = Reaction.find(params[:id])
@reaction.update(status: params[:reaction][:status])

View file

@ -1,7 +1,10 @@
class Internal::TagsController < Internal::ApplicationController
include AuditInstrumentation
layout "internal"
after_action only: [:update] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def index
@tags = if params[:state] == "supported"
Tag.where(supported: true).order("taggings_count DESC").page(params[:page]).per(50)
@ -25,7 +28,6 @@ class Internal::TagsController < Internal::ApplicationController
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

@ -1,6 +1,10 @@
class Internal::UsersController < Internal::ApplicationController
layout "internal"
after_action only: %i[update user_status banish full_delete merge] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def index
@users = case params[:state]
when /role\-/
@ -68,6 +72,7 @@ class Internal::UsersController < Internal::ApplicationController
rescue StandardError => e
flash[:danger] = e.message
end
redirect_to "/internal/users/#{@user.id}/edit"
end

View file

@ -1,5 +1,8 @@
class RatingVotesController < ApplicationController
after_action :verify_authorized
after_action only: [:create] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def create
authorize RatingVote

View file

@ -49,7 +49,9 @@ class ReactionsController < ApplicationController
def create
authorize Reaction
Rails.cache.delete "count_for_reactable-#{params[:reactable_type]}-#{params[:reactable_id]}"
category = params[:category] || "like"
reaction = Reaction.where(
user_id: current_user.id,
@ -57,40 +59,38 @@ class ReactionsController < ApplicationController
reactable_type: params[:reactable_type],
category: category,
).first
result = ""
if reaction
current_user.touch
reaction.destroy
Moderator::SinkArticles.call(reaction.reactable_id) if vomit_reaction_on_user?(reaction)
Notification.send_reaction_notification_without_delay(reaction, reaction_user(reaction))
Notification.send_reaction_notification_without_delay(reaction, reaction.reactable.organization) if organization_article?(reaction)
result = "destroy"
else
create_params = {
user_id: current_user.id,
reactable_id: params[:reactable_id],
reactable_type: params[:reactable_type],
category: category
}
create_params[:status] = "confirmed" if current_user&.any_admin?
reaction = Reaction.new(create_params)
unless reaction.save
# if the reaction already exists, destroy it
if reaction
result = destroy_reaction(reaction)
if reaction.negative? && current_user.auditable?
updated_params = params.dup
updated_params[:action] = "destroy"
Audit::Logger.log(:moderator, current_user, updated_params)
end
else
reaction = build_reaction(category)
if reaction.save
Moderator::SinkArticles.call(reaction.reactable_id) if reaction.vomit_on_user?
Notification.send_reaction_notification(reaction, reaction.target_user)
Notification.send_reaction_notification(reaction, reaction.reactable.organization) if reaction.reaction_on_organization_article?
result = "create"
if category == "readinglist" && current_user.experience_level
rate_article(reaction)
end
if reaction.negative? && current_user.auditable?
Audit::Logger.log(:moderator, current_user, params.dup)
end
else
render json: { error: reaction.errors.full_messages.join(", "), status: 422 }, status: :unprocessable_entity
return
end
result = "create"
Moderator::SinkArticles.call(reaction.reactable_id) if vomit_reaction_on_user?(reaction)
Notification.send_reaction_notification(reaction, reaction_user(reaction))
Notification.send_reaction_notification(reaction, reaction.reactable.organization) if organization_article?(reaction)
if category == "readinglist" && current_user.experience_level
RatingVote.create(article_id: reaction.reactable_id,
group: "experience_level",
user_id: current_user.id,
context: "readinglist_reaction",
rating: current_user.experience_level)
end
end
render json: { result: result, category: category }
end
@ -103,19 +103,31 @@ class ReactionsController < ApplicationController
private
def reaction_user(reaction)
if reaction.reactable_type == "User"
reaction.reactable
else
reaction.reactable.user
end
def build_reaction(category)
create_params = {
user_id: current_user.id,
reactable_id: params[:reactable_id],
reactable_type: params[:reactable_type],
category: category
}
create_params[:status] = "confirmed" if current_user&.any_admin?
Reaction.new(create_params)
end
def organization_article?(reaction)
reaction.reactable_type == "Article" && reaction.reactable.organization.present?
def destroy_reaction(reaction)
current_user.touch
reaction.destroy
Moderator::SinkArticles.call(reaction.reactable_id) if reaction.vomit_on_user?
Notification.send_reaction_notification_without_delay(reaction, reaction.target_user)
Notification.send_reaction_notification_without_delay(reaction, reaction.reactable.organization) if reaction.reaction_on_organization_article?
"destroy"
end
def vomit_reaction_on_user?(reaction)
reaction.reactable_type == "User" && reaction.category == "vomit"
def rate_article(reaction)
RatingVote.create(article_id: reaction.reactable_id,
group: "experience_level",
user_id: current_user.id,
context: "readinglist_reaction",
rating: current_user.experience_level)
end
end

View file

@ -1,4 +1,8 @@
class TagAdjustmentsController < ApplicationController
after_action only: %i[create destroy] do
Audit::Logger.log(:moderator, current_user, params.dup)
end
def create
authorize(User, :moderation_routes?)
service = TagAdjustmentCreationService.new(

View file

@ -0,0 +1,7 @@
class ModeratorAction < ApplicationRecord
resourcify
# This class exists to take advantage of Rolify for limiting authorization
# on internal reports.
# NOTE: It is not backed by a database table and should not be expected to
# function like a traditional Rails model
end

View file

@ -79,6 +79,26 @@ class Reaction < ApplicationRecord
(receiver.is_a?(User) && reactable.receive_notifications == false)
end
def vomit_on_user?
reactable_type == "User" && category == "vomit"
end
def reaction_on_organization_article?
reactable_type == "Article" && reactable.organization.present?
end
def target_user
if reactable_type == "User"
reactable
else
reactable.user
end
end
def negative?
category == "vomit" || category == "thumbsdown"
end
private
def touch_user
@ -180,10 +200,6 @@ class Reaction < ApplicationRecord
remove_from_index!
end
def negative?
category == "vomit" || category == "thumbsdown"
end
def record_field_test_event
Users::RecordFieldTestEventWorker.perform_async(user_id, :user_home_feed, "user_creates_reaction")
end

View file

@ -478,6 +478,10 @@ class User < ApplicationRecord
MailchimpBot.new(self).unsubscribe_all_newsletters
end
def auditable?
trusted || tag_moderator? || any_admin?
end
def tag_moderator?
roles.where(name: "tag_moderator").any?
end

View file

@ -0,0 +1,22 @@
module Audit
class Logger
REDACTED_KEYS = %w[authenticity_token utf8 commit].freeze
def self.log(listener, user, data = nil)
Audit::Notification.notify(listener) do |payload|
payload.user_id = user.id
payload.roles = user.roles.pluck(:name)
payload.data = if block_given?
yield
else
filter_redacted_keys(data)
end
payload.slug = data["action"]
end
end
def self.filter_redacted_keys(data)
data.except(*REDACTED_KEYS)
end
end
end

View file

@ -0,0 +1,42 @@
<div class="row m-3">
<div class="col">
</div>
<div class="col">
<%= search_form_for @q, url: internal_moderator_actions_path, class: "form-inline justify-content-end" do |f| %>
<%= f.label :user_username_cont, "User", class: "sr-only" %>
<%= f.search_field :user_username_cont, placeholder: "User", class: "form-control mx-3" %>
<%= f.submit "Search", class: "btn btn-secondary" %>
<% end %>
</div>
</div>
<%= paginate @moderator_actions %>
<table class="table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">User</th>
<th scope="col">Action</th>
<th scope="col">Data</th>
<th scope="col">
<%= content_tag :span, sort_link(@q, :created_at, "Date"), class: "mx-2" %>
</th>
</tr>
</thead>
<tbody>
<% @moderator_actions.each do |action| %>
<tr>
<td><%= action.id %></td>
<td><%= link_to action.user.username, internal_user_path(action.user_id) %></td>
<td><%= "#{action.data['action'].humanize} #{action.data['controller'].humanize.singularize.titleize}" if action.data.present? %></td>
<td><%= action.data %></td>
<td><%= action.created_at %></td>
</tr>
<% end %>
</tbody>
</table>
<%= paginate @moderator_actions %>

View file

@ -1,4 +1,4 @@
<% menu_items = %w[comments articles users tags welcome broadcasts reports response_templates pages tools chat_channels permissions growth mods config events badges organizations sponsorships podcasts negative_reactions].sort.each_with_object({}) { |v, h| h[v] = v } %>
<% menu_items = %w[comments articles users tags welcome broadcasts reports response_templates pages tools chat_channels permissions growth mods config events badges organizations sponsorships podcasts negative_reactions moderator_actions].sort.each_with_object({}) { |v, h| h[v] = v } %>
<% menu_items[:listings] = "classified_listings" %>
<% menu_items[:webhooks] = "webhook_endpoints" %>

View file

@ -52,6 +52,7 @@ Rails.application.routes.draw do
resources :listings, only: %i[index edit update destroy], controller: "classified_listings"
resources :pages, only: %i[index new create edit update destroy]
resources :mods, only: %i[index update]
resources :moderator_actions, only: %i[index]
resources :negative_reactions, only: %i[index]
resources :permissions, only: %i[index]
resources :podcasts, only: %i[index edit update destroy] do

View file

@ -1,4 +1,7 @@
FactoryBot.define do
factory :audit_log do
category { Faker::Name.name }
slug { Faker::Creature::Animal }
data { { action: Faker::ProgrammingLanguage.name, controller: Faker::Lorem.word } }
end
end

View file

@ -0,0 +1,40 @@
require "rails_helper"
RSpec.describe "/internal/moderator_reactions", type: :request do
context "when the user is not an admin" do
let(:user) { create(:user) }
before do
sign_in user
end
it "blocks the request" do
expect do
get "/internal/moderator_actions"
end.to raise_error(Pundit::NotAuthorizedError)
end
end
context "when the user is an admin" do
let(:admin) { create(:user, :admin) }
let!(:audit_log) { create(:audit_log, category: "moderator.audit.log", user_id: admin.id, roles: admin.roles.pluck(:name)) }
before do
sign_in admin
end
it "does not block the request" do
expect do
get "/internal/moderator_actions"
end.not_to raise_error
end
describe "GETS /internal/moderator_actions" do
it "renders to appropriate page" do
get "/internal/moderator_actions"
expect(response.body).to include(admin.username)
expect(response.body).to include(audit_log.id.to_s)
end
end
end
end

View file

@ -4,7 +4,7 @@ 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 }
let(:listener) { :moderator }
before do
sign_in super_admin
@ -15,26 +15,12 @@ RSpec.describe "/internal/tags", type: :request 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
describe "PUT /internal/tag/:id" do
it "creates entry for #update action" do
allow(AssignTagModerator).to receive(:add_tag_moderators)
put internal_tag_path(tag.id), params: { id: tag.id, tag: { short_summary: Faker::Hipster.sentence } }
sidekiq_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
log = AuditLog.where(user_id: super_admin.id, slug: :update)
expect(log.count).to eq(1)
end
end
end