about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-11-11 20:23:33 +0100
committerGitHub <noreply@github.com>2017-11-11 20:23:33 +0100
commit7bb8b0b2fc0e2e42a4234fed18198cbb7439fe9f (patch)
treef629ef40a5e7253c3d7bf353959951c93e5aad6f /app
parent2b1190065c54f071ae384f6e05b4a087987febba (diff)
Add moderator role and add pundit policies for admin actions (#5635)
* Add moderator role and add pundit policies for admin actions

* Add rake task for turning user into mod and revoking it again

* Fix handling of unauthorized exception

* Deliver new report e-mails to staff, not just admins

* Add promote/demote to admin UI, hide some actions conditionally

* Fix unused i18n
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb56
-rw-r--r--app/controllers/admin/accounts_controller.rb9
-rw-r--r--app/controllers/admin/base_controller.rb4
-rw-r--r--app/controllers/admin/confirmations_controller.rb9
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb11
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb9
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb5
-rw-r--r--app/controllers/admin/instances_controller.rb2
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb9
-rw-r--r--app/controllers/admin/reports_controller.rb3
-rw-r--r--app/controllers/admin/resets_controller.rb9
-rw-r--r--app/controllers/admin/roles_controller.rb25
-rw-r--r--app/controllers/admin/settings_controller.rb3
-rw-r--r--app/controllers/admin/silences_controller.rb2
-rw-r--r--app/controllers/admin/statuses_controller.rb17
-rw-r--r--app/controllers/admin/subscriptions_controller.rb1
-rw-r--r--app/controllers/admin/suspensions_controller.rb2
-rw-r--r--app/controllers/admin/two_factor_authentications_controller.rb1
-rw-r--r--app/controllers/api/v1/reports_controller.rb2
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/concerns/authorization.rb1
-rw-r--r--app/helpers/application_helper.rb5
-rw-r--r--app/models/user.rb42
-rw-r--r--app/policies/account_moderation_note_policy.rb17
-rw-r--r--app/policies/account_policy.rb43
-rw-r--r--app/policies/application_policy.rb18
-rw-r--r--app/policies/custom_emoji_policy.rb31
-rw-r--r--app/policies/domain_block_policy.rb19
-rw-r--r--app/policies/email_domain_block_policy.rb15
-rw-r--r--app/policies/instance_policy.rb11
-rw-r--r--app/policies/report_policy.rb15
-rw-r--r--app/policies/settings_policy.rb11
-rw-r--r--app/policies/status_policy.rb35
-rw-r--r--app/policies/subscription_policy.rb7
-rw-r--r--app/policies/user_policy.rb41
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml2
-rw-r--r--app/views/admin/accounts/show.html.haml46
37 files changed, 466 insertions, 77 deletions
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 414a875d0..7f69a3363 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -1,31 +1,41 @@
 # frozen_string_literal: true
 
-class Admin::AccountModerationNotesController < Admin::BaseController
-  def create
-    @account_moderation_note = current_account.account_moderation_notes.new(resource_params)
-    if @account_moderation_note.save
-      @target_account = @account_moderation_note.target_account
-      redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg')
-    else
-      @account = @account_moderation_note.target_account
-      @moderation_notes = @account.targeted_moderation_notes.latest
-      render template: 'admin/accounts/show'
+module Admin
+  class AccountModerationNotesController < BaseController
+    before_action :set_account_moderation_note, only: [:destroy]
+
+    def create
+      authorize AccountModerationNote, :create?
+
+      @account_moderation_note = current_account.account_moderation_notes.new(resource_params)
+
+      if @account_moderation_note.save
+        redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg')
+      else
+        @account          = @account_moderation_note.target_account
+        @moderation_notes = @account.targeted_moderation_notes.latest
+
+        render template: 'admin/accounts/show'
+      end
     end
-  end
 
-  def destroy
-    @account_moderation_note = AccountModerationNote.find(params[:id])
-    @target_account = @account_moderation_note.target_account
-    @account_moderation_note.destroy
-    redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
-  end
+    def destroy
+      authorize @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
 
-  private
+    private
 
-  def resource_params
-    params.require(:account_moderation_note).permit(
-      :content,
-      :target_account_id
-    )
+    def resource_params
+      params.require(:account_moderation_note).permit(
+        :content,
+        :target_account_id
+      )
+    end
+
+    def set_account_moderation_note
+      @account_moderation_note = AccountModerationNote.find(params[:id])
+    end
   end
 end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 7503b880d..0829bc769 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -7,40 +7,49 @@ module Admin
     before_action :require_local_account!, only: [:enable, :disable, :memorialize]
 
     def index
+      authorize :account, :index?
       @accounts = filtered_accounts.page(params[:page])
     end
 
     def show
+      authorize @account, :show?
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
       @moderation_notes = @account.targeted_moderation_notes.latest
     end
 
     def subscribe
+      authorize @account, :subscribe?
       Pubsubhubbub::SubscribeWorker.perform_async(@account.id)
       redirect_to admin_account_path(@account.id)
     end
 
     def unsubscribe
+      authorize @account, :unsubscribe?
       Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
       redirect_to admin_account_path(@account.id)
     end
 
     def memorialize
+      authorize @account, :memorialize?
       @account.memorialize!
       redirect_to admin_account_path(@account.id)
     end
 
     def enable
+      authorize @account.user, :enable?
       @account.user.enable!
       redirect_to admin_account_path(@account.id)
     end
 
     def disable
+      authorize @account.user, :disable?
       @account.user.disable!
       redirect_to admin_account_path(@account.id)
     end
 
     def redownload
+      authorize @account, :redownload?
+
       @account.reset_avatar!
       @account.reset_header!
       @account.save!
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 11fe326bc..db4839a8f 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -2,7 +2,9 @@
 
 module Admin
   class BaseController < ApplicationController
-    before_action :require_admin!
+    include Authorization
+
+    before_action :require_staff!
 
     layout 'admin'
   end
diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb
index 2542e21ee..c10b0ebee 100644
--- a/app/controllers/admin/confirmations_controller.rb
+++ b/app/controllers/admin/confirmations_controller.rb
@@ -2,15 +2,18 @@
 
 module Admin
   class ConfirmationsController < BaseController
+    before_action :set_user
+
     def create
-      account_user.confirm
+      authorize @user, :confirm?
+      @user.confirm!
       redirect_to admin_accounts_path
     end
 
     private
 
-    def account_user
-      Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
+    def set_user
+      @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
   end
 end
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index daa1460fb..693d28b1f 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -5,14 +5,18 @@ module Admin
     before_action :set_custom_emoji, except: [:index, :new, :create]
 
     def index
+      authorize :custom_emoji, :index?
       @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
     end
 
     def new
+      authorize :custom_emoji, :create?
       @custom_emoji = CustomEmoji.new
     end
 
     def create
+      authorize :custom_emoji, :create?
+
       @custom_emoji = CustomEmoji.new(resource_params)
 
       if @custom_emoji.save
@@ -23,6 +27,8 @@ module Admin
     end
 
     def update
+      authorize @custom_emoji, :update?
+
       if @custom_emoji.update(resource_params)
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
       else
@@ -31,11 +37,14 @@ module Admin
     end
 
     def destroy
+      authorize @custom_emoji, :destroy?
       @custom_emoji.destroy
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
     end
 
     def copy
+      authorize @custom_emoji, :copy?
+
       emoji = CustomEmoji.find_or_create_by(domain: nil, shortcode: @custom_emoji.shortcode)
 
       if emoji.update(image: @custom_emoji.image)
@@ -48,11 +57,13 @@ module Admin
     end
 
     def enable
+      authorize @custom_emoji, :enable?
       @custom_emoji.update!(disabled: false)
       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)
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
     end
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 1ab620e03..e383dc831 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -5,14 +5,18 @@ module Admin
     before_action :set_domain_block, only: [:show, :destroy]
 
     def index
+      authorize :domain_block, :index?
       @domain_blocks = DomainBlock.page(params[:page])
     end
 
     def new
+      authorize :domain_block, :create?
       @domain_block = DomainBlock.new
     end
 
     def create
+      authorize :domain_block, :create?
+
       @domain_block = DomainBlock.new(resource_params)
 
       if @domain_block.save
@@ -23,9 +27,12 @@ module Admin
       end
     end
 
-    def show; end
+    def show
+      authorize @domain_block, :show?
+    end
 
     def destroy
+      authorize @domain_block, :destroy?
       UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
       redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg')
     end
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index 09275d5dc..01058bf46 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -5,14 +5,18 @@ module Admin
     before_action :set_email_domain_block, only: [:show, :destroy]
 
     def index
+      authorize :email_domain_block, :index?
       @email_domain_blocks = EmailDomainBlock.page(params[:page])
     end
 
     def new
+      authorize :email_domain_block, :create?
       @email_domain_block = EmailDomainBlock.new
     end
 
     def create
+      authorize :email_domain_block, :create?
+
       @email_domain_block = EmailDomainBlock.new(resource_params)
 
       if @email_domain_block.save
@@ -23,6 +27,7 @@ module Admin
     end
 
     def destroy
+      authorize @email_domain_block, :destroy?
       @email_domain_block.destroy
       redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
     end
diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb
index 22f02e5d0..8ed0ea421 100644
--- a/app/controllers/admin/instances_controller.rb
+++ b/app/controllers/admin/instances_controller.rb
@@ -3,10 +3,12 @@
 module Admin
   class InstancesController < BaseController
     def index
+      authorize :instance, :index?
       @instances = ordered_instances
     end
 
     def resubscribe
+      authorize :instance, :resubscribe?
       params.require(:by_domain)
       Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id))
       redirect_to admin_instances_path
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
index 5a31adecf..4f66ce708 100644
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ b/app/controllers/admin/reported_statuses_controller.rb
@@ -2,19 +2,20 @@
 
 module Admin
   class ReportedStatusesController < BaseController
-    include Authorization
-
     before_action :set_report
     before_action :set_status, only: [:update, :destroy]
 
     def create
-      @form = Form::StatusBatch.new(form_status_batch_params)
-      flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
+      authorize :status, :update?
+
+      @form         = Form::StatusBatch.new(form_status_batch_params)
+      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_report_path(@report)
     end
 
     def update
+      authorize @status, :update?
       @status.update(status_params)
       redirect_to admin_report_path(@report)
     end
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index 226467739..745757ee8 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -5,14 +5,17 @@ module Admin
     before_action :set_report, except: [:index]
 
     def index
+      authorize :report, :index?
       @reports = filtered_reports.page(params[:page])
     end
 
     def show
+      authorize @report, :show?
       @form = Form::StatusBatch.new
     end
 
     def update
+      authorize @report, :update?
       process_report
       redirect_to admin_report_path(@report)
     end
diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb
index 6db648403..00b590bf6 100644
--- a/app/controllers/admin/resets_controller.rb
+++ b/app/controllers/admin/resets_controller.rb
@@ -2,17 +2,18 @@
 
 module Admin
   class ResetsController < BaseController
-    before_action :set_account
+    before_action :set_user
 
     def create
-      @account.user.send_reset_password_instructions
+      authorize @user, :reset_password?
+      @user.send_reset_password_instructions
       redirect_to admin_accounts_path
     end
 
     private
 
-    def set_account
-      @account = Account.find(params[:account_id])
+    def set_user
+      @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
   end
 end
diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb
new file mode 100644
index 000000000..8f8685827
--- /dev/null
+++ b/app/controllers/admin/roles_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Admin
+  class RolesController < BaseController
+    before_action :set_user
+
+    def promote
+      authorize @user, :promote?
+      @user.promote!
+      redirect_to admin_account_path(@user.account_id)
+    end
+
+    def demote
+      authorize @user, :demote?
+      @user.demote!
+      redirect_to admin_account_path(@user.account_id)
+    end
+
+    private
+
+    def set_user
+      @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
+    end
+  end
+end
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index a2f86b8a9..e81290228 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -28,10 +28,13 @@ module Admin
     ).freeze
 
     def edit
+      authorize :settings, :show?
       @admin_settings = Form::AdminSettings.new
     end
 
     def update
+      authorize :settings, :update?
+
       settings_params.each do |key, value|
         if UPLOAD_SETTINGS.include?(key)
           upload = SiteUpload.where(var: key).first_or_initialize(var: key)
diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb
index 81a3008b9..01fb292de 100644
--- a/app/controllers/admin/silences_controller.rb
+++ b/app/controllers/admin/silences_controller.rb
@@ -5,11 +5,13 @@ module Admin
     before_action :set_account
 
     def create
+      authorize @account, :silence?
       @account.update(silenced: true)
       redirect_to admin_accounts_path
     end
 
     def destroy
+      authorize @account, :unsilence?
       @account.update(silenced: false)
       redirect_to admin_accounts_path
     end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index b05000b16..b54a9b824 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -2,8 +2,6 @@
 
 module Admin
   class StatusesController < BaseController
-    include Authorization
-
     helper_method :current_params
 
     before_action :set_account
@@ -12,24 +10,30 @@ module Admin
     PER_PAGE = 20
 
     def index
+      authorize :status, :index?
+
       @statuses = @account.statuses
+
       if params[:media]
         account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
         @statuses.merge!(Status.where(id: account_media_status_ids))
       end
