Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/services/prune_login_activities_service.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions lib/tasks/prune_login_activities.rake
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions spec/lib/tasks/prune_login_activities_spec.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions spec/models/metrics_report_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 95 additions & 0 deletions spec/services/prune_login_activities_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading