diff --git a/app/services/prune_login_activities_service.rb b/app/services/prune_login_activities_service.rb new file mode 100644 index 0000000000..ef8e0768c0 --- /dev/null +++ b/app/services/prune_login_activities_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Prunes login_activities records older than the retention window (default: 13 months). +# Written by AuthTrail on every sign-in attempt, login_activities accumulates personal data +# (typed email identities, IP addresses, user agents) indefinitely. +# +# The retention window defaults to 13 months and enforces a hard minimum of 13 months so that +# the 12-month historical reporting window on the Metrics page (MetricsReport#monthly_active_users) +# is never clipped, even on the last day of the current month. +class PruneLoginActivitiesService + DEFAULT_RETENTION_MONTHS = 13 + MINIMUM_RETENTION_MONTHS = 13 + + def initialize(months: DEFAULT_RETENTION_MONTHS) + @months = parse_months(months) + end + + def perform + prunable.in_batches.delete_all + end + + private + + attr_reader :months + + def parse_months(months) + raise ArgumentError, "retention months cannot be nil" if months.nil? + raise ArgumentError, "retention months must be a whole number, got #{months.inspect}" if months.is_a?(Numeric) && !months.is_a?(Integer) + + parsed = begin + Integer(months) + rescue ArgumentError, TypeError + raise ArgumentError, "retention months must be a valid whole number, got #{months.inspect}" + end + + if parsed < MINIMUM_RETENTION_MONTHS + raise ArgumentError, "retention window must be at least #{MINIMUM_RETENTION_MONTHS} months, got #{parsed}" + end + + parsed + end + + def prunable + LoginActivity.where(created_at: ...months.months.ago) + end +end diff --git a/lib/tasks/prune_login_activities.rake b/lib/tasks/prune_login_activities.rake new file mode 100644 index 0000000000..a227190144 --- /dev/null +++ b/lib/tasks/prune_login_activities.rake @@ -0,0 +1,7 @@ +desc "Prune login activities older than retention window (default 13 months), scheduled daily in Heroku Scheduler" +task prune_login_activities: :environment do + months = ENV.fetch("LOGIN_ACTIVITY_RETENTION_MONTHS", PruneLoginActivitiesService::DEFAULT_RETENTION_MONTHS) + deleted = PruneLoginActivitiesService.new(months: months).perform + + puts "Pruned #{deleted} login #{"activity".pluralize(deleted)} older than #{months} months." +end diff --git a/spec/lib/tasks/prune_login_activities_spec.rb b/spec/lib/tasks/prune_login_activities_spec.rb new file mode 100644 index 0000000000..03224a426e --- /dev/null +++ b/spec/lib/tasks/prune_login_activities_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" +require "rake" + +RSpec.describe "prune_login_activities rake task" do + before(:all) do + Rails.application.load_tasks if Rake::Task.tasks.empty? + end + + before(:each) do + Rake::Task["prune_login_activities"].reenable + end + + subject(:run_task) { Rake::Task["prune_login_activities"].invoke } + + it "prunes login activities older than 13 months and prints the summary" do + create(:login_activity, created_at: 14.months.ago) + create(:login_activity, created_at: 2.months.ago) + + expect { run_task }.to output("Pruned 1 login activity older than 13 months.\n").to_stdout + .and change(LoginActivity, :count).by(-1) + end + + it "honours LOGIN_ACTIVITY_RETENTION_MONTHS from ENV" do + create(:login_activity, created_at: 14.months.ago) + + stub_const("ENV", ENV.to_h.merge("LOGIN_ACTIVITY_RETENTION_MONTHS" => "15")) + + expect { run_task }.to output("Pruned 0 login activities older than 15 months.\n").to_stdout + .and not_change(LoginActivity, :count) + end +end diff --git a/spec/models/metrics_report_spec.rb b/spec/models/metrics_report_spec.rb index 54cd9f834c..6322e996fd 100644 --- a/spec/models/metrics_report_spec.rb +++ b/spec/models/metrics_report_spec.rb @@ -69,5 +69,25 @@ expect(described_class.new(casa_org: org).monthly_active_users(3)[:series][0][:data].last).to eq(1) expect(described_class.new.monthly_active_users(3)[:series][0][:data].last).to eq(2) end + + it "preserves 12-month active user metric counts after pruning older records" do + volunteer = create(:volunteer, casa_org: org) + admin = create(:casa_admin, casa_org: org) + + create(:login_activity, user: volunteer, success: true, created_at: Time.current) + create(:login_activity, user: volunteer, success: true, created_at: 6.months.ago) + create(:login_activity, user: admin, success: true, created_at: 11.months.ago) + create(:login_activity, user: volunteer, success: true, created_at: 14.months.ago) + + report = described_class.new(casa_org: org) + before_prune = report.monthly_active_users(12) + + expect { + PruneLoginActivitiesService.new.perform + }.to change(LoginActivity, :count).by(-1) + + after_prune = report.monthly_active_users(12) + expect(after_prune).to eq(before_prune) + end end end diff --git a/spec/services/prune_login_activities_service_spec.rb b/spec/services/prune_login_activities_service_spec.rb new file mode 100644 index 0000000000..71fe0a175c --- /dev/null +++ b/spec/services/prune_login_activities_service_spec.rb @@ -0,0 +1,95 @@ +require "rails_helper" +require "bigdecimal" + +RSpec.describe PruneLoginActivitiesService do + describe "#perform" do + it "deletes login activities strictly older than 13 months" do + create(:login_activity, created_at: 14.months.ago) + + expect { described_class.new.perform }.to change(LoginActivity, :count).by(-1) + end + + it "keeps login activities inside the retention window" do + create(:login_activity, created_at: 12.months.ago) + create(:login_activity, created_at: 1.month.ago) + create(:login_activity, created_at: Time.current) + + expect { described_class.new.perform }.not_to change(LoginActivity, :count) + end + + it "uses frozen time to verify cutoff boundary conditions" do + freeze_time do + cutoff = 13.months.ago + older = create(:login_activity, created_at: cutoff - 1.second) + exact = create(:login_activity, created_at: cutoff) + newer = create(:login_activity, created_at: cutoff + 1.second) + + expect { described_class.new.perform }.to change(LoginActivity, :count).by(-1) + expect(LoginActivity.exists?(older.id)).to be false + expect(LoginActivity.exists?(exact.id)).to be true + expect(LoginActivity.exists?(newer.id)).to be true + end + end + + it "never modifies or selectively wipes identity, ip, or other fields on surviving rows" do + surviving = create( + :login_activity, + identity: "user@example.com", + ip: "192.168.1.100", + user_agent: "TestAgent/1.0", + success: false, + failure_reason: "invalid_credentials", + created_at: 2.months.ago + ) + create(:login_activity, created_at: 14.months.ago) + + described_class.new.perform + + reloaded = surviving.reload + expect(reloaded.identity).to eq("user@example.com") + expect(reloaded.ip).to eq("192.168.1.100") + expect(reloaded.user_agent).to eq("TestAgent/1.0") + expect(reloaded.success).to be false + expect(reloaded.failure_reason).to eq("invalid_credentials") + end + + it "returns the total number of deleted records" do + create_list(:login_activity, 3, created_at: 14.months.ago) + create_list(:login_activity, 2, created_at: 6.months.ago) + + expect(described_class.new.perform).to eq(3) + end + + it "returns 0 when there are no matching records to prune" do + create_list(:login_activity, 2, created_at: 3.months.ago) + + expect(described_class.new.perform).to eq(0) + end + + it "honours a custom retention window greater than or equal to 13 months" do + create(:login_activity, created_at: 14.months.ago) + + expect { described_class.new(months: 15).perform }.not_to change(LoginActivity, :count) + expect { described_class.new(months: "15").perform }.not_to change(LoginActivity, :count) + expect { described_class.new(months: 13).perform }.to change(LoginActivity, :count).by(-1) + end + + it "refuses retention windows under 13 months" do + expect { described_class.new(months: 12) }.to raise_error(ArgumentError, /at least 13 months/) + expect { described_class.new(months: "12") }.to raise_error(ArgumentError, /at least 13 months/) + expect { described_class.new(months: 0) }.to raise_error(ArgumentError, /at least 13 months/) + expect { described_class.new(months: -1) }.to raise_error(ArgumentError, /at least 13 months/) + end + + it "refuses fractional or non-numeric values without silently truncating" do + expect { described_class.new(months: 13.5) }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: BigDecimal("13.5")) }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: Rational(27, 2)) }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: "13.5") }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: "13.0") }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: "abc") }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: "") }.to raise_error(ArgumentError, /whole number/) + expect { described_class.new(months: nil) }.to raise_error(ArgumentError, /cannot be nil/) + end + end +end