Skip to content

Commit

Permalink
Add user anonimisation functionality and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanb777 committed Dec 17, 2024
1 parent ec9a97a commit 3a98812
Show file tree
Hide file tree
Showing 5 changed files with 307 additions and 0 deletions.
232 changes: 232 additions & 0 deletions app/lib/identity_tijuana/user_ghosting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
module IdentityTijuana
class UserGhosting
def initialize(user_ids, reason)
@user_ids = user_ids
@reason = reason
end

def ghost_users
tj_conn = IdentityTijuana::ReadWrite.connection
log_ids = log_anonymisation_started
begin
tj_conn.transaction do
anon_basic_info(tj_conn)
anon_automation_events(tj_conn)
anon_call_outcomes(tj_conn)
anon_comments(tj_conn)
anon_donations(tj_conn)
anon_event_tracking_logs(tj_conn)
anon_facebook_users(tj_conn)
anon_image_shares(tj_conn)
anon_testimonials(tj_conn)
anon_user_emails(tj_conn)
anon_vision_survey_hashes(tj_conn)
end
rescue ActiveRecord::RecordInvalid => e
log_anonymisation_failed(log_ids, e.message, e)
Rails.logger.error "Anonymisation failed: #{e.message}"
end

log_anonymisation_finished(log_ids)
end

private

def log_anonymisation_started
log_ids = []

@user_ids.each do |user_id|
logged = AnonymisationLog.create!(
user_id: user_id,
anonymisation_reason: @reason,
status: 'started',
started_at: DateTime.current.utc,
)
log_ids << logged.id
end

log_ids
end

def log_anonymisation_finished(log_ids)
# rubocop:disable Rails/SkipsModelValidations
AnonymisationLog.where(id: log_ids)
.update_all(
finished_at: DateTime.current.utc,
status: 'completed'
)
# rubocop:enable Rails/SkipsModelValidations
end

def log_anonymisation_failed(log_ids, error_msg, error_stack)
# rubocop:disable Rails/SkipsModelValidations
AnonymisationLog.where(id: log_ids)
.update_all(
finished_at: DateTime.current.utc,
status: 'failed',
message: error_msg,
error_stack: error_stack
)
# rubocop:enable Rails/SkipsModelValidations
end

