Browse Source

Add logging of admin actions (#5757)

* Add logging of admin actions

* Update brakeman whitelist

* Log creates, updates and destroys with history of changes

* i18n: Update Polish translation (#5782)

Signed-off-by: Marcin Mikołajczak <me@m4sk.in>

* Split admin navigation into moderation and administration

* Redesign audit log page

* 🇵🇱 (#5795)

* Add color coding to audit log

* Change dismiss->resolve, log all outcomes of report as resolve

* Update terminology (e-mail blacklist) (#5796)

* Update terminology (e-mail blacklist)

imho looks better

* Update en.yml

* Fix code style issues

* i18n-tasks normalize
Eugen Rochko 6 years ago
parent
commit
e84fecb7e9
34 changed files with 490 additions and 43 deletions
  1. 1 1
      app/controllers/admin/account_moderation_notes_controller.rb
  2. 3 0
      app/controllers/admin/accounts_controller.rb
  3. 9 0
      app/controllers/admin/action_logs_controller.rb
  4. 1 0
      app/controllers/admin/base_controller.rb
  5. 1 0
      app/controllers/admin/confirmations_controller.rb
  6. 7 1
      app/controllers/admin/custom_emojis_controller.rb
  7. 2 0
      app/controllers/admin/domain_blocks_controller.rb
  8. 3 1
      app/controllers/admin/email_domain_blocks_controller.rb
  9. 4 2
      app/controllers/admin/reported_statuses_controller.rb
  10. 7 2
      app/controllers/admin/reports_controller.rb
  11. 1 0
      app/controllers/admin/resets_controller.rb
  12. 2 0
      app/controllers/admin/roles_controller.rb
  13. 4 2
      app/controllers/admin/silences_controller.rb
  14. 4 2
      app/controllers/admin/statuses_controller.rb
  15. 2 0
      app/controllers/admin/suspensions_controller.rb
  16. 1 0
      app/controllers/admin/two_factor_authentications_controller.rb
  17. 9 0
      app/controllers/concerns/accountable_concern.rb
  18. 103 0
      app/helpers/admin/action_logs_helper.rb
  19. 101 0
      app/javascript/styles/mastodon/admin.scss
  20. 7 0
      app/models/admin.rb
  21. 40 0
      app/models/admin/action_log.rb
  22. 7 1
      app/models/form/status_batch.rb
  23. 15 0
      app/views/admin/action_logs/_action_log.html.haml
  24. 7 0
      app/views/admin/action_logs/index.html.haml
  25. 37 18
      config/brakeman.ignore
  26. 1 0
      config/i18n-tasks.yml
  27. 36 8
      config/locales/en.yml
  28. 30 0
      config/locales/pl.yml
  29. 8 4
      config/navigation.rb
  30. 1 0
      config/routes.rb
  31. 12 0
      db/migrate/20171119172437_create_admin_action_logs.rb
  32. 14 1
      db/schema.rb
  33. 5 0
      spec/fabricators/admin_action_log_fabricator.rb
  34. 5 0
      spec/models/admin/action_log_spec.rb

+ 1 - 1
app/controllers/admin/account_moderation_notes_controller.rb

@@ -21,7 +21,7 @@ module Admin
 
     def destroy
       authorize @account_moderation_note, :destroy?
-      @account_moderation_note.destroy
+      @account_moderation_note.destroy!
       redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
     end
 

+ 3 - 0
app/controllers/admin/accounts_controller.rb

@@ -32,18 +32,21 @@ module Admin
     def memorialize
       authorize @account, :memorialize?
       @account.memorialize!
+      log_action :memorialize, @account
       redirect_to admin_account_path(@account.id)
     end
 
     def enable
       authorize @account.user, :enable?
       @account.user.enable!
+      log_action :enable, @account.user
       redirect_to admin_account_path(@account.id)
     end
 
     def disable
       authorize @account.user, :disable?
       @account.user.disable!
+      log_action :disable, @account.user
       redirect_to admin_account_path(@account.id)
     end
 

+ 9 - 0
app/controllers/admin/action_logs_controller.rb

@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Admin
+  class ActionLogsController < BaseController
+    def index
+      @action_logs = Admin::ActionLog.page(params[:page])
+    end
+  end
+end

+ 1 - 0
app/controllers/admin/base_controller.rb

@@ -3,6 +3,7 @@
 module Admin
   class BaseController < ApplicationController
     include Authorization
+    include AccountableConcern
 
     before_action :require_staff!
 

+ 1 - 0
app/controllers/admin/confirmations_controller.rb

@@ -7,6 +7,7 @@ module Admin
     def create
       authorize @user, :confirm?
       @user.confirm!
+      log_action :confirm, @user
       redirect_to admin_accounts_path
     end
 

+ 7 - 1
app/controllers/admin/custom_emojis_controller.rb

@@ -20,6 +20,7 @@ module Admin
       @custom_emoji = CustomEmoji.new(resource_params)
 
       if @custom_emoji.save
+        log_action :create, @custom_emoji
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
       else
         render :new
@@ -30,6 +31,7 @@ module Admin
       authorize @custom_emoji, :update?
 
       if @custom_emoji.update(resource_params)
+        log_action :update, @custom_emoji
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
       else
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
@@ -38,7 +40,8 @@ module Admin
 
     def destroy
       authorize @custom_emoji, :destroy?
-      @custom_emoji.destroy
+      @custom_emoji.destroy!
+      log_action :destroy, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
     end
 
@@ -49,6 +52,7 @@ module Admin
       emoji.image = @custom_emoji.image
 
       if emoji.save
+        log_action :create, emoji
         flash[:notice] = I18n.t('admin.custom_emojis.copied_msg')
       else
         flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
@@ -60,12 +64,14 @@ module Admin
     def enable
       authorize @custom_emoji, :enable?
       @custom_emoji.update!(disabled: false)
+      log_action :enable, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
     end
 
     def disable
       authorize @custom_emoji, :disable?
       @custom_emoji.update!(disabled: true)
+      log_action :disable, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
     end
 

+ 2 - 0
app/controllers/admin/domain_blocks_controller.rb

@@ -21,6 +21,7 @@ module Admin
 
       if @domain_block.save
         DomainBlockWorker.perform_async(@domain_block.id)
+        log_action :create, @domain_block
         redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.created_msg')
       else
         render :new
@@ -34,6 +35,7 @@ module Admin
     def destroy
       authorize @domain_block, :destroy?
       UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
+      log_action :destroy, @domain_block
       redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg')
     end
 

+ 3 - 1
app/controllers/admin/email_domain_blocks_controller.rb

@@ -20,6 +20,7 @@ module Admin
       @email_domain_block = EmailDomainBlock.new(resource_params)
 
       if @email_domain_block.save
+        log_action :create, @email_domain_block
         redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
       else
         render :new
@@ -28,7 +29,8 @@ module Admin
 
     def destroy
       authorize @email_domain_block, :destroy?
-      @email_domain_block.destroy
+      @email_domain_block.destroy!
+      log_action :destroy, @email_domain_block
       redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
     end
 

+ 4 - 2
app/controllers/admin/reported_statuses_controller.rb

@@ -8,7 +8,7 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params)
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_report_path(@report)
@@ -16,13 +16,15 @@ module Admin
 
     def update
       authorize @status, :update?
-      @status.update(status_params)
+      @status.update!(status_params)
+      log_action :update, @status
       redirect_to admin_report_path(@report)
     end
 
     def destroy
       authorize @status, :destroy?
       RemovalWorker.perform_async(@status.id)
+      log_action :destroy, @status
       render json: @status
     end
 

+ 7 - 2
app/controllers/admin/reports_controller.rb

@@ -25,12 +25,17 @@ module Admin
     def process_report
       case params[:outcome].to_s
       when 'resolve'
-        @report.update(action_taken_by_current_attributes)
+        @report.update!(action_taken_by_current_attributes)
+        log_action :resolve, @report
       when 'suspend'
         Admin::SuspensionWorker.perform_async(@report.target_account.id)
+        log_action :resolve, @report
+        log_action :suspend, @report.target_account
         resolve_all_target_account_reports
       when 'silence'
-        @report.target_account.update(silenced: true)
+        @report.target_account.update!(silenced: true)
+        log_action :resolve, @report
+        log_action :silence, @report.target_account
         resolve_all_target_account_reports
       else
         raise ActiveRecord::RecordNotFound

+ 1 - 0
app/controllers/admin/resets_controller.rb

@@ -7,6 +7,7 @@ module Admin
     def create
       authorize @user, :reset_password?
       @user.send_reset_password_instructions
+      log_action :reset_password, @user
       redirect_to admin_accounts_path
     end
 

+ 2 - 0
app/controllers/admin/roles_controller.rb

@@ -7,12 +7,14 @@ module Admin
     def promote
       authorize @user, :promote?
       @user.promote!
+      log_action :promote, @user
       redirect_to admin_account_path(@user.account_id)
     end
 
     def demote
       authorize @user, :demote?
       @user.demote!
+      log_action :demote, @user
       redirect_to admin_account_path(@user.account_id)
     end
 

+ 4 - 2
app/controllers/admin/silences_controller.rb

@@ -6,13 +6,15 @@ module Admin
 
     def create
       authorize @account, :silence?
-      @account.update(silenced: true)
+      @account.update!(silenced: true)
+      log_action :silence, @account
       redirect_to admin_accounts_path
     end
 
     def destroy
       authorize @account, :unsilence?
-      @account.update(silenced: false)
+      @account.update!(silenced: false)
+      log_action :unsilence, @account
       redirect_to admin_accounts_path
     end
 

+ 4 - 2
app/controllers/admin/statuses_controller.rb

@@ -26,7 +26,7 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params)
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_account_statuses_path(@account.id, current_params)
@@ -34,13 +34,15 @@ module Admin
 
     def update
       authorize @status, :update?
-      @status.update(status_params)
+      @status.update!(status_params)
+      log_action :update, @status
       redirect_to admin_account_statuses_path(@account.id, current_params)
     end
 
     def destroy
       authorize @status, :destroy?
       RemovalWorker.perform_async(@status.id)
+      log_action :destroy, @status
       render json: @status
     end
 

+ 2 - 0
app/controllers/admin/suspensions_controller.rb

@@ -7,12 +7,14 @@ module Admin
     def create
       authorize @account, :suspend?
       Admin::SuspensionWorker.perform_async(@account.id)
+      log_action :suspend, @account
       redirect_to admin_accounts_path
     end
 
     def destroy
       authorize @account, :unsuspend?
       @account.unsuspend!
+      log_action :unsuspend, @account
       redirect_to admin_accounts_path
     end
 

+ 1 - 0
app/controllers/admin/two_factor_authentications_controller.rb

@@ -7,6 +7,7 @@ module Admin
     def destroy
       authorize @user, :disable_2fa?
       @user.disable_two_factor!
+      log_action :disable_2fa, @user
       redirect_to admin_accounts_path
     end
 

+ 9 - 0
app/controllers/concerns/accountable_concern.rb

@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AccountableConcern
+  extend ActiveSupport::Concern
+
+  def log_action(action, target)
+    Admin::ActionLog.create(account: current_account, action: action, target: target)
+  end
+end

+ 103 - 0
app/helpers/admin/action_logs_helper.rb

@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Admin::ActionLogsHelper
+  def log_target(log)
+    if log.target
+      linkable_log_target(log.target)
+    else
+      log_target_from_history(log.target_type, log.recorded_changes)
+    end
+  end
+
+  def linkable_log_target(record)
+    case record.class.name
+    when 'Account'
+      link_to record.acct, admin_account_path(record.id)
+    when 'User'
+      link_to record.account.acct, admin_account_path(record.account_id)
+    when 'CustomEmoji'
+      record.shortcode
+    when 'Report'
+      link_to "##{record.id}", admin_report_path(record)
+    when 'DomainBlock', 'EmailDomainBlock'
+      link_to record.domain, "https://#{record.domain}"
+    when 'Status'
+      link_to record.account.acct, TagManager.instance.url_for(record)
+    end
+  end
+
+  def log_target_from_history(type, attributes)
+    case type
+    when 'CustomEmoji'
+      attributes['shortcode']
+    when 'DomainBlock', 'EmailDomainBlock'
+      link_to attributes['domain'], "https://#{attributes['domain']}"
+    when 'Status'
+      tmp_status = Status.new(attributes)
+      link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status)
+    end
+  end
+
+  def relevant_log_changes(log)
+    if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)
+      log.recorded_changes.slice('domain')
+    elsif log.target_type == 'CustomEmoji' && log.action == :update
+      log.recorded_changes.slice('domain', 'visible_in_picker')
+    elsif log.target_type == 'User' && [:promote, :demote].include?(log.action)
+      log.recorded_changes.slice('moderator', 'admin')
+    elsif log.target_type == 'DomainBlock'
+      log.recorded_changes.slice('severity', 'reject_media')
+    elsif log.target_type == 'Status' && log.action == :update
+      log.recorded_changes.slice('sensitive')
+    end
+  end
+
+  def log_extra_attributes(hash)
+    safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ')
+  end
+
+  def log_change(val)
+    return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array)
+    safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→')
+  end
+
+  def icon_for_log(log)
+    case log.target_type
+    when 'Account', 'User'
+      'user'
+    when 'CustomEmoji'
+      'file'
+    when 'Report'
+      'flag'
+    when 'DomainBlock'
+      'lock'
+    when 'EmailDomainBlock'
+      'envelope'
+    when 'Status'
+      'pencil'
+    end
+  end
+
+  def class_for_log_icon(log)
+    case log.action
+    when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve
+      'positive'
+    when :create
+      opposite_verbs?(log) ? 'negative' : 'positive'
+    when :update, :reset_password, :disable_2fa, :memorialize
+      'neutral'
+    when :demote, :silence, :disable, :suspend
+      'negative'
+    when :destroy
+      opposite_verbs?(log) ? 'positive' : 'negative'
+    else
+      ''
+    end
+  end
+
+  private
+
+  def opposite_verbs?(log)
+    %w(DomainBlock EmailDomainBlock).include?(log.target_type)
+  end
+end

+ 101 - 0
app/javascript/styles/mastodon/admin.scss

@@ -347,3 +347,104 @@
     }
   }
 }
+
+.spacer {
+  flex: 1 1 auto;
+}
+
+.log-entry {
+  margin-bottom: 8px;
+  line-height: 20px;
+
+  &__header {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    padding: 10px;
+    background: $ui-base-color;
+    color: $ui-primary-color;
+    border-radius: 4px 4px 0 0;
+    font-size: 14px;
+    position: relative;
+  }
+
+  &__avatar {
+    margin-right: 10px;
+
+    .avatar {
+      display: block;
+      margin: 0;
+      border-radius: 50%;
+      width: 40px;
+      height: 40px;
+    }
+  }
+
+  &__title {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__timestamp {
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__extras {
+    background: lighten($ui-base-color, 6%);
+    border-radius: 0 0 4px 4px;
+    padding: 10px;
+    color: $ui-primary-color;
+    font-family: 'mastodon-font-monospace', monospace;
+    font-size: 12px;
+    white-space: nowrap;
+    min-height: 20px;
+  }
+
+  &__icon {
+    font-size: 28px;
+    margin-right: 10px;
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__icon__overlay {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+
+    &.positive {
+      background: $success-green;
+    }
+
+    &.negative {
+      background: $error-red;
+    }
+
+    &.neutral {
+      background: $ui-highlight-color;
+    }
+  }
+
+  a,
+  .username,
+  .target {
+    color: $ui-secondary-color;
+    text-decoration: none;
+    font-weight: 500;
+  }
+
+  .diff-old {
+    color: $error-red;
+  }
+
+  .diff-neutral {
+    color: $ui-secondary-color;
+  }
+
+  .diff-new {
+    color: $success-green;
+  }
+}

+ 7 - 0
app/models/admin.rb

@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Admin
+  def self.table_name_prefix
+    'admin_'
+  end
+end

+ 40 - 0
app/models/admin/action_log.rb

@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: admin_action_logs
+#
+#  id               :integer          not null, primary key
+#  account_id       :integer
+#  action           :string           default(""), not null
+#  target_type      :string
+#  target_id        :integer
+#  recorded_changes :text             default(""), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class Admin::ActionLog < ApplicationRecord
+  serialize :recorded_changes
+
+  belongs_to :account, required: true
+  belongs_to :target, required: true, polymorphic: true
+
+  default_scope -> { order('id desc') }
+
+  def action
+    super.to_sym
+  end
+
+  before_validation :set_changes
+
+  private
+
+  def set_changes
+    case action
+    when :destroy, :create
+      self.recorded_changes = target.attributes
+    when :update, :promote, :demote
+      self.recorded_changes = target.previous_changes
+    end
+  end
+end

+ 7 - 1
app/models/form/status_batch.rb

@@ -2,8 +2,9 @@
 
 class Form::StatusBatch
   include ActiveModel::Model
+  include AccountableConcern
 
-  attr_accessor :status_ids, :action
+  attr_accessor :status_ids, :action, :current_account
 
   ACTION_TYPE = %w(nsfw_on nsfw_off delete).freeze
 
@@ -20,11 +21,14 @@ class Form::StatusBatch
 
   def change_sensitive(sensitive)
     media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
+
     ApplicationRecord.transaction do
       Status.where(id: media_attached_status_ids).find_each do |status|
         status.update!(sensitive: sensitive)
+        log_action :update, status
       end
     end
+
     true
   rescue ActiveRecord::RecordInvalid
     false
@@ -33,7 +37,9 @@ class Form::StatusBatch
   def delete_statuses
     Status.where(id: status_ids).find_each do |status|
       RemovalWorker.perform_async(status.id)
+      log_action :destroy, status
     end
+
     true
   end
 end

+ 15 - 0
app/views/admin/action_logs/_action_log.html.haml

@@ -0,0 +1,15 @@
+%li.log-entry
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag action_log.account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
+      .log-entry__timestamp
+        %time= l action_log.created_at
+    .spacer
+    .log-entry__icon
+      = fa_icon icon_for_log(action_log)
+      .log-entry__icon__overlay{ class: class_for_log_icon(action_log) }
+  .log-entry__extras
+    = log_extra_attributes relevant_log_changes(action_log)

+ 7 - 0
app/views/admin/action_logs/index.html.haml

@@ -0,0 +1,7 @@
+- content_for :page_title do
+  = t('admin.action_logs.title')
+
+%ul
+  = render @action_logs
+
+= paginate @action_logs

+ 37 - 18
config/brakeman.ignore

@@ -7,10 +7,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 122,
+      "line": 143,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).inbox_url, Account.find(params[:id]).inbox_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -26,10 +26,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 128,
+      "line": 149,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).shared_inbox_url, Account.find(params[:id]).shared_inbox_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -45,10 +45,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 35,
+      "line": 54,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).url, Account.find(params[:id]).url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -76,6 +76,25 @@
       "confidence": "Weak",
       "note": ""
     },
