anony 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []