Skip to content

Commit

Permalink
Add for_associations selector for anonymising related records
Browse files Browse the repository at this point in the history
It is rare that records need to be anonymised in isolation, and this
makes it easy to configure the associations on a model which should be
anonymised at the same time. This should greatly simplify finding and
anonymising all records for a given subject across large, complex
database structures
  • Loading branch information
Tabby committed Jun 13, 2024
1 parent e46961e commit b48e21b
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# v1.4.0

- Add `for_association` selector for anonymising related models

# v1.3.0

- Add support for Ruby 3.2, 3.3 and Rails 7.1
Expand Down
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,65 @@ ModelName.anonymise_for!(:user_id, "user_1234")
If you attempt to anonymise records with a selector that has not been defined it
will throw an error.

#### Anonymising associations

You can also use selectors to anonymise associations that should have the same lifetime as
a given model, which can make it much easier to ensure all related models are anonymised
together.

For example, if our `Employee` model has an association with an `Address` model and we
want the `Address` to be anonymised when we anonymise the `Employee`, it can be done like
this:

```ruby
class Address < ApplicationRecord
belongs_to :employee

anonymise do
overwrite do
hex :address_line1, :postal_code
end
end
end

class Employees < ApplicationRecord
has_one :address

anonymise do
overwrite do
hex :first_name
end

selectors do
for_association :address
end
end
end
```

By calling `anonymise!` on a record of the `Employee` model, Anony will recursively call
`anonymise!` on the associated model records as well and return an array of all the
`Result` objects from all the anonymised records.

```shell
irb(main):001:0> employee = Employee.find(1)
=> #<Employee id=1>

irb(main):002:0> employee.anonymised?
=> false

irb(main):003:0> employee.address.anonymised?
=> false

irb(main):004:0> employee.anonymise!
=> [#<Anony::Result status="overwritten" fields=[:first_name] error=nil>,#<Anony::Result status="overwritten" fields=[:address_line1, :postal_code] error=nil>]

irb(main):005:0> employee.anonymised?
=> true

irb(main):006:0> employee.address.anonymised?
=> true
```
### Identifying anonymised records
Expand Down Expand Up @@ -492,7 +551,7 @@ Lint/DefineDeletionStrategy:
If your models use multiple superclasses, you can specify a list of superclasses in your `.rubocop.yml`. Note that you will have to specify `ApplicationRecord` explicitly in this list should you want to lint all models which inherit from `ApplicationRecord`.
```yml
Lint/DefineDeletionStrategy:
ModelSuperclass:
ModelSuperclass:
- Acme::Record
- UmbrellaCorp::Record
Expand Down
13 changes: 12 additions & 1 deletion lib/anony/anonymisable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,18 @@ def anonymise!
end

self.class.anonymise_config.validate!
self.class.anonymise_config.apply(self)
result = self.class.anonymise_config.apply(self)

if self.class.anonymise_config.associations
[
result,
*self.class.anonymise_config.associations&.flat_map do |association|
send(association).map(&:anonymise!)
end,
]
else
result
end
rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotDestroyed => e
Result.failed(e)
end
Expand Down
1 change: 1 addition & 0 deletions lib/anony/model_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def apply(instance)

delegate :valid?, :validate!, to: :@strategy
delegate :select, to: :@selectors_config
delegate :associations, to: :@selectors_config, allow_nil: true

# Use the deletion strategy instead of anonymising individual fields. This method is
# incompatible with the fields strategy.
Expand Down
9 changes: 8 additions & 1 deletion lib/anony/selectors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ class Selectors
def initialize(model_class, &block)
@model_class = model_class
@selectors = {}
@associations = nil
instance_exec(&block) if block
end

attr_reader :selectors
attr_reader :selectors, :associations

def for_subject(subject, &block)
selectors[subject] = block
end

def for_associations(*associations)
raise ArgumentError, "One or more associations required" unless associations.any?

@associations = associations
end

def select(subject, subject_id)
selector = selectors[subject]
raise SelectorNotFoundException.new(subject.to_s, @model_class.name) if selector.nil?
Expand Down
2 changes: 1 addition & 1 deletion lib/anony/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Anony
VERSION = "1.3.0"
VERSION = "1.4.0"
end
63 changes: 63 additions & 0 deletions spec/anony/anonymisable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,67 @@ def some_instance_method?
expect(a_class.anonymise_config).to_not eq(b_class.anonymise_config)
end
end

context "with two associated models" do
Anony.const_set(:EmployeePet, Class.new(ActiveRecord::Base) do
include Anony::Anonymisable

# give this anon class a name
def self.name
"EmployeePet"
end

self.table_name = :employee_pets

belongs_to :employee, class_name: "Anony::Employee"

anonymise do
overwrite do
ignore :id, :employee_id, :first_name, :animal
nilable :last_name
end
end
end)

Anony.const_set(:Employee, Class.new(ActiveRecord::Base) do
include Anony::Anonymisable

# give this anon class a name
def self.name
"Employee"
end

self.table_name = :employees

has_many :employee_pets, class_name: "Anony::EmployeePet"

anonymise do
overwrite do
ignore :id
with_strategy StubAnoynmiser, :company_name, :first_name
nilable :last_name, :email_address
ignore :phone_number, :onboarded_at
end

selectors do
for_associations :employee_pets
end
end
end)

let!(:child_model) do
Anony::EmployeePet.create!(first_name: "jeremy", last_name: "splashington", animal: "fish",
employee: parent_model)
end

let(:parent_model) do
Anony::Employee.create!(first_name: "abc", last_name: "foo", company_name: "alpha")
end

it "anonymises the configured associated model when we anonymise the parent" do
parent_model.anonymise!
expect(parent_model.reload.anonymised?).to be(true)
expect(child_model.reload.anonymised?).to be(true)
end
end
end
8 changes: 8 additions & 0 deletions spec/anony/helpers/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
t.datetime :anonymised_at
end

create_table :employee_pets do |t|
t.string :first_name, null: false
t.string :last_name
t.string :animal, null: false
t.datetime :anonymised_at
t.belongs_to :employee
end

create_table :only_ids

create_table :a_fields, id: false do |t|
Expand Down

0 comments on commit b48e21b

Please sign in to comment.