+    {
+      "warning_type": "Dynamic Render Path",
+      "warning_code": 15,
+      "fingerprint": "4b6a895e2805578d03ceedbe1d469cc75a0c759eba093722523edb4b8683c873",
+      "check_name": "Render",
+      "message": "Render path contains parameter value",
+      "file": "app/views/admin/action_logs/index.html.haml",
+      "line": 5,
+      "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+      "code": "render(action => Admin::ActionLog.page(params[:page]), {})",
+      "render_path": [{"type":"controller","class":"Admin::ActionLogsController","method":"index","line":7,"file":"app/controllers/admin/action_logs_controller.rb"}],
+      "location": {
+        "type": "template",
+        "template": "admin/action_logs/index"
+      },
+      "user_input": "params[:page]",
+      "confidence": "Weak",
+      "note": ""
+    },
     {
       "warning_type": "Cross-Site Scripting",
       "warning_code": 4,
@@ -83,10 +102,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 131,
+      "line": 152,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).followers_url, Account.find(params[:id]).followers_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -102,10 +121,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 106,
+      "line": 127,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).salmon_url, Account.find(params[:id]).salmon_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -124,7 +143,7 @@
       "line": 31,
       "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
       "code": "render(action => filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]), {})",
-      "render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":9,"file":"app/controllers/admin/custom_emojis_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":10,"file":"app/controllers/admin/custom_emojis_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/custom_emojis/index"
@@ -163,7 +182,7 @@
       "line": 64,
       "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
       "code": "render(action => filtered_accounts.page(params[:page]), {})",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":10,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":12,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/index"
