Skip to content

Commit

Permalink
Functionality to anonymise deactivated users
Browse files Browse the repository at this point in the history
Here we add functionality to anonymise an already deactivated user.

An anonymised user has:
- a name which is simply "Deleted User <database id>"
- an email address which is "deleted.user.<database id>@<original domain name>"
- no telephone number

In the frontend, an admin user will see an "Anonymise" button when editing
an inactive user; this leads to a confirmation screen which submits a form to
UsersController#anonymise_update, which calls the AnonymiseUser service
object.
  • Loading branch information
benshimmin committed Jan 16, 2025
1 parent 16c4875 commit f72221f
Show file tree
Hide file tree
Showing 13 changed files with 178 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

[Full changelog][unreleased]

- Add functionality to anonymise a user

## Release 161 - 2025-01-14

[Full changelog][161]
Expand Down
22 changes: 22 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ def reactivate
add_breadcrumb t("breadcrumb.users.reactivate"), reactivate_user_path(@user)
end

def anonymise
@user = User.find(id)
authorize @user

add_breadcrumb t("breadcrumb.users.edit"), edit_user_path(@user)
add_breadcrumb t("breadcrumb.users.anonymise"), anonymise_user_path(@user)
end

def anonymise_update
@user = User.find(id)
authorize @user

result = AnonymiseUser.new(user: @user).call

if result.success?
flash[:notice] = t("action.user.update.success_anonymised")
redirect_to user_path(@user)
else
render :anonymise
end
end

def user_params
params.require(:user).permit(:name, :email, :organisation_id, :active, :reset_mfa, additional_organisations: [])
end
Expand Down
6 changes: 4 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ class User < ApplicationRecord
organisation_id: :organisation
}.freeze

scope :active, -> { where(deactivated_at: nil) }
scope :deactivated, -> { where.not(deactivated_at: nil) }
scope :active, -> { where(deactivated_at: nil, anonymised_at: nil) }
scope :deactivated, -> { where(anonymised_at: nil).where.not(deactivated_at: nil) }

scope :all_active, -> {
active.includes(:organisation).joins(:organisation).order("organisations.name ASC, users.name ASC")
Expand Down Expand Up @@ -70,6 +70,8 @@ def ensure_otp_secret!
end

def email_cannot_be_changed_after_create
return true if !anonymised_at.nil?

if email.to_s.squish.downcase != email_was.to_s.squish.downcase
errors.add(:email, :cannot_be_changed)
end
Expand Down
8 changes: 8 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ def deactivate?
def reactivate?
beis_user?
end

def anonymise?
beis_user?
end

def anonymise_update?
beis_user?
end
end
22 changes: 22 additions & 0 deletions app/services/anonymise_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class AnonymiseUser
attr_accessor :user

def initialize(user:)
self.user = user
end

def call
result = Result.new(true)

User.transaction do
user.anonymised_at = DateTime.now
new_email = "deleted.user.#{user.id}@#{user.email.split("@").last}"
user.email = new_email
user.name = "Deleted User #{user.id}"
user.mobile_number = ""
result.success = user.save!
end

result
end
end
1 change: 1 addition & 0 deletions app/views/users/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@
= link_to t("form.button.user.deactivate"), deactivate_user_path, class: "govuk-button govuk-button--secondary"
- else
= link_to t("form.button.user.reactivate"), reactivate_user_path, class: "govuk-button govuk-button--secondary"
= link_to t("form.button.user.anonymise"), anonymise_user_path, class: "govuk-button govuk-button--warning"
14 changes: 14 additions & 0 deletions app/views/users/anonymise.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
=content_for :page_title_prefix, t("page_title.users.anonymise")

%main.govuk-main-wrapper#main-content{ role: "main" }
.govuk-grid-row
.govuk-grid-column-two-thirds
%h1.govuk-heading-xl
= t("page_content.users.anonymise.title")

= t("page_content.users.anonymise.content_html", email: @user.email)

= form_with model: @user, url: anonymise_update_user_path do |f|
.govuk-button-group
= f.govuk_submit t("page_content.users.button.continue"), class: "govuk-button govuk-button--warning"
= link_to t("page_content.users.button.cancel"), edit_user_path, class: "govuk-button govuk-button--secondary"
26 changes: 26 additions & 0 deletions config/locales/models/user.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ en:
success: User successfully updated
success_deactivated: User successfully deactivated
success_reactivated: User successfully reactivated
success_anonymised: User successfully anonymised
form:
button:
user:
submit: Submit
deactivate: Deactivate user
reactivate: Reactivate user
anonymise: Anonymise user
label:
user:
email: Email address
Expand Down Expand Up @@ -95,6 +97,28 @@ en:
<p class="govuk-body">
Doing so will allow them to sign in to the application.
</p>
anonymise:
title: Anonymise user?
content_html: |
<p class="govuk-body">
You are about to anonymise the user %{email}.
</p>
<p class="govuk-body">
Doing so will prevent the user from signing in to the application.
</p>
<p class="govuk-body">
The changes the user has made will be preserved in the history but no personal information will be shown along with the change.
</p>
<p class="govuk-body">
Anonymous users are essentially deleted and cannot be re-activated.
</p>
<p class="govuk-body">
Should an anonymised user need to regain access to the application, a new users will have to be created.
</p>
page_title:
users:
edit: Edit user
Expand All @@ -103,6 +127,7 @@ en:
show: User
deactivate: Deactivate user
reactivate: Reactivate user
anonymise: Anonymise user
breadcrumb:
users:
edit: Edit user
Expand All @@ -111,6 +136,7 @@ en:
show: User
deactivate: Deactivate user
reactivate: Reactivate user
anonymise: Anonymise user
tabs:
users:
active: Active
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
member do
get :deactivate
get :reactivate
get :anonymise
patch :anonymise_update
end
end
resources :activities, only: [:index]
Expand Down
18 changes: 18 additions & 0 deletions spec/features/beis_users_can_edit_a_user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@
expect(user.reload.active).to be(true)
end

scenario "an inactive user can be anonymised" do
administrator_user = create(:beis_user)
user = create(:inactive_user, organisation: create(:partner_organisation))
authenticate!(user: administrator_user)

# Navigate to the users page
visit users_index_path(user_state: "inactive")

find("tr", text: user.name).click_link("Edit")

click_on t("form.button.user.anonymise")
click_on t("page_content.users.button.continue")

expect(page).to have_content(t("action.user.update.success_anonymised"))
expect(user.reload.active).to be(false)
expect(user.reload.anonymised_at).to_not be(nil)
end

scenario "a user can have additional organisations" do
administrator_user = create(:beis_user)
authenticate!(user: administrator_user)
Expand Down
12 changes: 12 additions & 0 deletions spec/features/beis_users_can_view_other_users_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,17 @@
expect(page).to have_content("Active? No")
expect(page).to have_content("1 hour")
end

scenario "an anonymised user cannot be viewed" do
anonymised_user = create(:inactive_user, deactivated_at: DateTime.now, anonymised_at: DateTime.now)

# Navigate from the landing page
visit organisation_path(user.organisation)
click_on(t("page_title.users.index"))
# Navigate to inactive users tab
click_on(t("tabs.users.inactive"))

expect(page).not_to have_content(anonymised_user.email)
end
end
end
9 changes: 9 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
expect(user.errors[:email]).to eq([I18n.t("activerecord.errors.models.user.attributes.email.cannot_be_changed")])
end

it "should allow an email to be changed if the user is anonymised" do
user = create(:administrator, email: "[email protected]")

user.anonymised_at = DateTime.now
user.email = "[email protected]"

expect(user).to be_valid
end

it "is not case sensitive" do
# When a non-lowercase email address exists (Devise lowercases emails on creation so this is for pre-existing addresses)
user = create(:partner_organisation_user)
Expand Down
38 changes: 38 additions & 0 deletions spec/services/anonymise_user_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
require "rails_helper"

RSpec.describe AnonymiseUser do
let(:user) { create(:administrator) }

describe "#call" do
it "returns a successful result" do
result = described_class.new(user: user).call

expect(result.success?).to be(true)
expect(result.failure?).to be(false)
end

it "anonymises the email address" do
email = user.email
described_class.new(user:).call
expect(user.email).not_to eql(email)

# Domain element of email address should be unchanged:
expect(user.email.split("@").last).to eql(email.split("@").last)
end

it "anonymises the name" do
name = user.name
described_class.new(user:).call
expect(user.name).not_to eql(name)

# Check IDs match; they're in this format:
# "Deleted User <id>", "deleted.user.<id>@<domain>"
expect(user.name.split(" ").last).to eql(user.email.split("@").first.split(".").last)
end

it "removes the mobile number" do
described_class.new(user:).call
expect(user.mobile_number).to be_blank
end
end
end

0 comments on commit f72221f

Please sign in to comment.