about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml4
-rw-r--r--app/controllers/admin/account_actions_controller.rb4
-rw-r--r--app/controllers/admin/accounts_controller.rb2
-rw-r--r--app/controllers/admin/action_logs_controller.rb5
-rw-r--r--app/controllers/admin/base_controller.rb2
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb4
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb2
-rw-r--r--app/controllers/admin/follow_recommendations_controller.rb2
-rw-r--r--app/controllers/admin/ip_blocks_controller.rb2
-rw-r--r--app/controllers/admin/relationships_controller.rb2
-rw-r--r--app/controllers/admin/roles_controller.rb65
-rw-r--r--app/controllers/admin/statuses_controller.rb2
-rw-r--r--app/controllers/admin/subscriptions_controller.rb20
-rw-r--r--app/controllers/admin/trends/links/preview_card_providers_controller.rb4
-rw-r--r--app/controllers/admin/trends/links_controller.rb4
-rw-r--r--app/controllers/admin/trends/statuses_controller.rb4
-rw-r--r--app/controllers/admin/trends/tags_controller.rb4
-rw-r--r--app/controllers/admin/users/roles_controller.rb33
-rw-r--r--app/controllers/admin/users/two_factor_authentications_controller.rb (renamed from app/controllers/admin/two_factor_authentications_controller.rb)2
-rw-r--r--app/controllers/api/v1/admin/account_actions_controller.rb7
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/dimensions_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/domain_allows_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/domain_blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/measures_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/retention_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/trends/links_controller.rb20
-rw-r--r--app/controllers/api/v1/admin/trends/statuses_controller.rb20
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb20
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb10
-rw-r--r--app/controllers/api/v1/trends/statuses_controller.rb10
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb12
-rw-r--r--app/controllers/api/v2/admin/accounts_controller.rb13
-rw-r--r--app/controllers/application_controller.rb8
-rw-r--r--app/controllers/custom_css_controller.rb2
-rw-r--r--app/helpers/accounts_helper.rb14
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js6
-rw-r--r--app/javascript/mastodon/containers/mastodon.js1
-rw-r--r--app/javascript/mastodon/features/account/components/header.js9
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js10
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/link_footer.js9
-rw-r--r--app/javascript/mastodon/initial_state.js2
-rw-r--r--app/javascript/mastodon/permissions.js3
-rw-r--r--app/javascript/mastodon/reducers/meta.js3
-rw-r--r--app/javascript/styles/mastodon/admin.scss15
-rw-r--r--app/javascript/styles/mastodon/forms.scss4
-rw-r--r--app/lib/admin/system_check.rb6
-rw-r--r--app/lib/admin/system_check/base_check.rb10
-rw-r--r--app/lib/admin/system_check/database_schema_check.rb4
-rw-r--r--app/lib/admin/system_check/elasticsearch_check.rb8
-rw-r--r--app/lib/admin/system_check/rules_check.rb4
-rw-r--r--app/lib/admin/system_check/sidekiq_process_check.rb4
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/account_filter.rb27
-rw-r--r--app/models/concerns/user_roles.rb68
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/trends.rb2
-rw-r--r--app/models/user.rb38
-rw-r--r--app/models/user_role.rb179
-rw-r--r--app/policies/account_moderation_note_policy.rb4
-rw-r--r--app/policies/account_policy.rb40
-rw-r--r--app/policies/account_warning_policy.rb2
-rw-r--r--app/policies/account_warning_preset_policy.rb8
-rw-r--r--app/policies/announcement_policy.rb8
-rw-r--r--app/policies/appeal_policy.rb8
-rw-r--r--app/policies/application_policy.rb6
-rw-r--r--app/policies/audit_log_policy.rb7
-rw-r--r--app/policies/custom_emoji_policy.rb14
-rw-r--r--app/policies/dashboard_policy.rb7
-rw-r--r--app/policies/delivery_policy.rb6
-rw-r--r--app/policies/domain_allow_policy.rb8
-rw-r--r--app/policies/domain_block_policy.rb10
-rw-r--r--app/policies/email_domain_block_policy.rb6
-rw-r--r--app/policies/follow_recommendation_policy.rb6
-rw-r--r--app/policies/instance_policy.rb6
-rw-r--r--app/policies/invite_policy.rb12
-rw-r--r--app/policies/ip_block_policy.rb6
-rw-r--r--app/policies/preview_card_policy.rb4
-rw-r--r--app/policies/preview_card_provider_policy.rb4
-rw-r--r--app/policies/relay_policy.rb2
-rw-r--r--app/policies/report_note_policy.rb4
-rw-r--r--app/policies/report_policy.rb6
-rw-r--r--app/policies/rule_policy.rb8
-rw-r--r--app/policies/settings_policy.rb6
-rw-r--r--app/policies/status_policy.rb8
-rw-r--r--app/policies/tag_policy.rb8
-rw-r--r--app/policies/user_policy.rb38
-rw-r--r--app/policies/user_role_policy.rb19
-rw-r--r--app/policies/webhook_policy.rb16
-rw-r--r--app/presenters/initial_state_presenter.rb4
-rw-r--r--app/serializers/initial_state_serializer.rb3
-rw-r--r--app/serializers/rest/credential_account_serializer.rb6
-rw-r--r--app/serializers/rest/instance_serializer.rb2
-rw-r--r--app/serializers/rest/role_serializer.rb13
-rw-r--r--app/services/account_search_service.rb4
-rw-r--r--app/services/appeal_service.rb2
-rw-r--r--app/services/bootstrap_timeline_service.rb2
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/report_service.rb2
-rw-r--r--app/views/admin/accounts/index.html.haml55
-rw-r--r--app/views/admin/accounts/show.html.haml9
-rw-r--r--app/views/admin/action_logs/index.html.haml2
-rw-r--r--app/views/admin/instances/show.html.haml53
-rw-r--r--app/views/admin/roles/_form.html.haml37
-rw-r--r--app/views/admin/roles/_role.html.haml18
-rw-r--r--app/views/admin/roles/edit.html.haml8
-rw-r--r--app/views/admin/roles/index.html.haml17
-rw-r--r--app/views/admin/roles/new.html.haml4
-rw-r--r--app/views/admin/settings/edit.html.haml6
-rw-r--r--app/views/admin/tags/show.html.haml87
-rw-r--r--app/views/admin/users/roles/show.html.haml9
-rw-r--r--app/views/custom_css/show.css.erb10
-rwxr-xr-xapp/views/layouts/application.html.haml4
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml10
-rw-r--r--config/application.rb1
-rw-r--r--config/locales/activerecord.en.yml9
-rw-r--r--config/locales/en.yml85
-rw-r--r--config/locales/simple_form.en.yml15
-rw-r--r--config/navigation.rb81
-rw-r--r--config/roles.yml35
-rw-r--r--config/routes.rb13
-rw-r--r--db/migrate/20220611210335_create_user_roles.rb13
-rw-r--r--db/migrate/20220611212541_add_role_id_to_users.rb8
-rw-r--r--db/post_migrate/20220617202502_migrate_roles.rb26
-rw-r--r--db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb41
-rw-r--r--db/schema.rb15
-rw-r--r--db/seeds.rb12
-rw-r--r--db/seeds/01_web_app.rb1
-rw-r--r--db/seeds/02_instance_actor.rb1
-rw-r--r--db/seeds/03_roles.rb9
-rw-r--r--db/seeds/04_admin.rb8
-rw-r--r--lib/mastodon/accounts_cli.rb41
-rw-r--r--lib/simple_navigation/item_extensions.rb15
-rw-r--r--spec/controllers/admin/account_moderation_notes_controller_spec.rb2
-rw-r--r--spec/controllers/admin/accounts_controller_spec.rb52
-rw-r--r--spec/controllers/admin/action_logs_controller_spec.rb2
-rw-r--r--spec/controllers/admin/base_controller_spec.rb7
-rw-r--r--spec/controllers/admin/change_email_controller_spec.rb2
-rw-r--r--spec/controllers/admin/confirmations_controller_spec.rb2
-rw-r--r--spec/controllers/admin/custom_emojis_controller_spec.rb2
-rw-r--r--spec/controllers/admin/dashboard_controller_spec.rb2
-rw-r--r--spec/controllers/admin/disputes/appeals_controller_spec.rb4
-rw-r--r--spec/controllers/admin/domain_blocks_controller_spec.rb2
-rw-r--r--spec/controllers/admin/email_domain_blocks_controller_spec.rb2
-rw-r--r--spec/controllers/admin/instances_controller_spec.rb8
-rw-r--r--spec/controllers/admin/invites_controller_spec.rb2
-rw-r--r--spec/controllers/admin/report_notes_controller_spec.rb2
-rw-r--r--spec/controllers/admin/reports_controller_spec.rb2
-rw-r--r--spec/controllers/admin/resets_controller_spec.rb2
-rw-r--r--spec/controllers/admin/roles_controller_spec.rb244
-rw-r--r--spec/controllers/admin/settings_controller_spec.rb2
-rw-r--r--spec/controllers/admin/statuses_controller_spec.rb2
-rw-r--r--spec/controllers/admin/tags_controller_spec.rb2
-rw-r--r--spec/controllers/admin/users/roles_controller.rb81
-rw-r--r--spec/controllers/admin/users/two_factor_authentications_controller_spec.rb (renamed from spec/controllers/admin/two_factor_authentications_controller_spec.rb)5
-rw-r--r--spec/controllers/api/v1/admin/account_actions_controller_spec.rb6
-rw-r--r--spec/controllers/api/v1/admin/accounts_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/admin/domain_allows_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/admin/reports_controller_spec.rb16
-rw-r--r--spec/controllers/api/v1/reports_controller_spec.rb2
-rw-r--r--spec/controllers/api/v2/admin/accounts_controller_spec.rb6
-rw-r--r--spec/controllers/application_controller_spec.rb64
-rw-r--r--spec/controllers/disputes/appeals_controller_spec.rb2
-rw-r--r--spec/controllers/invites_controller_spec.rb40
-rw-r--r--spec/fabricators/user_role_fabricator.rb5
-rw-r--r--spec/models/account_spec.rb14
-rw-r--r--spec/models/admin/account_action_spec.rb2
-rw-r--r--spec/models/user_role_spec.rb189
-rw-r--r--spec/models/user_spec.rb159
-rw-r--r--spec/policies/account_moderation_note_policy_spec.rb4
-rw-r--r--spec/policies/account_policy_spec.rb8
-rw-r--r--spec/policies/custom_emoji_policy_spec.rb2
-rw-r--r--spec/policies/domain_block_policy_spec.rb2
-rw-r--r--spec/policies/email_domain_block_policy_spec.rb2
-rw-r--r--spec/policies/instance_policy_spec.rb2
-rw-r--r--spec/policies/invite_policy_spec.rb54
-rw-r--r--spec/policies/relay_policy_spec.rb2
-rw-r--r--spec/policies/report_note_policy_spec.rb5
-rw-r--r--spec/policies/report_policy_spec.rb2
-rw-r--r--spec/policies/settings_policy_spec.rb2
-rw-r--r--spec/policies/status_policy_spec.rb2
-rw-r--r--spec/policies/tag_policy_spec.rb2
-rw-r--r--spec/policies/user_policy_spec.rb55
187 files changed, 1952 insertions, 1039 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 9e3ff21f2..8dc2d1c47 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -67,7 +67,7 @@ Lint/UselessAccessModifier:
     - class_methods
 
 Metrics/AbcSize:
-  Max: 100
+  Max: 115
   Exclude:
     - 'lib/mastodon/*_cli.rb'
 
@@ -84,7 +84,7 @@ Metrics/BlockNesting:
 
 Metrics/ClassLength:
   CountComments: false
-  Max: 400
+  Max: 500
   Exclude:
     - 'lib/mastodon/*_cli.rb'
 
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index ea56fa0ac..3f2e28b6a 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -5,11 +5,15 @@ module Admin
     before_action :set_account
 
     def new
+      authorize @account, :show?
+
       @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
       @warning_presets = AccountWarningPreset.all
     end
 
     def create
+      authorize @account, :show?
+
       account_action                 = Admin::AccountAction.new(resource_params)
       account_action.target_account  = @account
       account_action.current_account = current_account
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e0ae71b9f..46c9aba91 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -14,6 +14,8 @@ module Admin
     end
 
     def batch
+      authorize :account, :index?
+
       @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb
index 2d77620df..42edec15a 100644
--- a/app/controllers/admin/action_logs_controller.rb
+++ b/app/controllers/admin/action_logs_controller.rb
@@ -4,7 +4,10 @@ module Admin
   class ActionLogsController < BaseController
     before_action :set_action_logs
 
-    def index; end
+    def index
+      authorize :audit_log, :index?
+      @auditable_accounts = Account.where(id: Admin::ActionLog.reorder(nil).select('distinct account_id')).select(:id, :username)
+    end
 
     private
 
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 7b81a2b01..5b7a7ec11 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -7,8 +7,8 @@ module Admin
 
     layout 'admin'
 
-    before_action :require_staff!
     before_action :set_body_classes
+    after_action :verify_authorized
 
     private
 
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 71efb543e..2c33e9f8f 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -29,6 +29,8 @@ module Admin
     end
 
     def batch
+      authorize :custom_emoji, :index?
+
       @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index da9c6dd16..924b623ad 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -5,7 +5,9 @@ module Admin
     include Redisable
 
     def index
-      @system_checks         = Admin::SystemCheck.perform
+      authorize :dashboard, :index?
+
+      @system_checks         = Admin::SystemCheck.perform(current_user)
       @time_period           = (29.days.ago.to_date...Time.now.utc.to_date)
       @pending_users_count   = User.pending.count
       @pending_reports_count = Report.unresolved.count
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index a4bbbba5b..593457b94 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -12,6 +12,8 @@ module Admin
     end
 
     def batch
+      authorize :email_domain_block, :index?
+
       @form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb
index e3eac62b3..841e3cc7f 100644
--- a/app/controllers/admin/follow_recommendations_controller.rb
+++ b/app/controllers/admin/follow_recommendations_controller.rb
@@ -12,6 +12,8 @@ module Admin
     end
 
     def update
+      authorize :follow_recommendation, :show?
+
       @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb
index 92b8b0d2b..a87520f4e 100644
--- a/app/controllers/admin/ip_blocks_controller.rb
+++ b/app/controllers/admin/ip_blocks_controller.rb
@@ -29,6 +29,8 @@ module Admin
     end
 
     def batch
+      authorize :ip_block, :index?
+
       @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb
index 085ded21c..67645f054 100644
--- a/app/controllers/admin/relationships_controller.rb
+++ b/app/controllers/admin/relationships_controller.rb
@@ -7,7 +7,7 @@ module Admin
     PER_PAGE = 40
 
     def index
-      authorize :account, :index?
+      authorize @account, :show?
 
       @accounts = RelationshipFilter.new(@account, filter_params).results.includes(:account_stat, user: [:ips, :invite_request]).page(params[:page]).per(PER_PAGE)
       @form     = Form::AccountBatch.new
diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb
index 13f56e9be..3e502ccc4 100644
--- a/app/controllers/admin/roles_controller.rb
+++ b/app/controllers/admin/roles_controller.rb
@@ -2,20 +2,63 @@
 
 module Admin
   class RolesController < BaseController
-    before_action :set_user
+    before_action :set_role, except: [:index, :new, :create]
 
-    def promote
-      authorize @user, :promote?
-      @user.promote!
-      log_action :promote, @user
-      redirect_to admin_account_path(@user.account_id)
+    def index
+      authorize :user_role, :index?
+
+      @roles = UserRole.order(position: :desc).page(params[:page])
+    end
+
+    def new
+      authorize :user_role, :create?
+
+      @role = UserRole.new
+    end
+
+    def create
+      authorize :user_role, :create?
+
+      @role = UserRole.new(resource_params)
+      @role.current_account = current_account
+
+      if @role.save
+        redirect_to admin_roles_path
+      else
+        render :new
+      end
+    end
+
+    def edit
+      authorize @role, :update?
+    end
+
+    def update
+      authorize @role, :update?
+
+      @role.current_account = current_account
+
+      if @role.update(resource_params)
+        redirect_to admin_roles_path
+      else
+        render :edit
+      end
+    end
+
+    def destroy
+      authorize @role, :destroy?
+      @role.destroy!
+      redirect_to admin_roles_path
+    end
+
+    private
+
+    def set_role
+      @role = UserRole.find(params[:id])
     end
 
-    def demote
-      authorize @user, :demote?
-      @user.demote!
-      log_action :demote, @user
-      redirect_to admin_account_path(@user.account_id)
+    def resource_params
+      params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: [])
     end
   end
 end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index 817c0caa9..084921ceb 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -14,6 +14,8 @@ module Admin
     end
 
     def batch
+      authorize :status, :index?
+
       @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
       @status_batch_action.save!
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb
deleted file mode 100644
index 40500ef43..000000000
--- a/app/controllers/admin/subscriptions_controller.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SubscriptionsController < BaseController
-    def index
-      authorize :subscription, :index?
-      @subscriptions = ordered_subscriptions.page(requested_page)
-    end
-
-    private
-
-    def ordered_subscriptions
-      Subscription.order(id: :desc).includes(:account)
-    end
-
-    def requested_page
-      params[:page].to_i
-    end
-  end
-end
diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
index 40a466cd6..97dee8eca 100644
--- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb
+++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController
   def index
-    authorize :preview_card_provider, :index?
+    authorize :preview_card_provider, :review?
 
     @preview_card_providers = filtered_preview_card_providers.page(params[:page])
     @form = Trends::PreviewCardProviderBatch.new
   end
 
   def batch
+    authorize :preview_card_provider, :review?
+
     @form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb
index 434eec5fe..a497eae41 100644
--- a/app/controllers/admin/trends/links_controller.rb
+++ b/app/controllers/admin/trends/links_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::LinksController < Admin::BaseController
   def index
-    authorize :preview_card, :index?
+    authorize :preview_card, :review?
 
     @preview_cards = filtered_preview_cards.page(params[:page])
     @form          = Trends::PreviewCardBatch.new
   end
 
   def batch
+    authorize :preview_card, :review?
+
     @form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb
index 766242738..c538962f9 100644
--- a/app/controllers/admin/trends/statuses_controller.rb
+++ b/app/controllers/admin/trends/statuses_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::StatusesController < Admin::BaseController
   def index
-    authorize :status, :index?
+    authorize :status, :review?
 
     @statuses = filtered_statuses.page(params[:page])
     @form     = Trends::StatusBatch.new
   end
 
   def batch
+    authorize :status, :review?
+
     @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb
index f4d1ec0d1..98dd6c8ec 100644
--- a/app/controllers/admin/trends/tags_controller.rb
+++ b/app/controllers/admin/trends/tags_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::TagsController < Admin::BaseController
   def index
-    authorize :tag, :index?
+    authorize :tag, :review?
 
     @tags = filtered_tags.page(params[:page])
     @form = Trends::TagBatch.new
   end
 
   def batch
+    authorize :tag, :review?
+
     @form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb
new file mode 100644
index 000000000..0db50cee9
--- /dev/null
+++ b/app/controllers/admin/users/roles_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Admin
+  class Users::RolesController < BaseController
+    before_action :set_user
+
+    def show
+      authorize @user, :change_role?
+    end
+
+    def update
+      authorize @user, :change_role?
+
+      @user.current_account = current_account
+
+      if @user.update(resource_params)
+        redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg')
+      else
+        render :show
+      end
+    end
+
+    private
+
+    def set_user
+      @user = User.find(params[:user_id])
+    end
+
+    def resource_params
+      params.require(:user).permit(:role_id)
+    end
+  end
+end
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/users/two_factor_authentications_controller.rb
index f7fb7eb8f..5e3fb2b3c 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/users/two_factor_authentications_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Admin
-  class TwoFactorAuthenticationsController < BaseController
+  class Users::TwoFactorAuthenticationsController < BaseController
     before_action :set_target_user
 
     def destroy
diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb
index 6c9e04402..7249797a4 100644
--- a/app/controllers/api/v1/admin/account_actions_controller.rb
+++ b/app/controllers/api/v1/admin/account_actions_controller.rb
@@ -1,11 +1,16 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountActionsController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
-  before_action :require_staff!
   before_action :set_account
 
+  after_action :verify_authorized
+
   def create
+    authorize @account, :show?
+
     account_action                 = Admin::AccountAction.new(resource_params)
     account_action.target_account  = @account
     account_action.current_account = current_account
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 65ed69f7b..0dee02e94 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -8,11 +8,11 @@ class Api::V1::Admin::AccountsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_accounts, only: :index
   before_action :set_account, except: :index
   before_action :require_local_account!, only: [:enable, :approve, :reject]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   FILTER_PARAMS = %i(
@@ -119,7 +119,9 @@ class Api::V1::Admin::AccountsController < Api::BaseController
       translated_params[:status] = status.to_s if params[status].present?
     end
 
-    translated_params[:permissions] = 'staff' if params[:staff].present?
+    if params[:staff].present?
+      translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id)
+    end
 
     translated_params
   end
diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb
index 49a5be1c3..4a72ad08b 100644
--- a/app/controllers/api/v1/admin/dimensions_controller.rb
+++ b/app/controllers/api/v1/admin/dimensions_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::DimensionsController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_dimensions
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb
index 838978ddb..59aa807d6 100644
--- a/app/controllers/api/v1/admin/domain_allows_controller.rb
+++ b/app/controllers/api/v1/admin/domain_allows_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_domain_allows, only: :index
   before_action :set_domain_allow, only: [:show, :destroy]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   PAGINATION_PARAMS = %i(limit).freeze
diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb
index 229870eee..de8fd9d08 100644
--- a/app/controllers/api/v1/admin/domain_blocks_controller.rb
+++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_domain_blocks, only: :index
   before_action :set_domain_block, only: [:show, :update, :destroy]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   PAGINATION_PARAMS = %i(limit).freeze
diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb
index da95d3422..d78d7e10b 100644
--- a/app/controllers/api/v1/admin/measures_controller.rb
+++ b/app/controllers/api/v1/admin/measures_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::MeasuresController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_measures
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @measures, each_serializer: REST::Admin::MeasureSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb
index 865ba3d23..9dfb181a2 100644
--- a/app/controllers/api/v1/admin/reports_controller.rb
+++ b/app/controllers/api/v1/admin/reports_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_reports, only: :index
   before_action :set_report, except: :index
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   FILTER_PARAMS = %i(
diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb
index 98d1a3d81..59d6b8388 100644
--- a/app/controllers/api/v1/admin/retention_controller.rb
+++ b/app/controllers/api/v1/admin/retention_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::RetentionController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_cohorts
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb
index 0a191fe4b..cc6388980 100644
--- a/app/controllers/api/v1/admin/trends/links_controller.rb
+++ b/app/controllers/api/v1/admin/trends/links_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::LinksController < Api::BaseController
+class Api::V1::Admin::Trends::LinksController < Api::V1::Trends::LinksController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_links
-
-  def index
-    render json: @links, each_serializer: REST::Trends::LinkSerializer
-  end
 
   private
 
-  def set_links
-    @links = Trends.links.query.limit(limit_param(10))
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def links_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.links.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb
index cb145f165..c39f77363 100644
--- a/app/controllers/api/v1/admin/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::StatusesController < Api::BaseController
+class Api::V1::Admin::Trends::StatusesController < Api::V1::Trends::StatusesController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_statuses
-
-  def index
-    render json: @statuses, each_serializer: REST::StatusSerializer
-  end
 
   private
 
-  def set_statuses
-    @statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def statuses_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.statuses.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index 9c28b0412..f3c0c4b6b 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::TagsController < Api::BaseController
+class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_tags
-
-  def index
-    render json: @tags, each_serializer: REST::Admin::TagSerializer
-  end
 
   private
 
-  def set_tags
-    @tags = Trends.tags.query.limit(limit_param(10))
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def tags_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.tags.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
index 2385fe438..1a9f918f2 100644
--- a/app/controllers/api/v1/trends/links_controller.rb
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -13,10 +13,14 @@ class Api::V1::Trends::LinksController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_links
     @links = begin
-      if Setting.trends
-        links_from_trends
+      if enabled?
+        links_from_trends.offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT))
       else
         []
       end
@@ -24,7 +28,7 @@ class Api::V1::Trends::LinksController < Api::BaseController
   end
 
   def links_from_trends
-    Trends.links.query.allowed.in_locale(content_locale).offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT))
+    Trends.links.query.allowed.in_locale(content_locale)
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb
index 1f2fff582..c275d5fc8 100644
--- a/app/controllers/api/v1/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/trends/statuses_controller.rb
@@ -11,10 +11,14 @@ class Api::V1::Trends::StatusesController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_statuses
     @statuses = begin
-      if Setting.trends
-        cache_collection(statuses_from_trends, Status)
+      if enabled?
+        cache_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
       else
         []
       end
@@ -24,7 +28,7 @@ class Api::V1::Trends::StatusesController < Api::BaseController
   def statuses_from_trends
     scope = Trends.statuses.query.allowed.in_locale(content_locale)
     scope = scope.filtered_for(current_account) if user_signed_in?
-    scope.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT))
+    scope
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 38003f599..41f9ffac1 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -13,16 +13,24 @@ class Api::V1::Trends::TagsController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_tags
     @tags = begin
-      if Setting.trends
-        Trends.tags.query.allowed.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT))
+      if enabled?
+        tags_from_trends.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT))
       else
         []
       end
     end
   end
 
+  def tags_from_trends
+    Trends.tags.query.allowed
+  end
+
   def insert_pagination_headers
     set_pagination_headers(next_path, prev_path)
   end
diff --git a/app/controllers/api/v2/admin/accounts_controller.rb b/app/controllers/api/v2/admin/accounts_controller.rb
index a89e6835e..bcc1a0733 100644
--- a/app/controllers/api/v2/admin/accounts_controller.rb
+++ b/app/controllers/api/v2/admin/accounts_controller.rb
@@ -11,6 +11,7 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
     email
     ip
     invited_by
+    role_ids
   ).freeze
 
   PAGINATION_PARAMS = (%i(limit) + FILTER_PARAMS).freeze
@@ -18,7 +19,17 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
   private
 
   def filtered_accounts
-    AccountFilter.new(filter_params).results
+    AccountFilter.new(translated_filter_params).results
+  end
+
+  def translated_filter_params
+    translated_params = filter_params.slice(*AccountFilter::KEYS)
+
+    if params[:permissions] == 'staff'
+      translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id)
+    end
+
+    translated_params
   end
 
   def filter_params
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3d2f8280b..615536b96 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -56,14 +56,6 @@ class ApplicationController < ActionController::Base
     store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym)
   end
 
-  def require_admin!
-    forbidden unless current_user&.admin?
-  end
-
-  def require_staff!
-    forbidden unless current_user&.staff?
-  end
-
   def require_functional!
     redirect_to edit_user_registration_path unless current_user.functional?
   end
diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb
index e1dc5eaf6..9270c467d 100644
--- a/app/controllers/custom_css_controller.rb
+++ b/app/controllers/custom_css_controller.rb
@@ -13,6 +13,6 @@ class CustomCssController < ApplicationController
   def show
     expires_in 3.minutes, public: true
     request.session_options[:skip] = true
-    render plain: Setting.custom_css || '', content_type: 'text/css'
+    render content_type: 'text/css'
   end
 end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index d37634964..59664373d 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -61,21 +61,13 @@ module AccountsHelper
     end
   end
 
-  def account_badge(account, all: false)
+  def account_badge(account)
     if account.bot?
       content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
     elsif account.group?
       content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
-    elsif (Setting.show_staff_badge && account.user_staff?) || all
-      content_tag(:div, class: 'roles') do
-        if all && !account.user_staff?
-          content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
-        elsif account.user_admin?
-          content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
-        elsif account.user_moderator?
-          content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
-        end
-      end
+    elsif account.user_role&.highlighted?
+      content_tag(:div, content_tag(:div, account.user_role.name, class: "account-role user-role-#{account.user_role.id}"), class: 'roles')
     end
   end
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index ab8755be0..d44da482d 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -6,8 +6,9 @@ import IconButton from './icon_button';
 import DropdownMenuContainer from '../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, isStaff } from '../initial_state';
+import { me } from '../initial_state';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -55,6 +56,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -306,7 +308,7 @@ class StatusActionBar extends ImmutablePureComponent {
         }
       }
 
-      if (isStaff) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 0c3f6afa8..f4bef4686 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -26,6 +26,7 @@ const createIdentityContext = state => ({
   signedIn: !!state.meta.me,
   accountId: state.meta.me,
   accessToken: state.meta.access_token,
+  permissions: state.role.permissions,
 });
 
 export default class Mastodon extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8e6b9f063..1ad9341c7 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Button from 'mastodon/components/button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
+import { autoPlayGif, me } from 'mastodon/initial_state';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import IconButton from 'mastodon/components/icon_button';
@@ -14,6 +14,7 @@ import ShortNumber from 'mastodon/components/short_number';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
 import AccountNoteContainer from '../containers/account_note_container';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -64,6 +65,10 @@ const dateFormatOptions = {
 export default @injectIntl
 class Header extends ImmutablePureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     account: ImmutablePropTypes.map,
     identity_props: ImmutablePropTypes.list,
@@ -241,7 +246,7 @@ class Header extends ImmutablePureComponent {
       }
     }
 
-    if (account.get('id') !== me && isStaff) {
+    if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
     }
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 61df79b46..b1618c1b4 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -5,10 +5,14 @@ import { FormattedMessage } from 'react-intl';
 import ClearColumnButton from './clear_column_button';
 import GrantPermissionButton from './grant_permission_button';
 import SettingToggle from './setting_toggle';
-import { isStaff } from 'mastodon/initial_state';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
 
 export default class ColumnSettings extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     settings: ImmutablePropTypes.map.isRequired,
     pushSettings: ImmutablePropTypes.map.isRequired,