@@ -179,10 +198,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 95,
+      "line": 116,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).remote_url, Account.find(params[:id]).remote_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -221,7 +240,7 @@
       "line": 25,
       "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
       "code": "render(action => filtered_reports.page(params[:page]), {})",
-      "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":9,"file":"app/controllers/admin/reports_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":10,"file":"app/controllers/admin/reports_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/reports/index"
@@ -237,10 +256,10 @@
       "check_name": "LinkToHref",
       "message": "Potentially unsafe model attribute in link_to href",
       "file": "app/views/admin/accounts/show.html.haml",
-      "line": 125,
+      "line": 146,
       "link": "http://brakemanscanner.org/docs/warning_types/link_to_href",
       "code": "link_to(Account.find(params[:id]).outbox_url, Account.find(params[:id]).outbox_url)",
-      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}],
+      "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}],
       "location": {
         "type": "template",
         "template": "admin/accounts/show"
@@ -269,6 +288,6 @@
       "note": ""
     }
   ],
-  "updated": "2017-10-20 00:00:54 +0900",
+  "updated": "2017-11-19 20:34:18 +0100",
   "brakeman_version": "4.0.1"
 }

+ 1 - 0
config/i18n-tasks.yml

@@ -60,3 +60,4 @@ ignore_unused:
   - 'activerecord.errors.models.doorkeeper/*'
   - 'errors.429'
   - 'admin.accounts.roles.*'
