Add customizable user roles (#18641)
* Add customizable user roles * Various fixes and improvements * Add migration for old settings and fix tootctl role management
This commit is contained in:
parent
1b4054256f
commit
44b2ee3485
187 changed files with 1945 additions and 1032 deletions
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
app/models/user_role.rb
Normal file
179
app/models/user_role.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue