anony 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "anony/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "anony"
9
+ spec.version = Anony::VERSION
10
+ spec.authors = ["GoCardless Engineering"]
11
+ spec.email = ["engineering@gocardless.com"]
12
+
13
+ spec.summary = "A small library that defines how ActiveRecord models should be " \
14
+ "anonymised for deletion purposes."
15
+ spec.homepage = "https://github.com/gocardless/anony"
16
+ spec.license = "MIT"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.required_ruby_version = ">= 2.4"
26
+
27
+ spec.add_development_dependency "bundler", "~> 2.1.4"
28
+ spec.add_development_dependency "gc_ruboconfig", "~> 2.9.0"
29
+ spec.add_development_dependency "rspec", "~> 3.9"
30
+ spec.add_development_dependency "rspec_junit_formatter", "~> 0.4"
31
+ spec.add_development_dependency "yard", "~> 0.9.20"
32
+
33
+ # For integration testing
34
+ spec.add_development_dependency "sqlite3", "~> 1.4.1"
35
+
36
+ spec.add_dependency "activerecord", ">= 5.2", "< 6.1"
37
+ spec.add_dependency "activesupport", ">= 5.2", "< 6.1"
38
+ end
@@ -0,0 +1,17 @@
1
+ # Compatibility
2
+
3
+ Our goal as Anony maintainers is for the library to be compatible with all supported versions of Ruby and Rails.
4
+
5
+ Specifically, any CRuby/MRI version that has not received an End of Life notice ([e.g. this notice for Ruby 2.1](https://www.ruby-lang.org/en/news/2017/04/01/support-of-ruby-2-1-has-ended/)) is supported. Similarly, any version of Rails listed as currently supported on [this page](http://guides.rubyonrails.org/maintenance_policy.html) is one we aim to support in Anony.
6
+
7
+ To that end, [our build matrix](../.circleci/config.yml) includes all these versions.
8
+
9
+ Any time Anony doesn't work on a supported combination of Ruby and Rails, it's a bug, and can be reported [here](https://github.com/gocardless/Anony/issues).
10
+
11
+ # Deprecation
12
+
13
+ Whenever a version of Ruby or Rails falls out of support, we will mirror that change in Anony by updating the build matrix and releasing a new major version.
14
+
15
+ At that point, we will close any issues that only affect the unsupported version, and may choose to remove any workarounds from the code that are only necessary for the unsupported version.
16
+
17
+ We will then bump the major version of Anony, to indicate the break in compatibility. Even if the new version of Anony happens to work on the unsupported version of Ruby or Rails, we consider compatibility to be broken at this point.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ require_relative "anony/anonymisable"
5
+ require_relative "anony/config"
6
+ require_relative "anony/duplicate_strategy_exception"
7
+ require_relative "anony/field_exception"
8
+ require_relative "anony/field_level_strategies"
9
+ require_relative "anony/model_config"
10
+ require_relative "anony/skipped_exception"
11
+ require_relative "anony/result"
12
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ require_relative "./strategies/overwrite"
6
+ require_relative "model_config"
7
+
8
+ module Anony
9
+ # The main Anony object to include in your ActiveRecord class.
10
+ #
11
+ # @example Using in a single model
12
+ # class Manager < ApplicationRecord
13
+ # include Anony::Anonymisable
14
+ # end
15
+ #
16
+ # @example Making this available to your whole application
17
+ # class ApplicationRecord < ActiveRecord::Base
18
+ # include Anony::Anonymisable
19
+ # end
20
+ module Anonymisable
21
+ # Mixin containing methods that will be exposed on the ActiveRecord class after
22
+ # including the Anonymisable module.
23
+ #
24
+ # The primary method, .anonymise, is used to configure the strategies to apply. This
25
+ # configuration is lazily executed when trying to actually anonymise an instance:
26
+ # this is because the database or other lazily-loaded properties are not necessarily
27
+ # available when the class is configured.
28
+ module ClassMethods
29
+ # Define a set of anonymisation configuration on the ActiveRecord class.
30
+ #
31
+ # @yield A configuration block
32
+ # @see DSL Anony::Strategies::Overwrite - the methods available inside this block
33
+ # @example
34
+ # class Manager < ApplicationRecord
35
+ # anonymise do
36
+ # overwrite do
37
+ # with_strategy(:first_name) { "ANONYMISED" }
38
+ # end
39
+ # end
40
+ # end
41
+ def anonymise(&block)
42
+ @anonymise_config = ModelConfig.new(self, &block)
43
+ end
44
+
45
+ # Check whether the model has been configured correctly. Returns a simple
46
+ # `true`/`false`. If configuration has not yet been configured, it returns `false`.
47
+ #
48
+ # @return [Boolean]
49
+ # @example
50
+ # Manager.valid_anonymisation?
51
+ def valid_anonymisation?
52
+ return false unless @anonymise_config
53
+
54
+ @anonymise_config.valid?
55
+ end
56
+
57
+ attr_reader :anonymise_config
58
+ end
59
+
60
+ # Run all anonymisation strategies on the model instance before saving it.
61
+ #
62
+ # @return [Anony::Result] described if the save was successful, and the fields or errors created
63
+ # @example
64
+ # manager = Manager.first
65
+ # manager.anonymise!
66
+ def anonymise!
67
+ raise ArgumentError, ".anonymise not yet invoked" unless self.class.anonymise_config
68
+
69
+ self.class.anonymise_config.validate!
70
+ self.class.anonymise_config.apply(self)
71
+ rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotDestroyed => e
72
+ Result.failed(e)
73
+ end
74
+
75
+ # @!visibility private
76
+ def self.included(base)
77
+ base.extend(ClassMethods)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/attribute_accessors"
4
+
5
+ module Anony
6
+ # Configuration which modifies how the gem will behave in your application. It's
7
+ # recommended practice to configure this in an initializer.
8
+ #
9
+ # @example
10
+ # # config/initializers/anony.rb
11
+ # require "anony"
12
+ #
13
+ # Anony::Config.ignore_fields(:id)
14
+ module Config
15
+ mattr_accessor :ignores
16
+
17
+ # @!visibility private
18
+ def self.ignore?(field)
19
+ # In this case, we want to support literal matches, regular expressions and blocks,
20
+ # all of which are helpfully handled by Object#===.
21
+ #
22
+ # rubocop:disable Style/CaseEquality
23
+ ignores.any? { |rule| rule === field }
24
+ # rubocop:enable Style/CaseEquality
25
+ end
26
+
27
+ # A list of database or model properties to be ignored when anonymising. This is
28
+ # helpful in Rails applications when there are common columns such as `id`,
29
+ # `created_at` and `updated_at`, which we would never want to try and anonymise.
30
+ #
31
+ # By default, this is an empty collection (i.e. no fields are ignored).
32
+ #
33
+ # @param [Array<Symbol>] fields A list of fields names to ignore.
34
+ # @example Ignoring common Rails fields
35
+ # Anony::Config.ignore_fields(:id, :created_at, :updated_at)
36
+ def self.ignore_fields(*fields)
37
+ self.ignores = Array(fields)
38
+ end
39
+
40
+ self.ignores = []
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cops/define_deletion_strategy"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "../version.rb"
6
+
7
+ module RuboCop
8
+ module Cop
9
+ module Lint
10
+ # This cop checks whether an ActiveRecord model implements the `.anonymise`
11
+ # preference (using the Anony gem).
12
+ #
13
+ # @example Good
14
+ # class User < ApplicationRecord
15
+ # anonymise do
16
+ # overwrite do
17
+ # email :email
18
+ # hex :given_name
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # @example Bad
24
+ # class MyNewThing < ApplicationRecord; end
25
+ class DefineDeletionStrategy < Cop
26
+ MSG = "Define .anonymise for %<model>s, see https://github.com/gocardless/" \
27
+ "anony/blob/#{Anony::VERSION}/README.md for details"
28
+
29
+ def_node_search :uses_anonymise?, "(send nil? :anonymise)"
30
+
31
+ def on_class(node)
32
+ return unless model?(node)
33
+ return if uses_anonymise?(node)
34
+
35
+ add_offense(node, message: sprintf(MSG, model: class_name(node)))
36
+ end
37
+
38
+ def model?(node)
39
+ return unless (superclass = node.children[1])
40
+
41
+ superclass.const_name == model_superclass_name
42
+ end
43
+
44
+ def class_name(node)
45
+ node.children[0].const_name
46
+ end
47
+
48
+ def model_superclass_name
49
+ cop_config["ModelSuperclass"] || "ApplicationRecord"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ # This exception is thrown if you define more than one strategy for the same field.
5
+ #
6
+ # @example
7
+ # anonymise do
8
+ # overwrite do
9
+ # ignore :first_name
10
+ # nilable :first_name
11
+ # end
12
+ # end
13
+ class DuplicateStrategyException < StandardError
14
+ def initialize(fields)
15
+ fields = Array(fields)
16
+ super("Duplicate anonymisation strategy for field(s) #{fields}")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anony
4
+ # This exception is thrown when validating the anonymisation strategies for all fields.
5
+ # If some are missing, they will be included in the message.
6
+ #
7
+ # @example Missing the first_name field
8
+ # class Employee
9
+ # anonymise { fields { ignore :last_name } }
10
+ # end
11
+ #
12
+ # Employee.first.valid_anonymisation?
13
+ # => FieldException, Invalid anonymisation strategy for field(s) [:first_name]
14
+ class FieldException < StandardError
15
+ def initialize(fields)
16
+ fields = Array(fields)
17
+ super("Invalid anonymisation strategy for field(s) #{fields}")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Anony
6
+ # This class is a singleton, containing all of the known strategies that Anony can use
7
+ # to anonymise individual fields in your models.
8
+ module FieldLevelStrategies
9
+ # Registers a new Anony strategy (or overwrites an existing strategy) of a given name.
10
+ # Strategies are then available everywhere inside the `anonymise` block.
11
+ #
12
+ # @param name [Symbol] The name of the strategy you'd like to use
13
+ # @param klass_or_constant [Object] The object you'd like to statically return, or an
14
+ # object which responds to `#call(original_value)`.
15
+ # @yield [original_value] The previous value of the field. The result of the block
16
+ # will be applied to that field. If a block is not given, klass_or_constant will be
17
+ # used as the strategy instead.
18
+ # @raise [ArgumentError] If using neither a block nor strategy class
19
+ #
20
+ # @example Reversing a string using a block
21
+ # Anony::FieldLevelStrategies.register(:reverse) do |original_value|
22
+ # original_value.reverse
23
+ # end
24
+ #
25
+ # class Manager
26
+ # anonymise { reverse :first_name }
27
+ # end
28
+ #
29
+ # @example Using a named strategy class
30
+ # class Classifier
31
+ # def self.call(original_value)
32
+ # "Classy version of #{original_value}"
33
+ # end
34
+ # end
35
+ #
36
+ # Anony::FieldLevelStrategies.register(:classify, Classifier)
37
+ #
38
+ # class Manager
39
+ # anonymise { classify :resource_type }
40
+ # end
41
+ #
42
+ # @example Using a constant value
43
+ # Anony::FieldLevelStrategies.register(:forty_two, 42)
44
+ #
45
+ # class Manager
46
+ # anonymise { forty_two :date_of_birth }
47
+ # end
48
+ def self.register(name, klass_or_constant = nil, &block)
49
+ if block_given?
50
+ strategy = block
51
+ elsif !klass_or_constant.nil?
52
+ strategy = klass_or_constant
53
+ else
54
+ raise ArgumentError, "Must pass either a block, constant value or strategy class"
55
+ end
56
+
57
+ define_method(name) { |*fields| with_strategy(strategy, *fields) }
58
+
59
+ @strategies[name] = strategy
60
+ end
61
+
62
+ # Helper method for retrieving the strategy block (or testing that it exists).
63
+ #
64
+ # @param name [Symbol] The name of the strategy to retrieve.
65
+ # @raise [ArgumentError] If the strategy is not already registered
66
+ def self.[](name)
67
+ @strategies.fetch(name) do
68
+ raise ArgumentError, "Unrecognised strategy `#{name.inspect}`"
69
+ end
70
+ end
71
+
72
+ @strategies = {}
73
+
74
+ # @!method email(field)
75
+ # Overwrite a field with a randomised email, where the user part is generated with
76
+ # `SecureRandom.uuid` and the domain is "example.com".
77
+ #
78
+ # For example, this might generate an email like:
79
+ # `"86b5b19d-2224-4c3d-bead-e9d4a1934303@example.com"`
80
+ #
81
+ # @example Overwriting the field called :email_address
82
+ # email :email_address
83
+ register(:email) do
84
+ sprintf("%<random>s@example.com", random: SecureRandom.uuid)
85
+ end
86
+
87
+ # @!method phone_number(field)
88
+ # Overwrite a field with a static phone number. Currently this is "+1 617 555 1294"
89
+ # but you would probably want to override this for your use case.
90
+ #
91
+ # @example Overwriting the field called :phone
92
+ # phone_number :phone
93
+ #
94
+ # @example Using a different phone number
95
+ # Anony::FieldLevelStrategies.register(:phone_number, "+44 07700 000 000")
96
+ register(:phone_number, "+1 617 555 1294")
97
+
98
+ # @!method current_datetime(field)
99
+ # Overwrite a field with the current datetime. This is provided by
100
+ # `current_time_from_proper_timezone`, an internal method exposed from
101
+ # ActiveRecord::Timestamp.
102
+ #
103
+ # @example Overwriting the field called :signed_up_at
104
+ # current_datetime :signed_up_at
105
+ register(:current_datetime) { |_original| current_time_from_proper_timezone }
106
+
107
+ # @!method nilable(field)
108
+ # Overwrite a field with the value `nil`.
109
+ #
110
+ # @example Overwriting the field called :optional_field
111
+ # nilable :optional_field
112
+ register(:nilable) { nil }
113
+
114
+ # @!method noop(field)
115
+ # This strategy applies no transformation rules at all. It is used internally by the
116
+ # ignore strategy so you should probably use that instead.
117
+ #
118
+ # @see Anony::Strategies::Fields#ignore
119
+ register(:no_op) { |value| value }
120
+ end
121
+ end
122
+
123
+ module Anony
124
+ module Strategies
125
+ # This class curries the max_length into itself so it exists as a parameterless block
126
+ # that can be called by Anony.
127
+ #
128
+ # @example Direct usage:
129
+ # anonymise do
130
+ # overwrite do
131
+ # with_strategy(OverwriteHex.new(20), :field, :field)
132
+ # end
133
+ # end
134
+ #
135
+ # @example Helper method, assumes length = 36
136
+ # anonymise do
137
+ # overwrite do
138
+ # hex :field
139
+ # end
140
+ # end
141
+ #
142
+ # @example Helper method with explicit length
143
+ # anonymise do
144
+ # overwrite do
145
+ # hex :field, max_length: 20
146
+ # end
147
+ # end
148
+ OverwriteHex = Struct.new(:max_length) do
149
+ def call(_existing_value)
150
+ hex_length = max_length / 2 + 1
151
+ SecureRandom.hex(hex_length)[0, max_length]
152
+ end
153
+ end
154
+ end
155
+ end