@@ -166,7 +170,7 @@ export default class ColumnSettings extends React.PureComponent {
           </div>
         </div>
 
-        {isStaff && (
+        {(this.context.identity.permissions & PERMISSION_MANAGE_USERS === PERMISSION_MANAGE_USERS) && (
           <div role='group' aria-labelledby='notifications-admin-sign-up'>
             <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
 
@@ -179,7 +183,7 @@ export default class ColumnSettings extends React.PureComponent {
           </div>
         )}
 
-        {isStaff && (
+        {(this.context.identity.permissions & PERMISSION_MANAGE_REPORTS === PERMISSION_MANAGE_REPORTS) && (
           <div role='group' aria-labelledby='notifications-admin-report'>
             <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
 
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index edaff959e..50bda69f8 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -5,8 +5,9 @@ import IconButton from '../../../components/icon_button';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
-import { me, isStaff } from '../../../initial_state';
+import { me } from '../../../initial_state';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -50,6 +51,7 @@ class ActionBar extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -248,7 +250,7 @@ class ActionBar extends React.PureComponent {
         }
       }
 
-      if (isStaff) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
index edf1104c4..bbb9b122a 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -3,9 +3,10 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { invitesEnabled, limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state';
+import { limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state';
 import { logOut } from 'mastodon/utils/log_out';
 import { openModal } from 'mastodon/actions/modal';
+import { PERMISSION_INVITE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
@@ -27,6 +28,10 @@ export default @injectIntl
 @connect(null, mapDispatchToProps)
 class LinkFooter extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     withHotkeys: PropTypes.bool,
     onLogout: PropTypes.func.isRequired,
@@ -48,7 +53,7 @@ class LinkFooter extends React.PureComponent {
     return (
       <div className='getting-started__footer'>
         <ul>
-          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
           {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
           <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
           {!limitedFederationMode && <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index a6d54f134..709975270 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -12,14 +12,12 @@ export const boostModal = getMeta('boost_modal');
 export const deleteModal = getMeta('delete_modal');
 export const me = getMeta('me');
 export const searchEnabled = getMeta('search_enabled');
-export const invitesEnabled = getMeta('invites_enabled');
 export const limitedFederationMode = getMeta('limited_federation_mode');
 export const repository = getMeta('repository');
 export const source_url = getMeta('source_url');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const profile_directory = getMeta('profile_directory');
-export const isStaff = getMeta('is_staff');
 export const forceSingleColumn = !getMeta('advanced_layout');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
diff --git a/app/javascript/mastodon/permissions.js b/app/javascript/mastodon/permissions.js
new file mode 100644
index 000000000..752ddd6c5
--- /dev/null
+++ b/app/javascript/mastodon/permissions.js
@@ -0,0 +1,3 @@
+export const PERMISSION_INVITE_USERS   = 0x0000000000010000;
+export const PERMISSION_MANAGE_USERS   = 0x0000000000000400;
+export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
index 65becc44f..5040a340f 100644
--- a/app/javascript/mastodon/reducers/meta.js
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -7,12 +7,13 @@ const initialState = ImmutableMap({
   streaming_api_base_url: null,
   access_token: null,
   layout: layoutFromWindow(),
+  permissions: '0',
 });
 
 export default function meta(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return state.merge(action.state.get('meta'));
+    return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions']));
   case APP_LAYOUT_CHANGE:
     return state.set('layout', action.layout);
   default:
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 4ce5cd101..1c5494cde 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -924,6 +924,10 @@ a.name-tag,
   margin-top: 15px;
 }
 
+.user-role {
+  color: var(--user-role-accent);
+}
+
 .announcements-list,
 .filters-list {
   border: 1px solid lighten($ui-base-color, 4%);
@@ -960,6 +964,17 @@ a.name-tag,
     &__meta {
       padding: 0 15px;
       color: $dark-text-color;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: none;
+        }
+      }
     }
 
     &__action-bar {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index da699dd25..990903859 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -256,6 +256,10 @@ code {
     }
   }
 
+  .input.with_block_label.user_role_permissions_as_keys ul {
+    columns: unset;
+  }
+
   .input.datetime .label_input select {
     display: inline-block;
     width: auto;
diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb
index 877a42ef6..f512635ab 100644
--- a/app/lib/admin/system_check.rb
+++ b/app/lib/admin/system_check.rb
@@ -8,11 +8,11 @@ class Admin::SystemCheck
     Admin::SystemCheck::ElasticsearchCheck,
   ].freeze
 
-  def self.perform
+  def self.perform(current_user)
     ACTIVE_CHECKS.each_with_object([]) do |klass, arr|
-      check = klass.new
+      check = klass.new(current_user)
 
-      if check.pass?
+      if check.skip? || check.pass?
         arr
       else
         arr << check.message
diff --git a/app/lib/admin/system_check/base_check.rb b/app/lib/admin/system_check/base_check.rb
index fcad8daca..c2974c218 100644
--- a/app/lib/admin/system_check/base_check.rb
+++ b/app/lib/admin/system_check/base_check.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::BaseCheck
+  attr_reader :current_user
+
+  def initialize(current_user)
+    @current_user = current_user
+  end
+
+  def skip?
+    false
+  end
+
   def pass?
     raise NotImplementedError
   end
diff --git a/app/lib/admin/system_check/database_schema_check.rb b/app/lib/admin/system_check/database_schema_check.rb
index b93d1954e..c2f01fd55 100644
--- a/app/lib/admin/system_check/database_schema_check.rb
+++ b/app/lib/admin/system_check/database_schema_check.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::DatabaseSchemaCheck < Admin::SystemCheck::BaseCheck
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     !ActiveRecord::Base.connection.migration_context.needs_migration?
   end
diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
index 1b48a5415..8aee18267 100644
--- a/app/lib/admin/system_check/elasticsearch_check.rb
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     return true unless Chewy.enabled?
 
@@ -32,8 +36,4 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
   def compatible_version?
     Gem::Version.new(running_version) >= Gem::Version.new(required_version)
   end
-
-  def missing_queues
-    @missing_queues ||= Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] }
-  end
 end
diff --git a/app/lib/admin/system_check/rules_check.rb b/app/lib/admin/system_check/rules_check.rb
index 1fbdf955d..8206a5df3 100644
--- a/app/lib/admin/system_check/rules_check.rb
+++ b/app/lib/admin/system_check/rules_check.rb
@@ -3,6 +3,10 @@
 class Admin::SystemCheck::RulesCheck < Admin::SystemCheck::BaseCheck
   include RoutingHelper
 
+  def skip?
+    !current_user.can?(:manage_rules)
+  end
+
   def pass?
     Rule.kept.exists?
   end
diff --git a/app/lib/admin/system_check/sidekiq_process_check.rb b/app/lib/admin/system_check/sidekiq_process_check.rb
index 22446edaf..648811d6c 100644
--- a/app/lib/admin/system_check/sidekiq_process_check.rb
+++ b/app/lib/admin/system_check/sidekiq_process_check.rb
@@ -9,6 +9,10 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck
     scheduler
   ).freeze
 
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     missing_queues.empty?
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 730ef6293..628692d22 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -116,7 +116,7 @@ class Account < ApplicationRecord
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
   scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
-  scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
+  scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
 
@@ -132,9 +132,6 @@ class Account < ApplicationRecord
            :unconfirmed?,
            :unconfirmed_or_pending?,
            :role,
-           :admin?,
-           :moderator?,
-           :staff?,
            :locale,
            :shows_application?,
            to: :user,
@@ -454,7 +451,7 @@ class Account < ApplicationRecord
       DeliveryFailureTracker.without_unavailable(urls)
     end
 
-    def search_for(terms, limit = 10, offset = 0)
+    def search_for(terms, limit: 10, offset: 0)
       tsquery = generate_query_for_search(terms)
 
       sql = <<-SQL.squish
@@ -476,7 +473,7 @@ class Account < ApplicationRecord
       records
     end
 
-    def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
+    def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
       tsquery = generate_query_for_search(terms)
       sql = advanced_search_for_sql_template(following)
       records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index ec309ce09..e214e0bad 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -4,7 +4,7 @@ class AccountFilter
   KEYS = %i(
     origin
     status
-    permissions
+    role_ids
     username
     by_domain
     display_name
@@ -26,7 +26,7 @@ class AccountFilter
     params.each do |key, value|
       next if key.to_s == 'page'
 
-      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+      scope.merge!(scope_for(key, value)) if value.present?
     end
 
     scope
@@ -38,18 +38,18 @@ class AccountFilter
     case key.to_s
     when 'origin'
       origin_scope(value)
-    when 'permissions'
-      permissions_scope(value)
+    when 'role_ids'
+      role_scope(value)
     when 'status'
       status_scope(value)
     when 'by_domain'
-      Account.where(domain: value)
+      Account.where(domain: value.to_s)
     when 'username'
-      Account.matches_username(value)
+      Account.matches_username(value.to_s)
     when 'display_name'
-      Account.matches_display_name(value)
+      Account.matches_display_name(value.to_s)
     when 'email'
-      accounts_with_users.merge(User.matches_email(value))
+      accounts_with_users.merge(User.matches_email(value.to_s))
     when 'ip'
       valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none
     when 'invited_by'
@@ -104,13 +104,8 @@ class AccountFilter
     Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s))
   end
 
-  def permissions_scope(value)
-    case value.to_s
-    when 'staff'
-      accounts_with_users.merge(User.staff)
-    else
-      raise "Unknown permissions: #{value}"
-    end
+  def role_scope(value)
+    accounts_with_users.merge(User.where(role_id: Array(value).map(&:to_s)))
   end
 
   def accounts_with_users
@@ -118,7 +113,7 @@ class AccountFilter
   end
 
   def valid_ip?(value)
-    IPAddr.new(value) && true
+    IPAddr.new(value.to_s) && true
   rescue IPAddr::InvalidAddressError
     false
   end
diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb
deleted file mode 100644
index a42b4a172..000000000
--- a/app/models/concerns/user_roles.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-module UserRoles
-  extend ActiveSupport::Concern
-
-  included do
-    scope :admins, -> { where(admin: true) }
-    scope :moderators, -> { where(moderator: true) }
-    scope :staff, -> { admins.or(moderators) }
-  end
-
-  def staff?
-    admin? || moderator?
-  end
-
-  def role=(value)
-    case value
-    when 'admin'
-      self.admin     = true
-      self.moderator = false
-    when 'moderator'
-      self.admin     = false
-      self.moderator = true
-    else
-      self.admin     = false
-      self.moderator = false
-    end
-  end
-
-  def role
-    if admin?
-      'admin'
-    elsif moderator?
-      'moderator'
-    else
-      'user'
-    end
-  end
-
-  def role?(role)
-    case role
-    when 'user'
-      true
-    when 'moderator'
-      staff?
-    when 'admin'
-      admin?
-    else
-      false
-    end
-  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
-end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 6fc7c56fd..97fabc6ac 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -15,10 +15,8 @@ class Form::AdminSettings
     closed_registrations_message
     open_deletion
     timeline_preview
-    show_staff_badge
     bootstrap_timeline_accounts
     theme
-    min_invite_role
     activity_api_enabled
     peers_api_enabled
     show_known_fediverse_at_about_page
@@ -39,7 +37,6 @@ class Form::AdminSettings
   BOOLEAN_KEYS = %i(
     open_deletion
     timeline_preview
-    show_staff_badge
     activity_api_enabled
     peers_api_enabled
     show_known_fediverse_at_about_page
@@ -62,7 +59,6 @@ class Form::AdminSettings
   validates :site_short_description, :site_description, html: { wrap_with: :p }
   validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
   validates :registrations_mode, inclusion: { in: %w(open approved none) }
-  validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) }
   validates :site_contact_email, :site_contact_username, presence: true
   validates :site_contact_username, existing_username: true
   validates :bootstrap_timeline_accounts, existing_username: { multiple: true }
diff --git a/app/models/trends.rb b/app/models/trends.rb
index f8864e55f..d886be89a 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -34,7 +34,7 @@ module Trends
 
     return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
 
-    User.staff.includes(:account).find_each do |user|
+    User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
       AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
     end
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index 81f6a58f6..60abaf77e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -37,6 +37,7 @@
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
 #  sign_up_ip                :inet
+#  role_id                   :bigint(8)
 #
 
 class User < ApplicationRecord
@@ -50,7 +51,6 @@ class User < ApplicationRecord
   )
 
   include Settings::Extend
-  include UserRoles
   include Redisable
   include LanguagesHelper
 
@@ -79,6 +79,7 @@ class User < ApplicationRecord
   belongs_to :account, inverse_of: :user
   belongs_to :invite, counter_cache: :uses, optional: true
   belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true
+  belongs_to :role, class_name: 'UserRole', optional: true
   accepts_nested_attributes_for :account
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
@@ -103,6 +104,7 @@ class User < ApplicationRecord
   validates_with RegistrationFormTimeValidator, on: :create
   validates :website, absence: true, on: :create
   validates :confirm_password, absence: true, on: :create
+  validate :validate_role_elevation
 
   scope :recent, -> { order(id: :desc) }
   scope :pending, -> { where(approved: false) }
@@ -117,6 +119,7 @@ class User < ApplicationRecord
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
+  before_validation :sanitize_role
   before_create :set_approved
   after_commit :send_pending_devise_notifications
   after_create_commit :trigger_webhooks
@@ -135,8 +138,28 @@ class User < ApplicationRecord
            :disable_swiping, :always_send_emails,
            to: :settings, prefix: :setting, allow_nil: false
 
+  delegate :can?, to: :role
+
   attr_reader :invite_code
-  attr_writer :external, :bypass_invite_request_check
+  attr_writer :external, :bypass_invite_request_check, :current_account
+
+  def self.those_who_can(*any_of_privileges)
+    matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id)
+
+    if matching_role_ids.empty?
+      none
+    else
+      where(role_id: matching_role_ids)
+    end
+  end
+
+  def role
+    if role_id.nil?
+      UserRole.everyone
+    else
+      super
+    end
+  end
 
   def confirmed?
     confirmed_at.present?
@@ -441,6 +464,11 @@ class User < ApplicationRecord
     self.chosen_languages = nil if chosen_languages.empty?
   end
 
+  def sanitize_role
+    return if role.nil?
+    self.role = nil if role.everyone?
+  end
+
   def prepare_new_user!
     BootstrapTimelineWorker.perform_async(account_id)
     ActivityTracker.increment('activity:accounts:local')
@@ -453,7 +481,7 @@ class User < ApplicationRecord
   end
 
   def notify_staff_about_pending_account!
-    User.staff.includes(:account).find_each do |u|
+    User.those_who_can(:manage_users).includes(:account).find_each do |u|
       next unless u.allows_pending_account_emails?
       AdminMailer.new_pending_account(u.account, self).deliver_later
     end
@@ -471,6 +499,10 @@ class User < ApplicationRecord
     email_changed? && !external? && !(Rails.env.test? || Rails.env.development?)
   end
 
+  def validate_role_elevation
+    errors.add(:role_id, :elevated) if defined?(@current_account) && role&.overrides?(@current_account&.user_role)
+  end
+
   def invite_text_required?
     Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check?
   end
diff --git a/app/models/user_role.rb b/app/models/user_role.rb
new file mode 100644
index 000000000..833b96d71
--- /dev/null
+++ b/app/models/user_role.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: user_roles
+#
+#  id          :bigint(8)        not null, primary key
+#  name        :string           default(""), not null
+#  color       :string           default(""), not null
+#  position    :integer          default(0), not null
+#  permissions :bigint(8)        default(0), not null
+#  highlighted :boolean          default(FALSE), not null
+#  created_at  :datetime         not null
+#  updated_at  :datetime         not null
+#
+
+class UserRole < ApplicationRecord
+  FLAGS = {
+    administrator: (1 << 0),
+    view_devops: (1 << 1),
+    view_audit_log: (1 << 2),
+    view_dashboard: (1 << 3),
+    manage_reports: (1 << 4),
+    manage_federation: (1 << 5),
+    manage_settings: (1 << 6),
+    manage_blocks: (1 << 7),
+    manage_taxonomies: (1 << 8),
+    manage_appeals: (1 << 9),
+    manage_users: (1 << 10),
+    manage_invites: (1 << 11),
+    manage_rules: (1 << 12),
+    manage_announcements: (1 << 13),
+    manage_custom_emojis: (1 << 14),
+    manage_webhooks: (1 << 15),
+    invite_users: (1 << 16),
+    manage_roles: (1 << 17),
+    manage_user_access: (1 << 18),
+    delete_user_data: (1 << 19),
+  }.freeze
+
+  module Flags
+    NONE = 0
+    ALL  = FLAGS.values.reduce(&:|)
+
+    DEFAULT = FLAGS[:invite_users]
+
+    CATEGORIES = {
+      invites: %i(
+        invite_users
+      ).freeze,
+
+      moderation: %w(
+        view_dashboard
+        view_audit_log
+        manage_users
+        manage_user_access
+        delete_user_data
+        manage_reports
+        manage_appeals
+        manage_federation
+        manage_blocks
+        manage_taxonomies
+        manage_invites
+      ).freeze,
+
+      administration: %w(
+        manage_settings
+        manage_rules
+        manage_roles
+        manage_webhooks
+        manage_custom_emojis
+        manage_announcements
+      ).freeze,
+
+      devops: %w(
+        view_devops
+      ).freeze,
+
+      special: %i(
+        administrator
+      ).freeze,
+    }.freeze
+  end
+
+  attr_writer :current_account
+
+  validates :name, presence: true, unless: :everyone?
+  validates :color, format: { with: /\A#?(?:[A-F0-9]{3}){1,2}\z/i }, unless: -> { color.blank? }
+
+  validate :validate_permissions_elevation
+  validate :validate_position_elevation
+  validate :validate_dangerous_permissions
+
+  before_validation :set_position
+
+  scope :assignable, -> { where.not(id: -99).order(position: :asc) }
+
+  has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify
+
+  def self.nobody
+    @nobody ||= UserRole.new(permissions: Flags::NONE, position: -1)
+  end
+
+  def self.everyone
+    UserRole.find(-99)
+  rescue ActiveRecord::RecordNotFound
+    UserRole.create!(id: -99, permissions: Flags::DEFAULT)
+  end
+
+  def self.that_can(*any_of_privileges)
+    all.select { |role| role.can?(*any_of_privileges) }
+  end
+
+  def everyone?
+    id == -99
+  end
+
+  def nobody?
+    id.nil?
+  end
+
+  def permissions_as_keys
+    FLAGS.keys.select { |privilege| permissions & FLAGS[privilege] == FLAGS[privilege] }.map(&:to_s)
+  end
+
+  def permissions_as_keys=(value)
+    self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask }
+  end
+
+  def can?(*any_of_privileges)
+    any_of_privileges.any? { |privilege| in_permissions?(privilege) }
+  end
+
+  def overrides?(other_role)
+    other_role.nil? || position > other_role.position
+  end
+
+  def computed_permissions
+    # If called on the everyone role, no further computation needed
+    return permissions if everyone?
+
+    # If called on the nobody role, no permissions are there to be given
+    return Flags::NONE if nobody?
+
+    # Otherwise, compute permissions based on special conditions
+    @computed_permissions ||= begin
+      permissions = self.class.everyone.permissions | self.permissions
+
+      if permissions & FLAGS[:administrator] == FLAGS[:administrator]
+        Flags::ALL
+      else
+        permissions
+      end
+    end
+  end
+
+  private
+
+  def in_permissions?(privilege)
+    raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege)
+    computed_permissions & FLAGS[privilege] == FLAGS[privilege]
+  end
+
+  def set_position
+    self.position = -1 if everyone?
+  end
+
+  def validate_permissions_elevation
+    errors.add(:permissions_as_keys, :elevated) if defined?(@current_account) && @current_account.user_role.computed_permissions & permissions != permissions
+  end
+
+  def validate_position_elevation
+    errors.add(:position, :elevated) if defined?(@current_account) && @current_account.user_role.position < position
+  end
+
+  def validate_dangerous_permissions
+    errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions
+  end
+end
diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb
index 885411a5b..310ce854c 100644
--- a/app/policies/account_moderation_note_policy.rb
+++ b/app/policies/account_moderation_note_policy.rb
@@ -2,11 +2,11 @@
 
 class AccountModerationNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def destroy?
-    admin? || owner?
+    owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role))
   end
 
   private
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index cc23771e7..a744af81d 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -2,74 +2,66 @@
 
 class AccountPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_users)
   end
 
   def show?
-    staff?
+    role.can?(:manage_users)
   end
 
   def warn?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def suspend?
-    staff? && !record.user&.staff? && !record.instance_actor?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) && !record.instance_actor?
   end
 
   def destroy?
-    record.suspended_temporarily? && admin?
+    record.suspended_temporarily? && role.can?(:delete_user_data)
   end
 
   def unsuspend?
-    staff? && record.suspension_origin_local?
+    role.can?(:manage_users) && record.suspension_origin_local?
   end
 
   def sensitive?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def unsensitive?
-    staff?
+    role.can?(:manage_users)
   end
 
   def silence?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def unsilence?
-    staff?
+    role.can?(:manage_users)
   end
 
   def redownload?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def remove_avatar?
-    staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def remove_header?
-    staff?
-  end
-
-  def subscribe?
-    admin?
-  end
-
-  def unsubscribe?
-    admin?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def memorialize?
-    admin? && !record.user&.admin? && !record.instance_actor?
+    role.can?(:delete_user_data) && role.overrides?(record.user_role) && !record.instance_actor?
   end
 
   def unblock_email?
-    staff?
+    role.can?(:manage_users)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb
index 65707dfa7..4f8df7420 100644
--- a/app/policies/account_warning_policy.rb
+++ b/app/policies/account_warning_policy.rb
@@ -2,7 +2,7 @@
 
 class AccountWarningPolicy < ApplicationPolicy
   def show?
-    target? || staff?
+    target? || role.can?(:manage_appeals)
   end
 
   def appeal?
diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb
index bccbd33ef..59514e951 100644
--- a/app/policies/account_warning_preset_policy.rb
+++ b/app/policies/account_warning_preset_policy.rb
@@ -2,18 +2,18 @@
 
 class AccountWarningPresetPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def create?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def update?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def destroy?
-    staff?
+    role.can?(:manage_settings)
   end
 end
diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb
index 0a4e4575c..b5dc6a18a 100644
--- a/app/policies/announcement_policy.rb
+++ b/app/policies/announcement_policy.rb
@@ -2,18 +2,18 @@
 
 class AnnouncementPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_announcements)
   end
 
   def create?
-    admin?
+    role.can?(:manage_announcements)
   end
 
   def update?
-    admin?
+    role.can?(:manage_announcements)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_announcements)
   end
 end
diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb
index a25187172..7466b334b 100644
--- a/app/policies/appeal_policy.rb
+++ b/app/policies/appeal_policy.rb
@@ -2,12 +2,14 @@
 
 class AppealPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_appeals)
   end
 
   def approve?
-    record.pending? && staff?
+    record.pending? && role.can?(:manage_appeals)
   end
 
-  alias reject? approve?
+  def reject?
+    record.pending? && role.can?(:manage_appeals)
+  end
 end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
index d1de5e81a..163b81e9e 100644
--- a/app/policies/application_policy.rb
+++ b/app/policies/application_policy.rb
@@ -8,8 +8,6 @@ class ApplicationPolicy
     @record          = record
   end
 
-  delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true
-
   private
 
   def current_user
@@ -19,4 +17,8 @@ class ApplicationPolicy
   def user_signed_in?
     !current_user.nil?
   end
+
+  def role
+    current_user&.role || UserRole.nobody
+  end
 end
diff --git a/app/policies/audit_log_policy.rb b/app/policies/audit_log_policy.rb
new file mode 100644
index 000000000..f78aa9a8e
--- /dev/null
+++ b/app/policies/audit_log_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AuditLogPolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_audit_log)
+  end
+end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index a8c3cbc73..18de71c19 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -2,30 +2,30 @@
 
 class CustomEmojiPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def create?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def update?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def copy?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def enable?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def disable?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 end
diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb
new file mode 100644
index 000000000..3df1c3088
--- /dev/null
+++ b/app/policies/dashboard_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DashboardPolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_dashboard)
+  end
+end
diff --git a/app/policies/delivery_policy.rb b/app/policies/delivery_policy.rb
index 24d06c168..f6ba2eb18 100644
--- a/app/policies/delivery_policy.rb
+++ b/app/policies/delivery_policy.rb
@@ -2,14 +2,14 @@
 
 class DeliveryPolicy < ApplicationPolicy
   def clear_delivery_errors?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def restart_delivery?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def stop_delivery?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/domain_allow_policy.rb b/app/policies/domain_allow_policy.rb
index 7a5b5d780..45c797ecd 100644
--- a/app/policies/domain_allow_policy.rb
+++ b/app/policies/domain_allow_policy.rb
@@ -2,18 +2,18 @@
 
 class DomainAllowPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def create?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb
index 543259cce..0fea2e035 100644
--- a/app/policies/domain_block_policy.rb
+++ b/app/policies/domain_block_policy.rb
@@ -2,22 +2,22 @@
 
 class DomainBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def create?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def update?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb
index 5a75ee183..1a0ddfa87 100644
--- a/app/policies/email_domain_block_policy.rb
+++ b/app/policies/email_domain_block_policy.rb
@@ -2,14 +2,14 @@
 
 class EmailDomainBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_blocks)
   end
 end
diff --git a/app/policies/follow_recommendation_policy.rb b/app/policies/follow_recommendation_policy.rb
index 68cd0e547..9245733ea 100644
--- a/app/policies/follow_recommendation_policy.rb
+++ b/app/policies/follow_recommendation_policy.rb
@@ -2,14 +2,14 @@
 
 class FollowRecommendationPolicy < ApplicationPolicy
   def show?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def suppress?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def unsuppress?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
index 801ca162e..b15e123fe 100644
--- a/app/policies/instance_policy.rb
+++ b/app/policies/instance_policy.rb
@@ -2,14 +2,14 @@
 
 class InstancePolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
index 14236f78b..24eacd08e 100644
--- a/app/policies/invite_policy.rb
+++ b/app/policies/invite_policy.rb
@@ -2,19 +2,19 @@
 
 class InvitePolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_invites)
   end
 
   def create?
-    min_required_role?
+    role.can?(:invite_users)
   end
 
   def deactivate_all?
-    admin?
+    role.can?(:manage_invites)
   end
 
   def destroy?
-    owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?)
+    owner? || role.can?(:manage_invites)
   end
 
   private
@@ -22,8 +22,4 @@ class InvitePolicy < ApplicationPolicy
   def owner?
     record.user_id == current_user&.id
   end
-
-  def min_required_role?
-    current_user&.role?(Setting.min_invite_role)
-  end
 end
diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb
index 34dbd746a..1abc97ad8 100644
--- a/app/policies/ip_block_policy.rb
+++ b/app/policies/ip_block_policy.rb
@@ -2,14 +2,14 @@
 
 class IpBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_blocks)
   end
 end
diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb
index 0410987e4..a7bb41634 100644
--- a/app/policies/preview_card_policy.rb
+++ b/app/policies/preview_card_policy.rb
@@ -2,10 +2,10 @@
 
 class PreviewCardPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb
index 44d2ad5cf..131ccb5dd 100644
--- a/app/policies/preview_card_provider_policy.rb
+++ b/app/policies/preview_card_provider_policy.rb
@@ -2,10 +2,10 @@
 
 class PreviewCardProviderPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/relay_policy.rb b/app/policies/relay_policy.rb
index bd75e2197..4305bcfaa 100644
--- a/app/policies/relay_policy.rb
+++ b/app/policies/relay_policy.rb
@@ -2,6 +2,6 @@
 
 class RelayPolicy < ApplicationPolicy
   def update?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/report_note_policy.rb b/app/policies/report_note_policy.rb
index 694bc096b..dc31416e8 100644
--- a/app/policies/report_note_policy.rb
+++ b/app/policies/report_note_policy.rb
@@ -2,11 +2,11 @@
 
 class ReportNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def destroy?
-    admin? || owner?
+    owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role))
   end
 
   private
diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb
index 95b5c30c8..c9f7639bd 100644
--- a/app/policies/report_policy.rb
+++ b/app/policies/report_policy.rb
@@ -2,14 +2,14 @@
 
 class ReportPolicy < ApplicationPolicy
   def update?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def index?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def show?
-    staff?
+    role.can?(:manage_reports)
   end
 end
diff --git a/app/policies/rule_policy.rb b/app/policies/rule_policy.rb
index 6a4def009..51b2a6977 100644
--- a/app/policies/rule_policy.rb
+++ b/app/policies/rule_policy.rb
@@ -2,18 +2,18 @@
 
 class RulePolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_rules)
   end
 
   def create?
-    admin?
+    role.can?(:manage_rules)
   end
 
   def update?
-    admin?
+    role.can?(:manage_rules)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_rules)
   end
 end
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
index 874f97bab..2b052af27 100644
--- a/app/policies/settings_policy.rb
+++ b/app/policies/settings_policy.rb
@@ -2,14 +2,14 @@
 
 class SettingsPolicy < ApplicationPolicy
   def update?
-    admin?
+    role.can?(:manage_settings)
   end
 
   def show?
-    admin?
+    role.can?(:manage_settings)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_settings)
   end
 end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 400f1ec79..2f48b5d70 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -8,7 +8,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def index?
-    staff?
+    role.can?(:manage_reports, :manage_users)
   end
 
   def show?
@@ -32,17 +32,17 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def destroy?
-    staff? || owned?
+    role.can?(:manage_reports) || owned?
   end
 
   alias unreblog? destroy?
 
   def update?
-    staff? || owned?
+    role.can?(:manage_reports) || owned?
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   private
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
index bdfcec0c9..bb1d37d6c 100644
--- a/app/policies/tag_policy.rb
+++ b/app/policies/tag_policy.rb
@@ -2,18 +2,18 @@
 
 class TagPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def show?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def update?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 140905e1f..6751b8b8f 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -2,52 +2,38 @@
 
 class UserPolicy < ApplicationPolicy
   def reset_password?
-    staff? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
   end
 
   def change_email?
-    staff? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
   end
 
   def disable_2fa?
-    admin? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
+  end
+
+  def change_role?
+    role.can?(:manage_roles) && role.overrides?(record.role)
   end
 
   def confirm?
-    staff? && !record.confirmed?
+    role.can?(:manage_user_access) && !record.confirmed?
   end
 
   def enable?
-    staff?
+    role.can?(:manage_users)
   end
 
   def approve?
-    staff? && !record.approved?
+    role.can?(:manage_users) && !record.approved?
   end
 
   def reject?
-    staff? && !record.approved?
+    role.can?(:manage_users) && !record.approved?
   end
 
   def disable?
-    staff? && !record.admin?
-  end
-
-  def promote?
-    admin? && promotable?
-  end
-
-  def demote?
-    admin? && !record.admin? && demoteable?
-  end
-
-  private
-
-  def promotable?
-    record.approved? && (!record.staff? || !record.admin?)
-  end
-
-  def demoteable?
-    record.staff?
+    role.can?(:manage_users) && role.overrides?(record.role)
   end
 end
diff --git a/app/policies/user_role_policy.rb b/app/policies/user_role_policy.rb
new file mode 100644
index 000000000..7019637fc
--- /dev/null
+++ b/app/policies/user_role_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UserRolePolicy < ApplicationPolicy
+  def index?
+    role.can?(:manage_roles)
+  end
+
+  def create?
+    role.can?(:manage_roles)
+  end
+
+  def update?
+    role.can?(:manage_roles) && role.overrides?(record)
+  end
+
+  def destroy?
+    !record.everyone? && role.can?(:manage_roles) && role.overrides?(record) && role.id != record.id
+  end
+end
diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb
index 2c55703a1..a2199a333 100644
--- a/app/policies/webhook_policy.rb
+++ b/app/policies/webhook_policy.rb
@@ -2,34 +2,34 @@
 
 class WebhookPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def show?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def update?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def enable?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def disable?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def rotate_secret?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_webhooks)
   end
 end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 06482935c..129ea2a46 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -3,4 +3,8 @@
 class InitialStatePresenter < ActiveModelSerializers::Model
   attributes :settings, :push_subscription, :token,
              :current_account, :admin, :text, :visibility
+
+  def role
+    current_account&.user_role
+  end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 34190a91d..5eda87757 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -6,6 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
              :languages
 
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
+  has_one :role, serializer: REST::RoleSerializer
 
   def meta
     store = {
@@ -19,7 +20,6 @@ class InitialStateSerializer < ActiveModel::Serializer
       repository: Mastodon::Version.repository,
       source_url: Mastodon::Version.source_url,
       version: Mastodon::Version.to_s,
-      invites_enabled: Setting.min_invite_role == 'user',
       limited_federation_mode: Rails.configuration.x.whitelist_mode,
       mascot: instance_presenter.mascot&.file&.url,
       profile_directory: Setting.profile_directory,
@@ -39,7 +39,6 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:advanced_layout]   = object.current_account.user.setting_advanced_layout
       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash
       store[:use_pending_items] = object.current_account.user.setting_use_pending_items
-      store[:is_staff]          = object.current_account.user.staff?
       store[:trends]            = Setting.trends && object.current_account.user.setting_trends
       store[:crop_images]       = object.current_account.user.setting_crop_images
     else
diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb
index be0d763dc..27e1db207 100644
--- a/app/serializers/rest/credential_account_serializer.rb
+++ b/app/serializers/rest/credential_account_serializer.rb
@@ -3,6 +3,8 @@
 class REST::CredentialAccountSerializer < REST::AccountSerializer
   attributes :source
 
+  has_one :role, serializer: REST::RoleSerializer
+
   def source
     user = object.user
 
@@ -15,4 +17,8 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer
       follow_requests_count: FollowRequest.where(target_account: object).limit(40).count,
     }
   end
+
+  def role
+    object.user_role
+  end
 end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index f5dec0dac..9cc245422 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -93,7 +93,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
   end
 
   def invites_enabled
-    Setting.min_invite_role == 'user'
+    UserRole.everyone.can?(:invite_users)
   end
 
   private
diff --git a/app/serializers/rest/role_serializer.rb b/app/serializers/rest/role_serializer.rb
new file mode 100644
index 000000000..5b81c6e04
--- /dev/null
+++ b/app/serializers/rest/role_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::RoleSerializer < ActiveModel::Serializer
+  attributes :id, :name, :permissions, :color, :highlighted
+
+  def id
+    object.id.to_s
+  end
+
+  def permissions
+    object.computed_permissions.to_s
+  end
+end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 6fe4b6593..4dcae20eb 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -61,11 +61,11 @@ class AccountSearchService < BaseService
   end
 
   def advanced_search_results
-    Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, options[:following], offset)
+    Account.advanced_search_for(terms_for_query, account, limit: limit_for_non_exact_results, following: options[:following], offset: offset)
   end
 
   def simple_search_results
-    Account.search_for(terms_for_query, limit_for_non_exact_results, offset)
+    Account.search_for(terms_for_query, limit: limit_for_non_exact_results, offset: offset)
   end
 
   def from_elasticsearch
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
index cef9be05f..399a053d6 100644
--- a/app/services/appeal_service.rb
+++ b/app/services/appeal_service.rb
@@ -22,7 +22,7 @@ class AppealService < BaseService
   end
 
   def notify_staff!
-    User.staff.includes(:account).each do |u|
+    User.those_who_can(:manage_appeals).includes(:account).each do |u|
       AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
     end
   end
diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb
index a02e55a6d..126c0fa2e 100644
--- a/app/services/bootstrap_timeline_service.rb
+++ b/app/services/bootstrap_timeline_service.rb
@@ -17,7 +17,7 @@ class BootstrapTimelineService < BaseService
   end
 
   def notify_staff!
-    User.staff.includes(:account).find_each do |user|
+    User.those_who_can(:manage_users).includes(:account).find_each do |user|
       LocalNotificationWorker.perform_async(user.account_id, @source_account.id, 'Account', 'admin.sign_up')
     end
   end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index d30b33876..c7454fc60 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -76,7 +76,7 @@ class NotifyService < BaseService
   end
 
   def from_staff?
-    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff?
+    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user_role&.overrides?(@recipient.user_role)
   end
 
   def optional_non_following_and_direct?
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index bd67ff8d3..8c92cf334 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -38,7 +38,7 @@ class ReportService < BaseService
   def notify_staff!
     return if @report.unresolved_siblings?
 
-    User.staff.includes(:account).each do |u|
+    User.those_who_can(:manage_reports).includes(:account).each do |u|
       LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report')
       AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails?
     end
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 60e4894d0..7560fac7a 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -4,45 +4,36 @@
 - content_for :header_tags do
   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
 
-.filters
-  .filter-subset
-    %strong= t('admin.accounts.location.title')
-    %ul
-      %li= filter_link_to t('generic.all'), origin: nil
-      %li= filter_link_to t('admin.accounts.location.local'), origin: 'local'
-      %li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote'
-  .filter-subset
-    %strong= t('admin.accounts.moderation.title')
-    %ul
-      %li= filter_link_to t('generic.all'), status: nil
-      %li= filter_link_to t('admin.accounts.moderation.active'), status: 'active'
-      %li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended'
-      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending'
-  .filter-subset
-    %strong= t('admin.accounts.role')
-    %ul
-      %li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil
-      %li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff'
-  .filter-subset
-    %strong= t 'generic.order_by'
-    %ul
-      %li= filter_link_to t('relationships.most_recent'), order: nil
-      %li= filter_link_to t('relationships.last_active'), order: 'active'
-
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
-  .fields-group
-    - (AccountFilter::KEYS - %i(origin status permissions)).each do |key|
-      - if params[key].present?
-        = hidden_field_tag key, params[key]
+  .filters
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.location.title')
+      .input.select.optional
+        = select_tag :origin, options_for_select([[t('admin.accounts.location.local'), 'local'], [t('admin.accounts.location.remote'), 'remote']], params[:origin]), prompt: I18n.t('generic.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.moderation.title')
+      .input.select.optional
+        = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.role')
+      .input.select.optional
+        = select_tag :role_ids, options_from_collection_for_select(UserRole.assignable, :id, :name, params[:role_ids]), prompt: I18n.t('admin.accounts.moderation.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t 'generic.order_by'
+      .input.select
+        = select_tag :order, options_for_select([[t('relationships.most_recent'), nil], [t('relationships.last_active'), 'active']], params[:order])
 
+  .fields-group
     - %i(username by_domain display_name email ip).each do |key|
       - unless key == :by_domain && params[:origin] != 'remote'
         .input.string.optional
           = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}")
 
-    .actions
-      %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
+  .actions
+    %button.button= t('admin.accounts.search')
+    = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
+
+%hr.spacer/
 
 = form_for(@form, url: batch_admin_accounts_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index a69832b04..dc3b35956 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -92,10 +92,13 @@
 
           %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)
+              - if @account.user_role&.everyone?
+                = t('admin.accounts.no_role_assigned')
+              - else
+                = @account.user_role&.name
+            %td
+              = table_link_to 'vcard', t('admin.accounts.change_role.label'), admin_user_role_path(@account.user) if can?(:change_role, @account.user)
 
           %tr
             %th{ rowspan: can?(:create, :email_domain_block) ? 3 : 2 }= t('admin.accounts.email')
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index f611bfe9d..d8b7132f5 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -11,7 +11,7 @@
     .filter-subset.filter-subset--with-select
       %strong= t('admin.action_logs.filter_by_user')
       .input.select.optional
-        = select_tag :account_id, options_from_collection_for_select(Account.joins(:user).merge(User.staff), :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all')
+        = select_tag :account_id, options_from_collection_for_select(@auditable_accounts, :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all')
 
     .filter-subset.filter-subset--with-select
       %strong= t('admin.action_logs.filter_by_action')
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index ef4de602d..ab290912e 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -4,32 +4,33 @@
 - content_for :header_tags do
   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
 
-- content_for :heading_actions do
-  = l(@time_period.first)
-  = ' - '
-  = l(@time_period.last)
-
-%p
-  = fa_icon 'info fw'
-  = t('admin.instances.totals_time_period_hint_html')
-
-.dashboard
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain)
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain)
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension')
+- if current_user.can?(:view_dashboard)
+  - content_for :heading_actions do
+    = l(@time_period.first)
+    = ' - '
+    = l(@time_period.last)
+
+  %p
+    = fa_icon 'info fw'
+    = t('admin.instances.totals_time_period_hint_html')
+
+  .dashboard
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain)
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain)
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension')
 
 %hr.spacer/
 
diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml
new file mode 100644
index 000000000..68607ce68
--- /dev/null
+++ b/app/views/admin/roles/_form.html.haml
@@ -0,0 +1,37 @@
+= simple_form_for @role, url: @role.new_record? ? admin_roles_path : admin_role_path(@role) do |f|
+  = render 'shared/error_messages', object: @role
+
+  - if @role.everyone?
+    .flash-message.info
+      = t('admin.roles.everyone_full_description_html')
+  - else
+    .fields-group
+      = f.input :name, wrapper: :with_label
+
+    .fields-group
+      = f.input :position, wrapper: :with_label
+
+    .fields-group
+      = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' }
+
+    %hr.spacer/
+
+    .fields-group
+      = f.input :highlighted, wrapper: :with_label
+
+    %hr.spacer/
+
+  .field-group
+    .input.with_block_label
+      %label= t('simple_form.labels.user_role.permissions_as_keys')
+      %span.hint= t('simple_form.hints.user_role.permissions_as_keys')
+
+    - (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions|
+      %h4= t(category, scope: 'admin.roles.categories')
+
+      = f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: lambda { |privilege| safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false
+
+  %hr.spacer/
+
+  .actions
+    = f.button :button, @role.new_record? ? t('admin.roles.add_new') : t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml
new file mode 100644
index 000000000..6804f4f15
--- /dev/null
+++ b/app/views/admin/roles/_role.html.haml
@@ -0,0 +1,18 @@
+.announcements-list__item
+  = link_to edit_admin_role_path(role), class: 'announcements-list__item__title' do
+    %span.user-role{ class: "user-role-#{role.id}" }
+      = fa_icon 'users fw'
+
+      - if role.everyone?
+        = t('admin.roles.everyone')
+      - else
+        = role.name
+
+  .announcements-list__item__action-bar
+    .announcements-list__item__meta
+      - if role.everyone?
+        = t('admin.roles.everyone_full_description_html')
+      - else
+        = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_id: role.id)
+        •
+        %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
diff --git a/app/views/admin/roles/edit.html.haml b/app/views/admin/roles/edit.html.haml
new file mode 100644
index 000000000..659ccb8dc
--- /dev/null
+++ b/app/views/admin/roles/edit.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+  = t('admin.roles.edit', name: @role.everyone? ? t('admin.roles.everyone') : @role.name)
+
+- content_for :heading_actions do
+  = link_to t('admin.roles.delete'), admin_role_path(@role), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:destroy, @role)
+
+= render partial: 'form'
+
diff --git a/app/views/admin/roles/index.html.haml b/app/views/admin/roles/index.html.haml
new file mode 100644
index 000000000..4f6c511b4
--- /dev/null
+++ b/app/views/admin/roles/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('admin.roles.title')
+
+- content_for :heading_actions do
+  = link_to t('admin.roles.add_new'), new_admin_role_path, class: 'button' if can?(:create, :user_role)
+
+%p= t('admin.roles.description_html')
+
+%hr.spacer/
+
+.applications-list
+  = render partial: 'role', collection: @roles.select(&:everyone?)
+
+%hr.spacer/
+
+.applications-list
+  = render partial: 'role', collection: @roles.reject(&:everyone?)
diff --git a/app/views/admin/roles/new.html.haml b/app/views/admin/roles/new.html.haml
new file mode 100644
index 000000000..821079271
--- /dev/null
+++ b/app/views/admin/roles/new.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_title do
+  = t('admin.roles.add_new')
+
+= render partial: 'form'
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 33bfc43d3..d7896bbc0 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -62,9 +62,6 @@
       = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html')
 
   .fields-group
-    = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html')
-
-  .fields-group
     = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html')
 
   - unless whitelist_mode?
@@ -91,9 +88,6 @@
 
   %hr.spacer/
 
-  .fields-group
-    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
-
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
       = f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index df72bd5f5..fd9acce4a 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -4,49 +4,50 @@
 - content_for :page_title do
   = "##{@tag.name}"
 
-- content_for :heading_actions do
-  = l(@time_period.first)
-  = ' - '
-  = l(@time_period.last)
-
-.dashboard
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank'
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
-  .dashboard__item
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
-      - if @tag.usable?
-        %span= t('admin.trends.tags.usable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_usable')
-        = fa_icon 'lock fw'
-
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
-      - if @tag.trendable?
-        %span= t('admin.trends.tags.trendable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_trendable')
-        = fa_icon 'lock fw'
-
-
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
-      - if @tag.listable?
-        %span= t('admin.trends.tags.listable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_listable')
-        = fa_icon 'lock fw'
-
-%hr.spacer/
+- if current_user.can?(:view_dashboard)
+  - content_for :heading_actions do
+    = l(@time_period.first)
+    = ' - '
+    = l(@time_period.last)
+
+  .dashboard
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank'
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
+    .dashboard__item
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
+        - if @tag.usable?
+          %span= t('admin.trends.tags.usable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_usable')
+          = fa_icon 'lock fw'
+
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
+        - if @tag.trendable?
+          %span= t('admin.trends.tags.trendable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_trendable')
+          = fa_icon 'lock fw'
+
+
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
+        - if @tag.listable?
+          %span= t('admin.trends.tags.listable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_listable')
+          = fa_icon 'lock fw'
+
+  %hr.spacer/
 
 = simple_form_for @tag, url: admin_tag_path(@tag.id) do |f|
   = render 'shared/error_messages', object: @tag
diff --git a/app/views/admin/users/roles/show.html.haml b/app/views/admin/users/roles/show.html.haml
new file mode 100644
index 000000000..821618060
--- /dev/null
+++ b/app/views/admin/users/roles/show.html.haml
@@ -0,0 +1,9 @@
+- content_for :page_title do
+  = t('admin.accounts.change_role.title', username: @user.account.username)
+
+= simple_form_for @user, url: admin_user_role_path(@user) do |f|
+  .fields-group
+    = f.association :role, wrapper: :with_block_label, collection: UserRole.assignable, label_method: :name, include_blank: I18n.t('admin.accounts.change_role.no_role')
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/custom_css/show.css.erb b/app/views/custom_css/show.css.erb
new file mode 100644
index 000000000..521834832
--- /dev/null
+++ b/app/views/custom_css/show.css.erb
@@ -0,0 +1,10 @@
+<%- if Setting.custom_css.present? %>
+<%= Setting.custom_css %>
+
+<%- end %>
+<%- UserRole.where(highlighted: true).select { |role| role.color.present? }.each do |role| %>
+.user-role-<%= role.id %> {
+  --user-role-accent: <%= role.color %>;
+}
+
+<%- end %>
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 25fd5bc34..bf164223c 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -34,9 +34,7 @@
     %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
 
     = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'
-
-    - if Setting.custom_css.present?
-      = stylesheet_link_tag custom_css_path, host: request.host, media: 'all'
+    = stylesheet_link_tag custom_css_path, host: default_url_options[:host], media: 'all'
 
     = yield :header_tags
 
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index 42754a852..bc7afb993 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -18,12 +18,10 @@
       = ff.input :reblog, as: :boolean, wrapper: :with_label
       = ff.input :favourite, as: :boolean, wrapper: :with_label
       = ff.input :mention, as: :boolean, wrapper: :with_label
-
-      - if current_user.staff?
-        = ff.input :report, as: :boolean, wrapper: :with_label
-        = ff.input :appeal, as: :boolean, wrapper: :with_label
-        = ff.input :pending_account, as: :boolean, wrapper: :with_label
-        = ff.input :trending_tag, as: :boolean, wrapper: :with_label
+      = ff.input :report, as: :boolean, wrapper: :with_label if current_user.can?(:manage_reports)
+      = ff.input :appeal, as: :boolean, wrapper: :with_label if current_user.can?(:manage_appeals)
+      = ff.input :pending_account, as: :boolean, wrapper: :with_label if current_user.can?(:manage_users)
+      = ff.input :trending_tag, as: :boolean, wrapper: :with_label if current_user.can?(:manage_taxonomies)
 
   .fields-group
     = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label
diff --git a/config/application.rb b/config/application.rb
index 24fa2a978..06360832c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -44,6 +44,7 @@ require_relative '../lib/webpacker/helper_extensions'
 require_relative '../lib/rails/engine_extensions'
 require_relative '../lib/active_record/database_tasks_extensions'
 require_relative '../lib/active_record/batches'
+require_relative '../lib/simple_navigation/item_extensions'
 
 Dotenv::Railtie.load
 
diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml
index 720b0f5e3..daeed58b8 100644
--- a/config/locales/activerecord.en.yml
+++ b/config/locales/activerecord.en.yml
@@ -38,3 +38,12 @@ en:
             email:
               blocked: uses a disallowed e-mail provider
               unreachable: does not seem to exist
+            role_id:
+              elevated: cannot be higher than your current role
+        user_role:
+          attributes:
+            permissions_as_keys:
+              dangerous: include permissions that are not safe for the base role
+              elevated: cannot include permissions your current role does not possess
+            position:
+              elevated: cannot be higher than your current role
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 91ae3a3bc..2cd4f45ac 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -83,10 +83,8 @@ en:
     posts_tab_heading: Posts
     posts_with_replies: Posts and replies
     roles:
-      admin: Admin
       bot: Bot
       group: Group
-      moderator: Mod
     unavailable: Profile unavailable
     unfollow: Unfollow
   admin:
@@ -105,12 +103,17 @@ en:
       avatar: Avatar
       by_domain: Domain
       change_email:
-        changed_msg: Account email successfully changed!
+        changed_msg: Email successfully changed!
         current_email: Current email
         label: Change email
         new_email: New email
         submit: Change email
         title: Change email for %{username}
+      change_role:
+        changed_msg: Role successfully changed!
+        label: Change role
+        no_role: No role
+        title: Change role for %{username}
       confirm: Confirm
       confirmed: Confirmed
       confirming: Confirming
@@ -154,6 +157,7 @@ en:
         active: Active
         all: All
         pending: Pending
+        silenced: Limited
         suspended: Suspended
         title: Moderation
       moderation_notes: Moderation notes
@@ -161,6 +165,7 @@ en:
       most_recent_ip: Most recent IP
       no_account_selected: No accounts were changed as none were selected
       no_limits_imposed: No limits imposed
+      no_role_assigned: No role assigned
       not_subscribed: Not subscribed
       pending: Pending review
       perform_full_suspension: Suspend
@@ -187,12 +192,7 @@ en:
       reset: Reset
       reset_password: Reset password
       resubscribe: Resubscribe
-      role: Permissions
-      roles:
-        admin: Administrator
-        moderator: Moderator
-        staff: Staff
-        user: User
+      role: Role
       search: Search
       search_same_email_domain: Other users with the same e-mail domain
       search_same_ip: Other users with the same IP
@@ -649,6 +649,67 @@ en:
       unresolved: Unresolved
       updated_at: Updated
       view_profile: View profile
+    roles:
+      add_new: Add role
+      assigned_users:
+        one: "%{count} user"
+        other: "%{count} users"
+      categories:
+        administration: Administration
+        devops: Devops
+        invites: Invites
+        moderation: Moderation
+        special: Special
+      delete: Delete
+      description_html: With <strong>user roles</strong>, you can customize which functions and areas of Mastodon your users can access.
+      edit: Edit '%{name}' role
+      everyone: Default permissions
+      everyone_full_description_html: This is the <strong>base role</strong> affecting <strong>all users</strong>, even those without an assigned role. All other roles inherit permissions from it.
+      permissions_count:
+        one: "%{count} permission"
+        other: "%{count} permissions"
+      privileges:
+        administrator: Administrator
+        administrator_description: Users with this permission will bypass every permission
+        delete_user_data: Delete User Data
+        delete_user_data_description: Allows users to delete other users' data without delay
+        invite_users: Invite Users
+        invite_users_description: Allows users to invite new people to the server
+        manage_announcements: Manage Announcements
+        manage_announcements_description: Allows users to manage announcements on the server
+        manage_appeals: Manage Appeals
+        manage_appeals_description: Allows users to review appeals against moderation actions
+        manage_blocks: Manage Blocks
+        manage_blocks_description: Allows users to block e-mail providers and IP addresses
+        manage_custom_emojis: Manage Custom Emojis
+        manage_custom_emojis_description: Allows users to manage custom emojis on the server
+        manage_federation: Manage Federation
+        manage_federation_description: Allows users to block or allow federation with other domains, and control deliverability
+        manage_invites: Manage Invites
+        manage_invites_description: Allows users to browse and deactivate invite links
+        manage_reports: Manage Reports
+        manage_reports_description: Allows users to review reports and perform moderation actions against them
+        manage_roles: Manage Roles
+        manage_roles_description: Allows users to manage and assign roles below theirs
+        manage_rules: Manage Rules
+        manage_rules_description: Allows users to change server rules
+        manage_settings: Manage Settings
+        manage_settings_description: Allows users to change site settings
+        manage_taxonomies: Manage Taxonomies
+        manage_taxonomies_description: Allows users to review trending content and update hashtag settings
+        manage_user_access: Manage User Access
+        manage_user_access_description: Allows users to disable other users' two-factor authentication, change their e-mail address, and reset their password
+        manage_users: Manage Users
+        manage_users_description: Allows users to view other users' details and perform moderation actions against them
+        manage_webhooks: Manage Webhooks
+        manage_webhooks_description: Allows users to set up webhooks for administrative events
+        view_audit_log: View Audit Log
+        view_audit_log_description: Allows users to see a history of administrative actions on the server
+        view_dashboard: View Dashboard
+        view_dashboard_description: Allows users to access the dashboard and various metrics
+        view_devops: Devops
+        view_devops_description: Allows users to access Sidekiq and pgHero dashboards
+      title: Roles
     rules:
       add_new: Add rule
       delete: Delete
@@ -701,9 +762,6 @@ en:
         deletion:
           desc_html: Allow anyone to delete their account
           title: Open account deletion
-        min_invite_role:
-          disabled: No one
-          title: Allow invitations by
         require_invite_text:
           desc_html: When registrations require manual approval, make the “Why do you want to join?” text input mandatory rather than optional
           title: Require new users to enter a reason to join
@@ -716,9 +774,6 @@ en:
       show_known_fediverse_at_about_page:
         desc_html: When disabled, restricts the public timeline linked from the landing page to showing only local content
         title: Include federated content on unauthenticated public timeline page
-      show_staff_badge:
-        desc_html: Show a staff badge on a user page
-        title: Show staff badge
       site_description:
         desc_html: Introductory paragraph on the API. Describe what makes this Mastodon server special and anything else important. You can use HTML tags, in particular <code>&lt;a&gt;</code> and <code>&lt;em&gt;</code>.
         title: Server description
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index ea4f68562..932f34d82 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -96,6 +96,13 @@ en:
         name: You can only change the casing of the letters, for example, to make it more readable
       user:
         chosen_languages: When checked, only posts in selected languages will be displayed in public timelines
+        role: The role controls which permissions the user has
+      user_role:
+        color: Color to be used for the role throughout the UI, as RGB in hex format
+        highlighted: This makes the role publicly visible
+        name: Public name of the role, if role is set to be displayed as a badge
+        permissions_as_keys: Users with this role will have access to...
+        position: Higher role decides conflict resolution in certain situations
       webhook:
         events: Select events to send
         url: Where events will be sent to
@@ -232,6 +239,14 @@ en:
         name: Hashtag
         trendable: Allow this hashtag to appear under trends
         usable: Allow posts to use this hashtag
+      user:
+        role: Role
+      user_role:
+        color: Badge color
+        highlighted: Display role as badge on user profiles
+        name: Name
+        permissions_as_keys: Permissions
+        position: Priority
       webhook:
         events: Enabled events
         url: Endpoint URL
diff --git a/config/navigation.rb b/config/navigation.rb
index ec5719e3e..706de0471 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -2,66 +2,67 @@
 
 SimpleNavigation::Configuration.run do |navigation|
   navigation.items do |n|
-    n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url
+    n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path
 
-    n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_url, if: -> { current_user.functional? } do |s|
-      s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url
-      s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url
+    n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? } do |s|
+      s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_path
+      s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_path
     end
 
-    n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s|
-      s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url
-      s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url
-      s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
+    n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s|
+      s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_path
+      s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_path
+      s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_path
     end
 
-    n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url, if: -> { current_user.functional? }
+    n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? }
     n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
-    n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_url, if: -> { current_user.functional? }
+    n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional? }
 
-    n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
-      s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes}
-      s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys}
-      s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
+    n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_path do |s|
+      s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_path, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes}
+      s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_path, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys}
+      s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_path
     end
 
-    n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url do |s|
-      s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url, if: -> { current_user.functional? }
-      s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
+    n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_path do |s|
+      s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_path, if: -> { current_user.functional? }
+      s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_path
     end
 
-    n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? }
-    n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
+    n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: -> { current_user.can?(:invite_users) && current_user.functional? }
+    n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_path, if: -> { current_user.functional? }
 
-    n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_tags_path, if: proc { current_user.staff? } do |s|
+    n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_statuses_path, if: -> { current_user.can?(:manage_taxonomies) } do |s|
       s.item :statuses, safe_join([fa_icon('comments-o fw'), t('admin.trends.statuses.title')]), admin_trends_statuses_path, highlights_on: %r{/admin/trends/statuses}
       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags}
       s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links}
     end
 
-    n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s|
-      s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
-      s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
-      s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes}
-      s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
-      s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}
-      s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
-      s.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? }
-      s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
+    n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), nil, if: -> { current_user.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks) } do |s|
+      s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_path, highlights_on: %r{/admin/reports}, if: -> { current_user.can?(:manage_reports) }
+      s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_path(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes|/admin/users}, if: -> { current_user.can?(:manage_users) }
+      s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path, if: -> { current_user.can?(:manage_invites) }
+      s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}, if: -> { current_user.can?(:manage_taxonomies) }
+      s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_path(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.can?(:manage_federation) }
+      s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) }
+      s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) }
+      s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) }
     end
 
-    n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
-      s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url
-      s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings}
-      s.item :rules, safe_join([fa_icon('gavel fw'), t('admin.rules.title')]), admin_rules_path, highlights_on: %r{/admin/rules}
-      s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
-      s.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}
-      s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}
-      s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
-      s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
-      s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
+    n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), nil, if: -> { current_user.can?(:view_dashboard, :manage_settings, :manage_rules, :manage_announcements, :manage_custom_emojis, :manage_webhooks, :manage_federation) } do |s|
+      s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_path, if: -> { current_user.can?(:view_dashboard) }
+      s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_path, if: -> { current_user.can?(:manage_settings) }, highlights_on: %r{/admin/settings}
+      s.item :rules, safe_join([fa_icon('gavel fw'), t('admin.rules.title')]), admin_rules_path, highlights_on: %r{/admin/rules}, if: -> { current_user.can?(:manage_rules) }
+      s.item :roles, safe_join([fa_icon('vcard fw'), t('admin.roles.title')]), admin_roles_path, highlights_on: %r{/admin/roles}, if: -> { current_user.can?(:manage_roles) }
+      s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) }
+      s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) }
+      s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) }
+      s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !whitelist_mode? && current_user.can?(:manage_federation) }
     end
 
-    n.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_url, link_html: { 'data-method' => 'delete' }
+    n.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_path, link_html: { target: 'sidekiq' }, if: -> { current_user.can?(:view_devops) }
+    n.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_path, link_html: { target: 'pghero' }, if: -> { current_user.can?(:view_devops) }
+    n.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_path, link_html: { 'data-method' => 'delete' }
   end
 end
diff --git a/config/roles.yml b/config/roles.yml
new file mode 100644
index 000000000..f443250d1
--- /dev/null
+++ b/config/roles.yml
@@ -0,0 +1,35 @@
+moderator:
+  name: Moderator
+  position: 10
+  permissions:
+    - view_dashboard
+    - view_audit_log
+    - manage_users
+    - manage_reports
+    - manage_taxonomies
+admin:
+  name: Admin
+  position: 100
+  permissions:
+    - view_dashboard
+    - view_audit_log
+    - manage_users
+    - manage_user_access
+    - delete_user_data
+    - manage_reports
+    - manage_taxonomies
+    - manage_federation
+    - manage_settings
+    - manage_blocks
+    - manage_appeals
+    - manage_rules
+    - manage_invites
+    - manage_announcements
+    - manage_custom_emojis
+    - manage_webhooks
+    - manage_roles
+owner:
+  name: Owner
+  position: 1000
+  permissions:
+    - administrator
diff --git a/config/routes.rb b/config/routes.rb
index 4abf55655..177c1cff4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -10,7 +10,7 @@ Rails.application.routes.draw do
 
   get 'health', to: 'health#show'
 
-  authenticate :user, lambda { |u| u.admin? } do
+  authenticate :user, lambda { |u| u.role&.can?(:view_devops) } do
     mount Sidekiq::Web, at: 'sidekiq', as: :sidekiq
     mount PgHero::Engine, at: 'pghero', as: :pghero
   end
@@ -295,17 +295,11 @@ Rails.application.routes.draw do
           post :resend
         end
       end
-
-      resource :role, only: [] do
-        member do
-          post :promote
-          post :demote
-        end
-      end
     end
 
     resources :users, only: [] do
-      resource :two_factor_authentication, only: [:destroy]
+      resource :two_factor_authentication, only: [:destroy], controller: 'users/two_factor_authentications'
+      resource :role, only: [:show, :update], controller: 'users/roles'
     end
 
     resources :custom_emojis, only: [:index, :new, :create] do
@@ -320,6 +314,7 @@ Rails.application.routes.draw do
       end
     end
 
+    resources :roles, except: [:show]
     resources :account_moderation_notes, only: [:create, :destroy]
     resource :follow_recommendations, only: [:show, :update]
     resources :tags, only: [:show, :update]
diff --git a/db/migrate/20220611210335_create_user_roles.rb b/db/migrate/20220611210335_create_user_roles.rb
new file mode 100644
index 000000000..6b7f2b637
--- /dev/null
+++ b/db/migrate/20220611210335_create_user_roles.rb
@@ -0,0 +1,13 @@
+class CreateUserRoles < ActiveRecord::Migration[6.1]
+  def change
+    create_table :user_roles do |t|
+      t.string :name, null: false, default: ''
+      t.string :color, null: false, default: ''
+      t.integer :position, null: false, default: 0
+      t.bigint :permissions, null: false, default: 0
+      t.boolean :highlighted, null: false, default: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20220611212541_add_role_id_to_users.rb b/db/migrate/20220611212541_add_role_id_to_users.rb
new file mode 100644
index 000000000..2fda647d4
--- /dev/null
+++ b/db/migrate/20220611212541_add_role_id_to_users.rb
@@ -0,0 +1,8 @@
+class AddRoleIdToUsers < ActiveRecord::Migration[6.1]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured { add_reference :users, :role, foreign_key: { to_table: 'user_roles', on_delete: :nullify }, index: false }
+    add_index :users, :role_id, algorithm: :concurrently, where: 'role_id IS NOT NULL'
+  end
+end
diff --git a/db/post_migrate/20220617202502_migrate_roles.rb b/db/post_migrate/20220617202502_migrate_roles.rb
new file mode 100644
index 000000000..b7a7b2201
--- /dev/null
+++ b/db/post_migrate/20220617202502_migrate_roles.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class MigrateRoles < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  class UserRole < ApplicationRecord; end
+  class User < ApplicationRecord; end
+
+  def up
+    load Rails.root.join('db', 'seeds', '03_roles.rb')
+
+    admin_role     = UserRole.find_by(name: 'Admin')
+    moderator_role = UserRole.find_by(name: 'Moderator')
+
+    User.where(admin: true).in_batches.update_all(role_id: admin_role.id)
+    User.where(moderator: true).in_batches.update_all(role_id: moderator_role.id)
+  end
+
+  def down
+    admin_role     = UserRole.find_by(name: 'Admin')
+    moderator_role = UserRole.find_by(name: 'Moderator')
+
+    User.where(role_id: admin_role.id).in_batches.update_all(admin: true) if admin_role
+    User.where(role_id: moderator_role.id).in_batches.update_all(moderator: true) if moderator_role
+  end
+end
diff --git a/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb b/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb
new file mode 100644
index 000000000..254690cc3
--- /dev/null
+++ b/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class MigrateSettingsToUserRoles < ActiveRecord::Migration[6.1]
+  disable_ddl_transaction!
+
+  class UserRole < ApplicationRecord; end
+
+  def up
+    owner_role     = UserRole.find_by(name: 'Owner')
+    admin_role     = UserRole.find_by(name: 'Admin')
+    moderator_role = UserRole.find_by(name: 'Moderator')
+    everyone_role  = UserRole.find_by(id: -99)
+
+    min_invite_role  = Setting.min_invite_role
+    show_staff_badge = Setting.show_staff_badge
+
+    if everyone_role
+      everyone_role.permissions &= ~::UserRole::FLAGS[:invite_users] unless min_invite_role == 'user'
+      everyone_role.save
+    end
+
+    if owner_role
+      owner_role.highlighted = show_staff_badge
+      owner_role.save
+    end
+
+    if admin_role
+      admin_role.permissions |= ::UserRole::FLAGS[:invite_users] if %w(admin moderator).include?(min_invite_role)
+      admin_role.highlighted  = show_staff_badge
+      admin_role.save
+    end
+
+    if moderator_role
+      moderator_role.permissions |= ::UserRole::FLAGS[:invite_users] if %w(moderator).include?(min_invite_role)
+      moderator_role.highlighted  = show_staff_badge
+      moderator_role.save
+    end
+  end
+
+  def down; end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 759dc712b..54966ef64 100644
--- a/db/schema.rb
+++ b/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: 2022_06_13_110903) do
+ActiveRecord::Schema.define(version: 2022_07_04_024901) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -968,6 +968,16 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do
     t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
   end
 
+  create_table "user_roles", force: :cascade do |t|
+    t.string "name", default: "", null: false
+    t.string "color", default: "", null: false
+    t.integer "position", default: 0, null: false
+    t.bigint "permissions", default: 0, null: false
+    t.boolean "highlighted", default: false, null: false
+    t.datetime "created_at", precision: 6, null: false
+    t.datetime "updated_at", precision: 6, null: false
+  end
+
   create_table "users", force: :cascade do |t|
     t.string "email", default: "", null: false
     t.datetime "created_at", null: false
@@ -1003,11 +1013,13 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do
     t.string "webauthn_id"
     t.inet "sign_up_ip"
     t.boolean "skip_sign_in_token"
+    t.bigint "role_id"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"
     t.index ["email"], name: "index_users_on_email", unique: true
     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, opclass: :text_pattern_ops, where: "(reset_password_token IS NOT NULL)"
+    t.index ["role_id"], name: "index_users_on_role_id", where: "(role_id IS NOT NULL)"
   end
 
   create_table "web_push_subscriptions", force: :cascade do |t|
@@ -1159,6 +1171,7 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do
   add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
   add_foreign_key "users", "invites", on_delete: :nullify
   add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
+  add_foreign_key "users", "user_roles", column: "role_id", on_delete: :nullify
   add_foreign_key "web_push_subscriptions", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
   add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade
   add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade
diff --git a/db/seeds.rb b/db/seeds.rb
index 0bfb5d0db..1ca300de7 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,11 +1,5 @@
-Doorkeeper::Application.create!(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow push')
+# frozen_string_literal: true
 
-domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain
-account = Account.find_or_initialize_by(id: -99, actor_type: 'Application', locked: true, username: domain)
-account.save!
-
-if Rails.env.development?
-  admin  = Account.where(username: 'admin').first_or_initialize(username: 'admin')
-  admin.save(validate: false)
-  User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, admin: true, account: admin, agreement: true, approved: true).save!
+Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |seed|
+  load seed
 end
diff --git a/db/seeds/01_web_app.rb b/db/seeds/01_web_app.rb
new file mode 100644
index 000000000..a457a883b
--- /dev/null
+++ b/db/seeds/01_web_app.rb
@@ -0,0 +1 @@
+Doorkeeper::Application.create_with(name: 'Web', redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow push').find_or_create_by(superapp: true)
diff --git a/db/seeds/02_instance_actor.rb b/db/seeds/02_instance_actor.rb
new file mode 100644
index 000000000..39186b273
--- /dev/null
+++ b/db/seeds/02_instance_actor.rb
@@ -0,0 +1 @@
+Account.create_with(actor_type: 'Application', locked: true, username: ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain).find_or_create_by(id: -99)
diff --git a/db/seeds/03_roles.rb b/db/seeds/03_roles.rb
new file mode 100644
index 000000000..7fedf0f71
--- /dev/null
+++ b/db/seeds/03_roles.rb
@@ -0,0 +1,9 @@
+# Pre-create base role
+UserRole.everyone
+
+# Create default roles defined in config file
+default_roles = YAML.load_file(Rails.root.join('config', 'roles.yml'))
+
+default_roles.each do |_, config|
+  UserRole.create_with(position: config['position'], permissions_as_keys: config['permissions'], highlighted: true).find_or_create_by(name: config['name'])
+end
diff --git a/db/seeds/04_admin.rb b/db/seeds/04_admin.rb
new file mode 100644
index 000000000..a67040e4e
--- /dev/null
+++ b/db/seeds/04_admin.rb
@@ -0,0 +1,8 @@
+if Rails.env.development?
+  domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain
+
+  admin = Account.where(username: 'admin').first_or_initialize(username: 'admin')
+  admin.save(validate: false)
+
+  User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, role: UserRole.find_by(name: 'Owner'), account: admin, agreement: true, approved: true).save!
+end
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 7256d1da9..29c934453 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -54,7 +54,7 @@ module Mastodon
 
     option :email, required: true
     option :confirmed, type: :boolean
-    option :role, default: 'user', enum: %w(user moderator admin)
+    option :role
     option :reattach, type: :boolean
     option :force, type: :boolean
     desc 'create USERNAME', 'Create a new user'
@@ -65,8 +65,7 @@ module Mastodon
       With the --confirmed option, the confirmation e-mail will
       be skipped and the account will be active straight away.
 
-      With the --role option one of  "user", "admin" or "moderator"
-      can be supplied. Defaults to "user"
+      With the --role option, the role can be supplied.
 
       With the --reattach option, the new user will be reattached
       to a given existing username of an old account. If the old
@@ -75,9 +74,22 @@ module Mastodon
       username to the new account anyway.
     LONG_DESC
     def create(username)
+      role_id  = nil
+
+      if options[:role]
+        role = UserRole.find_by(name: options[:role])
+
+        if role.nil?
+          say('Cannot find user role with that name', :red)
+          exit(1)
+        end
+
+        role_id = role.id
+      end
+
       account  = Account.new(username: username)
       password = SecureRandom.hex
-      user     = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true)
+      user     = User.new(email: options[:email], password: password, agreement: true, approved: true, role_id: role_id, confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true)
 
       if options[:reattach]
         account = Account.find_local(username) || Account.new(username: username)
@@ -106,14 +118,14 @@ module Mastodon
         user.errors.to_h.each do |key, error|
           say('Failure/Error: ', :red)
           say(key)
-          say('    ' + error, :red)
+          say("    #{error}", :red)
         end
 
         exit(1)
       end
     end
 
-    option :role, enum: %w(user moderator admin)
+    option :role
     option :email
     option :confirm, type: :boolean
     option :enable, type: :boolean
@@ -125,8 +137,7 @@ module Mastodon
     long_desc <<-LONG_DESC
       Modify a user account.
 
-      With the --role option, update the user's role to one of "user",
-      "moderator" or "admin".
+      With the --role option, update the user's role.
 
       With the --email option, update the user's e-mail address. With
       the --confirm option, mark the user's e-mail as confirmed.
@@ -152,8 +163,14 @@ module Mastodon
       end
 
       if options[:role]
-        user.admin = options[:role] == 'admin'
-        user.moderator = options[:role] == 'moderator'
+        role = UserRole.find_by(name: options[:role])
+
+        if role.nil?
+          say('Cannot find user role with that name', :red)
+          exit(1)
+        end
+
+        user.role_id = role.id
       end
 
       password = SecureRandom.hex if options[:reset_password]
@@ -172,7 +189,7 @@ module Mastodon
         user.errors.to_h.each do |key, error|
           say('Failure/Error: ', :red)
           say(key)
-          say('    ' + error, :red)
+          say("    #{error}", :red)
         end
 
         exit(1)
@@ -319,7 +336,7 @@ module Mastodon
 
       unless skip_domains.empty?
         say('The following domains were not available during the check:', :yellow)
-        skip_domains.each { |domain| say('    ' + domain) }
+        skip_domains.each { |domain| say("    #{domain}") }
       end
     end
 
diff --git a/lib/simple_navigation/item_extensions.rb b/lib/simple_navigation/item_extensions.rb
new file mode 100644
index 000000000..28af37a18
--- /dev/null
+++ b/lib/simple_navigation/item_extensions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module SimpleNavigation
+  module ItemExtensions
+    def url
+      if @url.nil? && @sub_navigation
+        @sub_navigation.items.first.url
+      else
+        @url
+      end
+    end
+  end
+end
+
+SimpleNavigation::Item.prepend(SimpleNavigation::ItemExtensions)
diff --git a/spec/controllers/admin/account_moderation_notes_controller_spec.rb b/spec/controllers/admin/account_moderation_notes_controller_spec.rb
index 410ce6543..d3f3263f8 100644
--- a/spec/controllers/admin/account_moderation_notes_controller_spec.rb
+++ b/spec/controllers/admin/account_moderation_notes_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Admin::AccountModerationNotesController, type: :controller do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
   let(:target_account) { Fabricate(:account) }
 
   before do
diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb
index 1779fb7c0..1bd51a0c8 100644
--- a/spec/controllers/admin/accounts_controller_spec.rb
+++ b/spec/controllers/admin/accounts_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
   before { sign_in current_user, scope: :user }
 
   describe 'GET #index' do
-    let(:current_user) { Fabricate(:user, admin: true) }
+    let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     around do |example|
       default_per_page = Account.default_per_page
@@ -60,7 +60,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
   end
 
   describe 'GET #show' do
-    let(:current_user) { Fabricate(:user, admin: true) }
+    let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
     let(:account) { Fabricate(:account) }
 
     it 'returns http success' do
@@ -72,15 +72,15 @@ RSpec.describe Admin::AccountsController, type: :controller do
   describe 'POST #memorialize' do
     subject { post :memorialize, params: { id: account.id } }
 
-    let(:current_user) { Fabricate(:user, admin: current_user_admin) }
+    let(:current_user) { Fabricate(:user, role: current_role) }
     let(:account) { user.account }
-    let(:user) { Fabricate(:user, admin: target_user_admin) }
+    let(:user) { Fabricate(:user, role: target_role) }
 
     context 'when user is admin' do
-      let(:current_user_admin) { true }
+      let(:current_role) { UserRole.find_by(name: 'Admin') }
 
       context 'when target user is admin' do
-        let(:target_user_admin) { true }
+        let(:target_role) { UserRole.find_by(name: 'Admin') }
 
         it 'fails to memorialize account' do
           is_expected.to have_http_status :forbidden
@@ -89,7 +89,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
       end
 
       context 'when target user is not admin' do
-        let(:target_user_admin) { false }
+        let(:target_role) { UserRole.find_by(name: 'Moderator') }
 
         it 'succeeds in memorializing account' do
           is_expected.to redirect_to admin_account_path(account.id)
@@ -99,10 +99,10 @@ RSpec.describe Admin::AccountsController, type: :controller do
     end
 
     context 'when user is not admin' do
-      let(:current_user_admin) { false }
+      let(:current_role) { UserRole.find_by(name: 'Moderator') }
 
       context 'when target user is admin' do
-        let(:target_user_admin) { true }
+        let(:target_role) { UserRole.find_by(name: 'Admin') }
 
         it 'fails to memorialize account' do
           is_expected.to have_http_status :forbidden
@@ -111,7 +111,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
       end
 
       context 'when target user is not admin' do
-        let(:target_user_admin) { false }
+        let(:target_role) { UserRole.find_by(name: 'Moderator') }
 
         it 'fails to memorialize account' do
           is_expected.to have_http_status :forbidden
@@ -124,12 +124,12 @@ RSpec.describe Admin::AccountsController, type: :controller do
   describe 'POST #enable' do
     subject { post :enable, params: { id: account.id } }
 
-    let(:current_user) { Fabricate(:user, admin: admin) }
+    let(:current_user) { Fabricate(:user, role: role) }
     let(:account) { user.account }
     let(:user) { Fabricate(:user, disabled: true) }
 
     context 'when user is admin' do
-      let(:admin) { true }
+      let(:role) { UserRole.find_by(name: 'Admin') }
 
       it 'succeeds in enabling account' do
         is_expected.to redirect_to admin_account_path(account.id)
@@ -138,7 +138,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
     end
 
     context 'when user is not admin' do
-      let(:admin) { false }
+      let(:role) { UserRole.everyone }
 
       it 'fails to enable account' do
         is_expected.to have_http_status :forbidden
@@ -150,19 +150,23 @@ RSpec.describe Admin::AccountsController, type: :controller do
   describe 'POST #redownload' do
     subject { post :redownload, params: { id: account.id } }
 
-    let(:current_user) { Fabricate(:user, admin: admin) }
-    let(:account) { Fabricate(:account) }
+    let(:current_user) { Fabricate(:user, role: role) }
+    let(:account) { Fabricate(:account, domain: 'example.com') }
+
+    before do
+      allow_any_instance_of(ResolveAccountService).to receive(:call)
+    end
 
     context 'when user is admin' do
-      let(:admin) { true }
+      let(:role) { UserRole.find_by(name: 'Admin') }
 
-      it 'succeeds in redownloadin' do
+      it 'succeeds in redownloading' do
         is_expected.to redirect_to admin_account_path(account.id)
       end
     end
 
     context 'when user is not admin' do
-      let(:admin) { false }
+      let(:role) { UserRole.everyone }
 
       it 'fails to redownload' do
         is_expected.to have_http_status :forbidden
@@ -173,11 +177,11 @@ RSpec.describe Admin::AccountsController, type: :controller do
   describe 'POST #remove_avatar' do
     subject { post :remove_avatar, params: { id: account.id } }
 
-    let(:current_user) { Fabricate(:user, admin: admin) }
+    let(:current_user) { Fabricate(:user, role: role) }
     let(:account) { Fabricate(:account) }
 
     context 'when user is admin' do
-      let(:admin) { true }
+      let(:role) { UserRole.find_by(name: 'Admin') }
 
       it 'succeeds in removing avatar' do
         is_expected.to redirect_to admin_account_path(account.id)
@@ -185,7 +189,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
     end
 
     context 'when user is not admin' do
-      let(:admin) { false }
+      let(:role) { UserRole.everyone }
 
       it 'fails to remove avatar' do
         is_expected.to have_http_status :forbidden
@@ -196,12 +200,12 @@ RSpec.describe Admin::AccountsController, type: :controller do
   describe 'POST #unblock_email' do
     subject { post :unblock_email, params: { id: account.id } }
 
-    let(:current_user) { Fabricate(:user, admin: admin) }
+    let(:current_user) { Fabricate(:user, role: role) }
     let(:account) { Fabricate(:account, suspended: true) }
     let!(:email_block) { Fabricate(:canonical_email_block, reference_account: account) }
 
     context 'when user is admin' do
-      let(:admin) { true }
+      let(:role) { UserRole.find_by(name: 'Admin') }
 
       it 'succeeds in removing email blocks' do
         expect { subject }.to change { CanonicalEmailBlock.where(reference_account: account).count }.from(1).to(0)
@@ -214,7 +218,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
     end
 
     context 'when user is not admin' do
-      let(:admin) { false }
+      let(:role) { UserRole.everyone }
 
       it 'fails to remove avatar' do
         subject
diff --git a/spec/controllers/admin/action_logs_controller_spec.rb b/spec/controllers/admin/action_logs_controller_spec.rb
index 4720ed2e2..c1957258f 100644
--- a/spec/controllers/admin/action_logs_controller_spec.rb
+++ b/spec/controllers/admin/action_logs_controller_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
 describe Admin::ActionLogsController, type: :controller do
   describe 'GET #index' do
     it 'returns 200' do
-      sign_in Fabricate(:user, admin: true)
+      sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
       get :index, params: { page: 1 }
 
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/admin/base_controller_spec.rb b/spec/controllers/admin/base_controller_spec.rb
index 9ac833623..44be91951 100644
--- a/spec/controllers/admin/base_controller_spec.rb
+++ b/spec/controllers/admin/base_controller_spec.rb
@@ -5,13 +5,14 @@ require 'rails_helper'
 describe Admin::BaseController, type: :controller do
   controller do
     def success
+      authorize :dashboard, :index?
       render 'admin/reports/show'
     end
   end
 
   it 'requires administrator or moderator' do
     routes.draw { get 'success' => 'admin/base#success' }
-    sign_in(Fabricate(:user, admin: false, moderator: false))
+    sign_in(Fabricate(:user))
     get :success
 
     expect(response).to have_http_status(:forbidden)
@@ -19,14 +20,14 @@ describe Admin::BaseController, type: :controller do
 
   it 'renders admin layout as a moderator' do
     routes.draw { get 'success' => 'admin/base#success' }
-    sign_in(Fabricate(:user, moderator: true))
+    sign_in(Fabricate(:user, role: UserRole.find_by(name: 'Moderator')))
     get :success
     expect(response).to render_template layout: 'admin'
   end
 
   it 'renders admin layout as an admin' do
     routes.draw { get 'success' => 'admin/base#success' }
-    sign_in(Fabricate(:user, admin: true))
+    sign_in(Fabricate(:user, role: UserRole.find_by(name: 'Admin')))
     get :success
     expect(response).to render_template layout: 'admin'
   end
diff --git a/spec/controllers/admin/change_email_controller_spec.rb b/spec/controllers/admin/change_email_controller_spec.rb
index e7f3f7c97..cf8a27d39 100644
--- a/spec/controllers/admin/change_email_controller_spec.rb
+++ b/spec/controllers/admin/change_email_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Admin::ChangeEmailsController, type: :controller do
   render_views
 
-  let(:admin) { Fabricate(:user, admin: true) }
+  let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   before do
     sign_in admin
diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb
index 5b4f7e925..6268903c4 100644
--- a/spec/controllers/admin/confirmations_controller_spec.rb
+++ b/spec/controllers/admin/confirmations_controller_spec.rb
@@ -4,7 +4,7 @@ RSpec.describe Admin::ConfirmationsController, type: :controller do
   render_views
 
   before do
-    sign_in Fabricate(:user, admin: true), scope: :user
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
   end
 
   describe 'POST #create' do
diff --git a/spec/controllers/admin/custom_emojis_controller_spec.rb b/spec/controllers/admin/custom_emojis_controller_spec.rb
index a8d96948c..06cd0c22d 100644
--- a/spec/controllers/admin/custom_emojis_controller_spec.rb
+++ b/spec/controllers/admin/custom_emojis_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 describe Admin::CustomEmojisController do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   before do
     sign_in user, scope: :user
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb
index 7824854f9..6231a09a2 100644
--- a/spec/controllers/admin/dashboard_controller_spec.rb
+++ b/spec/controllers/admin/dashboard_controller_spec.rb
@@ -12,7 +12,7 @@ describe Admin::DashboardController, type: :controller do
         Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path),
         Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'),
       ])
-      sign_in Fabricate(:user, admin: true)
+      sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
     end
 
     it 'returns 200' do
diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb
index 6a06f9406..712657791 100644
--- a/spec/controllers/admin/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Admin::Disputes::AppealsController, type: :controller do
   end
 
   describe 'POST #approve' do
-    let(:current_user) { Fabricate(:user, admin: true) }
+    let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     before do
       allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil))
@@ -35,7 +35,7 @@ RSpec.describe Admin::Disputes::AppealsController, type: :controller do
   end
 
   describe 'POST #reject' do
-    let(:current_user) { Fabricate(:user, admin: true) }
+    let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     before do
       allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil))
diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb
index ecc79292b..5c2dcd268 100644
--- a/spec/controllers/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/domain_blocks_controller_spec.rb
@@ -4,7 +4,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
   render_views
 
   before do
-    sign_in Fabricate(:user, admin: true), scope: :user
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
   end
 
   describe 'GET #new' do
diff --git a/spec/controllers/admin/email_domain_blocks_controller_spec.rb b/spec/controllers/admin/email_domain_blocks_controller_spec.rb
index cf194579d..e9cef4a94 100644
--- a/spec/controllers/admin/email_domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/email_domain_blocks_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Admin::EmailDomainBlocksController, type: :controller do
   render_views
 
   before do
-    sign_in Fabricate(:user, admin: true), scope: :user
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
   end
 
   describe 'GET #index' do
diff --git a/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb
index 53427b874..337f7a80c 100644
--- a/spec/controllers/admin/instances_controller_spec.rb
+++ b/spec/controllers/admin/instances_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Admin::InstancesController, type: :controller do
   render_views
 
-  let(:current_user) { Fabricate(:user, admin: true) }
+  let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   let!(:account)     { Fabricate(:account, domain: 'popular') }
   let!(:account2)    { Fabricate(:account, domain: 'popular') }
@@ -35,11 +35,11 @@ RSpec.describe Admin::InstancesController, type: :controller do
   describe 'DELETE #destroy' do
     subject { delete :destroy, params: { id: Instance.first.id } }
 
-    let(:current_user) { Fabricate(:user, admin: admin) }
+    let(:current_user) { Fabricate(:user, role: role) }
     let(:account) { Fabricate(:account) }
 
     context 'when user is admin' do
-      let(:admin) { true }
+      let(:role) { UserRole.find_by(name: 'Admin') }
 
       it 'succeeds in purging instance' do
         is_expected.to redirect_to admin_instances_path
@@ -47,7 +47,7 @@ RSpec.describe Admin::InstancesController, type: :controller do
     end
 
     context 'when user is not admin' do
-      let(:admin) { false }
+      let(:role) { nil }
 
       it 'fails to purge instance' do
         is_expected.to have_http_status :forbidden
diff --git a/spec/controllers/admin/invites_controller_spec.rb b/spec/controllers/admin/invites_controller_spec.rb
index 449a699e4..1fb488742 100644
--- a/spec/controllers/admin/invites_controller_spec.rb
+++ b/spec/controllers/admin/invites_controller_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
 describe Admin::InvitesController do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   before do
     sign_in user, scope: :user
diff --git a/spec/controllers/admin/report_notes_controller_spec.rb b/spec/controllers/admin/report_notes_controller_spec.rb
index c0013f41a..fa7572d18 100644
--- a/spec/controllers/admin/report_notes_controller_spec.rb
+++ b/spec/controllers/admin/report_notes_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 describe Admin::ReportNotesController do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   before do
     sign_in user, scope: :user
diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb
index d421f0739..4cd1524bf 100644
--- a/spec/controllers/admin/reports_controller_spec.rb
+++ b/spec/controllers/admin/reports_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 describe Admin::ReportsController do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
   before do
     sign_in user, scope: :user
   end
diff --git a/spec/controllers/admin/resets_controller_spec.rb b/spec/controllers/admin/resets_controller_spec.rb
index 28510b5af..aeb172318 100644
--- a/spec/controllers/admin/resets_controller_spec.rb
+++ b/spec/controllers/admin/resets_controller_spec.rb
@@ -5,7 +5,7 @@ describe Admin::ResetsController do
 
   let(:account) { Fabricate(:account) }
   before do
-    sign_in Fabricate(:user, admin: true), scope: :user
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
   end
 
   describe 'POST #create' do
diff --git a/spec/controllers/admin/roles_controller_spec.rb b/spec/controllers/admin/roles_controller_spec.rb
index 8e0de73cb..8ff891205 100644
--- a/spec/controllers/admin/roles_controller_spec.rb
+++ b/spec/controllers/admin/roles_controller_spec.rb
@@ -3,31 +3,247 @@ require 'rails_helper'
 describe Admin::RolesController do
   render_views
 
-  let(:admin) { Fabricate(:user, admin: true) }
+  let(:permissions)  { UserRole::Flags::NONE }
+  let(:current_role) { UserRole.create(name: 'Foo', permissions: permissions, position: 10) }
+  let(:current_user) { Fabricate(:user, role: current_role) }
 
   before do
-    sign_in admin, scope: :user
+    sign_in current_user, scope: :user
   end
 
-  describe 'POST #promote' do
-    subject { post :promote, params: { account_id: user.account_id } }
+  describe 'GET #index' do
+    before do
+      get :index
+    end
+
+    context 'when user does not have permission to manage roles' do
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
 
-    let(:user) { Fabricate(:user, moderator: false, admin: false) }
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
 
-    it 'promotes user' do
-      expect(subject).to redirect_to admin_account_path(user.account_id)
-      expect(user.reload).to be_moderator
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
     end
   end
 
-  describe 'POST #demote' do
-    subject { post :demote, params: { account_id: user.account_id } }
+  describe 'GET #new' do
+    before do
+      get :new
+    end
+
+    context 'when user does not have permission to manage roles' do
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
+    end
+  end
+
+  describe 'POST #create' do
+    let(:selected_position) { 1 }
+    let(:selected_permissions_as_keys) { %w(manage_roles) }
+
+    before do
+      post :create, params: { user_role: { name: 'Bar', position: selected_position, permissions_as_keys: selected_permissions_as_keys } }
+    end
+
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+
+      context 'when new role\'s does not elevate above the user\'s role' do
+        let(:selected_position) { 1 }
+        let(:selected_permissions_as_keys) { %w(manage_roles) }
+
+        it 'redirects to roles page' do
+          expect(response).to redirect_to(admin_roles_path)
+        end
+
+        it 'creates new role' do
+          expect(UserRole.find_by(name: 'Bar')).to_not be_nil
+        end
+      end
+
+      context 'when new role\'s position is higher than user\'s role' do
+        let(:selected_position) { 100 }
+        let(:selected_permissions_as_keys) { %w(manage_roles) }
+
+        it 'renders new template' do
+          expect(response).to render_template(:new)
+        end
+
+        it 'does not create new role' do
+          expect(UserRole.find_by(name: 'Bar')).to be_nil
+        end
+      end
+
+      context 'when new role has permissions the user does not have' do
+        let(:selected_position) { 1 }
+        let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) }
+
+        it 'renders new template' do
+          expect(response).to render_template(:new)
+        end
+
+        it 'does not create new role' do
+          expect(UserRole.find_by(name: 'Bar')).to be_nil
+        end
+      end
+
+      context 'when user has administrator permission' do
+        let(:permissions) { UserRole::FLAGS[:administrator] }
+
+        let(:selected_position) { 1 }
+        let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) }
+
+        it 'redirects to roles page' do
+          expect(response).to redirect_to(admin_roles_path)
+        end
+
+        it 'creates new role' do
+          expect(UserRole.find_by(name: 'Bar')).to_not be_nil
+        end
+      end
+    end
+  end
+
+  describe 'GET #edit' do
+    let(:role_position) { 8 }
+    let(:role) { UserRole.create(name: 'Bar', permissions: UserRole::FLAGS[:manage_users], position: role_position) }
+
+    before do
+      get :edit, params: { id: role.id }
+    end
+
+    context 'when user does not have permission to manage roles' do
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+
+      context 'when user outranks the role' do
+        it 'returns http success' do
+          expect(response).to have_http_status(:success)
+        end
+      end
+
+      context 'when role outranks user' do
+        let(:role_position) { current_role.position + 1 }
+
+        it 'returns http forbidden' do
+          expect(response).to have_http_status(:forbidden)
+        end
+      end
+    end
+  end
+
+  describe 'PUT #update' do
+    let(:role_position) { 8 }
+    let(:role_permissions) { UserRole::FLAGS[:manage_users] }
+    let(:role) { UserRole.create(name: 'Bar', permissions: role_permissions, position: role_position) }
+
+    let(:selected_position) { 8 }
+    let(:selected_permissions_as_keys) { %w(manage_users) }
+
+    before do
+      put :update, params: { id: role.id, user_role: { name: 'Baz', position: selected_position, permissions_as_keys: selected_permissions_as_keys } }
+    end
+
+    context 'when user does not have permission to manage roles' do
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+
+      it 'does not update the role' do
+        expect(role.reload.name).to eq 'Bar'
+      end
+    end
+
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+
+      context 'when role has permissions the user doesn\'t' do
+        it 'renders edit template' do
+          expect(response).to render_template(:edit)
+        end
+
+        it 'does not update the role' do
+          expect(role.reload.name).to eq 'Bar'
+        end
+      end
+
+      context 'when user has all permissions of the role' do
+        let(:permissions) { UserRole::FLAGS[:manage_roles] | UserRole::FLAGS[:manage_users] }
+
+        context 'when user outranks the role' do
+          it 'redirects to roles page' do
+            expect(response).to redirect_to(admin_roles_path)
+          end
+
+          it 'updates the role' do
+            expect(role.reload.name).to eq 'Baz'
+          end
+        end
+
+        context 'when role outranks user' do
+          let(:role_position) { current_role.position + 1 }
+
+          it 'returns http forbidden' do
+            expect(response).to have_http_status(:forbidden)
+          end
+
+          it 'does not update the role' do
+            expect(role.reload.name).to eq 'Bar'
+          end
+        end
+      end
+    end
+  end
+
+  describe 'DELETE #destroy' do
+    let(:role_position) { 8 }
+    let(:role) { UserRole.create(name: 'Bar', permissions: UserRole::FLAGS[:manage_users], position: role_position) }
+
+    before do
+      delete :destroy, params: { id: role.id }
+    end
+
+    context 'when user does not have permission to manage roles' do
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+
+    context 'when user has permission to manage roles' do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+
+      context 'when user outranks the role' do
+        it 'redirects to roles page' do
+          expect(response).to redirect_to(admin_roles_path)
+        end
+      end
 
-    let(:user) { Fabricate(:user, moderator: true, admin: false) }
+      context 'when role outranks user' do
+        let(:role_position) { current_role.position + 1 }
 
-    it 'demotes user' do
-      expect(subject).to redirect_to admin_account_path(user.account_id)
-      expect(user.reload).not_to be_moderator
+        it 'returns http forbidden' do
+          expect(response).to have_http_status(:forbidden)
+        end
+      end
     end
   end
 end
diff --git a/spec/controllers/admin/settings_controller_spec.rb b/spec/controllers/admin/settings_controller_spec.rb
index 6cf0ee20a..46749f76c 100644
--- a/spec/controllers/admin/settings_controller_spec.rb
+++ b/spec/controllers/admin/settings_controller_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Admin::SettingsController, type: :controller do
 
   describe 'When signed in as an admin' do
     before do
-      sign_in Fabricate(:user, admin: true), scope: :user
+      sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
     end
 
     describe 'GET #edit' do
diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb
index de32fd18e..227688e23 100644
--- a/spec/controllers/admin/statuses_controller_spec.rb
+++ b/spec/controllers/admin/statuses_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 describe Admin::StatusesController do
   render_views
 
-  let(:user) { Fabricate(:user, admin: true) }
+  let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
   let(:account) { Fabricate(:account) }
   let!(:status) { Fabricate(:status, account: account) }
   let(:media_attached_status) { Fabricate(:status, account: account, sensitive: !sensitive) }
diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb
index 85c801a9c..52fd09eb1 100644
--- a/spec/controllers/admin/tags_controller_spec.rb
+++ b/spec/controllers/admin/tags_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Admin::TagsController, type: :controller do
   render_views
 
   before do
-    sign_in Fabricate(:user, admin: true)
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
   end
 
   describe 'GET #show' do
diff --git a/spec/controllers/admin/users/roles_controller.rb b/spec/controllers/admin/users/roles_controller.rb
new file mode 100644
index 000000000..bd6a3fa67
--- /dev/null
+++ b/spec/controllers/admin/users/roles_controller.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+
+describe Admin::Users::RolesController do
+  render_views
+
+  let(:current_role) { UserRole.create(name: 'Foo', permissions: UserRole::FLAGS[:manage_roles], position: 10) }
+  let(:current_user) { Fabricate(:user, role: current_role) }
+
+  let(:previous_role) { nil }
+  let(:user) { Fabricate(:user, role: previous_role) }
+
+  before do
+    sign_in current_user, scope: :user
+  end
+
+  describe 'GET #show' do
+    before do
+      get :show, params: { user_id: user.id }
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+
+    context 'when target user is higher ranked than current user' do
+      let(:previous_role) { UserRole.create(name: 'Baz', permissions: UserRole::FLAGS[:administrator], position: 100) }
+
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+  end
+
+  describe 'PUT #update' do
+    let(:selected_role) { UserRole.create(name: 'Bar', permissions: permissions, position: position) }
+
+    before do
+      put :update, params: { user_id: user.id, user: { role_id: selected_role.id } }
+    end
+
+    context do
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+      let(:position) { 1 }
+
+      it 'updates user role' do
+        expect(user.reload.role_id).to eq selected_role&.id
+      end
+
+      it 'redirects back to account page' do
+        expect(response).to redirect_to(admin_account_path(user.account_id))
+      end
+    end
+
+    context 'when selected role has higher position than current user\'s role' do
+      let(:permissions) { UserRole::FLAGS[:administrator] }
+      let(:position) { 100 }
+
+      it 'does not update user role' do
+        expect(user.reload.role_id).to eq previous_role&.id
+      end
+
+      it 'renders edit form' do
+        expect(response).to render_template(:show)
+      end
+    end
+
+    context 'when target user is higher ranked than current user' do
+      let(:previous_role) { UserRole.create(name: 'Baz', permissions: UserRole::FLAGS[:administrator], position: 100) }
+      let(:permissions) { UserRole::FLAGS[:manage_roles] }
+      let(:position) { 1 }
+
+      it 'does not update user role' do
+        expect(user.reload.role_id).to eq previous_role&.id
+      end
+
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+  end
+end
diff --git a/spec/controllers/admin/two_factor_authentications_controller_spec.rb b/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb
index c65095729..e56264ef6 100644
--- a/spec/controllers/admin/two_factor_authentications_controller_spec.rb
+++ b/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb
@@ -1,12 +1,13 @@
 require 'rails_helper'
 require 'webauthn/fake_client'
 
-describe Admin::TwoFactorAuthenticationsController do
+describe Admin::Users::TwoFactorAuthenticationsController do
   render_views
 
   let(:user) { Fabricate(:user) }
+
   before do
-    sign_in Fabricate(:user, admin: true), scope: :user
+    sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user
   end
 
   describe 'DELETE #destroy' do
diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
index 601290b82..199395f55 100644
--- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do
   render_views
 
