From 7bf27db007803b6b3ce3f16eb25469ae24a3c9c4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko <eugen@zeonfederated.com> Date: Sun, 12 Jan 2020 19:42:24 +0100 Subject: [PATCH] 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 --- .../admin/account_actions_controller.rb | 2 +- .../v1/admin/account_actions_controller.rb | 3 +- .../settings/deletes_controller.rb | 2 +- app/javascript/packs/admin.js | 13 ++ app/models/admin/account_action.rb | 11 +- app/models/secure_account_summary.rb | 6 + app/services/summarize_account_service.rb | 142 ++++++++++++++++++ app/services/suspend_account_service.rb | 8 + app/views/admin/account_actions/new.html.haml | 6 + app/workers/admin/suspension_worker.rb | 4 +- config/locales/simple_form.en.yml | 2 + ...2170923_create_secure_account_summaries.rb | 11 ++ db/schema.rb | 25 ++- .../secure_account_summary_fabricator.rb | 4 + spec/models/secure_account_summary_spec.rb | 4 + 15 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 app/models/secure_account_summary.rb create mode 100644 app/services/summarize_account_service.rb create mode 100644 db/migrate/20200112170923_create_secure_account_summaries.rb create mode 100644 spec/fabricators/secure_account_summary_fabricator.rb create mode 100644 spec/models/secure_account_summary_spec.rb diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index ea56fa0ac72..5d5a431400a 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -30,7 +30,7 @@ module Admin end 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 diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb index 29c9b7107bf..8febf4b3ad5 100644 --- a/app/controllers/api/v1/admin/account_actions_controller.rb +++ b/app/controllers/api/v1/admin/account_actions_controller.rb @@ -26,7 +26,8 @@ class Api::V1::Admin::AccountActionsController < Api::BaseController :report_id, :warning_preset_id, :text, - :send_email_notification + :send_email_notification, + :create_account_summary ) end end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 15a59c999df..26e759a4ab5 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -46,7 +46,7 @@ class Settings::DeletesController < Settings::BaseController def destroy_account! 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 end end diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index b318cadc66c..ae78f488e00 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -60,10 +60,23 @@ const 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(() => { const domainBlockSeverityInput = document.getElementById('domain_block_severity'); if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput); const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts'); if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const accountActionSeverityInput = document.getElementById('admin_account_action_type'); + if (accountActionSeverityInput) onAccountActionSeverityChange(accountActionSeverityInput); }); diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index e9da003a306..fbbc39a8e77 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -19,7 +19,10 @@ class Admin::AccountAction :report_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) @send_email_notification = ActiveModel::Type::Boolean.new.cast(value) @@ -29,6 +32,10 @@ class Admin::AccountAction @include_statuses = ActiveModel::Type::Boolean.new.cast(value) end + def create_account_summary=(value) + @create_account_summary = ActiveModel::Type::Boolean.new.cast(value) + end + def save! ApplicationRecord.transaction do process_action! @@ -138,7 +145,7 @@ class Admin::AccountAction end def queue_suspension_worker! - Admin::SuspensionWorker.perform_async(target_account.id) + Admin::SuspensionWorker.perform_async(target_account.id, summarize_account: create_account_summary) end def process_queue! diff --git a/app/models/secure_account_summary.rb b/app/models/secure_account_summary.rb new file mode 100644 index 00000000000..602afce422a --- /dev/null +++ b/app/models/secure_account_summary.rb @@ -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 diff --git a/app/services/summarize_account_service.rb b/app/services/summarize_account_service.rb new file mode 100644 index 00000000000..e309b35b803 --- /dev/null +++ b/app/services/summarize_account_service.rb @@ -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 diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index ecc893931d5..1f0674e9953 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -42,6 +42,7 @@ class SuspendAccountService < BaseService # @option [Boolean] :reserve_username Keep account record # @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 [Boolean] :summarize_account Create a secure summary of access, network and media data def call(account, **options) @account = account @options = { reserve_username: true, reserve_email: true }.merge(options) @@ -52,6 +53,7 @@ class SuspendAccountService < BaseService @options[:skip_side_effects] = true end + summarize_account! reject_follows! purge_user! purge_profile! @@ -60,6 +62,12 @@ class SuspendAccountService < BaseService private + def summarize_account! + return unless @account.local? && @options[:summarize_account] + + SummarizeAccountService.new.call(@account) + end + def reject_follows! return if @account.local? || !@account.activitypub? diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml index 20fbeef335b..dbfe088488e 100644 --- a/app/views/admin/account_actions/new.html.haml +++ b/app/views/admin/account_actions/new.html.haml @@ -1,6 +1,9 @@ - content_for :page_title do = 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| = f.input :report_id, as: :hidden @@ -10,6 +13,9 @@ - if @account.local? %hr.spacer/ + .fields-group + = f.input :create_account_summary, as: :boolean, wrapper: :with_label + .fields-group = f.input :send_email_notification, as: :boolean, wrapper: :with_label diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb index 83c815efd7c..72b99ec25eb 100644 --- a/app/workers/admin/suspension_worker.rb +++ b/app/workers/admin/suspension_worker.rb @@ -5,7 +5,7 @@ class Admin::SuspensionWorker sidekiq_options queue: 'pull' - def perform(account_id, remove_user = false) - SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user) + def perform(account_id, options = {}) + SuspendAccountService.new.call(Account.find(account_id), **options.symbolize_keys) end end diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 65951b73baf..65f418dbaf8 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -9,6 +9,7 @@ en: account_warning_preset: text: You can use toot syntax, such as URLs, hashtags and mentions 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 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 @@ -73,6 +74,7 @@ en: account_warning_preset: text: Preset text admin_account_action: + create_account_summary: Keep evidence include_statuses: Include reported toots in the e-mail send_email_notification: Notify the user per e-mail text: Custom warning diff --git a/db/migrate/20200112170923_create_secure_account_summaries.rb b/db/migrate/20200112170923_create_secure_account_summaries.rb new file mode 100644 index 00000000000..bce10ab709e --- /dev/null +++ b/db/migrate/20200112170923_create_secure_account_summaries.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 5a6b2530c71..23db27f3f82 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 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" 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| t.bigint "user_id" t.string "dump_file_name" t.string "dump_content_type" - t.bigint "dump_file_size" t.datetime "dump_updated_at" t.boolean "processed", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "dump_file_size" end 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" 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| t.string "session_id", null: false t.datetime "created_at", null: false diff --git a/spec/fabricators/secure_account_summary_fabricator.rb b/spec/fabricators/secure_account_summary_fabricator.rb new file mode 100644 index 00000000000..6005ff32806 --- /dev/null +++ b/spec/fabricators/secure_account_summary_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:secure_account_summary) do + account + summary "{}" +end diff --git a/spec/models/secure_account_summary_spec.rb b/spec/models/secure_account_summary_spec.rb new file mode 100644 index 00000000000..891c1818943 --- /dev/null +++ b/spec/models/secure_account_summary_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe SecureAccountSummary, type: :model do +end -- GitLab