about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-07-05 02:41:40 +0200
committerGitHub <noreply@github.com>2022-07-05 02:41:40 +0200
commit44b2ee3485ba0845e5910cefcb4b1e2f84f34470 (patch)
treecc91189c9b36aaf0a04d339455c6d238992753a9 /app/models
parent1b4054256f9d3302b44f71627a23bb0902578867 (diff)
Add customizable user roles (#18641)
* Add customizable user roles

* Various fixes and improvements

* Add migration for old settings and fix tootctl role management
Diffstat (limited to 'app/models')
-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
7 files changed, 229 insertions, 98 deletions
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