FEATURE: Allow admins to export users (#30918)

The GDPR requires all users to be able to export their data, or request an export of their data. This is fine for active users as we have a data export button on user profiles, but suspended users have no way of accessing the data export function, and the workaround for admins to export data for suspended users involves temporarily unsuspending them, then impersonating the user to export the data as them.

Since suspended users no longer have access to their account, we can safely assume that the export request will be coming via a medium outside of Discourse (eg, email). This change is built with this workflow in mind.

This change adds a new "User exports" section to the admin user page, allowing admins to start a new export, and to download the latest export file.
This commit is contained in:
Gary Pendergast
2025-01-24 08:13:25 +11:00
committed by GitHub
parent 3070b8eeae
commit 7fc8d74f3e
18 changed files with 404 additions and 13 deletions

View File

@@ -0,0 +1,117 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { notEmpty } from "@ember/object/computed";
import { service } from "@ember/service";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { exportEntity } from "discourse/lib/export-csv";
import { i18n } from "discourse-i18n";
import UserExport from "admin/models/user-export";
const EXPORT_PROGRESS_CHANNEL = "/user-export-progress";
export default class extends Component {
@service dialog;
@service messageBus;
@service toasts;
@tracked userExport = null;
@tracked userExportReloading = false;
@notEmpty("userExport") userExportAvailable;
constructor() {
super(...arguments);
this.messageBus.subscribe(EXPORT_PROGRESS_CHANNEL, this.onExportProgress);
this.model = this.args.model;
this.userExport = UserExport.create(this.model.latest_export?.user_export);
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(EXPORT_PROGRESS_CHANNEL, this.onExportProgress);
}
@bind
onExportProgress(data) {
if (data.user_export_id === this.model.id) {
this.userExportReloading = false;
if (data.failed) {
this.dialog.alert(i18n("admin.user.exports.download.export_failed"));
} else {
this.userExport = UserExport.create(data.export_data.user_export);
this.toasts.success({
autoClose: false,
data: { message: i18n("admin.user.exports.download.success") },
});
}
}
}
@action
triggerUserExport() {
this.dialog.yesNoConfirm({
message: i18n("admin.user.exports.download.confirm"),
didConfirm: () => {
this.userExportReloading = true;
try {
exportEntity("user_archive", {
export_user_id: this.model.id,
});
this.toasts.success({
duration: 3000,
data: { message: i18n("admin.user.exports.download.started") },
});
} catch (err) {
popupAjaxError(err);
}
},
});
}
get userExportExpiry() {
return i18n("admin.user.exports.download.expires_in", {
count: this.userExport.retain_hours,
});
}
<template>
<section class="details">
<h1>{{i18n "admin.user.exports.title"}}</h1>
<div class="display-row">
<div class="field">{{i18n
"admin.user.exports.download.description"
}}</div>
<div class="value">
{{#if this.userExportAvailable}}
<a
class="download"
href={{this.userExport.uri}}
>{{this.userExport.filename}}</a><br />
{{this.userExport.human_filesize}}<br />
{{this.userExportExpiry}}
{{else}}
{{i18n "admin.user.exports.download.not_available"}}
{{/if}}
</div>
<div class="controls">
<ConditionalLoadingSpinner @condition={{this.userExportReloading}}>
<DButton
@action={{this.triggerUserExport}}
@icon="download"
@label="admin.user.exports.download.button"
class="btn-default"
/>
</ConditionalLoadingSpinner>
</div>
</div>
</section>
</template>
}

View File

@@ -0,0 +1,13 @@
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest";
export default class UserExport extends RestModel {
static async findLatest(user_id) {
const result = await ajax(
`/export_csv/latest_user_archive/${user_id}.json`
);
if (result !== null) {
return UserExport.create(result.user_export);
}
}
}

View File

@@ -866,6 +866,10 @@
</section>
{{/if}}
{{#if this.currentUser.admin}}
<AdminUserExportsTable @model={{this.model}} />
{{/if}}
<span>
<PluginOutlet
@name="after-user-details"

View File

@@ -1372,6 +1372,10 @@ export function applyDefaultHandlers(pretender) {
pretender.get("/session/passkey/challenge.json", () =>
response({ challenge: "123" })
);
pretender.get("/export_csv/latest_user_archive/:id.json", () =>
response(null)
);
}
export function resetPretender() {

View File

@@ -4,8 +4,9 @@ class ExportCsvController < ApplicationController
skip_before_action :preload_json, :check_xhr, only: [:show]
def export_entity
guardian.ensure_can_export_entity!(export_params[:entity])
entity = export_params[:entity]
entity_id = params.dig(:args, :export_user_id)&.to_i if entity == "user_archive"
guardian.ensure_can_export_entity!(entity, entity_id)
raise Discourse::InvalidParameters.new(:entity) unless entity.is_a?(String) && entity.size < 100
(export_params[:args] || {}).each do |key, value|
@@ -15,7 +16,13 @@ class ExportCsvController < ApplicationController
end
if entity == "user_archive"
Jobs.enqueue(:export_user_archive, user_id: current_user.id, args: export_params[:args])
requesting_user_id = current_user.id if entity_id
Jobs.enqueue(
:export_user_archive,
user_id: entity_id || current_user.id,
requesting_user_id:,
args: export_params[:args],
)
else
Jobs.enqueue(
:export_csv_file,
@@ -30,6 +37,19 @@ class ExportCsvController < ApplicationController
render_json_error I18n.t("csv_export.rate_limit_error")
end
def latest_user_archive
user_id = params[:user_id].to_i
# If we can't export the entity, we shouldn't be able to see it either
guardian.ensure_can_export_entity!("user_archive", user_id)
render json:
UserExport
.where(user_id:)
.where("created_at > ?", UserExport::DESTROY_CREATED_BEFORE.ago)
.order(created_at: :desc)
.first
end
private
def export_params

View File

@@ -18,7 +18,7 @@ module Jobs
# "[%{export_title}] 資料匯出已完成" gets converted to "%-topic", do not match that slug.
slugs = slugs.reject { |s| s == "%-topic" }
topics = Topic.with_deleted.where(<<~SQL, slugs, UserExport::DESTROY_CREATED_BEFORE)
topics = Topic.with_deleted.where(<<~SQL, slugs, UserExport::DESTROY_CREATED_BEFORE.ago)
slug LIKE ANY(ARRAY[?]) AND
archetype = 'private_message' AND
subtype = 'system_message' AND

View File

@@ -184,9 +184,9 @@ module Jobs
begin
# create upload
upload = create_upload_for_user(user_export, zip_filename)
create_upload_for_user(user_export, zip_filename)
ensure
post = notify_user(upload, export_title)
post = notify_user(user_export, export_title)
if user_export.present? && post.present?
topic = post.topic
@@ -623,19 +623,34 @@ module Jobs
%w[composer_open_duration_msecs is_poll reply_to_post_number tags title typing_duration_msecs]
end
def notify_user(upload, export_title)
def notify_user(export, export_title)
post = nil
if @requesting_user
post =
if upload.persisted?
if export.upload&.persisted?
::MessageBus.publish(
"/user-export-progress",
{
user_export_id: @archive_for_user.id,
export_data: UserExportSerializer.new(export, scope: guardian).as_json,
},
user_ids: [@requesting_user.id],
)
SystemMessage.create_from_system_user(
@requesting_user,
:csv_export_succeeded,
download_link: UploadMarkdown.new(upload).attachment_markdown,
download_link: UploadMarkdown.new(export.upload).attachment_markdown,
export_title: export_title,
)
else
::MessageBus.publish(
"/user-export-progress",
{ user_export_id: @archive_for_user.id, failed: true },
user_ids: [@requesting_user.id],
)
SystemMessage.create_from_system_user(@requesting_user, :csv_export_failed)
end
end

View File

@@ -13,11 +13,11 @@ class UserExport < ActiveRecord::Base
end
end
DESTROY_CREATED_BEFORE = 2.days.ago
DESTROY_CREATED_BEFORE = 2.days
def self.remove_old_exports
UserExport
.where("created_at < ?", DESTROY_CREATED_BEFORE)
.where("created_at < ?", DESTROY_CREATED_BEFORE.ago)
.find_each do |user_export|
UserExport.transaction do
begin
@@ -32,6 +32,10 @@ class UserExport < ActiveRecord::Base
end
end
def retain_hours
(created_at + DESTROY_CREATED_BEFORE - Time.zone.now).to_i / 1.hour
end
def self.base_directory
File.join(
Rails.root,

View File

@@ -36,7 +36,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:can_delete_sso_record,
:api_key_count,
:external_ids,
:similar_users_count
:similar_users_count,
:latest_export
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
@@ -167,4 +168,15 @@ class AdminDetailedUserSerializer < AdminUserSerializer
def can_delete_sso_record
scope.can_delete_sso_record?(object)
end
def latest_export
export =
UserExport
.where(user_id: object&.id)
.where("created_at > ?", UserExport::DESTROY_CREATED_BEFORE.ago)
.order(created_at: :desc)
.first
UserExportSerializer.new(export, scope:).as_json if export
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
class UserExportSerializer < ApplicationSerializer
attributes :id, :filename, :uri, :filesize, :extension, :retain_hours, :human_filesize
def filename
object.upload.original_filename
end
def uri
object.upload.short_path
end
def filesize
object.upload.filesize
end
def extension
object.upload.extension
end
def human_filesize
object.upload.human_filesize
end
end

View File

@@ -6963,6 +6963,19 @@ en:
approve_bulk_success: "Success! All selected users have been approved and notified."
time_read: "Read Time"
post_edits_count: "Post Edits"
exports:
title: User exports
download:
description: Download latest export
expires_in:
one: Expires in %{count} hour
other: Expires in %{count} hours
not_available: No export available
button: Request archive
confirm: Do you really want to create an archive of this user's activity and preferences?
started: We've started collecting collecting the archive, the download link will update when the process is complete.
success: The archive is ready for download.
export_failed: We're sorry, but the export failed. Please check the logs for further information.
anonymize: "Anonymize User"
anonymize_confirm: "Are you sure you want to anonymize this account? This will change the username and email, and reset all profile information."
delete_associated_accounts_confirm: "Are you sure you want to delete all associated accounts from this account? The user may not be able to log in if their email has changed."

View File

@@ -1517,6 +1517,7 @@ Discourse::Application.routes.draw do
post "/export_csv/export_entity" => "export_csv#export_entity",
:as => "export_entity_export_csv_index"
get "/export_csv/latest_user_archive/:user_id.json" => "export_csv#latest_user_archive"
get "onebox" => "onebox#show"
get "inline-onebox" => "inline_onebox#show"

View File

@@ -536,14 +536,15 @@ class Guardian
@user.in_any_groups?(SiteSetting.send_email_messages_allowed_groups_map)
end
def can_export_entity?(entity)
def can_export_entity?(entity, entity_id = nil)
return false if anonymous?
return true if is_admin?
return can_see_emails? if entity == "screened_email"
return entity != "user_list" if is_moderator?
return entity != "user_list" if is_moderator? && (entity != "user_archive" || entity_id.nil?)
# Regular users can only export their archives
return false unless entity == "user_archive"
return false unless entity_id == @user.id || entity_id.nil?
UserExport.where(
user_id: @user.id,
created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day),

View File

@@ -3532,6 +3532,12 @@ RSpec.describe Guardian do
it "does not allow anonymous to export" do
expect(anonymous_guardian.can_export_entity?("user_archive")).to be_falsey
end
it "only allows admins to export user_archive of other users" do
expect(user_guardian.can_export_entity?("user_archive", another_user.id)).to be_falsey
expect(moderator_guardian.can_export_entity?("user_archive", another_user.id)).to be_falsey
expect(admin_guardian.can_export_entity?("user_archive", another_user.id)).to be_truthy
end
end
describe "#can_ignore_user?" do

View File

@@ -39,4 +39,22 @@ RSpec.describe UserExport do
expect(Topic.exists?(id: topic_2.id)).to eq(true)
end
end
describe "#retain_hours" do
it "should return the correct number of hours" do
csv_file_1 = Fabricate(:upload, created_at: 1.day.ago)
topic_1 = Fabricate(:topic, created_at: 1.day.ago)
Fabricate(:post, topic: topic_1)
export =
UserExport.create!(
file_name: "test",
user: user,
upload_id: csv_file_1.id,
topic_id: topic_1.id,
created_at: 1.day.ago,
)
expect(export.retain_hours).to eq(23)
end
end
end

View File

@@ -134,6 +134,9 @@
"full_suspend_reason": {
"type": ["string", "null"]
},
"latest_export": {
"type": ["object", "null"]
},
"silence_reason": {
"type": ["string", "null"]
},

View File

@@ -3,6 +3,7 @@
RSpec.describe ExportCsvController do
context "while logged in as normal user" do
fab!(:user)
fab!(:user2) { Fabricate(:user) }
before { sign_in(user) }
describe "#export_entity" do
@@ -28,6 +29,18 @@ RSpec.describe ExportCsvController do
expect(Jobs::ExportCsvFile.jobs.size).to eq(0)
end
it "does not allow a normal user to export another user's archive" do
post "/export_csv/export_entity.json",
params: {
entity: "user_archive",
args: {
export_user_id: user2.id,
},
}
expect(response.status).to eq(422)
expect(Jobs::ExportUserArchive.jobs.size).to eq(0)
end
it "correctly logs the entity export" do
post "/export_csv/export_entity.json", params: { entity: "user_archive" }
@@ -37,9 +50,32 @@ RSpec.describe ExportCsvController do
expect(log_entry.subject).to eq("user_archive")
end
end
describe "#latest_user_archive" do
it "returns the latest user archive" do
export = generate_exports(user)
get "/export_csv/latest_user_archive/#{user.id}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["user_export"]["id"]).to eq(export.id)
end
it "returns nothing when the user has no archives" do
get "/export_csv/latest_user_archive/#{user.id}.json"
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(nil)
end
it "does not allow a normal user to view another user's archive" do
generate_exports(user2)
get "/export_csv/latest_user_archive/#{user2.id}.json"
expect(response.status).to eq(403)
end
end
end
context "while logged in as an admin" do
fab!(:user)
fab!(:admin)
before { sign_in(admin) }
@@ -65,6 +101,21 @@ RSpec.describe ExportCsvController do
expect(job_data["user_id"]).to eq(admin.id)
end
it "allows user archives for other users" do
post "/export_csv/export_entity.json",
params: {
entity: "user_archive",
args: {
export_user_id: user.id,
},
}
expect(response.status).to eq(200)
expect(Jobs::ExportUserArchive.jobs.size).to eq(1)
job_data = Jobs::ExportUserArchive.jobs.first["args"].first
expect(job_data["user_id"]).to eq(user.id)
end
it "correctly logs the entity export" do
post "/export_csv/export_entity.json", params: { entity: "user_list" }
@@ -84,9 +135,19 @@ RSpec.describe ExportCsvController do
expect(response.status).to eq(400)
end
end
describe "#latest_user_archive" do
it "allows an admin to view another user's archive" do
export = generate_exports(user)
get "/export_csv/latest_user_archive/#{user.id}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["user_export"]["id"]).to eq(export.id)
end
end
end
context "while logged in as a moderator" do
fab!(:user)
fab!(:moderator)
before { sign_in(moderator) }
@@ -114,6 +175,18 @@ RSpec.describe ExportCsvController do
expect(job_data["user_id"]).to eq(moderator.id)
end
it "does not allow moderators to export another user's archive" do
post "/export_csv/export_entity.json",
params: {
entity: "user_archive",
args: {
export_user_id: user.id,
},
}
expect(response.status).to eq(422)
expect(Jobs::ExportUserArchive.jobs.size).to eq(0)
end
it "allows moderator to export other entities" do
post "/export_csv/export_entity.json", params: { entity: "staff_action" }
expect(response.status).to eq(200)
@@ -124,5 +197,36 @@ RSpec.describe ExportCsvController do
expect(job_data["user_id"]).to eq(moderator.id)
end
end
describe "#latest_user_archive" do
it "does not allow a moderator to view another user's archive" do
generate_exports(user)
get "/export_csv/latest_user_archive/#{user.id}.json"
expect(response.status).to eq(403)
end
end
end
def generate_exports(user)
csv_file_1 = Fabricate(:upload, created_at: 1.day.ago)
topic_1 = Fabricate(:topic, created_at: 1.day.ago)
Fabricate(:post, topic: topic_1)
UserExport.create!(
file_name: "test",
user: user,
upload_id: csv_file_1.id,
topic_id: topic_1.id,
created_at: 1.day.ago,
)
csv_file_2 = Fabricate(:upload, created_at: 12.hours.ago)
topic_2 = Fabricate(:topic, created_at: 12.hours.ago)
UserExport.create!(
file_name: "test2",
user: user,
upload_id: csv_file_2.id,
topic_id: topic_2.id,
created_at: 12.hours.ago,
)
end
end

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
RSpec.describe UserExportSerializer do
subject(:serializer) { UserExportSerializer.new(user_export, root: false) }
fab!(:user_export) do
user = Fabricate(:user)
csv_file_1 = Fabricate(:upload, created_at: 1.day.ago)
topic_1 = Fabricate(:topic, created_at: 1.day.ago)
Fabricate(:post, topic: topic_1)
UserExport.create!(
file_name: "test",
user: user,
upload_id: csv_file_1.id,
topic_id: topic_1.id,
created_at: 1.day.ago,
)
end
it "should render without errors" do
json_data = JSON.parse(serializer.to_json)
expect(json_data["id"]).to eql user_export.id
expect(json_data["filename"]).to eql user_export.upload.original_filename
expect(json_data["uri"]).to eql user_export.upload.short_path
expect(json_data["filesize"]).to eql user_export.upload.filesize
expect(json_data["extension"]).to eql user_export.upload.extension
expect(json_data["retain_hours"]).to eql user_export.retain_hours
expect(json_data["human_filesize"]).to eql user_export.upload.human_filesize
end
end