Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce helpers (selectors) for anonymise all a subject's records. #97

Merged
merged 3 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ inherit_gem:

AllCops:
TargetRubyVersion: 3.0
NewCops: enable

Layout/LineLength:
Max: 100

Gemspec/RequiredRubyVersion:
Enabled: false

RSpec/MultipleExpectations:
Enabled: false
21 changes: 21 additions & 0 deletions lib/anony/anonymisable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ def valid_anonymisation?
@anonymise_config.valid?
end

# Finds the records that relate to a particular subject and runs anonymise on
# each of them. If a selector is not defined it will raise an exception.
def anonymise_for!(subject, subject_id)
anonymise_config.
select(subject, subject_id, &:anonymise!)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth double checking that each subject responds to anoynmise!?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do - what behaviour would you expect? An error being raised?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this 70d18ab?

end

# Checks if a selector has been defined for a given subject.
# This is useful for when writing tests to check all models have a valid selector
# for a given subject.
# @return [Boolean]
# @example
# Manager.selector_for?(:user_id)
def selector_for?(subject)
anonymise_config.selector_for?(subject)
end

attr_reader :anonymise_config
end

Expand All @@ -74,6 +91,10 @@ def anonymise!
Result.failed(e)
end

def anonymised?
anonymised_at.present?
end

# @!visibility private
def self.included(base)
base.extend(ClassMethods)
Expand Down
30 changes: 30 additions & 0 deletions lib/anony/model_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require_relative "./strategies/destroy"
require_relative "./strategies/overwrite"
require_relative "./selectors"

module Anony
class ModelConfig
Expand All @@ -29,6 +30,7 @@ def validate!
def initialize(model_class, &block)
@model_class = model_class
@strategy = UndefinedStrategy.new
@selectors_config = nil
@skip_filter = nil
instance_exec(&block) if block
end
Expand Down Expand Up @@ -86,6 +88,34 @@ def overwrite(&block)
@strategy = Strategies::Overwrite.new(@model_class, &block)
end

# Define selectors to select records that apply to a particular subject.
# This method taks a configuration block that then builds Selectors
#
# @see Anony::Selectors
#
# @example
#
# anonymise do
# selectors do
# for_subject(:user_id) { |user_id| self.select_for_user(user_id) }
# end
# end
#
# ModelName.anonymise_for!(:user_id, "user_1234")
def selectors(&block)
@selectors_config = Selectors.new(@model_class, &block)
end

def select(subject, subject_id, &block)
@selectors_config.select(subject, subject_id, &block)
end

def selector_for?(subject)
return nil if @selectors_config.nil?

@selectors_config.selectors[subject].present?
end

# Prevent any anonymisation strategy being applied when the provided block evaluates
# to true. The block is executed in the model context.
#
Expand Down
9 changes: 9 additions & 0 deletions lib/anony/selector_not_found_exception.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Anony
class SelectorNotFoundException < StandardError
def initialize(selector, model_name)
super("Selector for #{selector} not found. Make sure you have one defined in #{model_name}")
end
end
end
27 changes: 27 additions & 0 deletions lib/anony/selectors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require_relative "./selector_not_found_exception"

module Anony
class Selectors
def initialize(model_class, &block)
@model_class = model_class
@selectors = {}
instance_exec(&block) if block
end

attr_reader :selectors

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

def select(subject, subject_id, &block)
selector = selectors[subject]
raise SelectorNotFoundException.new(subject.to_s, @model_class.name) if selector.nil?

matching = @model_class.instance_exec(subject_id, &selector)
matching.map(&block)
end
end
end
4 changes: 2 additions & 2 deletions spec/anony/activerecord_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
# rubocop:disable RSpec/ExampleLength
it "applies the correct changes to each column" do
expect { instance.anonymise! }.
to change(instance, :first_name).to(/[\h\-]{36}/).
to change(instance, :first_name).to(/[\h-]{36}/).
and change(instance, :last_name).to(nil).
and change(instance, :email_address).to(/[\h\-]@example.com/).
and change(instance, :email_address).to(/[\h-]@example.com/).
and change(instance, :phone_number).to("+1 617 555 1294").
and change(instance, :company_name).to("anonymised-Microsoft").
and change(instance, :onboarded_at).to be_within(1).of(Time.now)
Expand Down
45 changes: 44 additions & 1 deletion spec/anony/anonymisable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,24 @@ def self.call(*_)
Class.new(ActiveRecord::Base) do
include Anony::Anonymisable

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

self.table_name = :employees

anonymise do
selectors do
for_subject(:first_name) do |first_name|
where(first_name: first_name)
end

for_subject(:company_name) do |company_name|
where(company_name: company_name)
end
end

overwrite do
ignore :id
with_strategy StubAnoynmiser, :company_name
Expand All @@ -38,7 +53,35 @@ def some_instance_method?
end

let(:model) do
klass.new(first_name: "abc", last_name: "foo")
klass.create!(first_name: "abc", last_name: "foo", company_name: "alpha")
end

describe ".anonymise_for!" do
let!(:model_b) do
klass.create!(first_name: "matt", last_name: "brown", company_name: "alpha")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

end

context "selector not found" do
it "raises an error" do
expect do
klass.anonymise_for!(:random, "hello")
end.to raise_error(Anony::SelectorNotFoundException)
end
end

context "single record" do
it "changes the matching record" do
klass.anonymise_for!(:first_name, model.first_name)
expect(model.reload.anonymised?).to eq(true)
expect(model_b.reload.anonymised?).to eq(false)
end
end

it "anonymises only the matching models: company_name" do
klass.anonymise_for!(:company_name, model.company_name)
expect(model.reload.anonymised?).to be(true)
expect(model_b.reload.anonymised?).to be(true)
end
end

describe "#anonymise!" do
Expand Down
2 changes: 1 addition & 1 deletion spec/anony/field_level_strategies_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

let(:value) { "old value" }

it { is_expected.to match(/^[0-9a-f\-]+@example.com$/) }
it { is_expected.to match(/^[0-9a-f-]+@example.com$/) }
end

describe ":phone_number strategy" do
Expand Down