Browse Source

Add customizable user roles (#18641)

* Add customizable user roles

* Various fixes and improvements

* Add migration for old settings and fix tootctl role management
Eugen Rochko 1 year ago
parent
commit
44b2ee3485
100 changed files with 718 additions and 371 deletions
  1. 2 2
      .rubocop.yml
  2. 4 0
      app/controllers/admin/account_actions_controller.rb
  3. 2 0
      app/controllers/admin/accounts_controller.rb
  4. 4 1
      app/controllers/admin/action_logs_controller.rb
  5. 1 1
      app/controllers/admin/base_controller.rb
  6. 2 0
      app/controllers/admin/custom_emojis_controller.rb
  7. 3 1
      app/controllers/admin/dashboard_controller.rb
  8. 2 0
      app/controllers/admin/email_domain_blocks_controller.rb
  9. 2 0
      app/controllers/admin/follow_recommendations_controller.rb
  10. 2 0
      app/controllers/admin/ip_blocks_controller.rb
  11. 1 1
      app/controllers/admin/relationships_controller.rb
  12. 54 11
      app/controllers/admin/roles_controller.rb
  13. 2 0
      app/controllers/admin/statuses_controller.rb
  14. 0 20
      app/controllers/admin/subscriptions_controller.rb
  15. 3 1
      app/controllers/admin/trends/links/preview_card_providers_controller.rb
  16. 3 1
      app/controllers/admin/trends/links_controller.rb
  17. 3 1
      app/controllers/admin/trends/statuses_controller.rb
  18. 3 1
      app/controllers/admin/trends/tags_controller.rb
  19. 33 0
      app/controllers/admin/users/roles_controller.rb
  20. 1 1
      app/controllers/admin/users/two_factor_authentications_controller.rb
  21. 6 1
      app/controllers/api/v1/admin/account_actions_controller.rb
  22. 4 2
      app/controllers/api/v1/admin/accounts_controller.rb
  23. 5 1
      app/controllers/api/v1/admin/dimensions_controller.rb
  24. 1 1
      app/controllers/api/v1/admin/domain_allows_controller.rb
  25. 1 1
      app/controllers/api/v1/admin/domain_blocks_controller.rb
  26. 5 1
      app/controllers/api/v1/admin/measures_controller.rb
  27. 1 1
      app/controllers/api/v1/admin/reports_controller.rb
  28. 5 1
      app/controllers/api/v1/admin/retention_controller.rb
  29. 11 9
      app/controllers/api/v1/admin/trends/links_controller.rb
  30. 11 9
      app/controllers/api/v1/admin/trends/statuses_controller.rb
  31. 11 9
      app/controllers/api/v1/admin/trends/tags_controller.rb
  32. 7 3
      app/controllers/api/v1/trends/links_controller.rb
  33. 7 3
      app/controllers/api/v1/trends/statuses_controller.rb
  34. 10 2
      app/controllers/api/v1/trends/tags_controller.rb
  35. 12 1
      app/controllers/api/v2/admin/accounts_controller.rb
  36. 0 8
      app/controllers/application_controller.rb
  37. 1 1
      app/controllers/custom_css_controller.rb
  38. 3 11
      app/helpers/accounts_helper.rb
  39. 4 2
      app/javascript/mastodon/components/status_action_bar.js
  40. 1 0
      app/javascript/mastodon/containers/mastodon.js
  41. 7 2
      app/javascript/mastodon/features/account/components/header.js
  42. 7 3
      app/javascript/mastodon/features/notifications/components/column_settings.js
  43. 4 2
      app/javascript/mastodon/features/status/components/action_bar.js
  44. 7 2
      app/javascript/mastodon/features/ui/components/link_footer.js
  45. 0 2
      app/javascript/mastodon/initial_state.js
  46. 3 0
      app/javascript/mastodon/permissions.js
  47. 2 1
      app/javascript/mastodon/reducers/meta.js
  48. 15 0
      app/javascript/styles/mastodon/admin.scss
  49. 4 0
      app/javascript/styles/mastodon/forms.scss
  50. 3 3
      app/lib/admin/system_check.rb
  51. 10 0
      app/lib/admin/system_check/base_check.rb
  52. 4 0
      app/lib/admin/system_check/database_schema_check.rb
  53. 4 4
      app/lib/admin/system_check/elasticsearch_check.rb
  54. 4 0
      app/lib/admin/system_check/rules_check.rb
  55. 4 0
      app/lib/admin/system_check/sidekiq_process_check.rb
  56. 3 6
      app/models/account.rb
  57. 11 16
      app/models/account_filter.rb
  58. 0 68
      app/models/concerns/user_roles.rb
  59. 0 4
      app/models/form/admin_settings.rb
  60. 1 1
      app/models/trends.rb
  61. 35 3
      app/models/user.rb
  62. 179 0
      app/models/user_role.rb
  63. 2 2
      app/policies/account_moderation_note_policy.rb
  64. 16 24
      app/policies/account_policy.rb
  65. 1 1
      app/policies/account_warning_policy.rb
  66. 4 4
      app/policies/account_warning_preset_policy.rb
  67. 4 4
      app/policies/announcement_policy.rb
  68. 5 3
      app/policies/appeal_policy.rb
  69. 4 2
      app/policies/application_policy.rb
  70. 7 0
      app/policies/audit_log_policy.rb
  71. 7 7
      app/policies/custom_emoji_policy.rb
  72. 7 0
      app/policies/dashboard_policy.rb
  73. 3 3
      app/policies/delivery_policy.rb
  74. 4 4
      app/policies/domain_allow_policy.rb
  75. 5 5
      app/policies/domain_block_policy.rb
  76. 3 3
      app/policies/email_domain_block_policy.rb
  77. 3 3
      app/policies/follow_recommendation_policy.rb
  78. 3 3
      app/policies/instance_policy.rb
  79. 4 8
      app/policies/invite_policy.rb
  80. 3 3
      app/policies/ip_block_policy.rb
  81. 2 2
      app/policies/preview_card_policy.rb
  82. 2 2
      app/policies/preview_card_provider_policy.rb
  83. 1 1
      app/policies/relay_policy.rb
  84. 2 2
      app/policies/report_note_policy.rb
  85. 3 3
      app/policies/report_policy.rb
  86. 4 4
      app/policies/rule_policy.rb
  87. 3 3
      app/policies/settings_policy.rb
  88. 4 4
      app/policies/status_policy.rb
  89. 4 4
      app/policies/tag_policy.rb
  90. 12 26
      app/policies/user_policy.rb
  91. 19 0
      app/policies/user_role_policy.rb
  92. 8 8
      app/policies/webhook_policy.rb
  93. 4 0
      app/presenters/initial_state_presenter.rb
  94. 1 2
      app/serializers/initial_state_serializer.rb
  95. 6 0
      app/serializers/rest/credential_account_serializer.rb
  96. 1 1
      app/serializers/rest/instance_serializer.rb
  97. 13 0
      app/serializers/rest/role_serializer.rb
  98. 2 2
      app/services/account_search_service.rb
  99. 1 1
      app/services/appeal_service.rb
  100. 1 1
      app/services/bootstrap_timeline_service.rb

+ 2 - 2
.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'
 

+ 4 - 0
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

+ 2 - 0
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

+ 4 - 1
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
 

+ 1 - 1
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
 

+ 2 - 0
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

+ 3 - 1
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

+ 2 - 0
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

+ 2 - 0
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

+ 2 - 0
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

+ 1 - 1
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

+ 54 - 11
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

+ 2 - 0
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

+ 0 - 20
app/controllers/admin/subscriptions_controller.rb

@@ -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

+ 3 - 1
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

+ 3 - 1
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

+ 3 - 1
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

+ 3 - 1
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

+ 33 - 0
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

+ 1 - 1
app/controllers/admin/two_factor_authentications_controller.rb → 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

+ 6 - 1
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

+ 4 - 2
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

+ 5 - 1
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
 

+ 1 - 1
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

+ 1 - 1
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

+ 5 - 1
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
 

+ 1 - 1
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(

+ 5 - 1
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
 

+ 11 - 9
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

+ 11 - 9
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

+ 11 - 9
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

+ 7 - 3
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

+ 7 - 3
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

+ 10 - 2
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

+ 12 - 1
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

+ 0 - 8
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

+ 1 - 1
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

+ 3 - 11
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
 

+ 4 - 2
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')}` });

+ 1 - 0
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 {

+ 7 - 2
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')}` });
     }

+ 7 - 3
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>
 

+ 4 - 2
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')}` });

+ 7 - 2
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>}

+ 0 - 2
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');

+ 3 - 0
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;

+ 2 - 1
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:

+ 15 - 0
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 {

+ 4 - 0
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;

+ 3 - 3
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

+ 10 - 0
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

+ 4 - 0
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

+ 4 - 4
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

+ 4 - 0
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

+ 4 - 0
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

+ 3 - 6
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])

