From 2afc3dcc2d2c58b2472037afd8cff3d399a11c7c Mon Sep 17 00:00:00 2001
From: Hunter Smith <1946720+hunternet93@users.noreply.github.com>
Date: Wed, 6 May 2026 15:45:01 -0700
Subject: [PATCH 1/3] AO3-7307 Created Resque job to clean up old user audits
---
app/jobs/audits_cleanup_job.rb | 42 ++++++++++
app/models/admin_setting.rb | 1 +
app/policies/admin_setting_policy.rb | 2 +
app/views/admin/settings/index.html.erb | 3 +
config/config.yml | 6 ++
config/locales/views/en.yml | 1 +
...e_audit_records_usernames_admin_setting.rb | 5 ++
spec/jobs/audits_cleanup_job_spec.rb | 77 +++++++++++++++++++
8 files changed, 137 insertions(+)
create mode 100644 app/jobs/audits_cleanup_job.rb
create mode 100644 db/migrate/20260506200220_add_preserve_audit_records_usernames_admin_setting.rb
create mode 100644 spec/jobs/audits_cleanup_job_spec.rb
diff --git a/app/jobs/audits_cleanup_job.rb b/app/jobs/audits_cleanup_job.rb
new file mode 100644
index 00000000000..a6b2edba6e6
--- /dev/null
+++ b/app/jobs/audits_cleanup_job.rb
@@ -0,0 +1,42 @@
+class AuditsCleanupJob < ApplicationJob
+ DELETE_LIMIT = 10_000
+
+ queue_as :utilities
+
+ def self.perform
+ preserve_usernames = AdminSetting.current.preserve_audit_records_usernames&.split(/,\s*/) || []
+ preserve_user_ids = User.where(login: preserve_usernames).select(:id).map(&:id)
+
+ query = Audited.audit_class.where("0")
+
+ if ArchiveConfig.USER_KEEP_AUDIT_UPDATES_DAYS > -1
+ query = query.or(
+ Audited.audit_class.where(
+ auditable_type: "User",
+ action: "update",
+ created_at: ..ArchiveConfig.USER_KEEP_AUDIT_UPDATES_DAYS.days.ago
+ ).and(
+ Audited.audit_class.where.not(
+ auditable_id: preserve_user_ids
+ )
+ )
+ )
+ end
+
+ if ArchiveConfig.USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS > -1
+ query = query.or(
+ Audited.audit_class.where(
+ auditable_type: "User",
+ action: %w[create destroy],
+ created_at: ..ArchiveConfig.USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS.days.ago
+ ).and(
+ Audited.audit_class.where.not(
+ auditable_id: preserve_user_ids
+ )
+ )
+ )
+ end
+
+ query.limit(DELETE_LIMIT).delete_all
+ end
+end
diff --git a/app/models/admin_setting.rb b/app/models/admin_setting.rb
index 7c9f87306a4..2075906eea1 100644
--- a/app/models/admin_setting.rb
+++ b/app/models/admin_setting.rb
@@ -19,6 +19,7 @@ class AdminSetting < ApplicationRecord
invite_from_queue_frequency: ArchiveConfig.INVITE_FROM_QUEUE_FREQUENCY,
account_creation_enabled?: ArchiveConfig.ACCOUNT_CREATION_ENABLED,
days_to_purge_unactivated: 2,
+ preserve_audit_records_usernames: nil,
suspend_filter_counts?: false,
enable_test_caching?: false,
cache_expiration: 10,
diff --git a/app/policies/admin_setting_policy.rb b/app/policies/admin_setting_policy.rb
index 38981c8da92..8c50af558cd 100644
--- a/app/policies/admin_setting_policy.rb
+++ b/app/policies/admin_setting_policy.rb
@@ -5,6 +5,7 @@ class AdminSettingPolicy < ApplicationPolicy
# Define which roles can update which settings.
ALLOWED_SETTINGS_BY_ROLES = {
"policy_and_abuse" => %i[
+ preserve_audit_records_usernames
hide_spam
invite_from_queue_enabled
invite_from_queue_number
@@ -16,6 +17,7 @@ class AdminSettingPolicy < ApplicationPolicy
cache_expiration
creation_requires_invite
days_to_purge_unactivated
+ preserve_audit_records_usernames
disable_support_form
disabled_support_form_text
downloads_enabled
diff --git a/app/views/admin/settings/index.html.erb b/app/views/admin/settings/index.html.erb
index c48dd3c03fd..50d64a6bdc9 100644
--- a/app/views/admin/settings/index.html.erb
+++ b/app/views/admin/settings/index.html.erb
@@ -28,6 +28,9 @@
<%= f.label :days_to_purge_unactivated, t(".fields.days_to_purge_unactivated") %>
<%= admin_setting_text_field(f, :days_to_purge_unactivated, size: "3") %>
+
+ <%= f.label :preserve_audit_records_usernames, t(".fields.preserve_audit_records_usernames") %>
+ <%= admin_setting_text_field(f, :preserve_audit_records_usernames, size: "20") %>
diff --git a/config/config.yml b/config/config.yml
index bd8c99fdc76..67b9bb175f3 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -247,6 +247,12 @@ TAGGINGS_COUNT_REINDEX_LIMIT: 1000
# How many rows we should get from audits for backfilling user history tables
USER_HISTORIC_VALUES_LIMIT: 10_000
+# How many days should we keep user update audit records. Set to -1 to keep forever
+USER_KEEP_AUDIT_UPDATES_DAYS: 1460
+
+# How many days should we keep user create and destroy audit records. Set to -1 to keep forever
+USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS: -1
+
# how many signups in a challenge before we move to static summaries generated hourly
MAX_SIGNUPS_FOR_LIVE_SUMMARY: 20
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
index feb9327cb6f..20fc3465b7c 100644
--- a/config/locales/views/en.yml
+++ b/config/locales/views/en.yml
@@ -409,6 +409,7 @@ en:
request_invite_enabled: Users can request invitations
suspend_filter_counts: Suspend some filter tracking due to high posting volume
tag_wrangling_off: Turn off tag wrangling for non-admins
+ preserve_audit_records_usernames: Preserve audit records for these usernames (comma separated)
heading: Archive Settings
last_updated_by_admin: Settings last updated on %{updated_at} by %{admin_name}.
legend:
diff --git a/db/migrate/20260506200220_add_preserve_audit_records_usernames_admin_setting.rb b/db/migrate/20260506200220_add_preserve_audit_records_usernames_admin_setting.rb
new file mode 100644
index 00000000000..8a153da76c0
--- /dev/null
+++ b/db/migrate/20260506200220_add_preserve_audit_records_usernames_admin_setting.rb
@@ -0,0 +1,5 @@
+class AddPreserveAuditRecordsUsernamesAdminSetting < ActiveRecord::Migration[8.1]
+ def change
+ add_column :admin_settings, :preserve_audit_records_usernames, :string
+ end
+end
diff --git a/spec/jobs/audits_cleanup_job_spec.rb b/spec/jobs/audits_cleanup_job_spec.rb
new file mode 100644
index 00000000000..85a88d054ec
--- /dev/null
+++ b/spec/jobs/audits_cleanup_job_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+def create_audits(user)
+ user.audits.delete_all
+
+ user.audits.create!(action: "create", auditable: user, user: user, auditable_id: user.id,
+ auditable_type: "User", audited_changes: user.to_json,
+ created_at: (ArchiveConfig.USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS + 1).days.ago)
+
+ user.audits.create!(action: "update", auditable: user, user: user, auditable_id: user.id,
+ auditable_type: "User", audited_changes: { "sign_in_count" => [0, 1] },
+ created_at: (ArchiveConfig.USER_KEEP_AUDIT_UPDATES_DAYS + 1).days.ago)
+
+ user.audits.create!(action: "update", auditable: user, user: user, auditable_id: user.id,
+ auditable_type: "User", audited_changes: { "sign_in_count" => [1, 2] },
+ created_at: Time.now.utc)
+
+ user.audits.create!(action: "destroy", auditable: user, user: user, auditable_id: user.id,
+ auditable_type: "User", audited_changes: user.to_json,
+ created_at: Time.now.utc)
+end
+
+describe AuditsCleanupJob do
+ let(:existing_user) { create(:user) }
+ let(:no_cleanup_user) { create(:user) }
+
+ before do
+ ArchiveConfig.USER_KEEP_AUDIT_UPDATES_DAYS = 30
+ ArchiveConfig.USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS = 30
+ end
+
+ context "when old audits exist" do
+ before do
+ create_audits(existing_user)
+ end
+
+ it "deletes audit records older than the configured limits" do
+ AuditsCleanupJob.perform
+ expect(existing_user.audits.count).to eq(2)
+ end
+
+ it "doesn't delete 'update' audit records when configured limit is -1" do
+ ArchiveConfig.USER_KEEP_AUDIT_UPDATES_DAYS = -1
+ AuditsCleanupJob.perform
+ expect(existing_user.audits.where(action: "update").count).to eq(2)
+ end
+
+ it "doesn't delete 'create' or 'destroy' audit records when configured limit is -1" do
+ ArchiveConfig.USER_KEEP_AUDIT_CREATES_DESTROYS_DAYS = -1
+ AuditsCleanupJob.perform
+ expect(existing_user.audits.where(action: %w[create destroy]).count).to eq(2)
+ end
+ end
+
+ context "when audits exist for protected users" do
+ before do
+ admin_setting = AdminSetting.default
+ admin_setting.preserve_audit_records_usernames = [no_cleanup_user.login, "non_existing_user"].join(", ")
+ admin_setting.save(validate: false)
+
+ create_audits(existing_user)
+ create_audits(no_cleanup_user)
+ end
+
+ it "doesn't delete audit records for users whose usernames are in preserve_audit_records_usernames admin setting" do
+ AuditsCleanupJob.perform
+ expect(no_cleanup_user.audits.count).to eq(4)
+ end
+
+ it "does delete audit records for users whose username are not in preserve_audit_records_usernames admin setting" do
+ AuditsCleanupJob.perform
+ expect(existing_user.audits.count).to eq(2)
+ end
+ end
+end
From b2e39a7f6970ea4e6b3ec4b0a6d4fcc814a31593 Mon Sep 17 00:00:00 2001
From: Hunter Smith <1946720+hunternet93@users.noreply.github.com>
Date: Wed, 6 May 2026 16:05:02 -0700
Subject: [PATCH 2/3] Normalize locale strings
---
config/locales/views/en.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
index af2653076dc..8c5628e60b8 100644
--- a/config/locales/views/en.yml
+++ b/config/locales/views/en.yml
@@ -406,10 +406,10 @@ en:
invite_from_queue_enabled: Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)
invite_from_queue_frequency: How often (in hours) should we invite people from the queue
invite_from_queue_number: Number of people to invite from the queue at once
+ preserve_audit_records_usernames: Preserve audit records for these usernames (comma separated)
request_invite_enabled: Users can request invitations
suspend_filter_counts: Suspend some filter tracking due to high posting volume
tag_wrangling_off: Turn off tag wrangling for non-admins
- preserve_audit_records_usernames: Preserve audit records for these usernames (comma separated)
heading: Archive Settings
last_updated_by_admin: Settings last updated on %{updated_at} by %{admin_name}.
legend:
From ee866adea446f3cbe492267c9d6d1c13c1e38ee6 Mon Sep 17 00:00:00 2001
From: Hunter Smith <1946720+hunternet93@users.noreply.github.com>
Date: Wed, 6 May 2026 16:05:52 -0700
Subject: [PATCH 3/3] Add to Resque schedule
---
config/resque_schedule.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/config/resque_schedule.yml b/config/resque_schedule.yml
index 988be768cfb..c6813f73bc1 100644
--- a/config/resque_schedule.yml
+++ b/config/resque_schedule.yml
@@ -122,3 +122,9 @@ disable_admin_post_comments:
description: >-
Disables all comments on admin (news) posts older than the
configured window.
+
+cleanup_audits:
+ cron: "0 0 1 * *"
+ class: AuditsCleanupJob
+ queue: utilities
+ description: Deletes user audit records older than the configured limit.