anony 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ require_relative "./strategies/destroy"
6
+ require_relative "./strategies/overwrite"
7
+
8
+ module Anony
9
+ class ModelConfig
10
+ # @api private
11
+ class UndefinedStrategy
12
+ def valid?
13
+ false
14
+ end
15
+
16
+ def validate!
17
+ raise ArgumentError, "Must specify either :destroy or :overwrite strategy"
18
+ end
19
+ end
20
+
21
+ # @api private
22
+ # Constructs a new instance of ModelConfig.
23
+ #
24
+ # @param [ActiveRecord::Base] model_class The model class the config is attached to.
25
+ # @yield [block] For configuration of the ModelConfig instance.
26
+ #
27
+ # @example
28
+ # Anony::ModelConfig.new(Manager) { destroy }
29
+ def initialize(model_class, &block)
30
+ @model_class = model_class
31
+ @strategy = UndefinedStrategy.new
32
+ @skip_filter = nil
33
+ instance_exec(&block) if block_given?
34
+ end
35
+
36
+ # @api private
37
+ # Applies the given strategy, taking into account any filters or conditions.
38
+ #
39
+ # @example
40
+ # Anony::ModelConfig.new(Manager).apply(Manager.new)
41
+ def apply(instance)
42
+ return Result.skipped if @skip_filter && instance.instance_exec(&@skip_filter)
43
+
44
+ @strategy.apply(instance)
45
+ end
46
+
47
+ delegate :valid?, :validate!, to: :@strategy
48
+
49
+ # Use the deletion strategy instead of anonymising individual fields. This method is
50
+ # incompatible with the fields strategy.
51
+ #
52
+ # This method takes no arguments or blocks.
53
+ #
54
+ # @example
55
+ # anonymise do
56
+ # destroy
57
+ # end
58
+ def destroy
59
+ raise ArgumentError, ":destroy takes no block" if block_given?
60
+ unless @strategy.is_a?(UndefinedStrategy)
61
+ raise ArgumentError, "Cannot specify :destroy when another strategy already defined"
62
+ end
63
+
64
+ @strategy = Strategies::Destroy.new
65
+ end
66
+
67
+ # Use the overwrite strategy to configure rules for individual fields. This method is
68
+ # incompatible with the destroy strategy.
69
+ #
70
+ # This method takes a configuration block. All configuration is applied to
71
+ # Anony::Strategies::Overwrite.
72
+ #
73
+ # @see Anony::Strategies::Overwrite
74
+ #
75
+ # @example
76
+ # anonymise do
77
+ # overwrite do
78
+ # hex :first_name
79
+ # end
80
+ # end
81
+ def overwrite(&block)
82
+ unless @strategy.is_a?(UndefinedStrategy)
83
+ raise ArgumentError, "Cannot specify :overwrite when another strategy already defined"
84
+ end
85
+
86
+ @strategy = Strategies::Overwrite.new(@model_class, &block)
87
+ end
88
+
89
+ # Prevent any anonymisation strategy being applied when the provided block evaluates
90
+ # to true. The block is executed in the model context.
91
+ #
92
+ # @example
93
+ # anonymise do
94
+ # skip_if { !persisted? }
95
+ # end
96
+ def skip_if(&if_condition)
97
+ raise ArgumentError, "Block required for :skip_if" unless block_given?
98
+
99
+ @skip_filter = if_condition
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+
5
+ module Anony
6
+ class Result
7
+ FAILED = "failed"
8
+ DESTROYED = "destroyed"
9
+ OVERWRITTEN = "overwritten"
10
+ SKIPPED = "skipped"
11
+
12
+ attr_reader :status, :fields, :error
13
+ delegate :failed?, :overwritten?, :skipped?, :destroyed?, to: :status
14
+
15
+ def self.failed(error)
16
+ new(FAILED, error: error)
17
+ end
18
+
19
+ def self.overwritten(fields)
20
+ new(OVERWRITTEN, fields: fields)
21
+ end
22
+
23
+ def self.skipped
24
+ new(SKIPPED)
25
+ end
26
+
27
+ def self.destroyed
28
+ new(DESTROYED)
29
+ end
30
+
31
+ private def initialize(status, fields: [], error: nil)
32
+ raise ArgumentError, "No error provided" if status == FAILED && error.nil?
33
+
34
+ @status = ActiveSupport::StringInquirer.new(status)
35
+ @fields = fields
36
+ @error = error
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+
5
+ RSpec.shared_examples "overwritten anonymisable model" do
6
+ it "has a valid strategy defined" do
7
+ expect(subject.class).to be_valid_anonymisation
8
+ end
9
+
10
+ it "#anonymise! causes overwrite" do
11
+ result = subject.anonymise!
12
+ expect(result).to be_overwritten
13
+ end
14
+ end
15
+
16
+ RSpec.shared_examples "skipped anonymisable model" do
17
+ it "has a valid strategy defined" do
18
+ expect(subject.class).to be_valid_anonymisation
19
+ end
20
+
21
+ it "#anonymise! is skipped" do
22
+ result = subject.anonymise!
23
+ expect(result).to be_skipped
24
+ end
25
+
26
+ it "does not change any fields" do
27
+ result = subject.anonymise!
28
+ expect(result.fields).to be_empty
29
+ end
30
+ end
31
+
32
+ RSpec.shared_examples "destroyed anonymisable model" do
33
+ it "has a valid strategy defined" do
34
+ expect(subject.class).to be_valid_anonymisation
35
+ end
36
+
37
+ it "destroys the model" do
38
+ expect { subject.anonymise! }.to change(described_class, :count).by(-1)
39
+ end
40
+
41
+ it "labels the model as destroyed" do
42
+ result = subject.anonymise!
43
+ expect(result).to be_destroyed
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ class SkippedException < StandardError
5
+ def initialize
6
+ super("Anonymisation skipped due to matching skip_if filter")
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ module Strategies
5
+ # The interface for configuring a destroy strategy. This strategy is not compatible
6
+ # with Anony::Strategies::Overwrite.
7
+ #
8
+ # @example
9
+ # anonymise do
10
+ # destroy
11
+ # end
12
+ class Destroy
13
+ # Whether the strategy is valid. This strategy takes no configuration, so #valid?
14
+ # always returns true
15
+ #
16
+ # @return [true]
17
+ def valid?
18
+ true
19
+ end
20
+
21
+ # Whether the strategy is valid, raising an exception if not. This strategy takes no
22
+ # configuration, so #validate! always returns true
23
+ #
24
+ # @return [true]
25
+ def validate!
26
+ true
27
+ end
28
+
29
+ # Apply the Destroy strategy to the model instance. In this case, it calls
30
+ # `#destroy!`.
31
+ #
32
+ # @param [ActiveRecord::Base] instance An instance of the model
33
+ def apply(instance)
34
+ instance.destroy!
35
+ Result.destroyed
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../field_level_strategies"
4
+
5
+ module Anony
6
+ module Strategies
7
+ # The interface for configuring a field-level strategy. All of the methods here are
8
+ # made available inside the `overwrite { ... }` block:
9
+ #
10
+ # @example
11
+ # anonymise do
12
+ # overwrite do
13
+ # nilable :first_name
14
+ # email :email_address
15
+ # with_strategy(:last_name) { "last-#{id}" }
16
+ # end
17
+ # end
18
+ class Overwrite
19
+ include FieldLevelStrategies
20
+
21
+ # @!visibility private
22
+ def initialize(model_class, &block)
23
+ @model_class = model_class
24
+ @anonymisable_fields = {}
25
+ instance_eval(&block) if block_given?
26
+ end
27
+
28
+ # A hash containing the fields and their anonymisation strategies.
29
+ attr_reader :anonymisable_fields
30
+
31
+ # Check whether the combination of field-level rules is valid
32
+ def valid?
33
+ validate!
34
+ true
35
+ rescue FieldException
36
+ false
37
+ end
38
+
39
+ def validate!
40
+ raise FieldException, unhandled_fields if unhandled_fields.any?
41
+ end
42
+
43
+ # Apply the Overwrite strategy on the model instance, which applies each of the
44
+ # configured transformations and updates the :anonymised_at field if it exists.
45
+ #
46
+ # @param [ActiveRecord::Base] instance An instance of the model
47
+ def apply(instance)
48
+ if !@anonymisable_fields.key?(:anonymised_at) &&
49
+ @model_class.column_names.include?("anonymised_at")
50
+ current_datetime(:anonymised_at)
51
+ end
52
+
53
+ @anonymisable_fields.each_key do |field|
54
+ anonymise_field(instance, field)
55
+ end
56
+
57
+ result_fields = instance.changes.keys.map(&:to_sym).reject { |s| s == :anonymised_at }
58
+
59
+ instance.save!
60
+
61
+ Result.overwritten(result_fields)
62
+ end
63
+
64
+ # Configure a custom strategy for one or more fields. If a block is given that is used
65
+ # as the strategy, otherwise the first argument is used as the strategy.
66
+ #
67
+ # @param [Proc, Object] strategy Any object which responds to
68
+ # `.call(previous_value)`. Not used if a block is provided.
69
+ # @param [Array<Symbol>] fields A list of one or more fields to apply this strategy to.
70
+ # @param [Block] &block A block to use as the strategy.
71
+ # @yieldparam previous [Object] The previous value of the field
72
+ # @yieldreturn [Object] The value to set on that field.
73
+ # @raise [ArgumentError] If the combination of strategy, fields and block is invalid.
74
+ # @raise [DuplicateStrategyException] If more than one strategy is defined for the same field.
75
+ #
76
+ # @example With a named class
77
+ # class Reverse
78
+ # def self.call(previous)
79
+ # previous.reverse
80
+ # end
81
+ # end
82
+ #
83
+ # with_strategy(Reverse, :first_name)
84
+ #
85
+ # @example With a constant value
86
+ # with_strategy({}, :metadata)
87
+ #
88
+ # @example With a block
89
+ # with_strategy(:first_name, :last_name) { |previous| previous.reverse }
90
+ def with_strategy(strategy, *fields, &block)
91
+ if block_given?
92
+ fields.unshift(strategy)
93
+ strategy = block
94
+ end
95
+
96
+ fields = fields.flatten
97
+
98
+ raise ArgumentError, "Block or Strategy object required" unless strategy
99
+ raise ArgumentError, "One or more fields required" unless fields.any?
100
+
101
+ guard_duplicate_strategies!(fields)
102
+
103
+ fields.each { |field| @anonymisable_fields[field] = strategy }
104
+ end
105
+
106
+ # Helper method to use the :hex strategy
107
+ # @param [Array<Symbol>] fields A list of one or more fields to apply this strategy to.
108
+ # @see Strategies::OverwriteHex
109
+ #
110
+ # @example
111
+ # hex :first_name
112
+ def hex(*fields, max_length: 36)
113
+ with_strategy(Strategies::OverwriteHex.new(max_length), *fields)
114
+ end
115
+
116
+ # Configure a list of fields that you don't want to anonymise.
117
+ #
118
+ # @param [Array<Symbol>] fields The fields to ignore
119
+ # @raise [ArgumentError] If trying to ignore a field which is already globally
120
+ # ignored in Anony::Config.ignores
121
+ #
122
+ # @example
123
+ # ignore :external_system_id, :externalised_at
124
+ def ignore(*fields)
125
+ already_ignored = fields.select { |field| Config.ignore?(field) }
126
+
127
+ if already_ignored.any?
128
+ raise ArgumentError, "Cannot ignore #{already_ignored.inspect} " \
129
+ "(fields already ignored in Anony::Config)"
130
+ end
131
+
132
+ no_op(*fields)
133
+ end
134
+
135
+ private def unhandled_fields
136
+ anonymisable_columns =
137
+ @model_class.column_names.map(&:to_sym).
138
+ reject { |c| Config.ignore?(c) }.
139
+ reject { |c| c == :anonymised_at }
140
+
141
+ handled_fields = @anonymisable_fields.keys
142
+
143
+ anonymisable_columns - handled_fields
144
+ end
145
+
146
+ private def anonymise_field(instance, field)
147
+ return unless @model_class.column_names.include?(field.to_s)
148
+
149
+ strategy = @anonymisable_fields.fetch(field)
150
+ current_value = instance.read_attribute(field)
151
+
152
+ instance.write_attribute(field, anonymised_value(instance, strategy, current_value))
153
+ end
154
+
155
+ private def anonymised_value(instance, strategy, current_value)
156
+ if strategy.is_a?(Proc)
157
+ instance.instance_exec(current_value, &strategy)
158
+ elsif strategy.respond_to?(:call)
159
+ strategy.call(current_value)
160
+ else
161
+ strategy
162
+ end
163
+ end
164
+
165
+ private def guard_duplicate_strategies!(fields)
166
+ defined_fields = @anonymisable_fields.keys
167
+ duplicate_fields = defined_fields & fields
168
+
169
+ raise DuplicateStrategyException, duplicate_fields if duplicate_fields.any?
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ VERSION = "1.0.0"
5
+ end
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: anony
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - GoCardless Engineering
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-02-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.4
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: gc_ruboconfig
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.9.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.9.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.9'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec_junit_formatter
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.4'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.4'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.9.20
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.9.20
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.4.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.4.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: activerecord
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '5.2'
104
+ - - "<"
105
+ - !ruby/object:Gem::Version
106
+ version: '6.1'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '5.2'
114
+ - - "<"
115
+ - !ruby/object:Gem::Version
116
+ version: '6.1'
117
+ - !ruby/object:Gem::Dependency
118
+ name: activesupport
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '5.2'
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: '6.1'
127
+ type: :runtime
128
+ prerelease: false
129
+ version_requirements: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '5.2'
134
+ - - "<"
135
+ - !ruby/object:Gem::Version
136
+ version: '6.1'
137
+ description:
138
+ email:
139
+ - engineering@gocardless.com
140
+ executables: []
141
+ extensions: []
142
+ extra_rdoc_files: []
143
+ files:
144
+ - ".circleci/config.yml"
145
+ - ".gitignore"
146
+ - ".rubocop.yml"
147
+ - ".rubocop_todo.yml"
148
+ - ".ruby-version"
149
+ - CHANGELOG.md
150
+ - Gemfile
151
+ - LICENSE.txt
152
+ - README.md
153
+ - anony.gemspec
154
+ - docs/COMPATIBILITY.md
155
+ - lib/anony.rb
156
+ - lib/anony/anonymisable.rb
157
+ - lib/anony/config.rb
158
+ - lib/anony/cops.rb
159
+ - lib/anony/cops/define_deletion_strategy.rb
160
+ - lib/anony/duplicate_strategy_exception.rb
161
+ - lib/anony/field_exception.rb
162
+ - lib/anony/field_level_strategies.rb
163
+ - lib/anony/model_config.rb
164
+ - lib/anony/result.rb
165
+ - lib/anony/rspec_shared_examples.rb
166
+ - lib/anony/skipped_exception.rb
167
+ - lib/anony/strategies/destroy.rb
168
+ - lib/anony/strategies/overwrite.rb
169
+ - lib/anony/version.rb
170
+ homepage: https://github.com/gocardless/anony
171
+ licenses:
172
+ - MIT
173
+ metadata: {}
174
+ post_install_message:
175
+ rdoc_options: []
176
+ require_paths:
177
+ - lib
178
+ required_ruby_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '2.4'
183
+ required_rubygems_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ requirements: []
189
+ rubygems_version: 3.0.3
190
+ signing_key:
191
+ specification_version: 4
192
+ summary: A small library that defines how ActiveRecord models should be anonymised
193
+ for deletion purposes.
194
+ test_files: []