+  - 'admin.action_logs.actions.*'

+ 36 - 8
config/locales/en.yml

@@ -133,6 +133,32 @@ en:
       unsubscribe: Unsubscribe
       username: Username
       web: Web
+    action_logs:
+      actions:
+        confirm_user: "%{name} confirmed e-mail address of user %{target}"
+        create_custom_emoji: "%{name} uploaded new emoji %{target}"
+        create_domain_block: "%{name} blocked domain %{target}"
+        create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
+        demote_user: "%{name} demoted user %{target}"
+        destroy_domain_block: "%{name} unblocked domain %{target}"
+        destroy_email_domain_block: "%{name} whitelisted e-mail domain %{target}"
+        destroy_status: "%{name} removed status by %{target}"
+        disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
+        disable_custom_emoji: "%{name} disabled emoji %{target}"
+        disable_user: "%{name} disabled login for user %{target}"
+        enable_custom_emoji: "%{name} enabled emoji %{target}"
+        enable_user: "%{name} enabled login for user %{target}"
+        memorialize_account: "%{name} turned %{target}'s account into a memoriam page"
+        promote_user: "%{name} promoted user %{target}"
+        reset_password_user: "%{name} reset password of user %{target}"
+        resolve_report: "%{name} dismissed report %{target}"
+        silence_account: "%{name} silenced %{target}'s account"
+        suspend_account: "%{name} suspended %{target}'s account"
+        unsilence_account: "%{name} unsilenced %{target}'s account"
+        unsuspend_account: "%{name} unsuspended %{target}'s account"
+        update_custom_emoji: "%{name} updated emoji %{target}"
+        update_status: "%{name} updated status by %{target}"
+      title: Audit log
     custom_emojis:
       copied_msg: Successfully created local copy of the emoji
       copy: Copy
@@ -187,24 +213,24 @@ en:
           suspend: Unsuspend all existing accounts from this domain
         title: Undo domain block for %{domain}
         undo: Undo
-      title: Domain Blocks
+      title: Domain blocks
       undo: Undo
     email_domain_blocks:
       add_new: Add new
-      created_msg: Email domain block successfully created
+      created_msg: Successfully added e-mail domain to blacklist
       delete: Delete
-      destroyed_msg: Email domain block successfully deleted
+      destroyed_msg: Successfully deleted e-mail domain from blacklist
       domain: Domain
       new:
-        create: Create block
-        title: New email domain block
-      title: Email Domain Block
+        create: Add domain
+        title: New e-mail blacklist entry
+      title: E-mail blacklist
     instances:
       account_count: Known accounts
       domain_name: Domain
       reset: Reset
       search: Search
-      title: Known Instances
+      title: Known instances
     reports:
       action_taken_by: Action taken by
       are_you_sure: Are you sure?
@@ -265,7 +291,7 @@ en:
       timeline_preview:
         desc_html: Display public timeline on landing page
         title: Timeline preview
-      title: Site Settings
+      title: Site settings
     statuses:
       back_to_account: Back to account page
       batch:
@@ -404,6 +430,8 @@ en:
     validations:
       images_and_video: Cannot attach a video to a status that already contains images
       too_many: Cannot attach more than 4 files