-      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
 
-      @form = Form::StatusBatch.new
+      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
+      @form     = Form::StatusBatch.new
     end
 
     def create
-      @form = Form::StatusBatch.new(form_status_batch_params)
-      flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
+      authorize :status, :update?
+
+      @form         = Form::StatusBatch.new(form_status_batch_params)
+      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_account_statuses_path(@account.id, current_params)
     end
 
     def update
+      authorize @status, :update?
       @status.update(status_params)
       redirect_to admin_account_statuses_path(@account.id, current_params)
     end
@@ -60,6 +64,7 @@ module Admin
 
     def current_params
       page = (params[:page] || 1).to_i
+
       {
         media: params[:media],
         page: page > 1 && page,
diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb
index 624a475a3..40500ef43 100644
--- a/app/controllers/admin/subscriptions_controller.rb
+++ b/app/controllers/admin/subscriptions_controller.rb
@@ -3,6 +3,7 @@
 module Admin
   class SubscriptionsController < BaseController
     def index
+      authorize :subscription, :index?
       @subscriptions = ordered_subscriptions.page(requested_page)
     end
 
diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb
index 5eaf1a2e9..778feea5e 100644
--- a/app/controllers/admin/suspensions_controller.rb
+++ b/app/controllers/admin/suspensions_controller.rb
@@ -5,11 +5,13 @@ module Admin
     before_action :set_account
 
     def create
+      authorize @account, :suspend?
       Admin::SuspensionWorker.perform_async(@account.id)
       redirect_to admin_accounts_path
     end
 
     def destroy
+      authorize @account, :unsuspend?
       @account.unsuspend!
       redirect_to admin_accounts_path
     end
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb
index 69c08f605..5a45d25cd 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/two_factor_authentications_controller.rb
@@ -5,6 +5,7 @@ module Admin
     before_action :set_user
 
     def destroy
+      authorize @user, :disable_2fa?
       @user.disable_two_factor!
       redirect_to admin_accounts_path
     end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 9592cd4bd..22828217d 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -19,7 +19,7 @@ class Api::V1::ReportsController < Api::BaseController
       comment: report_params[:comment]
     )
 
-    User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
+    User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
 
     render json: @report, serializer: REST::ReportSerializer
   end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d5eca6ffb..f41a7f9be 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -18,6 +18,7 @@ class ApplicationController < ActionController::Base
   rescue_from ActionController::RoutingError, with: :not_found
   rescue_from ActiveRecord::RecordNotFound, with: :not_found
   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
+  rescue_from Mastodon::NotPermittedError, with: :forbidden
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :check_suspension, if: :user_signed_in?
@@ -40,6 +41,10 @@ class ApplicationController < ActionController::Base
     redirect_to root_path unless current_user&.admin?
   end
 
+  def require_staff!
+    redirect_to root_path unless current_user&.staff?
+  end
+
   def check_suspension
     forbidden if current_user.account.suspended?
   end
diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb
index 7828fe48d..95a37e379 100644
--- a/app/controllers/concerns/authorization.rb
+++ b/app/controllers/concerns/authorization.rb
@@ -2,6 +2,7 @@
 
 module Authorization
   extend ActiveSupport::Concern
+
   include Pundit
 
   def pundit_user
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 310e1b1b1..7dfab1df1 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -35,6 +35,11 @@ module ApplicationHelper
     Rails.env.production? ? site_title : "#{site_title} (Dev)"
   end
 
+  def can?(action, record)
+    return false if record.nil?
+    policy(record).public_send("#{action}?")
+  end
+
   def fa_icon(icon, attributes = {})
     class_names = attributes[:class]&.split(' ') || []
     class_names << 'fa'
diff --git a/app/models/user.rb b/app/models/user.rb
index 836d54d15..9022e6ea8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -32,6 +32,7 @@
 #  filtered_languages        :string           default([]), not null, is an Array
 #  account_id                :integer          not null
 #  disabled                  :boolean          default(FALSE), not null
+#  moderator                 :boolean          default(FALSE), not null
 #
 
 class User < ApplicationRecord
@@ -53,8 +54,10 @@ class User < ApplicationRecord
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
 
-  scope :recent,    -> { order(id: :desc) }
-  scope :admins,    -> { where(admin: true) }
+  scope :recent, -> { order(id: :desc) }
+  scope :admins, -> { where(admin: true) }
+  scope :moderators, -> { where(moderator: true) }
+  scope :staff, -> { admins.or(moderators) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended: false }) }
@@ -74,6 +77,20 @@ class User < ApplicationRecord
     confirmed_at.present?
   end
 
+  def staff?
+    admin? || moderator?
+  end
+
+  def role
+    if admin?
+      'admin'
+    elsif moderator?
+      'moderator'
+    else
+      'user'
+    end
+  end
+
   def disable!
     update!(disabled: true,
             last_sign_in_at: current_sign_in_at,
@@ -84,6 +101,27 @@ class User < ApplicationRecord
     update!(disabled: false)
   end
 
+  def confirm!
+    skip_confirmation!
+    save!
+  end
+
+  def promote!
+    if moderator?
+      update!(moderator: false, admin: true)
+    elsif !admin?
+      update!(moderator: true)
+    end
+  end
+
+  def demote!
+    if admin?
+      update!(admin: false, moderator: true)
+    elsif moderator?
+      update!(moderator: false)
+    end
+  end
+
   def disable_two_factor!
     self.otp_required_for_login = false
     otp_backup_codes&.clear
diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb
new file mode 100644
index 000000000..885411a5b
--- /dev/null
+++ b/app/policies/account_moderation_note_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountModerationNotePolicy < ApplicationPolicy
+  def create?
+    staff?
+  end
+
+  def destroy?
+    admin? || owner?
+  end
+
+  private
+
+  def owner?
+    record.account_id == current_account&.id
+  end
+end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
new file mode 100644
index 000000000..85e2c8419
--- /dev/null
+++ b/app/policies/account_policy.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class AccountPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def show?
+    staff?
+  end
+
+  def suspend?
+    staff? && !record.user&.staff?
+  end
+
+  def unsuspend?
+    staff?
+  end
+
+  def silence?
+    staff? && !record.user&.staff?
+  end
+
+  def unsilence?
+    staff?
+  end
+
+  def redownload?
+    admin?
+  end
+
+  def subscribe?
+    admin?
+  end
+
+  def unsubscribe?
+    admin?
+  end
+
+  def memorialize?
+    admin? && !record.user&.admin?
+  end
+end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100644
index 000000000..3e617001f
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class ApplicationPolicy
+  attr_reader :current_account, :record
+
+  def initialize(current_account, record)
+    @current_account = current_account
+    @record          = record
+  end
+
+  delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true
+
+  private
+
+  def current_user
+    current_account&.user
+  end
+end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
new file mode 100644
index 000000000..a8c3cbc73
--- /dev/null
+++ b/app/policies/custom_emoji_policy.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class CustomEmojiPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def create?
+    admin?
+  end
+
+  def update?
+    admin?
+  end
+
+  def copy?
+    admin?
+  end
+
+  def enable?
+    staff?
+  end
+
+  def disable?
+    staff?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb
new file mode 100644
index 000000000..47c0a81af
--- /dev/null
+++ b/app/policies/domain_block_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class DomainBlockPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def show?
+    admin?
+  end
+
+  def create?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb
new file mode 100644
index 000000000..5a75ee183
--- /dev/null
+++ b/app/policies/email_domain_block_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class EmailDomainBlockPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def create?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
new file mode 100644
index 000000000..d1956e2de
--- /dev/null
+++ b/app/policies/instance_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class InstancePolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def resubscribe?
+    admin?
+  end
+end
diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb
new file mode 100644
index 000000000..95b5c30c8
--- /dev/null
+++ b/app/policies/report_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ReportPolicy < ApplicationPolicy
+  def update?
+    staff?
+  end
+
+  def index?
+    staff?
+  end
+
+  def show?
+    staff?
+  end
+end
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
new file mode 100644
index 000000000..2dcb79f51
--- /dev/null
+++ b/app/policies/settings_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class SettingsPolicy < ApplicationPolicy
+  def update?
+    admin?
+  end
+
+  def show?
+    admin?
+  end
+end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 2ded61850..0373fdf04 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -1,20 +1,17 @@
 # frozen_string_literal: true
 
-class StatusPolicy
-  attr_reader :account, :status
-
-  def initialize(account, status)
-    @account = account
-    @status = status
+class StatusPolicy < ApplicationPolicy
+  def index?
+    staff?
   end
 
   def show?
     if direct?
-      owned? || status.mentions.where(account: account).exists?
+      owned? || record.mentions.where(account: current_account).exists?
     elsif private?
-      owned? || account&.following?(status.account) || status.mentions.where(account: account).exists?
+      owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists?
     else
-      account.nil? || !status.account.blocking?(account)
+      current_account.nil? || !author.blocking?(current_account)
     end
   end
 
@@ -23,26 +20,30 @@ class StatusPolicy
   end
 
   def destroy?
-    admin? || owned?
+    staff? || owned?
   end
 
   alias unreblog? destroy?
 
-  private
-
-  def admin?
-    account&.user&.admin?
+  def update?
+    staff?
   end
 
+  private
+
   def direct?
-    status.direct_visibility?
+    record.direct_visibility?
   end
 
   def owned?
-    status.account.id == account&.id
+    author.id == current_account&.id
   end
 
   def private?
-    status.private_visibility?
+    record.private_visibility?
+  end
+
+  def author
+    record.account
   end
 end
diff --git a/app/policies/subscription_policy.rb b/app/policies/subscription_policy.rb
new file mode 100644
index 000000000..ac9a8a6c4
--- /dev/null
+++ b/app/policies/subscription_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class SubscriptionPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100644
index 000000000..aae207d06
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class UserPolicy < ApplicationPolicy
+  def reset_password?
+    staff? && !record.staff?
+  end
+
+  def disable_2fa?
+    admin? && !record.staff?
+  end
+
+  def confirm?
+    staff? && !record.confirmed?
+  end
+
+  def enable?
+    admin?
+  end
+
+  def disable?
+    admin? && !record.admin?
+  end
+
+  def promote?
+    admin? && promoteable?
+  end
+
+  def demote?
+    admin? && !record.admin? && demoteable?
+  end
+
+  private
+
+  def promoteable?
+    !record.staff? || !record.admin?
+  end
+
+  def demoteable?
+    record.staff?
+  end
+end
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
index 4651630e9..6761a4319 100644
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
@@ -7,4 +7,4 @@
     %time.formatted{ datetime: account_moderation_note.created_at.iso8601, title: l(account_moderation_note.created_at) }
       = l account_moderation_note.created_at
   %td
-    = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete
+    = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index b5ce56dbc..f49594828 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -17,16 +17,20 @@
       - if @account.local?
         %tr
           %th= t('admin.accounts.email')
-          %td= @account.user_email
+          %td
+            = @account.user_email
+
+            - if @account.user_confirmed?
+              = fa_icon('check')
         %tr
           %th= t('admin.accounts.login_status')
           %td
             - if @account.user&.disabled?
               = t('admin.accounts.disabled')
-              = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post
+              = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
             - else
               = t('admin.accounts.enabled')
-              = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post
+              = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post if can?(:disable, @account.user)
         %tr
           %th= t('admin.accounts.most_recent_ip')
           %td= @account.user_current_sign_in_ip
@@ -71,28 +75,28 @@
 %div{ style: 'overflow: hidden' }
   %div{ style: 'float: right' }
     - if @account.local?
-      = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button'
+      = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
       - if @account.user&.otp_required_for_login?
-        = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button'
+        = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
       - unless @account.memorial?
-        = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
+        = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:memorialize, @account)
     - else
