Add option to keep evidence when suspending accounts
Fix #547 When selected, before the account's data is removed, some of it is denormalized into a separate, symmetrically-encrypted table. In particular: - The e-mail - All IPs used to access the account - SHA256 fingerprints of all uploaded files - URIs of accounts followed by or following the account - URIs of accounts that were invited
This commit is contained in:
parent
3a6f9860fc
commit
7bf27db007
15 changed files with 234 additions and 9 deletions
|
@ -30,7 +30,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
|
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses, :create_account_summary)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,7 +26,8 @@ class Api::V1::Admin::AccountActionsController < Api::BaseController
|
||||||
:report_id,
|
:report_id,
|
||||||
:warning_preset_id,
|
:warning_preset_id,
|
||||||
:text,
|
:text,
|
||||||
:send_email_notification
|
:send_email_notification,
|
||||||
|
:create_account_summary
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -46,7 +46,7 @@ class Settings::DeletesController < Settings::BaseController
|
||||||
|
|
||||||
def destroy_account!
|
def destroy_account!
|
||||||
current_account.suspend!
|
current_account.suspend!
|
||||||
Admin::SuspensionWorker.perform_async(current_user.account_id, true)
|
Admin::SuspensionWorker.perform_async(current_user.account_id, reserve_email: false)
|
||||||
sign_out
|
sign_out
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -60,10 +60,23 @@ const onEnableBootstrapTimelineAccountsChange = (target) => {
|
||||||
|
|
||||||
delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
|
delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
|
||||||
|
|
||||||
|
const onAccountActionSeverityChange = (target) => {
|
||||||
|
const createAccountSummaryDiv = document.querySelector('.input.with_label.admin_account_action_create_account_summary');
|
||||||
|
|
||||||
|
if (createAccountSummaryDiv) {
|
||||||
|
createAccountSummaryDiv.style.display = (target.value === 'suspend') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
delegate(document, '#admin_account_action_type', 'change', ({ target }) => onAccountActionSeverityChange(target));
|
||||||
|
|
||||||
ready(() => {
|
ready(() => {
|
||||||
const domainBlockSeverityInput = document.getElementById('domain_block_severity');
|
const domainBlockSeverityInput = document.getElementById('domain_block_severity');
|
||||||
if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
|
if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
|
||||||
|
|
||||||
const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
|
const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
|
||||||
if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
|
if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
|
||||||
|
|
||||||
|
const accountActionSeverityInput = document.getElementById('admin_account_action_type');
|
||||||
|
if (accountActionSeverityInput) onAccountActionSeverityChange(accountActionSeverityInput);
|
||||||
});
|
});
|
||||||
|
|
|
@ -19,7 +19,10 @@ class Admin::AccountAction
|
||||||
:report_id,
|
:report_id,
|
||||||
:warning_preset_id
|
:warning_preset_id
|
||||||
|
|
||||||
attr_reader :warning, :send_email_notification, :include_statuses
|
attr_reader :warning,
|
||||||
|
:send_email_notification,
|
||||||
|
:include_statuses,
|
||||||
|
:create_account_summary
|
||||||
|
|
||||||
def send_email_notification=(value)
|
def send_email_notification=(value)
|
||||||
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
|
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
|
||||||
|
@ -29,6 +32,10 @@ class Admin::AccountAction
|
||||||
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
|
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_account_summary=(value)
|
||||||
|
@create_account_summary = ActiveModel::Type::Boolean.new.cast(value)
|
||||||
|
end
|
||||||
|
|
||||||
def save!
|
def save!
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
process_action!
|
process_action!
|
||||||
|
@ -138,7 +145,7 @@ class Admin::AccountAction
|
||||||
end
|
end
|
||||||
|
|
||||||
def queue_suspension_worker!
|
def queue_suspension_worker!
|
||||||
Admin::SuspensionWorker.perform_async(target_account.id)
|
Admin::SuspensionWorker.perform_async(target_account.id, summarize_account: create_account_summary)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_queue!
|
def process_queue!
|
||||||
|
|
6
app/models/secure_account_summary.rb
Normal file
6
app/models/secure_account_summary.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SecureAccountSummary < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
attr_encrypted :summary, key: Rails.configuration.x.otp_secret[0...32]
|
||||||
|
end
|
142
app/services/summarize_account_service.rb
Normal file
142
app/services/summarize_account_service.rb
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SummarizeAccountService < BaseService
|
||||||
|
def call(account)
|
||||||
|
raise ArgumentError, 'Must be a local account' unless account.user&.present?
|
||||||
|
|
||||||
|
@account = account
|
||||||
|
@user = account.user
|
||||||
|
|
||||||
|
@sessions = []
|
||||||
|
@following = []
|
||||||
|
@followers = []
|
||||||
|
@invited_by = nil
|
||||||
|
@invitees = []
|
||||||
|
@hashes = []
|
||||||
|
|
||||||
|
summarize_sessions!
|
||||||
|
summarize_network!
|
||||||
|
summarize_media!
|
||||||
|
|
||||||
|
SecureAccountSummary.create!(
|
||||||
|
account_id: @account.id,
|
||||||
|
summary: Oj.dump(summary_attributes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def summary_attributes
|
||||||
|
{
|
||||||
|
access: {
|
||||||
|
email: @user.email,
|
||||||
|
sessions: @sessions.uniq,
|
||||||
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
following: @following,
|
||||||
|
followers: @followers,
|
||||||
|
inviter: @invited_by,
|
||||||
|
invitees: @invitees,
|
||||||
|
},
|
||||||
|
|
||||||
|
media: {
|
||||||
|
fingerprints: @hashes.compact.uniq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize_sessions!
|
||||||
|
remember_current_session!
|
||||||
|
remember_last_session!
|
||||||
|
remember_other_sessions!
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize_network!
|
||||||
|
remember_followers!
|
||||||
|
remember_following!
|
||||||
|
remember_invitees!
|
||||||
|
remember_invited_by!
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize_media!
|
||||||
|
fingerprint_avatar!
|
||||||
|
fingerprint_header!
|
||||||
|
fingerprint_media_attachments!
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_following!
|
||||||
|
@account.following.find_each do |account|
|
||||||
|
@following << account_uri(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_followers!
|
||||||
|
@account.followers.find_each do |account|
|
||||||
|
@followers << account_uri(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_invited_by!
|
||||||
|
@invited_by = account_uri(@user.invite.user.account) if @user.invite&.user&.account&.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_invitees!
|
||||||
|
@user.invites.find_each do |invite|
|
||||||
|
invite.users.find_each do |user|
|
||||||
|
@invitees << account_uri(user.account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_current_session!
|
||||||
|
@sessions << ip_and_timestamp(@user.current_sign_in_ip, @user.current_sign_in_at) if @user.current_sign_in_ip&.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_last_session!
|
||||||
|
@sessions << ip_and_timestamp(@user.last_sign_in_ip, @user.last_sign_in_at) if @user.last_sign_in_ip&.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_other_sessions!
|
||||||
|
@user.session_activations.find_each do |session_activation|
|
||||||
|
@sessions << ip_and_timestamp(session_activation.ip, session_activation.updated_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fingerprint_avatar!
|
||||||
|
@hashes << fingerprint_attachment(@account.avatar) if @account.avatar.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def fingerprint_header!
|
||||||
|
@hashes << fingerprint_attachment(@account.header) if @account.header.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def fingerprint_media_attachments!
|
||||||
|
@account.media_attachments.find_each do |media_attachment|
|
||||||
|
@hashes << fingerprint_attachment(media_attachment.file)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
CHUNK_SIZE = 16.kilobytes
|
||||||
|
|
||||||
|
def fingerprint_attachment(attachment)
|
||||||
|
adapter = Paperclip.io_adapters.for(attachment)
|
||||||
|
digest = Digest::SHA256.new
|
||||||
|
|
||||||
|
while (buffer = adapter.read(CHUNK_SIZE))
|
||||||
|
digest.update(buffer)
|
||||||
|
end
|
||||||
|
|
||||||
|
digest.hexdigest
|
||||||
|
rescue Errno::ENOENT, Seahorse::Client::NetworkingError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_uri(account)
|
||||||
|
ActivityPub::TagManager.instance.uri_for(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ip_and_timestamp(ip, timestamp)
|
||||||
|
[ip&.to_s, timestamp&.iso8601]
|
||||||
|
end
|
||||||
|
end
|
|
@ -42,6 +42,7 @@ class SuspendAccountService < BaseService
|
||||||
# @option [Boolean] :reserve_username Keep account record
|
# @option [Boolean] :reserve_username Keep account record
|
||||||
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
|
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
|
||||||
# @option [Time] :suspended_at Only applicable when :reserve_username is true
|
# @option [Time] :suspended_at Only applicable when :reserve_username is true
|
||||||
|
# @option [Boolean] :summarize_account Create a secure summary of access, network and media data
|
||||||
def call(account, **options)
|
def call(account, **options)
|
||||||
@account = account
|
@account = account
|
||||||
@options = { reserve_username: true, reserve_email: true }.merge(options)
|
@options = { reserve_username: true, reserve_email: true }.merge(options)
|
||||||
|
@ -52,6 +53,7 @@ class SuspendAccountService < BaseService
|
||||||
@options[:skip_side_effects] = true
|
@options[:skip_side_effects] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
summarize_account!
|
||||||
reject_follows!
|
reject_follows!
|
||||||
purge_user!
|
purge_user!
|
||||||
purge_profile!
|
purge_profile!
|
||||||
|
@ -60,6 +62,12 @@ class SuspendAccountService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def summarize_account!
|
||||||
|
return unless @account.local? && @options[:summarize_account]
|
||||||
|
|
||||||
|
SummarizeAccountService.new.call(@account)
|
||||||
|
end
|
||||||
|
|
||||||
def reject_follows!
|
def reject_follows!
|
||||||
return if @account.local? || !@account.activitypub?
|
return if @account.local? || !@account.activitypub?
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.account_actions.title', acct: @account.acct)
|
= t('admin.account_actions.title', acct: @account.acct)
|
||||||
|
|
||||||
|
- content_for :header_tags do
|
||||||
|
= javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
|
||||||
|
|
||||||
= simple_form_for @account_action, url: admin_account_action_path(@account.id) do |f|
|
= simple_form_for @account_action, url: admin_account_action_path(@account.id) do |f|
|
||||||
= f.input :report_id, as: :hidden
|
= f.input :report_id, as: :hidden
|
||||||
|
|
||||||
|
@ -10,6 +13,9 @@
|
||||||
- if @account.local?
|
- if @account.local?
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :create_account_summary, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :send_email_notification, as: :boolean, wrapper: :with_label
|
= f.input :send_email_notification, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ class Admin::SuspensionWorker
|
||||||
|
|
||||||
sidekiq_options queue: 'pull'
|
sidekiq_options queue: 'pull'
|
||||||
|
|
||||||
def perform(account_id, remove_user = false)
|
def perform(account_id, options = {})
|
||||||
SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user)
|
SuspendAccountService.new.call(Account.find(account_id), **options.symbolize_keys)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,6 +9,7 @@ en:
|
||||||
account_warning_preset:
|
account_warning_preset:
|
||||||
text: You can use toot syntax, such as URLs, hashtags and mentions
|
text: You can use toot syntax, such as URLs, hashtags and mentions
|
||||||
admin_account_action:
|
admin_account_action:
|
||||||
|
create_account_summary: Retain sensitive information such as access IPs, fingerprints of uploaded files, who was following the account and others
|
||||||
include_statuses: The user will see which toots have caused the moderation action or warning
|
include_statuses: The user will see which toots have caused the moderation action or warning
|
||||||
send_email_notification: The user will receive an explanation of what happened with their account
|
send_email_notification: The user will receive an explanation of what happened with their account
|
||||||
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
|
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
|
||||||
|
@ -73,6 +74,7 @@ en:
|
||||||
account_warning_preset:
|
account_warning_preset:
|
||||||
text: Preset text
|
text: Preset text
|
||||||
admin_account_action:
|
admin_account_action:
|
||||||
|
create_account_summary: Keep evidence
|
||||||
include_statuses: Include reported toots in the e-mail
|
include_statuses: Include reported toots in the e-mail
|
||||||
send_email_notification: Notify the user per e-mail
|
send_email_notification: Notify the user per e-mail
|
||||||
text: Custom warning
|
text: Custom warning
|
||||||
|
|
11
db/migrate/20200112170923_create_secure_account_summaries.rb
Normal file
11
db/migrate/20200112170923_create_secure_account_summaries.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
class CreateSecureAccountSummaries < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
create_table :secure_account_summaries do |t|
|
||||||
|
t.bigint :account_id, index: true
|
||||||
|
t.string :encrypted_summary, default: '', null: false
|
||||||
|
t.string :encrypted_summary_iv, default: '', null: false, index: { unique: true }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
25
db/schema.rb
25
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2019_12_12_003415) do
|
ActiveRecord::Schema.define(version: 2020_01_12_170923) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -196,15 +196,26 @@ ActiveRecord::Schema.define(version: 2019_12_12_003415) do
|
||||||
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
|
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "announcements", force: :cascade do |t|
|
||||||
|
t.text "text", default: "", null: false
|
||||||
|
t.boolean "published", default: false, null: false
|
||||||
|
t.boolean "all_day", default: false, null: false
|
||||||
|
t.datetime "scheduled_at"
|
||||||
|
t.datetime "starts_at"
|
||||||
|
t.datetime "ends_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "backups", force: :cascade do |t|
|
create_table "backups", force: :cascade do |t|
|
||||||
t.bigint "user_id"
|
t.bigint "user_id"
|
||||||
t.string "dump_file_name"
|
t.string "dump_file_name"
|
||||||
t.string "dump_content_type"
|
t.string "dump_content_type"
|
||||||
t.bigint "dump_file_size"
|
|
||||||
t.datetime "dump_updated_at"
|
t.datetime "dump_updated_at"
|
||||||
t.boolean "processed", default: false, null: false
|
t.boolean "processed", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "dump_file_size"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "blocks", force: :cascade do |t|
|
create_table "blocks", force: :cascade do |t|
|
||||||
|
@ -614,6 +625,16 @@ ActiveRecord::Schema.define(version: 2019_12_12_003415) do
|
||||||
t.index ["scheduled_at"], name: "index_scheduled_statuses_on_scheduled_at"
|
t.index ["scheduled_at"], name: "index_scheduled_statuses_on_scheduled_at"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "secure_account_summaries", force: :cascade do |t|
|
||||||
|
t.bigint "account_id"
|
||||||
|
t.string "encrypted_summary", default: "", null: false
|
||||||
|
t.string "encrypted_summary_iv", default: "", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_secure_account_summaries_on_account_id"
|
||||||
|
t.index ["encrypted_summary_iv"], name: "index_secure_account_summaries_on_encrypted_summary_iv", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "session_activations", force: :cascade do |t|
|
create_table "session_activations", force: :cascade do |t|
|
||||||
t.string "session_id", null: false
|
t.string "session_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|
4
spec/fabricators/secure_account_summary_fabricator.rb
Normal file
4
spec/fabricators/secure_account_summary_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
Fabricator(:secure_account_summary) do
|
||||||
|
account
|
||||||
|
summary "{}"
|
||||||
|
end
|
4
spec/models/secure_account_summary_spec.rb
Normal file
4
spec/models/secure_account_summary_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SecureAccountSummary, type: :model do
|
||||||
|
end
|
Loading…
Reference in a new issue