+  moderation:
+    title: Moderation
   notification_mailer:
     digest:
       body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'

+ 30 - 0
config/locales/pl.yml

@@ -49,6 +49,7 @@ pl:
     reserved_username: Ta nazwa użytkownika jest zarezerwowana.
     roles:
       admin: Administrator
+      moderator: Moderator
     unfollow: Przestań śledzić
   admin:
     account_moderation_notes:
@@ -132,6 +133,32 @@ pl:
       unsubscribe: Przestań subskrybować
       username: Nazwa użytkownika
       web: Sieć
+    action_logs:
+      actions:
+        confirm_user: "%{name} potwierdził adres e-mail użytkownika %{target}"
+        create_custom_emoji: "%{name} dodał nowe emoji %{target}"
+        create_domain_block: "%{name} zablokował domenę %{target}"
+        create_email_domain_block: "%{name} dodał domenę e-mail %{target} na czarną listę"
+        demote_user: "%{name} zdegradował użytkownika %{target}"
+        destroy_domain_block: "%{name} odblokował domenę %{target}"
+        destroy_email_domain_block: "%{name} usunął domenę e-mail %{target} z czarnej listy"
+        destroy_status: "%{name} usunął wpis użytkownika %{target}"
+        disable_2fa_user: "%{name} wyłączył uwierzytelnianie dwustopniowe użytkownikowi %{target}"
+        disable_custom_emoji: "%{name} wyłączył emoji %{target}"
+        disable_user: "%{name} zablokował możliwość logowania użytkownikowi %{target}"
+        enable_custom_emoji: "%{name} włączył emoji %{target}"
+        enable_user: "%{name} przywrócił możliwość logowania użytkownikowi %{target}"
+        memorialize_account: "%{name} nadał kontu %{target} status in memoriam"
+        promote_user: "%{name} podniósł uprawnienia użytkownikowi %{target}"
+        reset_password_user: "%{name} przywrócił hasło użytkownikowi %{target}"
+        resolve_report: "%{name} odrzucił zgłoszenie %{target}"
+        silence_account: "%{name} wyciszył konto %{target}"
+        suspend_account: "%{name} zawiesił konto %{target}"
+        unsilence_account: "%{name} cofnął wyciszenie konta %{target}"
+        unsuspend_account: "%{name} cofnął zawieszenie konta %{target}"
+        update_custom_emoji: "%{name} zaktualizował emoji %{target}"
+        update_status: "%{name} zaktualizował wpis użytkownika %{target}"
+      title: Dziennik działań administracyjnych
     custom_emojis:
       copied_msg: Pomyślnie utworzono lokalną kopię emoji
       copy: Kopiuj
