mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 17:53:55 +08:00
Previously, moderators had full access to all staff action logs, which exposed sensitive information including webhook secrets, API keys, site settings, private messages, and restricted categories. This change implements an allowlist approach where moderators can only see actions relevant to their role (user management, posts, topics, badges, etc.) while admin-only actions (site settings, webhooks, API keys, themes, etc.) are hidden. Additionally, content-level redaction ensures moderators cannot see details of logs referencing private topics, restricted categories, or deleted content they don't have access to. Site setting gates control visibility of category, trust level, and email actions based on existing moderator permission settings. Ref - t/171137
498 lines
14 KiB
Ruby
Vendored
498 lines
14 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
# UserHistory stores information about actions that users have taken,
|
|
# like deleting users, changing site settings, dismissing notifications, etc.
|
|
# Use other classes, like StaffActionLogger, to log records to this table.
|
|
class UserHistory < ActiveRecord::Base
|
|
belongs_to :acting_user, class_name: "User"
|
|
belongs_to :target_user, class_name: "User"
|
|
|
|
belongs_to :post
|
|
belongs_to :topic
|
|
belongs_to :category
|
|
|
|
# Each value in the context should be shorter than this
|
|
MAX_CONTEXT_LENGTH = 50_000
|
|
|
|
# We often store multiple values in details, particularly during post edits
|
|
# Let's allow space for 2 values + a little extra for padding
|
|
MAX_DETAILS_LENGTH = 110_000
|
|
|
|
MAX_JSON_LENGTH = 300_000
|
|
|
|
validates :details, length: { maximum: MAX_DETAILS_LENGTH }
|
|
validates :context, length: { maximum: MAX_CONTEXT_LENGTH }
|
|
validates :subject, length: { maximum: MAX_CONTEXT_LENGTH }
|
|
validates :ip_address, length: { maximum: MAX_CONTEXT_LENGTH }
|
|
validates :email, length: { maximum: MAX_CONTEXT_LENGTH }
|
|
validates :previous_value, length: { maximum: MAX_JSON_LENGTH }
|
|
validates :new_value, length: { maximum: MAX_JSON_LENGTH }
|
|
|
|
validates :action, presence: true
|
|
|
|
# moderators can only see these if the corresponding site setting is enabled
|
|
CATEGORY_ACTIONS = %i[create_category change_category_settings delete_category].freeze
|
|
TRUST_LEVEL_ACTIONS = %i[change_trust_level lock_trust_level unlock_trust_level].freeze
|
|
EMAIL_ACTIONS = %i[check_email add_email update_email destroy_email].freeze
|
|
|
|
scope :only_staff_actions, -> { where("action IN (?)", UserHistory.staff_action_ids) }
|
|
|
|
before_save :set_admin_only
|
|
|
|
def self.actions
|
|
@actions ||=
|
|
Enum.new(
|
|
delete_user: 1,
|
|
change_trust_level: 2,
|
|
change_site_setting: 3,
|
|
change_theme: 4,
|
|
delete_theme: 5,
|
|
checked_for_custom_avatar: 6, # not used anymore
|
|
notified_about_avatar: 7,
|
|
notified_about_sequential_replies: 8,
|
|
notified_about_dominating_topic: 9,
|
|
suspend_user: 10,
|
|
unsuspend_user: 11,
|
|
facebook_no_email: 12, # not used anymore
|
|
grant_badge: 13,
|
|
revoke_badge: 14,
|
|
auto_trust_level_change: 15,
|
|
check_email: 16,
|
|
delete_post: 17,
|
|
delete_topic: 18,
|
|
impersonate: 19,
|
|
roll_up: 20,
|
|
change_username: 21,
|
|
custom: 22,
|
|
custom_staff: 23,
|
|
anonymize_user: 24,
|
|
reviewed_post: 25,
|
|
change_category_settings: 26,
|
|
delete_category: 27,
|
|
create_category: 28,
|
|
change_site_text: 29,
|
|
silence_user: 30,
|
|
unsilence_user: 31,
|
|
grant_admin: 32,
|
|
revoke_admin: 33,
|
|
grant_moderation: 34,
|
|
revoke_moderation: 35,
|
|
backup_create: 36,
|
|
rate_limited_like: 37, # not used anymore
|
|
revoke_email: 38,
|
|
deactivate_user: 39,
|
|
wizard_step: 40,
|
|
lock_trust_level: 41,
|
|
unlock_trust_level: 42,
|
|
activate_user: 43,
|
|
change_readonly_mode: 44,
|
|
backup_download: 45,
|
|
backup_destroy: 46,
|
|
notified_about_get_a_room: 47,
|
|
change_name: 48,
|
|
post_locked: 49,
|
|
post_unlocked: 50,
|
|
check_personal_message: 51,
|
|
disabled_second_factor: 52,
|
|
post_edit: 53,
|
|
topic_published: 54,
|
|
recover_topic: 55,
|
|
post_approved: 56,
|
|
create_badge: 57,
|
|
change_badge: 58,
|
|
delete_badge: 59,
|
|
removed_silence_user: 60,
|
|
removed_suspend_user: 61,
|
|
removed_unsilence_user: 62,
|
|
removed_unsuspend_user: 63,
|
|
post_rejected: 64,
|
|
merge_user: 65,
|
|
entity_export: 66,
|
|
change_password: 67,
|
|
topic_timestamps_changed: 68,
|
|
approve_user: 69,
|
|
web_hook_create: 70,
|
|
web_hook_update: 71,
|
|
web_hook_destroy: 72,
|
|
embeddable_host_create: 73,
|
|
embeddable_host_update: 74,
|
|
embeddable_host_destroy: 75,
|
|
web_hook_deactivate: 76,
|
|
change_theme_setting: 77,
|
|
disable_theme_component: 78,
|
|
enable_theme_component: 79,
|
|
api_key_create: 80,
|
|
api_key_update: 81,
|
|
api_key_destroy: 82,
|
|
revoke_title: 83,
|
|
change_title: 84,
|
|
override_upload_secure_status: 85,
|
|
page_published: 86,
|
|
page_unpublished: 87,
|
|
add_email: 88,
|
|
update_email: 89,
|
|
destroy_email: 90,
|
|
topic_closed: 91,
|
|
topic_opened: 92,
|
|
topic_archived: 93,
|
|
topic_unarchived: 94,
|
|
post_staff_note_create: 95,
|
|
post_staff_note_destroy: 96,
|
|
watched_word_create: 97,
|
|
watched_word_destroy: 98,
|
|
delete_group: 99,
|
|
permanently_delete_post_revisions: 100,
|
|
create_public_sidebar_section: 101,
|
|
update_public_sidebar_section: 102,
|
|
destroy_public_sidebar_section: 103,
|
|
reset_bounce_score: 104,
|
|
create_watched_word_group: 105,
|
|
update_watched_word_group: 106,
|
|
delete_watched_word_group: 107,
|
|
redirected_to_required_fields: 108,
|
|
filled_in_required_fields: 109,
|
|
topic_slow_mode_set: 110,
|
|
topic_slow_mode_removed: 111,
|
|
custom_emoji_create: 112,
|
|
custom_emoji_destroy: 113,
|
|
delete_post_permanently: 114,
|
|
delete_topic_permanently: 115,
|
|
tag_group_create: 116,
|
|
tag_group_destroy: 117,
|
|
tag_group_change: 118,
|
|
delete_associated_accounts: 119,
|
|
change_theme_site_setting: 120,
|
|
stop_impersonating: 121,
|
|
upcoming_change_toggled: 122,
|
|
change_site_setting_groups: 123,
|
|
upcoming_change_available: 124,
|
|
)
|
|
end
|
|
|
|
# Staff actions is a subset of all actions, used to audit actions taken by staff users.
|
|
def self.staff_actions
|
|
@staff_actions ||= %i[
|
|
delete_user
|
|
change_trust_level
|
|
change_site_setting
|
|
change_theme
|
|
delete_theme
|
|
change_site_text
|
|
suspend_user
|
|
unsuspend_user
|
|
removed_suspend_user
|
|
removed_unsuspend_user
|
|
grant_badge
|
|
revoke_badge
|
|
check_email
|
|
delete_post
|
|
delete_topic
|
|
impersonate
|
|
roll_up
|
|
change_username
|
|
custom_staff
|
|
anonymize_user
|
|
reviewed_post
|
|
change_category_settings
|
|
delete_category
|
|
create_category
|
|
silence_user
|
|
unsilence_user
|
|
removed_silence_user
|
|
removed_unsilence_user
|
|
grant_admin
|
|
revoke_admin
|
|
grant_moderation
|
|
revoke_moderation
|
|
backup_create
|
|
revoke_email
|
|
deactivate_user
|
|
lock_trust_level
|
|
unlock_trust_level
|
|
activate_user
|
|
change_readonly_mode
|
|
backup_download
|
|
backup_destroy
|
|
post_locked
|
|
post_unlocked
|
|
check_personal_message
|
|
disabled_second_factor
|
|
post_edit
|
|
topic_published
|
|
recover_topic
|
|
post_approved
|
|
create_badge
|
|
change_badge
|
|
delete_badge
|
|
post_rejected
|
|
merge_user
|
|
entity_export
|
|
change_name
|
|
topic_timestamps_changed
|
|
approve_user
|
|
web_hook_create
|
|
web_hook_update
|
|
web_hook_destroy
|
|
web_hook_deactivate
|
|
embeddable_host_create
|
|
embeddable_host_update
|
|
embeddable_host_destroy
|
|
change_theme_setting
|
|
disable_theme_component
|
|
enable_theme_component
|
|
revoke_title
|
|
change_title
|
|
api_key_create
|
|
api_key_update
|
|
api_key_destroy
|
|
override_upload_secure_status
|
|
page_published
|
|
page_unpublished
|
|
add_email
|
|
update_email
|
|
destroy_email
|
|
topic_closed
|
|
topic_opened
|
|
topic_archived
|
|
topic_unarchived
|
|
post_staff_note_create
|
|
post_staff_note_destroy
|
|
watched_word_create
|
|
watched_word_destroy
|
|
delete_group
|
|
permanently_delete_post_revisions
|
|
create_public_sidebar_section
|
|
update_public_sidebar_section
|
|
destroy_public_sidebar_section
|
|
reset_bounce_score
|
|
update_directory_columns
|
|
deleted_unused_tags
|
|
renamed_tag
|
|
deleted_tag
|
|
chat_channel_status_change
|
|
chat_auto_remove_membership
|
|
create_watched_word_group
|
|
update_watched_word_group
|
|
delete_watched_word_group
|
|
topic_slow_mode_set
|
|
topic_slow_mode_removed
|
|
custom_emoji_create
|
|
custom_emoji_destroy
|
|
delete_post_permanently
|
|
delete_topic_permanently
|
|
tag_group_create
|
|
tag_group_destroy
|
|
tag_group_change
|
|
delete_associated_accounts
|
|
toggle_flag
|
|
delete_flag
|
|
update_flag
|
|
create_flag
|
|
change_theme_site_setting
|
|
stop_impersonating
|
|
upcoming_change_toggled
|
|
change_site_setting_groups
|
|
upcoming_change_available
|
|
]
|
|
end
|
|
|
|
def self.staff_action_ids
|
|
@staff_action_ids ||= staff_actions.map { |a| actions[a] }
|
|
end
|
|
|
|
def self.moderator_visible_actions
|
|
@moderator_visible_actions ||= [
|
|
# user account
|
|
:approve_user,
|
|
:activate_user,
|
|
:deactivate_user,
|
|
:delete_user,
|
|
# user profile
|
|
:change_name,
|
|
:change_username,
|
|
:change_title,
|
|
:revoke_title,
|
|
# trust levels
|
|
:change_trust_level,
|
|
:lock_trust_level,
|
|
:unlock_trust_level,
|
|
# emails
|
|
:add_email,
|
|
:check_email,
|
|
:update_email,
|
|
:destroy_email,
|
|
# suspension
|
|
:suspend_user,
|
|
:unsuspend_user,
|
|
:removed_suspend_user,
|
|
:removed_unsuspend_user,
|
|
# silence
|
|
:silence_user,
|
|
:unsilence_user,
|
|
:removed_silence_user,
|
|
:removed_unsilence_user,
|
|
# badges
|
|
:grant_badge,
|
|
:revoke_badge,
|
|
# posts
|
|
:post_approved,
|
|
:post_rejected,
|
|
:post_edit,
|
|
:post_locked,
|
|
:post_unlocked,
|
|
:post_staff_note_create,
|
|
:post_staff_note_destroy,
|
|
:delete_post,
|
|
:delete_post_permanently,
|
|
:permanently_delete_post_revisions,
|
|
# topics
|
|
:topic_published,
|
|
:topic_closed,
|
|
:topic_opened,
|
|
:topic_archived,
|
|
:topic_unarchived,
|
|
:topic_timestamps_changed,
|
|
:topic_slow_mode_set,
|
|
:topic_slow_mode_removed,
|
|
:recover_topic,
|
|
:delete_topic,
|
|
:delete_topic_permanently,
|
|
# categories
|
|
:create_category,
|
|
:change_category_settings,
|
|
:delete_category,
|
|
# tags
|
|
:tag_group_create,
|
|
:tag_group_change,
|
|
:tag_group_destroy,
|
|
# watched words
|
|
:watched_word_create,
|
|
:watched_word_destroy,
|
|
:create_watched_word_group,
|
|
:update_watched_word_group,
|
|
:delete_watched_word_group,
|
|
# misc.
|
|
:check_personal_message,
|
|
:reset_bounce_score,
|
|
]
|
|
end
|
|
|
|
def self.site_setting_excluded_actions
|
|
excluded = []
|
|
excluded.concat(CATEGORY_ACTIONS) unless SiteSetting.moderators_manage_categories
|
|
excluded.concat(TRUST_LEVEL_ACTIONS) unless SiteSetting.moderators_change_trust_levels
|
|
excluded.concat(EMAIL_ACTIONS) unless SiteSetting.moderators_view_emails
|
|
excluded
|
|
end
|
|
|
|
def self.moderator_visible_action_ids
|
|
(moderator_visible_actions - site_setting_excluded_actions).map { |a| actions[a] }
|
|
end
|
|
|
|
def self.admin_only_action_ids
|
|
@admin_only_action_ids ||= staff_action_ids - moderator_visible_action_ids
|
|
end
|
|
|
|
def self.with_filters(filters)
|
|
query = self
|
|
query = query.where(action: filters[:action_id]) if filters[:action_id].present?
|
|
query = query.where(custom_type: filters[:custom_type]) if filters[:custom_type].present?
|
|
|
|
%i[acting_user target_user].each do |key|
|
|
if filters[key] && (obj_id = User.where(username_lower: filters[key].downcase).pluck(:id))
|
|
query = query.where("#{key}_id = ?", obj_id)
|
|
end
|
|
end
|
|
query = query.where("subject = ?", filters[:subject]) if filters[:subject]
|
|
query
|
|
end
|
|
|
|
def self.for(user, action_type)
|
|
self.where(target_user_id: user.id, action: UserHistory.actions[action_type])
|
|
end
|
|
|
|
def self.exists_for_user?(user, action_type, opts = nil)
|
|
opts = opts || {}
|
|
result = self.where(target_user_id: user.id, action: UserHistory.actions[action_type])
|
|
result = result.where(topic_id: opts[:topic_id]) if opts[:topic_id]
|
|
result.exists?
|
|
end
|
|
|
|
def self.staff_filters
|
|
%i[action_id custom_type acting_user target_user subject action_name]
|
|
end
|
|
|
|
def self.staff_action_records(viewer, opts = nil)
|
|
opts ||= {}
|
|
custom_staff = opts[:action_id].to_i == actions[:custom_staff]
|
|
|
|
if custom_staff
|
|
opts[:custom_type] = opts[:action_name]
|
|
else
|
|
opts[:action_id] = self.actions[opts[:action_name].to_sym] if opts[:action_name]
|
|
end
|
|
|
|
query =
|
|
self
|
|
.with_filters(opts.slice(*staff_filters))
|
|
.only_staff_actions
|
|
.order("id DESC")
|
|
.includes(:acting_user, :target_user, :topic, :post, :category)
|
|
|
|
query = query.where(action: moderator_visible_action_ids) unless viewer&.admin?
|
|
|
|
query = query.where("created_at >= ?", opts[:start_date].to_time) if opts[:start_date]
|
|
query = query.where("created_at <= ?", opts[:end_date].to_time) if opts[:end_date]
|
|
|
|
query
|
|
end
|
|
|
|
def set_admin_only
|
|
self.admin_only = UserHistory.admin_only_action_ids.include?(self.action)
|
|
self
|
|
end
|
|
|
|
def new_value_is_json?
|
|
%i[change_theme delete_theme tag_group_create tag_group_destroy tag_group_change]
|
|
.map { |i| UserHistory.actions[i] }
|
|
.include?(action)
|
|
end
|
|
|
|
def previous_value_is_json?
|
|
new_value_is_json?
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: user_histories
|
|
#
|
|
# id :integer not null, primary key
|
|
# action :integer not null
|
|
# acting_user_id :integer
|
|
# target_user_id :integer
|
|
# details :text
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# context :string
|
|
# ip_address :string
|
|
# email :string
|
|
# subject :text
|
|
# previous_value :text
|
|
# new_value :text
|
|
# topic_id :integer
|
|
# admin_only :boolean default(FALSE)
|
|
# post_id :integer
|
|
# custom_type :string
|
|
# category_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_user_histories_on_acting_user_id_and_action_and_id (acting_user_id,action,id)
|
|
# index_user_histories_on_action_and_id (action,id)
|
|
# index_user_histories_on_category_id (category_id)
|
|
# index_user_histories_on_post_id (post_id)
|
|
# index_user_histories_on_subject_and_id (subject,id)
|
|
# index_user_histories_on_target_user_id_and_id (target_user_id,id)
|
|
# index_user_histories_on_topic_id_and_target_user_id_and_action (topic_id,target_user_id,action)
|
|
#
|