0
0
Fork 0

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:
Eugen Rochko 2022-07-05 02:41:40 +02:00 committed by GitHub
parent 1b4054256f
commit 44b2ee3485
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
187 changed files with 1945 additions and 1032 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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