From 5b9d0f28549fdb164a6025f1db71b977b6968b3b Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Mon, 6 Mar 2023 15:50:58 +0000 Subject: [PATCH 1/3] Introduce helpers (selectors) for anonymise all a subject's records. These can be used to anonymise all records that relate to a specific subject. You first define a block for a specific subject that returns a list of anonymisable records. ```ruby anonymise do selectors do for_subject(:user_id) { |user_id| self.select_for_user(user_id) } end end ``` This can then be used to anonymise all those subject using this API: ```ruby ModelName.anonymise_for!(:user_id, "user_1234") ``` Attempting to use a selector that has not been defined will throw an error. --- .rubocop.yml | 4 ++ lib/anony/anonymisable.rb | 21 +++++++++++ lib/anony/model_config.rb | 30 +++++++++++++++ lib/anony/selector_not_found_exception.rb | 9 +++++ lib/anony/selectors.rb | 27 ++++++++++++++ spec/anony/activerecord_spec.rb | 4 +- spec/anony/anonymisable_spec.rb | 45 ++++++++++++++++++++++- spec/anony/field_level_strategies_spec.rb | 2 +- 8 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 lib/anony/selector_not_found_exception.rb create mode 100644 lib/anony/selectors.rb diff --git a/.rubocop.yml b/.rubocop.yml index 1e19555..d1393b1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,9 +4,13 @@ inherit_gem: AllCops: TargetRubyVersion: 3.0 + NewCops: enable Layout/LineLength: Max: 100 Gemspec/RequiredRubyVersion: Enabled: false + +RSpec/MultipleExpectations: + Enabled: false diff --git a/lib/anony/anonymisable.rb b/lib/anony/anonymisable.rb index b3ce6d2..02f485b 100644 --- a/lib/anony/anonymisable.rb +++ b/lib/anony/anonymisable.rb @@ -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!) + 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 @@ -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) diff --git a/lib/anony/model_config.rb b/lib/anony/model_config.rb index 0d9cd41..4df36ad 100644 --- a/lib/anony/model_config.rb +++ b/lib/anony/model_config.rb @@ -4,6 +4,7 @@ require_relative "./strategies/destroy" require_relative "./strategies/overwrite" +require_relative "./selectors" module Anony class ModelConfig @@ -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 @@ -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. # diff --git a/lib/anony/selector_not_found_exception.rb b/lib/anony/selector_not_found_exception.rb new file mode 100644 index 0000000..ad57547 --- /dev/null +++ b/lib/anony/selector_not_found_exception.rb @@ -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 diff --git a/lib/anony/selectors.rb b/lib/anony/selectors.rb new file mode 100644 index 0000000..b55c528 --- /dev/null +++ b/lib/anony/selectors.rb @@ -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 diff --git a/spec/anony/activerecord_spec.rb b/spec/anony/activerecord_spec.rb index 1001714..cdf8e8f 100644 --- a/spec/anony/activerecord_spec.rb +++ b/spec/anony/activerecord_spec.rb @@ -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) diff --git a/spec/anony/anonymisable_spec.rb b/spec/anony/anonymisable_spec.rb index be075b1..6063a5b 100644 --- a/spec/anony/anonymisable_spec.rb +++ b/spec/anony/anonymisable_spec.rb @@ -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 @@ -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") + 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 diff --git a/spec/anony/field_level_strategies_spec.rb b/spec/anony/field_level_strategies_spec.rb index 7d46fe5..ab5c08b 100644 --- a/spec/anony/field_level_strategies_spec.rb +++ b/spec/anony/field_level_strategies_spec.rb @@ -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 From 70d18ab9bd038acedd6fbb188f69e2dbf4e640df Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Tue, 7 Mar 2023 11:18:36 +0000 Subject: [PATCH 2/3] raise an exception if a selector selects a record it not anonymisable --- lib/anony/anonymisable.rb | 12 ++++++++++-- lib/anony/model_config.rb | 5 +---- lib/anony/not_anonymisable_exception.rb | 11 +++++++++++ lib/anony/selectors.rb | 5 ++--- 4 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 lib/anony/not_anonymisable_exception.rb diff --git a/lib/anony/anonymisable.rb b/lib/anony/anonymisable.rb index 02f485b..eb704e7 100644 --- a/lib/anony/anonymisable.rb +++ b/lib/anony/anonymisable.rb @@ -2,6 +2,7 @@ require "active_support/core_ext/module/delegation" +require_relative "./not_anonymisable_exception" require_relative "./strategies/overwrite" require_relative "model_config" @@ -57,8 +58,15 @@ def valid_anonymisation? # 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!) + records = anonymise_config. + select(subject, subject_id) + records.map do |record| + if !record.respond_to?(:anonymise!) + raise NotAnonymisableException, record + end + + record.anonymise! + end end # Checks if a selector has been defined for a given subject. diff --git a/lib/anony/model_config.rb b/lib/anony/model_config.rb index 4df36ad..cdfd04f 100644 --- a/lib/anony/model_config.rb +++ b/lib/anony/model_config.rb @@ -47,6 +47,7 @@ def apply(instance) end delegate :valid?, :validate!, to: :@strategy + delegate :select, to: :@selectors_config # Use the deletion strategy instead of anonymising individual fields. This method is # incompatible with the fields strategy. @@ -106,10 +107,6 @@ 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? diff --git a/lib/anony/not_anonymisable_exception.rb b/lib/anony/not_anonymisable_exception.rb new file mode 100644 index 0000000..8ca8fc2 --- /dev/null +++ b/lib/anony/not_anonymisable_exception.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Anony + class NotAnonymisableException < StandardError + def initialize(record) + @record = record + super("Record does not implement anonymise!. + Have you included Anony::Anonymisable and a config?") + end + end +end diff --git a/lib/anony/selectors.rb b/lib/anony/selectors.rb index b55c528..358c052 100644 --- a/lib/anony/selectors.rb +++ b/lib/anony/selectors.rb @@ -16,12 +16,11 @@ def for_subject(subject, &block) selectors[subject] = block end - def select(subject, subject_id, &block) + def select(subject, subject_id) 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) + @model_class.instance_exec(subject_id, &selector) end end end From bbad3a0c27b56c27a5804e4a1b881e523bbfe182 Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Tue, 7 Mar 2023 11:43:37 +0000 Subject: [PATCH 3/3] Update the README --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index ca15cf5..f14ed62 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,42 @@ irb(main):003:0> manager => # ``` +### Anonymising many records, or anonymising by subject + +**Note**: This is an experimental feature and has not been tested widely +in production environments. + +You can use selectors to anonymise multiple records. You first define a block for +a specific subject that returns a list of anonymisable records. + +```ruby +anonymise do + selectors do + for_subject(:user_id) { |user_id| find_all_users(user_id) } + end +end +``` + +You can also use `scopes`, `where`, etc when defining your selectors: + +```ruby +anonymise do + selectors do + for_subject(:user_id) { |user_id| where(user_id: user_id) } + end +end +``` + +This can then be used to anonymise all those subject using this API: + +```ruby +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. + + ### Identifying anonymised records If your model has an `anonymised_at` column, Anony will automatically set that value @@ -281,6 +317,12 @@ class Employees < ApplicationRecord end ``` +There is also a helper defined when `Anony::Anonymisable" is included: + +```ruby +Employees.anonymised? +``` + ### Preventing anonymisation You might have a need to preserve model data in some (or all) circumstances. Anony exposes @@ -365,6 +407,7 @@ Anony::Config.ignore_fields(:id, :created_at, :updated_at) By default, `Config.ignore_fields` is an empty array and all fields are considered anonymisable. + ## Testing This library ships with a set of useful RSpec examples for your specs. Just require them