@@ -148,6 +175,7 @@ pl:
       listed: Widoczne
       new:
         title: Dodaj nowe niestandardowe emoji
+      overwrite: Zastąp
       shortcode: Shortcode
       shortcode_hint: Co najmniej 2 znaki, tylko znaki alfanumeryczne i podkreślniki
       title: Niestandardowe emoji
@@ -403,6 +431,8 @@ pl:
     validations:
       images_and_video: Nie możesz załączyć pliku wideo do wpisu, który zawiera już zdjęcia
       too_many: Nie możesz załączyć więcej niż 4 plików
+  moderation:
+    title: Moderacja
   notification_mailer:
     digest:
       body: 'Oto krótkie podsumowanie co Cię ominęło na %{instance} od Twojej ostatniej wizyty (%{since}):'

+ 8 - 4
config/navigation.rb

@@ -20,17 +20,21 @@ SimpleNavigation::Configuration.run do |navigation|
       development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications}
     end
 
-    primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.staff? } do |admin|
+    primary.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |admin|
+      admin.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
       admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
       admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts}
       admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? }
-      admin.item :subscriptions, safe_join([fa_icon('paper-plane-o fw'), t('admin.subscriptions.title')]), admin_subscriptions_url, if: -> { current_user.admin? }
       admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? }
       admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
-      admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
-      admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
+    end
+
+    primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), edit_admin_settings_url, if: proc { current_user.staff? } do |admin|
       admin.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }
       admin.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
+      admin.item :subscriptions, safe_join([fa_icon('paper-plane-o fw'), t('admin.subscriptions.title')]), admin_subscriptions_url, if: -> { current_user.admin? }
+      admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
+      admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
     end
 
     primary.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_url, link_html: { 'data-method' => 'delete' }

+ 1 - 0
config/routes.rb

@@ -110,6 +110,7 @@ Rails.application.routes.draw do
     resources :subscriptions, only: [:index]
     resources :domain_blocks, only: [:index, :new, :create, :show, :destroy]
     resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
+    resources :action_logs, only: [:index]
     resource :settings, only: [:edit, :update]
 
     resources :instances, only: [:index] do

+ 12 - 0
db/migrate/20171119172437_create_admin_action_logs.rb

@@ -0,0 +1,12 @@
+class CreateAdminActionLogs < ActiveRecord::Migration[5.1]
+  def change
+    create_table :admin_action_logs do |t|
+      t.belongs_to :account, foreign_key: { on_delete: :cascade }
+      t.string :action, null: false, default: ''
+      t.references :target, polymorphic: true
+      t.text :recorded_changes, null: false, default: ''
+
+      t.timestamps
+    end
+  end
+end

+ 14 - 1
db/schema.rb

@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20171118012443) do
+ActiveRecord::Schema.define(version: 20171119172437) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -80,6 +80,18 @@ ActiveRecord::Schema.define(version: 20171118012443) do
     t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true
   end
 
+  create_table "admin_action_logs", force: :cascade do |t|
+    t.bigint "account_id"
+    t.string "action", default: "", null: false
+    t.string "target_type"
+    t.bigint "target_id"
+    t.text "recorded_changes", default: "", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_admin_action_logs_on_account_id"
+    t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
+  end
+
   create_table "blocks", force: :cascade do |t|
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
@@ -488,6 +500,7 @@ ActiveRecord::Schema.define(version: 20171118012443) do
   add_foreign_key "account_moderation_notes", "accounts"
   add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
   add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
+  add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
   add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
   add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
   add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade

+ 5 - 0
spec/fabricators/admin_action_log_fabricator.rb

@@ -0,0 +1,5 @@
+Fabricator('Admin::ActionLog') do
+  account nil
+  action  "MyString"
+  target  nil
+end

+ 5 - 0
spec/models/admin/action_log_spec.rb

@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Admin::ActionLog, type: :model do
+
+end