-  let(:role)   { 'moderator' }
+  let(:role)   { UserRole.find_by(name: 'Moderator') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -35,7 +35,7 @@ RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/api/v1/admin/accounts_controller_spec.rb b/spec/controllers/api/v1/admin/accounts_controller_spec.rb
index b69595f7e..cd38030e0 100644
--- a/spec/controllers/api/v1/admin/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/accounts_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
   render_views
 
-  let(:role)   { 'moderator' }
+  let(:role)   { UserRole.find_by(name: 'Moderator') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -46,7 +46,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     [
       [{ active: 'true', local: 'true', staff: 'true' }, [:admin_account]],
@@ -77,7 +77,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -91,7 +91,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -109,7 +109,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -127,7 +127,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -145,7 +145,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -163,7 +163,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -181,7 +181,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
index edee3ab6c..26a391a60 100644
--- a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
   render_views
 
-  let(:role)   { 'admin' }
+  let(:role)   { UserRole.find_by(name: 'Admin') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -21,7 +21,7 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -36,8 +36,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -58,8 +58,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -79,8 +79,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -99,8 +99,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
index 196f6dc28..f12285b2a 100644
--- a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
   render_views
 
-  let(:role)   { 'admin' }
+  let(:role)   { UserRole.find_by(name: 'Admin') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -21,7 +21,7 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -36,8 +36,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -58,8 +58,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -79,8 +79,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -100,8 +100,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
-    it_behaves_like 'forbidden for wrong role', 'moderator'
+    it_behaves_like 'forbidden for wrong role', ''
+    it_behaves_like 'forbidden for wrong role', 'Moderator'
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/api/v1/admin/reports_controller_spec.rb b/spec/controllers/api/v1/admin/reports_controller_spec.rb
index b6df53048..880e72030 100644
--- a/spec/controllers/api/v1/admin/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/reports_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
   render_views
 
-  let(:role)   { 'moderator' }
+  let(:role)   { UserRole.find_by(name: 'Moderator') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -35,7 +35,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -48,7 +48,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -61,7 +61,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -74,7 +74,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -87,7 +87,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
@@ -100,7 +100,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     it 'returns http success' do
       expect(response).to have_http_status(200)
diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb
index b5baf60e1..dbc64e704 100644
--- a/spec/controllers/api/v1/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/reports_controller_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Api::V1::ReportsController, type: :controller do
   end
 
   describe 'POST #create' do
-    let!(:admin) { Fabricate(:user, admin: true) }
+    let!(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
     let(:scopes) { 'write:reports' }
     let(:status) { Fabricate(:status) }
diff --git a/spec/controllers/api/v2/admin/accounts_controller_spec.rb b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
index 3212ddb84..2508a9e05 100644
--- a/spec/controllers/api/v2/admin/accounts_controller_spec.rb
+++ b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
 RSpec.describe Api::V2::Admin::AccountsController, type: :controller do
   render_views
 
-  let(:role)   { 'moderator' }
+  let(:role)   { UserRole.find_by(name: 'Moderator') }
   let(:user)   { Fabricate(:user, role: role) }
   let(:scopes) { 'admin:read admin:write' }
   let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
@@ -22,7 +22,7 @@ RSpec.describe Api::V2::Admin::AccountsController, type: :controller do
   end
 
   shared_examples 'forbidden for wrong role' do |wrong_role|
-    let(:role) { wrong_role }
+    let(:role) { UserRole.find_by(name: wrong_role) }
 
     it 'returns http forbidden' do
       expect(response).to have_http_status(403)
@@ -46,7 +46,7 @@ RSpec.describe Api::V2::Admin::AccountsController, type: :controller do
     end
 
     it_behaves_like 'forbidden for wrong scope', 'write:statuses'
-    it_behaves_like 'forbidden for wrong role', 'user'
+    it_behaves_like 'forbidden for wrong role', ''
 
     [
       [{ status: 'active', origin: 'local', permissions: 'staff' }, [:admin_account]],
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 53e163d49..1b002e01c 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -183,70 +183,6 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  describe 'require_admin!' do
-    controller do
-      before_action :require_admin!
-
-      def success
-        head 200
-      end
-    end
-
-    before do
-      routes.draw { get 'success' => 'anonymous#success' }
-    end
-
-    it 'returns a 403 if current user is not admin' do
-      sign_in(Fabricate(:user, admin: false))
-      get 'success'
-      expect(response).to have_http_status(403)
-    end
-
-    it 'returns a 403 if current user is only a moderator' do
-      sign_in(Fabricate(:user, moderator: true))
-      get 'success'
-      expect(response).to have_http_status(403)
-    end
-
-    it 'does nothing if current user is admin' do
-      sign_in(Fabricate(:user, admin: true))
-      get 'success'
-      expect(response).to have_http_status(200)
-    end
-  end
-
-  describe 'require_staff!' do
-    controller do
-      before_action :require_staff!
-
-      def success
-        head 200
-      end
-    end
-
-    before do
-      routes.draw { get 'success' => 'anonymous#success' }
-    end
-
-    it 'returns a 403 if current user is not admin or moderator' do
-      sign_in(Fabricate(:user, admin: false, moderator: false))
-      get 'success'
-      expect(response).to have_http_status(403)
-    end
-
-    it 'does nothing if current user is moderator' do
-      sign_in(Fabricate(:user, moderator: true))
-      get 'success'
-      expect(response).to have_http_status(200)
-    end
-
-    it 'does nothing if current user is admin' do
-      sign_in(Fabricate(:user, admin: true))
-      get 'success'
-      expect(response).to have_http_status(200)
-    end
-  end
-
   describe 'forbidden' do
     controller do
       def route_forbidden
diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb
index faa571fc9..90f222f49 100644
--- a/spec/controllers/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/disputes/appeals_controller_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe Disputes::AppealsController, type: :controller do
 
   before { sign_in current_user, scope: :user }
 
-  let!(:admin) { Fabricate(:user, admin: true) }
+  let!(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
 
   describe '#create' do
     let(:current_user) { Fabricate(:user) }
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index 76e617e6b..23b98fb12 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -7,30 +7,30 @@ describe InvitesController do
     sign_in user
   end
 
-  around do |example|
-    min_invite_role = Setting.min_invite_role
-    example.run
-    Setting.min_invite_role = min_invite_role
-  end
-
   describe 'GET #index' do
     subject { get :index }
 
-    let(:user) { Fabricate(:user, moderator: false, admin: false) }
+    let(:user) { Fabricate(:user) }
     let!(:invite) { Fabricate(:invite, user: user) }
 
-    context 'when user is a staff' do
+    context 'when everyone can invite' do
+      before do
+        UserRole.everyone.update(permissions: UserRole.everyone.permissions | UserRole::FLAGS[:invite_users])
+      end
+
       it 'renders index page' do
-        Setting.min_invite_role = 'user'
         expect(subject).to render_template :index
         expect(assigns(:invites)).to include invite
         expect(assigns(:invites).count).to eq 1
       end
     end
 
-    context 'when user is not a staff' do
+    context 'when not everyone can invite' do
+      before do
+        UserRole.everyone.update(permissions: UserRole.everyone.permissions & ~UserRole::FLAGS[:invite_users])
+      end
+
       it 'returns 403' do
-        Setting.min_invite_role = 'modelator'
         expect(subject).to have_http_status 403
       end
     end
@@ -39,8 +39,12 @@ describe InvitesController do
   describe 'POST #create' do
     subject { post :create, params: { invite: { max_uses: '10', expires_in: 1800 } } }
 
-    context 'when user is an admin' do
-      let(:user) { Fabricate(:user, moderator: false, admin: true) }
+    context 'when everyone can invite' do
+      let(:user) { Fabricate(:user) }
+
+      before do
+        UserRole.everyone.update(permissions: UserRole.everyone.permissions | UserRole::FLAGS[:invite_users])
+      end
 
       it 'succeeds to create a invite' do
         expect { subject }.to change { Invite.count }.by(1)
@@ -49,8 +53,12 @@ describe InvitesController do
       end
     end
 
-    context 'when user is not an admin' do
-      let(:user) { Fabricate(:user, moderator: true, admin: false) }
+    context 'when not everyone can invite' do
+      let(:user) { Fabricate(:user) }
+
+      before do
+        UserRole.everyone.update(permissions: UserRole.everyone.permissions & ~UserRole::FLAGS[:invite_users])
+      end
 
       it 'returns 403' do
         expect(subject).to have_http_status 403
@@ -61,8 +69,8 @@ describe InvitesController do
   describe 'DELETE #create' do
     subject { delete :destroy, params: { id: invite.id } }
 
+    let(:user) { Fabricate(:user) }
     let!(:invite) { Fabricate(:invite, user: user, expires_at: nil) }
-    let(:user) { Fabricate(:user, moderator: false, admin: true) }
 
     it 'expires invite' do
       expect(subject).to redirect_to invites_path
diff --git a/spec/fabricators/user_role_fabricator.rb b/spec/fabricators/user_role_fabricator.rb
new file mode 100644
index 000000000..28f76c8c4
--- /dev/null
+++ b/spec/fabricators/user_role_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:user_role) do
+  name        "MyString"
+  color       "MyString"
+  permissions ""
+end
\ No newline at end of file
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index dc0ca3da3..467d41836 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -445,7 +445,7 @@ RSpec.describe Account, type: :model do
 
     it 'accepts arbitrary limits' do
       2.times.each { Fabricate(:account, display_name: "Display Name") }
-      results = Account.search_for("display", 1)
+      results = Account.search_for("display", limit: 1)
       expect(results.size).to eq 1
     end
 
@@ -473,7 +473,7 @@ RSpec.describe Account, type: :model do
         )
         account.follow!(match)
 
-        results = Account.advanced_search_for('A?l\i:c e', account, 10, true)
+        results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true)
         expect(results).to eq [match]
       end
 
@@ -485,7 +485,7 @@ RSpec.describe Account, type: :model do
           domain: 'example.com'
         )
 
-        results = Account.advanced_search_for('A?l\i:c e', account, 10, true)
+        results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true)
         expect(results).to eq []
       end
 
@@ -498,7 +498,7 @@ RSpec.describe Account, type: :model do
           suspended: true
         )
 
-        results = Account.advanced_search_for('username', account, 10, true)
+        results = Account.advanced_search_for('username', account, limit: 10, following: true)
         expect(results).to eq []
       end
 
@@ -511,7 +511,7 @@ RSpec.describe Account, type: :model do
 
         match.user.update(approved: false)
 
-        results = Account.advanced_search_for('username', account, 10, true)
+        results = Account.advanced_search_for('username', account, limit: 10, following: true)
         expect(results).to eq []
       end
 
@@ -524,7 +524,7 @@ RSpec.describe Account, type: :model do
 
         match.user.update(confirmed_at: nil)
 
-        results = Account.advanced_search_for('username', account, 10, true)
+        results = Account.advanced_search_for('username', account, limit: 10, following: true)
         expect(results).to eq []
       end
     end
@@ -588,7 +588,7 @@ RSpec.describe Account, type: :model do
 
     it 'accepts arbitrary limits' do
       2.times { Fabricate(:account, display_name: "Display Name") }
-      results = Account.advanced_search_for("display", account, 1)
+      results = Account.advanced_search_for("display", account, limit: 1)
       expect(results.size).to eq 1
     end
 
diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb
index 809c7fc46..b6a052b76 100644
--- a/spec/models/admin/account_action_spec.rb
+++ b/spec/models/admin/account_action_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe Admin::AccountAction, type: :model do
 
   describe '#save!' do
     subject              { account_action.save! }
-    let(:account)        { Fabricate(:user, admin: true).account }
+    let(:account)        { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
     let(:target_account) { Fabricate(:account) }
     let(:type)           { 'disable' }
 
diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb
new file mode 100644
index 000000000..28019593e
--- /dev/null
+++ b/spec/models/user_role_spec.rb
@@ -0,0 +1,189 @@
+require 'rails_helper'
+
+RSpec.describe UserRole, type: :model do
+  subject { described_class.create(name: 'Foo', position: 1) }
+
+  describe '#can?' do
+    context 'with a single flag' do
+      it 'returns true if any of them are present' do
+        subject.permissions = UserRole::FLAGS[:manage_reports]
+        expect(subject.can?(:manage_reports)).to be true
+      end
+
+      it 'returns false if it is not set' do
+        expect(subject.can?(:manage_reports)).to be false
+      end
+    end
+
+    context 'with multiple flags' do
+      it 'returns true if any of them are present' do
+        subject.permissions = UserRole::FLAGS[:manage_users]
+        expect(subject.can?(:manage_reports, :manage_users)).to be true
+      end
+
+      it 'returns false if none of them are present' do
+        expect(subject.can?(:manage_reports, :manage_users)).to be false
+      end
+    end
+
+    context 'with an unknown flag' do
+      it 'raises an error' do
+        expect { subject.can?(:foo) }.to raise_error ArgumentError
+      end
+    end
+  end
+
+  describe '#overrides?' do
+    it 'returns true if other role has lower position' do
+      expect(subject.overrides?(described_class.new(position: subject.position - 1))).to be true
+    end
+
+    it 'returns true if other role is nil' do
+      expect(subject.overrides?(nil)).to be true
+    end
+
+    it 'returns false if other role has higher position' do
+      expect(subject.overrides?(described_class.new(position: subject.position + 1))).to be false
+    end
+  end
+
+  describe '#permissions_as_keys' do
+    before do
+      subject.permissions = UserRole::FLAGS[:invite_users] | UserRole::FLAGS[:view_dashboard] | UserRole::FLAGS[:manage_reports]
+    end
+
+    it 'returns an array' do
+      expect(subject.permissions_as_keys).to match_array %w(invite_users view_dashboard manage_reports)
+    end
+  end
+
+  describe '#permissions_as_keys=' do
+    let(:input) { }
+
+    before do
+      subject.permissions_as_keys = input
+    end
+
+    context 'with a single value' do
+      let(:input) { %w(manage_users) }
+
+      it 'sets permission flags' do
+        expect(subject.permissions).to eq UserRole::FLAGS[:manage_users]
+      end
+    end
+
+    context 'with multiple values' do
+      let(:input) { %w(manage_users manage_reports) }
+
+      it 'sets permission flags' do
+        expect(subject.permissions).to eq UserRole::FLAGS[:manage_users] | UserRole::FLAGS[:manage_reports]
+      end
+    end
+
+    context 'with an unknown value' do
+      let(:input) { %w(foo) }
+
+      it 'does not set permission flags' do
+        expect(subject.permissions).to eq UserRole::Flags::NONE
+      end
+    end
+  end
+
+  describe '#computed_permissions' do
+    context 'when the role is nobody' do
+      let(:subject) { described_class.nobody }
+
+      it 'returns none' do
+        expect(subject.computed_permissions).to eq UserRole::Flags::NONE
+      end
+    end
+
+    context 'when the role is everyone' do
+      let(:subject) { described_class.everyone }
+
+      it 'returns permissions' do
+        expect(subject.computed_permissions).to eq subject.permissions
+      end
+    end
+
+    context 'when role has the administrator flag' do
+      before do
+        subject.permissions = UserRole::FLAGS[:administrator]
+      end
+
+      it 'returns all permissions' do
+        expect(subject.computed_permissions).to eq UserRole::Flags::ALL
+      end
+    end
+
+    context do
+      it 'returns permissions combined with the everyone role' do
+        expect(subject.computed_permissions).to eq described_class.everyone.permissions
+      end
+    end
+  end
+
+  describe '.everyone' do
+    subject { described_class.everyone }
+
+    it 'returns a role' do
+      expect(subject).to be_kind_of(described_class)
+    end
+
+    it 'is identified as the everyone role' do
+      expect(subject.everyone?).to be true
+    end
+
+    it 'has default permissions' do
+      expect(subject.permissions).to eq UserRole::FLAGS[:invite_users]
+    end
+
+    it 'has negative position' do
+      expect(subject.position).to eq -1
+    end
+  end
+
+  describe '.nobody' do
+    subject { described_class.nobody }
+
+    it 'returns a role' do
+      expect(subject).to be_kind_of(described_class)
+    end
+
+    it 'is identified as the nobody role' do
+      expect(subject.nobody?).to be true
+    end
+
+    it 'has no permissions' do
+      expect(subject.permissions).to eq UserRole::Flags::NONE
+    end
+
+    it 'has negative position' do
+      expect(subject.position).to eq -1
+    end
+  end
+
+  describe '#everyone?' do
+    it 'returns true when id is -99' do
+      subject.id = -99
+      expect(subject.everyone?).to be true
+    end
+
+    it 'returns false when id is not -99' do
+      subject.id = 123
+      expect(subject.everyone?).to be false
+    end
+  end
+
+  describe '#nobody?' do
+    it 'returns true when id is nil' do
+      subject.id = nil
+      expect(subject.nobody?).to be true
+    end
+
+    it 'returns false when id is not nil' do
+      subject.id = 123
+      expect(subject.nobody?).to be false
+    end
+  end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 1645ab59e..a7da31e60 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -56,14 +56,6 @@ RSpec.describe User, type: :model do
       end
     end
 
-    describe 'admins' do
-      it 'returns an array of users who are admin' do
-        user_1 = Fabricate(:user, admin: false)
-        user_2 = Fabricate(:user, admin: true)
-        expect(User.admins).to match_array([user_2])
-      end
-    end
-
     describe 'confirmed' do
       it 'returns an array of users who are confirmed' do
         user_1 = Fabricate(:user, confirmed_at: nil)
@@ -289,49 +281,6 @@ RSpec.describe User, type: :model do
     end
   end
 
-  describe '#role' do
-    it 'returns admin for admin' do
-      user = User.new(admin: true)
-      expect(user.role).to eq 'admin'
-    end
-
-    it 'returns moderator for moderator' do
-      user = User.new(moderator: true)
-      expect(user.role).to eq 'moderator'
-    end
-
-    it 'returns user otherwise' do
-      user = User.new
-      expect(user.role).to eq 'user'
-    end
-  end
-
-  describe '#role?' do
-    it 'returns false when invalid role requested' do
-      user = User.new(admin: true)
-      expect(user.role?('disabled')).to be false
-    end
-
-    it 'returns true when exact role match' do
-      user  = User.new
-      mod   = User.new(moderator: true)
-      admin = User.new(admin: true)
-
-      expect(user.role?('user')).to be true
-      expect(mod.role?('moderator')).to be true
-      expect(admin.role?('admin')).to be true
-    end
-
-    it 'returns true when role higher than needed' do
-      mod   = User.new(moderator: true)
-      admin = User.new(admin: true)
-
-      expect(mod.role?('user')).to be true
-      expect(admin.role?('user')).to be true
-      expect(admin.role?('moderator')).to be true
-    end
-  end
-
   describe '#disable!' do
     subject(:user) { Fabricate(:user, disabled: false, current_sign_in_at: current_sign_in_at, last_sign_in_at: nil) }
     let(:current_sign_in_at) { Time.zone.now }
@@ -420,110 +369,6 @@ RSpec.describe User, type: :model do
     end
   end
 
-  describe '#promote!' do
-    subject(:user) { Fabricate(:user, admin: is_admin, moderator: is_moderator) }
-
-    before do
-      user.promote!
-    end
-
-    context 'when user is an admin' do
-      let(:is_admin) { true }
-
-      context 'when user is a moderator' do
-        let(:is_moderator) { true }
-
-        it 'changes moderator filed false' do
-          expect(user).to be_admin
-          expect(user).not_to be_moderator
-        end
-      end
-
-      context 'when user is not a moderator' do
-        let(:is_moderator) { false }
-
-        it 'does not change status' do
-          expect(user).to be_admin
-          expect(user).not_to be_moderator
-        end
-      end
-    end
-
-    context 'when user is not admin' do
-      let(:is_admin) { false }
-
-      context 'when user is a moderator' do
-        let(:is_moderator) { true }
-
-        it 'changes user into an admin' do
-          expect(user).to be_admin
-          expect(user).not_to be_moderator
-        end
-      end
-
-      context 'when user is not a moderator' do
-        let(:is_moderator) { false }
-
-        it 'changes user into a moderator' do
-          expect(user).not_to be_admin
-          expect(user).to be_moderator
-        end
-      end
-    end
-  end
-
-  describe '#demote!' do
-    subject(:user) { Fabricate(:user, admin: admin, moderator: moderator) }
-
-    before do
-      user.demote!
-    end
-
-    context 'when user is an admin' do
-      let(:admin) { true }
-
-      context 'when user is a moderator' do
-        let(:moderator) { true }
-
-        it 'changes user into a moderator' do
-          expect(user).not_to be_admin
-          expect(user).to be_moderator
-        end
-      end
-
-      context 'when user is not a moderator' do
-        let(:moderator) { false }
-
-        it 'changes user into a moderator' do
-          expect(user).not_to be_admin
-          expect(user).to be_moderator
-        end
-      end
-    end
-
-    context 'when user is not an admin' do
-      let(:admin) { false }
-
-      context 'when user is a moderator' do
-        let(:moderator) { true }
-
-        it 'changes user into a plain user' do
-          expect(user).not_to be_admin
-          expect(user).not_to be_moderator
-        end
-      end
-
-      context 'when user is not a moderator' do
-        let(:moderator) { false }
-
-        it 'does not change any fields' do
-          expect(user).not_to be_admin
-          expect(user).not_to be_moderator
-        end
-      end
-    end
-  end
-
   describe '#active_for_authentication?' do
     subject { user.active_for_authentication? }
     let(:user) { Fabricate(:user, disabled: disabled, confirmed_at: confirmed_at) }
@@ -560,4 +405,8 @@ RSpec.describe User, type: :model do
       end
     end
   end
+
+  describe '.those_who_can' do
+    pending
+  end
 end
diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb
index 39ec2008a..846747346 100644
--- a/spec/policies/account_moderation_note_policy_spec.rb
+++ b/spec/policies/account_moderation_note_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe AccountModerationNotePolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :create? do
@@ -31,7 +31,7 @@ RSpec.describe AccountModerationNotePolicy do
 
     context 'admin' do
       it 'grants to destroy' do
-        expect(subject).to permit(admin, AccountModerationNotePolicy)
+        expect(subject).to permit(admin, account_moderation_note)
       end
     end
 
diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb
index b55eb65a7..0f23fd97e 100644
--- a/spec/policies/account_policy_spec.rb
+++ b/spec/policies/account_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe AccountPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
   let(:alice)   { Fabricate(:account) }
 
@@ -55,7 +55,7 @@ RSpec.describe AccountPolicy do
     end
   end
 
-  permissions :redownload?, :subscribe?, :unsubscribe? do
+  permissions :redownload? do
     context 'admin' do
       it 'permits' do
         expect(subject).to permit(admin)
@@ -70,7 +70,7 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :suspend?, :silence? do
-    let(:staff) { Fabricate(:user, admin: true).account }
+    let(:staff) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
 
     context 'staff' do
       context 'record is staff' do
@@ -94,7 +94,7 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :memorialize? do
-    let(:other_admin) { Fabricate(:user, admin: true).account }
+    let(:other_admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
 
     context 'admin' do
       context 'record is admin' do
diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb
index e4f1af3c1..6a6ef6694 100644
--- a/spec/policies/custom_emoji_policy_spec.rb
+++ b/spec/policies/custom_emoji_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe CustomEmojiPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :enable?, :disable? do
diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb
index b24ed9e3a..01b97e823 100644
--- a/spec/policies/domain_block_policy_spec.rb
+++ b/spec/policies/domain_block_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe DomainBlockPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :create?, :destroy? do
diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb
index 1ff55af8e..913075c3d 100644
--- a/spec/policies/email_domain_block_policy_spec.rb
+++ b/spec/policies/email_domain_block_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe EmailDomainBlockPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :create?, :destroy? do
diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb
index 71ef1fe50..f6f51af06 100644
--- a/spec/policies/instance_policy_spec.rb
+++ b/spec/policies/instance_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe InstancePolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :destroy? do
diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb
index 122137804..01660322f 100644
--- a/spec/policies/invite_policy_spec.rb
+++ b/spec/policies/invite_policy_spec.rb
@@ -5,8 +5,8 @@ require 'pundit/rspec'
 
 RSpec.describe InvitePolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
-  let(:john)    { Fabricate(:account) }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
+  let(:john)    { Fabricate(:user).account }
 
   permissions :index? do
     context 'staff?' do
@@ -17,16 +17,22 @@ RSpec.describe InvitePolicy do
   end
 
   permissions :create? do
-    context 'min_required_role?' do
+    context 'has privilege' do
+      before do
+        UserRole.everyone.update(permissions: UserRole::FLAGS[:invite_users])
+      end
+
       it 'permits' do
-        allow_any_instance_of(described_class).to receive(:min_required_role?) { true }
         expect(subject).to permit(john, Invite)
       end
     end
 
-    context 'not min_required_role?' do
+    context 'does not have privilege' do
+      before do
+        UserRole.everyone.update(permissions: UserRole::Flags::NONE)
+      end
+
       it 'denies' do
-        allow_any_instance_of(described_class).to receive(:min_required_role?) { false }
         expect(subject).to_not permit(john, Invite)
       end
     end
@@ -54,39 +60,15 @@ RSpec.describe InvitePolicy do
     end
 
     context 'not owner?' do
-      context 'Setting.min_invite_role == "admin"' do
-        before do
-          Setting.min_invite_role = 'admin'
-        end
-
-        context 'admin?' do
-          it 'permits' do
-            expect(subject).to permit(admin, Fabricate(:invite))
-          end
-        end
-
-        context 'not admin?' do
-          it 'denies' do
-            expect(subject).to_not permit(john, Fabricate(:invite))
-          end
+      context 'admin?' do
+        it 'permits' do
+          expect(subject).to permit(admin, Fabricate(:invite))
         end
       end
 
-      context 'Setting.min_invite_role != "admin"' do
-        before do
-          Setting.min_invite_role = 'else'
-        end
-
-        context 'staff?' do
-          it 'permits' do
-            expect(subject).to permit(admin, Fabricate(:invite))
-          end
-        end
-
-        context 'not staff?' do
-          it 'denies' do
-            expect(subject).to_not permit(john, Fabricate(:invite))
-          end
+      context 'not admin?' do
+        it 'denies' do
+          expect(subject).to_not permit(john, Fabricate(:invite))
         end
       end
     end
diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb
index 139d945dc..2c50ba1e9 100644
--- a/spec/policies/relay_policy_spec.rb
+++ b/spec/policies/relay_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe RelayPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :update? do
diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb
index c34f99b71..99f5ffb8e 100644
--- a/spec/policies/report_note_policy_spec.rb
+++ b/spec/policies/report_note_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe ReportNotePolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :create? do
@@ -25,7 +25,8 @@ RSpec.describe ReportNotePolicy do
   permissions :destroy? do
     context 'admin?' do
       it 'permit' do
-        expect(subject).to permit(admin, ReportNote)
+        report_note = Fabricate(:report_note, account: john)
+        expect(subject).to permit(admin, report_note)
       end
     end
 
diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb
index 84c366d7f..8b005d8dd 100644
--- a/spec/policies/report_policy_spec.rb
+++ b/spec/policies/report_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe ReportPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :update?, :index?, :show? do
diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb
index 3fa183c50..e16ee51a4 100644
--- a/spec/policies/settings_policy_spec.rb
+++ b/spec/policies/settings_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe SettingsPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :update?, :show? do
diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb
index 28b808ee2..205ecd720 100644
--- a/spec/policies/status_policy_spec.rb
+++ b/spec/policies/status_policy_spec.rb
@@ -6,7 +6,7 @@ require 'pundit/rspec'
 RSpec.describe StatusPolicy, type: :model do
   subject { described_class }
 
-  let(:admin) { Fabricate(:user, admin: true) }
+  let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
   let(:alice) { Fabricate(:account, username: 'alice') }
   let(:bob) { Fabricate(:account, username: 'bob') }
   let(:status) { Fabricate(:status, account: alice) }
diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb
index 256e6786a..9be7140fc 100644
--- a/spec/policies/tag_policy_spec.rb
+++ b/spec/policies/tag_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe TagPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :update? do
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 731c041d1..ff0916674 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -5,7 +5,7 @@ require 'pundit/rspec'
 
 RSpec.describe UserPolicy do
   let(:subject) { described_class }
-  let(:admin)   { Fabricate(:user, admin: true).account }
+  let(:admin)   { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
   let(:john)    { Fabricate(:account) }
 
   permissions :reset_password?, :change_email? do
@@ -111,57 +111,4 @@ RSpec.describe UserPolicy do
       end
     end
   end
-
-  permissions :promote? do
-    context 'admin?' do
-      context 'promotable?' do
-        it 'permits' do
-          expect(subject).to permit(admin, john.user)
-        end
-      end
-
-      context '!promotable?' do
-        it 'denies' do
-          expect(subject).to_not permit(admin, admin.user)
-        end
-      end
-    end
-
-    context '!admin?' do
-      it 'denies' do
-        expect(subject).to_not permit(john, User)
-      end
-    end
-  end
-
-  permissions :demote? do
-    context 'admin?' do
-      context '!record.admin?' do
-        context 'demoteable?' do
-          it 'permits' do
-            john.user.update(moderator: true)
-            expect(subject).to permit(admin, john.user)
-          end
-        end
-
-        context '!demoteable?' do
-          it 'denies' do
-            expect(subject).to_not permit(admin, john.user)
-          end
-        end
-      end
-
-      context 'record.admin?' do
-        it 'denies' do
-          expect(subject).to_not permit(admin, admin.user)
-        end
-      end
-    end
-
-    context '!admin?' do
-      it 'denies' do
-        expect(subject).to_not permit(john, User)
-      end
-    end
-  end
 end