-      = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button'
+      = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
 
   %div{ style: 'float: left' }
     - if @account.silenced?
-      = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button'
+      = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' if can?(:unsilence, @account)
     - else
-      = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button'
+      = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button' if can?(:silence, @account)
 
     - if @account.local?
       - unless @account.user_confirmed?
-        = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button'
+        = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
 
     - if @account.suspended?
-      = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button'
+      = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' if can?(:unsuspend, @account)
     - else
-      = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
+      = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:suspend, @account)
 
 - unless @account.local?
   %hr
@@ -118,9 +122,9 @@
 
   %div{ style: 'overflow: hidden' }
     %div{ style: 'float: right' }
-      = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button'
+      = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' if can?(:subscribe, @account)
       - if @account.subscribed?
-        = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative'
+        = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' if can?(:unsubscribe, @account)
 
   %hr
   %h3 ActivityPub
@@ -141,6 +145,20 @@
           %th= t('admin.accounts.followers_url')
           %td= link_to @account.followers_url, @account.followers_url
 
+- else
+  %hr
+
+  .table-wrapper
+    %table.table
+      %tbody
+        %tr
+          %th= t('admin.accounts.role')
+          %td
+            = t("admin.accounts.roles.#{@account.user&.role}")
+          %td<
+            = table_link_to 'angle-double-up', t('admin.accounts.promote'), promote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:promote, @account.user)
+            = table_link_to 'angle-double-down', t('admin.accounts.demote'), demote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:demote, @account.user)
+
 %hr
 %h3= t('admin.accounts.moderation_notes')