anony 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +86 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +9 -0
- data/.rubocop_todo.yml +28 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +64 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +22 -0
- data/README.md +451 -0
- data/anony.gemspec +38 -0
- data/docs/COMPATIBILITY.md +17 -0
- data/lib/anony.rb +12 -0
- data/lib/anony/anonymisable.rb +80 -0
- data/lib/anony/config.rb +42 -0
- data/lib/anony/cops.rb +3 -0
- data/lib/anony/cops/define_deletion_strategy.rb +54 -0
- data/lib/anony/duplicate_strategy_exception.rb +19 -0
- data/lib/anony/field_exception.rb +20 -0
- data/lib/anony/field_level_strategies.rb +155 -0
- data/lib/anony/model_config.rb +102 -0
- data/lib/anony/result.rb +39 -0
- data/lib/anony/rspec_shared_examples.rb +45 -0
- data/lib/anony/skipped_exception.rb +9 -0
- data/lib/anony/strategies/destroy.rb +39 -0
- data/lib/anony/strategies/overwrite.rb +173 -0
- data/lib/anony/version.rb +5 -0
- metadata +194 -0
data/anony.gemspec
ADDED
@@ -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.
|
data/lib/anony.rb
ADDED
@@ -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
|
data/lib/anony/config.rb
ADDED
@@ -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
|
data/lib/anony/cops.rb
ADDED
@@ -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
|