# TO DO - make sure that DB schema corresponds
# exist in production and staging, but missing in testing db
# -- new_tags = null,
# -- fragment = null,
# mautic_id = null,
def anon_basic_info(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE users
SET email = concat(id, '@#{Settings.ghoster.email_domain}'),
first_name = null,
last_name = null,
mobile_number = null,
home_number = null,
street_address = null,
country_iso = null,
is_member = 0,
encrypted_password = null,
password_salt = null,
reset_password_token = null,
remember_created_at = null,
sign_in_count = 0,
current_sign_in_at = null,
last_sign_in_at = null,
current_sign_in_ip = null,
last_sign_in_ip = null,
is_admin = 0,
postcode_id = null,
old_tags = '',
is_volunteer = 0,
random = null,
notes = null,
quick_donate_trigger_id = null,
facebook_id = null,
otp_secret_key = null,
tracking_token = null,
do_not_call = 1,
active = 0,
do_not_sms = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id IN (#{@user_ids.join(',')});
SQL
end

def anon_automation_events(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE automation_events
SET payload = null
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_call_outcomes(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE call_outcomes
SET email = null,
payload = null,
dialed_number = null
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

# TODO - confirm
def anon_comments(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE comments
SET body = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_donations(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE donations
SET card_type = null,
card_expiry_month = null,
card_expiry_year = null,
card_last_four_digits = null,
name_on_card = null,
cheque_name = null,
cheque_number = null,
paypal_subscr_id = null,
cancel_reason = null,
identifier = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

# TODO how `user_email_id` relates to PI
# def anon_email_target_tracking_logs(tj_conn)
# tj_conn.execute(<<~SQL.squish)
# UPDATE email_target_tracking_logs
# SET ip = null,
# agent = null,
# cookie = null,
# updated_at = CURRENT_TIMESTAMP
# WHERE user_id IN (#{@user_ids.join(',')});
# SQL
# end

def anon_event_tracking_logs(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE event_tracking_logs
SET ip = null,
agent = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_facebook_users(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE facebook_users
SET facebook_id = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_image_shares(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE image_shares
SET caption = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_testimonials(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE testimonials
SET facebook_user_id = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

# TODO
# `user_email` is present in production (missing in testing + staging)
# user_email = null,
def anon_user_emails(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE user_emails
SET body = null,
`from` = null,
updated_at = CURRENT_TIMESTAMP
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end

def anon_vision_survey_hashes(tj_conn)
tj_conn.execute(<<~SQL.squish)
UPDATE vision_survey_hashes
SET `key` = null
WHERE user_id IN (#{@user_ids.join(',')});
SQL
end
end
end
6 changes: 6 additions & 0 deletions app/models/identity_tijuana/anonymisation_log.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module IdentityTijuana
class AnonymisationLog < ReadWrite
self.table_name = 'anonymisation_logs'
belongs_to :user
end
end
12 changes: 12 additions & 0 deletions lib/identity_tijuana.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ module IdentityTijuana
MEMBER_RECORD_DATA_TYPE = 'object'.freeze
MUTEX_EXPIRY_DURATION = 10.minutes

def self.ghost_members(member_ids:, reason:, admin_member_id:)
return if member_ids.empty?

# check that we have the matching user ids for TJ system
user_ids = MemberExternalId.where(system: 'tijuana', member_id: member_ids)
.pluck(:external_id)
.map(&:to_i)

anon_reason = "#{reason} - via Id - admin:#{admin_member_id}"
UserGhosting.new(user_ids, anon_reason).ghost_users if user_ids.any?
end

def self.push(_sync_id, member_ids, _external_system_params)
members = Member.where(id: member_ids).with_email.order(:id)
yield members, nil
Expand Down
51 changes: 51 additions & 0 deletions spec/app/lib/user_ghosting_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require 'rails_helper'

describe IdentityTijuana::UserGhosting do
before(:each) do
allow(Settings).to(
receive_message_chain("tijuana.database_url") { ENV['TIJUANA_DATABASE_URL'] }
)
allow(Settings).to(
receive_message_chain("ghoster.email_domain") { 'anoned.non' }
)
end

context '#ghosting' do
context 'user with all attributes' do
it 'should ghost all attributes' do
u = FactoryBot.create(:tijuana_user_with_everything)

anon_domain = Settings.ghoster.email_domain
described_class.new([u.id], 'test-reason').ghost_users

u.reload

expect(u.email).to eq("#{u.id}@#{anon_domain}")

nils = [:first_name, :last_name, :mobile_number,
:home_number, :street_address, :country_iso,
:encrypted_password, :password_salt, :reset_password_token,
:remember_created_at, :current_sign_in_at, :last_sign_in_at,
:current_sign_in_ip, :last_sign_in_ip, :postcode_id,
:random, :notes, :quick_donate_trigger_id, :facebook_id,
:otp_secret_key, :tracking_token]

nils.each do |prop|
expect(u.send(prop)).to be(nil)
end

falsey = [:is_member, :is_admin, :active, :is_volunteer]

falsey.each do |prop|
expect(u.send(prop)).to eq(false)
end

truthy = [:do_not_call, :do_not_sms]

truthy.each do |prop|
expect(u.send(prop)).to eq(true)
end
end
end
end
end
6 changes: 6 additions & 0 deletions spec/test_identity_app/app/lib/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ def self.databases
}
end

def self.ghoster
return {
"email_domain" => "example.com"
}
end

def self.redis_url
return ENV['REDIS_URL']
end
Expand Down

0 comments on commit 3a98812

Please sign in to comment.