+ 11 - 16
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

+ 0 - 68
app/models/concerns/user_roles.rb

@@ -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

+ 0 - 4
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 }

+ 1 - 1
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

+ 35 - 3
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

+ 179 - 0
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

+ 2 - 2
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

+ 16 - 24
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

+ 1 - 1
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?

+ 4 - 4
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

+ 4 - 4
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

+ 5 - 3
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

+ 4 - 2
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

+ 7 - 0
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

+ 7 - 7
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

+ 7 - 0
app/policies/dashboard_policy.rb

@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DashboardPolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_dashboard)
+  end
+end

+ 3 - 3
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

+ 4 - 4
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

+ 5 - 5
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

+ 3 - 3
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

+ 3 - 3
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

+ 3 - 3
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

+ 4 - 8
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

+ 3 - 3
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

+ 2 - 2
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

+ 2 - 2
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

+ 1 - 1
app/policies/relay_policy.rb

@@ -2,6 +2,6 @@
 
 class RelayPolicy < ApplicationPolicy
   def update?
-    admin?
+    role.can?(:manage_federation)
   end
 end

+ 2 - 2
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

+ 3 - 3
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

+ 4 - 4
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

+ 3 - 3
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

+ 4 - 4
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

+ 4 - 4
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

+ 12 - 26
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

+ 19 - 0
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

+ 8 - 8
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

+ 4 - 0
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

+ 1 - 2
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

+ 6 - 0
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

+ 1 - 1
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

+ 13 - 0
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

+ 2 - 2
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

+ 1 - 1
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

+ 1 - 1
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

Some files were not shown because too many files changed in this diff