DEV: Allow export user archive (job) to be requested and sent to an admin (#30543)

It is not possible for an admin to generate a suspended user's archive now, disallowing SAR (subject access requests) under the GDPR.

This commit expands the export_user_archive job to allow specifying a requesting_user_id which will send the archive to an admin. When not specified, this defaults to the user itself.
This commit is contained in:
Natalie Tay 2025-01-03 14:27:10 +08:00 committed by GitHub
parent 5a55c9062a
commit 91f7ae2741
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 108 additions and 54 deletions

View File

@ -6,7 +6,7 @@ module Jobs
class ExportUserArchive < ::Jobs::Base
sidekiq_options retry: false
attr_accessor :current_user
attr_accessor :archive_for_user
# note: contents provided entirely by user
attr_accessor :extra
@ -117,7 +117,19 @@ module Jobs
)
def execute(args)
@current_user = User.find_by(id: args[:user_id])
@archive_for_user = User.find_by(id: args[:user_id])
if args[:requesting_user_id].present?
@requesting_user = User.find_by(id: args[:requesting_user_id])
if !@requesting_user&.admin?
raise Discourse::InvalidParameters.new(
"requesting_user_id: can only be admins when specified",
)
end
else
@requesting_user = @archive_for_user
end
@extra = HashWithIndifferentAccess.new(args[:args]) if args[:args]
@timestamp ||= Time.now.strftime("%y%m%d-%H%M%S")
@ -136,8 +148,8 @@ module Jobs
end
export_title = "user_archive".titleize
filename = "user_archive-#{@current_user.username}-#{@timestamp}"
user_export = UserExport.create(file_name: filename, user_id: @current_user.id)
filename = "user_archive-#{@archive_for_user.username}-#{@timestamp}"
user_export = UserExport.create(file_name: filename, user_id: @archive_for_user.id)
filename = "#{filename}-#{user_export.id}"
dirname = "#{UserExport.base_directory}/#{filename}"
@ -170,30 +182,9 @@ module Jobs
FileUtils.rm_rf(dirname)
end
begin
# create upload
upload = nil
if File.exist?(zip_filename)
File.open(zip_filename) do |file|
upload =
UploadCreator.new(
file,
File.basename(zip_filename),
type: "csv_export",
for_export: "true",
).create_for(@current_user.id)
if upload.persisted?
user_export.update_columns(upload_id: upload.id)
else
Rails.logger.warn(
"Failed to upload the file #{zip_filename}: #{upload.errors.full_messages}",
)
end
end
File.delete(zip_filename)
end
upload = create_upload_for_user(user_export, zip_filename)
ensure
post = notify_user(upload, export_title)
@ -203,13 +194,14 @@ module Jobs
topic.update_status("closed", true, Discourse.system_user)
end
end
end
def user_archive_export
return enum_for(:user_archive_export) unless block_given?
Post
.includes(topic: :category)
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.select(:topic_id, :post_number, :raw, :cooked, :like_count, :reply_count, :created_at)
.order(:created_at)
.with_deleted
@ -220,13 +212,13 @@ module Jobs
return enum_for(:user_archive_profile_export) unless block_given?
UserProfile
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.select(:location, :website, :bio_raw, :views)
.each { |user_profile| yield get_user_archive_profile_fields(user_profile) }
end
def preferences_export
UserSerializer.new(@current_user, scope: guardian)
UserSerializer.new(@archive_for_user, scope: guardian)
end
def preferences_filetype
@ -237,7 +229,7 @@ module Jobs
return enum_for(:auth_tokens) unless block_given?
UserAuthToken
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.each do |token|
yield(
[
@ -258,14 +250,14 @@ module Jobs
def include_auth_token_logs?
# SiteSetting.verbose_auth_token_logging
UserAuthTokenLog.where(user_id: @current_user.id).exists?
UserAuthTokenLog.where(user_id: @archive_for_user.id).exists?
end
def auth_token_logs_export
return enum_for(:auth_token_logs) unless block_given?
UserAuthTokenLog
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.each do |log|
yield(
[
@ -286,7 +278,7 @@ module Jobs
return enum_for(:badges_export) unless block_given?
UserBadge
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.joins(:badge)
.select(
:badge_id,
@ -318,7 +310,7 @@ module Jobs
def bookmarks_export
return enum_for(:bookmarks_export) unless block_given?
@current_user
@archive_for_user
.bookmarks
.where.not(bookmarkable_type: nil)
.order(:id)
@ -353,7 +345,7 @@ module Jobs
return enum_for(:category_preferences_export) unless block_given?
CategoryUser
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.includes(:category)
.merge(Category.secured(guardian))
.each do |cu|
@ -377,7 +369,7 @@ module Jobs
PostAction
.with_deleted
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.where(post_action_type_id: post_action_type_view.flag_types.values)
.order(:created_at)
.each do |pa|
@ -403,7 +395,7 @@ module Jobs
return enum_for(:likes_export) unless block_given?
PostAction
.with_deleted
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.where(post_action_type_id: post_action_type_view.types[:like])
.order(:created_at)
.each do |pa|
@ -426,7 +418,7 @@ module Jobs
def include_post_actions?
# Most forums should not have post_action records other than flags and likes, but they are possible in historical oddities.
PostAction
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.where.not(
post_action_type_id:
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
@ -438,7 +430,7 @@ module Jobs
return enum_for(:likes_export) unless block_given?
PostAction
.with_deleted
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.where.not(
post_action_type_id:
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
@ -465,7 +457,7 @@ module Jobs
# Most Reviewable fields staff-private, but post content needs to be exported.
ReviewableQueuedPost
.where(target_created_by_id: @current_user.id)
.where(target_created_by_id: @archive_for_user.id)
.order(:created_at)
.each do |rev|
yield(
@ -485,7 +477,7 @@ module Jobs
return enum_for(:visits_export) unless block_given?
UserVisit
.where(user_id: @current_user.id)
.where(user_id: @archive_for_user.id)
.order(visited_at: :asc)
.each { |uv| yield [uv.visited_at, uv.posts_read, uv.mobile, uv.time_read] }
end
@ -512,8 +504,34 @@ module Jobs
private
def create_upload_for_user(user_export, zip_filename)
upload = nil
if File.exist?(zip_filename)
File.open(zip_filename) do |file|
upload =
UploadCreator.new(
file,
File.basename(zip_filename),
type: "csv_export",
for_export: "true",
).create_for(@requesting_user.id)
if upload.persisted?
user_export.update_columns(upload_id: upload.id)
else
Rails.logger.warn(
"Failed to upload the file #{zip_filename}: #{upload.errors.full_messages}",
)
end
end
File.delete(zip_filename)
end
upload
end
def guardian
@guardian ||= Guardian.new(@current_user)
@guardian ||= Guardian.new(@archive_for_user)
end
def piped_category_name(category_id, category)
@ -529,7 +547,7 @@ module Jobs
def self_or_other(user_id)
if user_id.nil?
nil
elsif user_id == @current_user.id
elsif user_id == @archive_for_user.id
"self"
else
"other"
@ -608,17 +626,17 @@ module Jobs
def notify_user(upload, export_title)
post = nil
if @current_user
if @requesting_user
post =
if upload.persisted?
SystemMessage.create_from_system_user(
@current_user,
@requesting_user,
:csv_export_succeeded,
download_link: UploadMarkdown.new(upload).attachment_markdown,
export_title: export_title,
)
else
SystemMessage.create_from_system_user(@current_user, :csv_export_failed)
SystemMessage.create_from_system_user(@requesting_user, :csv_export_failed)
end
end

View File

@ -8,7 +8,7 @@ RSpec.describe Jobs::ExportUserArchive do
let(:extra) { {} }
let(:job) do
j = Jobs::ExportUserArchive.new
j.current_user = user
j.archive_for_user = user
j.extra = extra
j
end
@ -115,6 +115,42 @@ RSpec.describe Jobs::ExportUserArchive do
I18n.t("system_messages.csv_export_failed.subject_template"),
)
end
context "with a requesting_user_id that is not the user being exported" do
it "raises an error when not admin" do
expect do
Jobs::ExportUserArchive.new.execute(user_id: user.id, requesting_user_id: user2.id)
end.to raise_error(
Discourse::InvalidParameters,
"requesting_user_id: can only be admins when specified",
)
end
it "creates the upload and sends the message to the specified requesting_user_id" do
expect do Jobs::ExportUserArchive.new.execute(user_id: user2.id) end.to change {
Upload.count
}.by(1)
system_message = user2.topics_allowed.last
expect(system_message.title).to eq(
I18n.t(
"system_messages.csv_export_succeeded.subject_template",
export_title: "User Archive",
),
)
upload = system_message.first_post.uploads.first
expect(system_message.first_post.raw).to eq(
I18n.t(
"system_messages.csv_export_succeeded.text_body_template",
download_link:
"[#{upload.original_filename}|attachment](#{upload.short_url}) (#{upload.human_filesize})",
).chomp,
)
end
end
end
describe "user_archive posts" do