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.