active_record_anonymizer 0.1.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +76 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +16 -0
- data/lib/active_record_anonymizer/anonymizer.rb +79 -0
- data/lib/active_record_anonymizer/configuration.rb +20 -0
- data/lib/active_record_anonymizer/encryptor.rb +27 -0
- data/lib/active_record_anonymizer/extensions.rb +86 -0
- data/lib/active_record_anonymizer/fake_value.rb +42 -0
- data/lib/active_record_anonymizer/railtie.rb +9 -0
- data/lib/active_record_anonymizer/version.rb +5 -0
- data/lib/active_record_anonymizer.rb +79 -0
- data/lib/generators/active_record_anonymizer/USAGE +10 -0
- data/lib/generators/active_record_anonymizer/anonymize_generator.rb +44 -0
- data/lib/generators/active_record_anonymizer/install_generator.rb +14 -0
- data/lib/generators/active_record_anonymizer/templates/anonymizer.rb +17 -0
- data/lib/generators/active_record_anonymizer/templates/migration.rb.erb +15 -0
- data/lib/tasks/active_record_anonymizer.rake +33 -0
- data/sig/active_record_anonymizer.rbs +4 -0
- metadata +123 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2595896543324c7c02e7876dc7094a6cef95453a782183426ce433e846e1e395
|
4
|
+
data.tar.gz: 01ddfc4df3b6a1458cb692e256951f96e3bd8995d098b5b263bc6671ac465ad7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 99e3e06eeac716238b5690011905e5b9633b7bd88eb82693bed0c27c142f4614b6b8daeef4030719a6e79126a54ed26672e4a8741a01c0f5c18f22a3c5c11b60
|
7
|
+
data.tar.gz: '09b7bc01508b9c320cfa15f2857707bfc5556df6dd2af696143eeeb29790a11b7235a6137a58d34dff76151bb4b1b9feb606a8d8029119c9b449149641ec6776'
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
AllCops:
|
2
|
+
TargetRubyVersion: 2.7
|
3
|
+
NewCops: disable
|
4
|
+
SuggestExtensions: false
|
5
|
+
Exclude:
|
6
|
+
- "test/dummy/db/**/*"
|
7
|
+
- "test/dummy/bin/*"
|
8
|
+
- "test/dummy/config/**/*"
|
9
|
+
|
10
|
+
Style/HashSyntax:
|
11
|
+
EnforcedStyle: ruby19_no_mixed_keys
|
12
|
+
EnforcedShorthandSyntax: never
|
13
|
+
|
14
|
+
Style/GuardClause:
|
15
|
+
Enabled: false
|
16
|
+
|
17
|
+
Style/ClassAndModuleChildren:
|
18
|
+
Exclude:
|
19
|
+
- "test/**/*"
|
20
|
+
|
21
|
+
# Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions.
|
22
|
+
# SupportedStyles: assign_to_condition, assign_inside_condition
|
23
|
+
Style/ConditionalAssignment:
|
24
|
+
Enabled: false
|
25
|
+
|
26
|
+
# Configuration parameters: AllowedConstants.
|
27
|
+
Style/Documentation:
|
28
|
+
Enabled: false
|
29
|
+
|
30
|
+
Style/IfUnlessModifier:
|
31
|
+
Enabled: false
|
32
|
+
|
33
|
+
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
|
34
|
+
# SupportedStyles: single_quotes, double_quotes
|
35
|
+
Style/StringLiterals:
|
36
|
+
EnforcedStyle: double_quotes
|
37
|
+
ConsistentQuotesInMultiline: true
|
38
|
+
|
39
|
+
# Configuration parameters: EnforcedStyle.
|
40
|
+
# SupportedStyles: both, prefix, postfix
|
41
|
+
Style/NegatedIf:
|
42
|
+
Enabled: false
|
43
|
+
|
44
|
+
Style/RedundantBegin:
|
45
|
+
Enabled: false
|
46
|
+
|
47
|
+
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.
|
48
|
+
# URISchemes: http, https
|
49
|
+
Layout/LineLength:
|
50
|
+
Max: 150
|
51
|
+
|
52
|
+
Metrics/AbcSize:
|
53
|
+
Max: 30
|
54
|
+
|
55
|
+
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
56
|
+
# AllowedMethods: refine
|
57
|
+
Metrics/BlockLength:
|
58
|
+
CountComments: false
|
59
|
+
Max: 30
|
60
|
+
|
61
|
+
# Configuration parameters: CountComments, CountAsOne.
|
62
|
+
Metrics/ClassLength:
|
63
|
+
Exclude:
|
64
|
+
- "test/**/*"
|
65
|
+
|
66
|
+
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
67
|
+
Metrics/CyclomaticComplexity:
|
68
|
+
Max: 17
|
69
|
+
|
70
|
+
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
71
|
+
Metrics/MethodLength:
|
72
|
+
Max: 30
|
73
|
+
|
74
|
+
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
75
|
+
Metrics/PerceivedComplexity:
|
76
|
+
Max: 15
|
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in active_record_anonymizer.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem "minitest", "~> 5.0"
|
9
|
+
gem "pry", "~> 0.14.2"
|
10
|
+
gem "rake", "~> 13.0"
|
11
|
+
gem "rubocop", "~> 1.21"
|
12
|
+
|
13
|
+
gem "mocha", "~> 2.1"
|
14
|
+
gem "mysql2", "~> 0.5"
|
15
|
+
gem "pg", "~> 1.2"
|
16
|
+
gem "rails", ">= 6.0.0"
|
17
|
+
gem "sqlite3", "~> 1.4"
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Keshav Biswa
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
# ActiveRecordAnonymizer
|
2
|
+
|
3
|
+
Anonymize your ActiveRecord models with ease :sunglasses:
|
4
|
+
|
5
|
+
`ActiveRecordAnonymizer` uses `faker` to anonymize your ActiveRecord model's attributes without the need to write custom anonymization logic for each model.
|
6
|
+
|
7
|
+
Using `ActiveRecordAnonymizer`, you can:
|
8
|
+
- Anonymize specific attributes of your model (uses `faker` under the hood)
|
9
|
+
- Provide custom anonymization logic for specific attributes
|
10
|
+
- Provide custom anonymized columns for each attributes
|
11
|
+
- Encrypt anonymized data using `ActiveRecord::Encryption` (Requires Rails 7+)
|
12
|
+
- Environment dependent, so you can decide whether you want to view original data in development and anonymized data in production
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'active_record_anonymizer'
|
20
|
+
```
|
21
|
+
|
22
|
+
And then execute:
|
23
|
+
|
24
|
+
$ bundle install
|
25
|
+
|
26
|
+
Or install it yourself as:
|
27
|
+
|
28
|
+
$ gem install active_record_anonymizer
|
29
|
+
|
30
|
+
Install the gem using the following command:
|
31
|
+
|
32
|
+
$ bin/rails generate active_record_anonymizer:install
|
33
|
+
|
34
|
+
You must have anonymized columns in your Model to store the anonymized data.
|
35
|
+
You can use the following migration generator to add anonymized columns to your existing table:
|
36
|
+
|
37
|
+
$ bin/rails generate anonymize User first_name last_name
|
38
|
+
This will generate a migration file similar to the following:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
class AddAnonymizedColumnsToUser < ActiveRecord::Migration[6.0]
|
42
|
+
def change
|
43
|
+
add_column :users, :anonymized_first_name, :string
|
44
|
+
add_column :users, :anonymized_last_name, :string
|
45
|
+
end
|
46
|
+
end
|
47
|
+
```
|
48
|
+
|
49
|
+
Add the following line to your model to enable anonymization:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
class User < ApplicationRecord
|
53
|
+
# There are other options available, please refer to the Usage section
|
54
|
+
anonymize :first_name, :last_name
|
55
|
+
end
|
56
|
+
```
|
57
|
+
To populate the anonymized columns, run the following command:
|
58
|
+
|
59
|
+
$ bin/rails anonymize:populate CLASS=User
|
60
|
+
|
61
|
+
The `CLASS` argument is optional, if not provided, it will anonymize all the models with anonymized columns.
|
62
|
+
|
63
|
+
## Usage
|
64
|
+
|
65
|
+
Attributes can be anonymized using the `anonymize` method. The `anonymize` method takes the following options:
|
66
|
+
|
67
|
+
- `:column_name` - The name of the column to store the anonymized data. ("anonymized_#{column_name}" by default)
|
68
|
+
- `:with` - The custom logic to anonymize the attribute. (Optional, uses `faker` by default)
|
69
|
+
- `:encrypt` - Encrypt the anonymized data using `ActiveRecord::Encryption` (Requires Rails 7+)
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
class User < ApplicationRecord
|
73
|
+
anonymize :first_name, :last_name
|
74
|
+
anonymize :email, with: ->(email) { Faker::Internet.email }
|
75
|
+
anonymize :age, with: ->(age) { age + 5 }
|
76
|
+
anonymize :phone, column_name: :fake_phone_number, with: ->(phone) { phone.gsub(/\d/, 'X') }
|
77
|
+
anonymize :address, encrypt: true
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
## Configuration
|
82
|
+
|
83
|
+
You can configure the gem using the following options:
|
84
|
+
|
85
|
+
- `:environments` - The environments in which the anonymized data should be used. (Defaults to `[:staging]`)
|
86
|
+
- `:skip_update` - Skip updating the anonymized data when the record is updated. This ensures your anonymized data remains the same even if it's updated. (Defaults to `false`)
|
87
|
+
- `:alias_original_columns` - Alias the original columns to the anonymized columns. You can still access the original value of the attribute using the alias `original_#{attribute_name}`(Defaults to `false`)
|
88
|
+
- `:alias_column_name` - The name of the alias column. (Defaults to `original_#{column_name}`)
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
ActiveRecordAnonymizer.configure do |config|
|
92
|
+
config.environments = [:staging, :production] # The environments in which the anonymized data should be used
|
93
|
+
config.skip_update = true # Skip updating the anonymized data when the record is updated
|
94
|
+
config.alias_original_columns = true # Alias the original columns to the anonymized columns
|
95
|
+
config.alias_column_name = "original" # The original column will be aliased to "original_#{column_name}
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
## Development
|
100
|
+
|
101
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
102
|
+
Then, run `rake test` to run the tests.
|
103
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
104
|
+
|
105
|
+
## Contributing
|
106
|
+
|
107
|
+
Bug reports are welcome on GitHub at https://github.com/keshavbiswa/active_record_anonymizer/issues.
|
108
|
+
|
109
|
+
- Fork the Repository: Start by forking this [repo](https://github.com/keshavbiswa/active_record_anonymizer.git) on GitHub.
|
110
|
+
|
111
|
+
- Set Up Your Local Environment: Navigate into the project directory and run the setup script to install dependencies:
|
112
|
+
|
113
|
+
```shell
|
114
|
+
$ cd active_record_anonymizer
|
115
|
+
$ bin/setup
|
116
|
+
```
|
117
|
+
- Create a New Branch: Before making any changes, create a new branch to keep your work organized:
|
118
|
+
|
119
|
+
```shell
|
120
|
+
$ git checkout -b my-new-feature
|
121
|
+
```
|
122
|
+
|
123
|
+
- Make Your Changes:
|
124
|
+
- Implement your changes or fixes in your local repository.
|
125
|
+
- Be sure to keep your changes as focused as possible.
|
126
|
+
- If you're working on multiple unrelated improvements, consider making separate branches and pull requests for each.
|
127
|
+
|
128
|
+
- Write Tests: If you're adding a new feature or fixing a bug, please add or update the corresponding tests.
|
129
|
+
|
130
|
+
- Run Tests: Before submitting your changes, run the test suite to ensure everything is working correctly:
|
131
|
+
```shell
|
132
|
+
$ bin/rake test
|
133
|
+
```
|
134
|
+
|
135
|
+
- Update Documentation: If your changes involve user-facing features or APIs, update the README or other relevant documentation accordingly.
|
136
|
+
|
137
|
+
- Submit a Pull Request: Go to the original `ActiveRecordAnonymizer` repository on GitHub, and you'll see a prompt to submit a pull request from your new branch.
|
138
|
+
|
139
|
+
|
140
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/keshavbiswa/active_record_anonymizer.
|
141
|
+
|
142
|
+
## License
|
143
|
+
|
144
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rake/testtask"
|
5
|
+
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
7
|
+
t.libs << "test"
|
8
|
+
t.libs << "lib"
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
10
|
+
end
|
11
|
+
|
12
|
+
require "rubocop/rake_task"
|
13
|
+
|
14
|
+
RuboCop::RakeTask.new
|
15
|
+
|
16
|
+
task default: %i[test rubocop]
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordAnonymizer
|
4
|
+
class Anonymizer
|
5
|
+
attr_reader :model, :attributes, :with, :column_name
|
6
|
+
|
7
|
+
def initialize(model, attributes, with: nil, column_name: nil)
|
8
|
+
@model = model
|
9
|
+
@attributes = attributes
|
10
|
+
@with = with
|
11
|
+
@column_name = column_name
|
12
|
+
end
|
13
|
+
|
14
|
+
# TODO: Extract this logic in a seperate Validation module/class
|
15
|
+
def validate
|
16
|
+
check_for_invalid_arguments(attributes, with, column_name)
|
17
|
+
check_for_missing_anonymized_columns(attributes)
|
18
|
+
end
|
19
|
+
|
20
|
+
def anonymize_attributes
|
21
|
+
attributes.each do |attribute|
|
22
|
+
anonymized_column = anonymized_column_name(attribute)
|
23
|
+
|
24
|
+
# I don't like that we're manipulating the class attribute here
|
25
|
+
# This breaks the SRP for this method
|
26
|
+
# TODO:- Will need to revisit how we set the class attribute later
|
27
|
+
model.anonymized_attributes[attribute.to_sym] = { column: anonymized_column.to_sym, with: with }
|
28
|
+
|
29
|
+
define_alias_method(attribute, ActiveRecordAnonymizer.configuration.alias_column_name) if ActiveRecordAnonymizer.alias_enabled?
|
30
|
+
define_anonymize_method(attribute, anonymized_column)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def check_for_missing_anonymized_columns(attributes)
|
37
|
+
missing_columns = attributes.reject do |attribute|
|
38
|
+
model.columns_hash[anonymized_column_name(attribute)]
|
39
|
+
end
|
40
|
+
|
41
|
+
if missing_columns.any?
|
42
|
+
raise ColumnNotFoundError, <<~ERROR_MESSAGE.strip
|
43
|
+
Following columns do not have anonymized_columns: #{missing_columns.join(', ')}.
|
44
|
+
You can generate them by running
|
45
|
+
`rails g active_record_anonymizer:anonymize #{model.name} #{missing_columns.join(' ')}`
|
46
|
+
ERROR_MESSAGE
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def check_for_invalid_arguments(attributes, with, column_name)
|
51
|
+
if attributes.size > 1 && (with || column_name)
|
52
|
+
raise InvalidArgumentsError, "with and column_names are not supported for multiple attributes. Try adding them seperately"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def anonymized_column_name(attribute)
|
57
|
+
column_name || generated_column_name(attribute)
|
58
|
+
end
|
59
|
+
|
60
|
+
def generated_column_name(attribute)
|
61
|
+
"anonymized_#{attribute}"
|
62
|
+
end
|
63
|
+
|
64
|
+
# This defines a method that returns the anonymized value of the attribute.
|
65
|
+
# It also creates an alias "original_#{attribute}" that returns the original value. (TODO)
|
66
|
+
# If column_name is provided, it will be used instead of "anonymized_#{attribute}"
|
67
|
+
def define_anonymize_method(attribute, anonymized_attr)
|
68
|
+
model.define_method(attribute) do
|
69
|
+
ActiveRecordAnonymizer.anonymization_enabled? ? self[anonymized_attr] : self[attribute]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def define_alias_method(attribute, alias_column_name)
|
74
|
+
model.define_method("#{alias_column_name}_#{attribute}") do
|
75
|
+
self[attribute]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordAnonymizer
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :environments, :skip_update, :alias_original_columns, :alias_column_name
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@environments = %i[staging]
|
9
|
+
@skip_update = false
|
10
|
+
@alias_original_columns = false
|
11
|
+
@alias_column_name = "original"
|
12
|
+
end
|
13
|
+
|
14
|
+
# Reset all configuration options to defaults.
|
15
|
+
# Required for tests to maintain isolation between test cases.
|
16
|
+
def reset
|
17
|
+
initialize
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordAnonymizer
|
4
|
+
class Encryptor
|
5
|
+
attr_reader :attributes
|
6
|
+
|
7
|
+
def initialize(model, attributes)
|
8
|
+
@model = model
|
9
|
+
@attributes = attributes
|
10
|
+
end
|
11
|
+
|
12
|
+
def encrypt
|
13
|
+
if !rails_version_supported?
|
14
|
+
raise ActiveRecordAnonymizer::UnsupportedVersionError,
|
15
|
+
"ActiveRecordAnonymizer relies on Rails 7+ for encrypted columns."
|
16
|
+
end
|
17
|
+
|
18
|
+
@model.encrypts(*@attributes)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def rails_version_supported?
|
24
|
+
ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 0
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module ActiveRecordAnonymizer
|
6
|
+
module Extensions
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
def ensure_mutex_initialized
|
11
|
+
unless defined?(@setup_mutex)
|
12
|
+
@setup_mutex = Mutex.new
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def anonymize(*attributes, with: nil, column_name: nil, encrypted: false)
|
17
|
+
ensure_mutex_initialized
|
18
|
+
Encryptor.new(self, attributes).encrypt if encrypted
|
19
|
+
|
20
|
+
ActiveRecordAnonymizer.register_model(self)
|
21
|
+
|
22
|
+
# These class variables required to generate anonymized values
|
23
|
+
# These are not thread safe!
|
24
|
+
cattr_accessor :anonymized_attributes, instance_accessor: false unless respond_to?(:anonymized_attributes)
|
25
|
+
self.anonymized_attributes ||= {}
|
26
|
+
|
27
|
+
anonymizer = Anonymizer.new(self, attributes, with: with, column_name: column_name)
|
28
|
+
anonymizer.validate
|
29
|
+
anonymizer.anonymize_attributes
|
30
|
+
|
31
|
+
# I'm ensuring that the before_save callback is only added once
|
32
|
+
# Models can call anonymize method multiple times per column
|
33
|
+
@setup_mutex.synchronize do
|
34
|
+
unless @anonymizer_setup_done
|
35
|
+
before_save :anonymize_columns, if: :anonymization_enabled?
|
36
|
+
@anonymizer_setup_done = true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def anonymization_enabled?
|
43
|
+
ActiveRecordAnonymizer.anonymization_enabled?
|
44
|
+
end
|
45
|
+
|
46
|
+
def anonymize_columns
|
47
|
+
if new_record?
|
48
|
+
# For new records, apply anonymization to all attributes
|
49
|
+
anonymize_all_attributes
|
50
|
+
else
|
51
|
+
# For existing records, only apply to attributes that have changed
|
52
|
+
return if ActiveRecordAnonymizer.configuration.skip_update
|
53
|
+
|
54
|
+
anonymize_changed_attributes
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def anonymize_all_attributes
|
59
|
+
self.class.anonymized_attributes.each_value do |settings|
|
60
|
+
generate_and_write_fake_value(settings[:column], settings[:with])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def anonymize_changed_attributes
|
65
|
+
changes = self.changes.keys.map(&:to_sym)
|
66
|
+
changed_attributes = changes & self.class.anonymized_attributes.keys
|
67
|
+
|
68
|
+
changed_attributes.each do |attribute|
|
69
|
+
settings = self.class.anonymized_attributes[attribute]
|
70
|
+
generate_and_write_fake_value(settings[:column], settings[:with])
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def generate_and_write_fake_value(anonymized_attr, with_strategy = nil)
|
75
|
+
fake_value = case with_strategy
|
76
|
+
when Proc
|
77
|
+
with_strategy.call(self)
|
78
|
+
when Symbol
|
79
|
+
send(with_strategy)
|
80
|
+
else
|
81
|
+
FakeValue.new(anonymized_attr, self.class.columns_hash[anonymized_attr.to_s]).generate_fake_value
|
82
|
+
end
|
83
|
+
write_attribute(anonymized_attr, fake_value)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordAnonymizer
|
4
|
+
class FakeValue
|
5
|
+
def initialize(name, column)
|
6
|
+
@name = name
|
7
|
+
@column = column
|
8
|
+
end
|
9
|
+
|
10
|
+
def generate_fake_value
|
11
|
+
case @column.type
|
12
|
+
when :string, :text, :citext then Faker::Lorem.word
|
13
|
+
when :uuid then SecureRandom.uuid
|
14
|
+
when :integer, :bigint, :smallint then Faker::Number.number(digits: 5)
|
15
|
+
when :decimal, :float, :real then Faker::Number.decimal(l_digits: 2, r_digits: 2)
|
16
|
+
when :datetime, :timestamp, :timestamptz then Faker::Time.between(from: DateTime.now - 1, to: DateTime.now)
|
17
|
+
when :date then Faker::Date.between(from: Date.today - 2, to: Date.today)
|
18
|
+
when :time, :timetz then Faker::Time.forward(days: 23, period: :morning)
|
19
|
+
when :boolean then Faker::Boolean.boolean
|
20
|
+
when :json, :jsonb then generate_json
|
21
|
+
when :inet then Faker::Internet.ip_v4_address
|
22
|
+
when :cidr, :macaddr then Faker::Internet.mac_address
|
23
|
+
when :bytea then Faker::Internet.password
|
24
|
+
when :bit, :bit_varying then %w[0 1].sample
|
25
|
+
when :money then generate_money
|
26
|
+
when :hstore then generate_json
|
27
|
+
when :year then rand(1901..2155)
|
28
|
+
else raise UnknownColumnTypeError, "Unknown column type: #{@column.type}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def generate_json
|
35
|
+
{ "value" => { "key1" => Faker::Lorem.word, "key2" => Faker::Number.number(digits: 2) } }
|
36
|
+
end
|
37
|
+
|
38
|
+
def generate_money
|
39
|
+
Faker::Commerce.price.to_s
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
require "zeitwerk"
|
5
|
+
require "faker"
|
6
|
+
|
7
|
+
module ActiveRecordAnonymizer
|
8
|
+
@mutex = Mutex.new
|
9
|
+
|
10
|
+
@loader = Zeitwerk::Loader.for_gem
|
11
|
+
@loader.ignore("#{__dir__}/generators")
|
12
|
+
@loader.setup
|
13
|
+
|
14
|
+
class Error < StandardError; end
|
15
|
+
class ColumnNotFoundError < StandardError; end
|
16
|
+
class InvalidArgumentsError < StandardError; end
|
17
|
+
class UnknownColumnTypeError < StandardError; end
|
18
|
+
class UnsupportedVersionError < StandardError; end
|
19
|
+
|
20
|
+
class << self
|
21
|
+
attr_reader :loader
|
22
|
+
|
23
|
+
def register_model(model)
|
24
|
+
@mutex.synchronize do
|
25
|
+
@models ||= []
|
26
|
+
@models << model unless @models.include?(model)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def models
|
31
|
+
@mutex.synchronize do
|
32
|
+
@models ||= []
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def configure
|
37
|
+
@mutex.synchronize do
|
38
|
+
yield configuration if block_given?
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def configuration
|
43
|
+
Thread.current[:active_record_anonymizer_configuration] ||= Configuration.new
|
44
|
+
end
|
45
|
+
|
46
|
+
def eager_load!
|
47
|
+
loader.eager_load
|
48
|
+
end
|
49
|
+
|
50
|
+
def anonymization_enabled?
|
51
|
+
@mutex.synchronize do
|
52
|
+
configuration.environments.include?(Rails.env.to_sym)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def alias_enabled?
|
57
|
+
@mutex.synchronize do
|
58
|
+
configuration.alias_original_columns
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def load_model(klass_name)
|
63
|
+
model = klass_name.safe_constantize
|
64
|
+
raise Error, "Could not find class: #{klass_name}" unless model
|
65
|
+
|
66
|
+
unless models.include?(model)
|
67
|
+
raise Error, "#{klass_name} is not an anonymized model"
|
68
|
+
end
|
69
|
+
|
70
|
+
model
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
ActiveSupport.on_load(:active_record) do
|
76
|
+
include ActiveRecordAnonymizer::Extensions
|
77
|
+
end
|
78
|
+
|
79
|
+
ActiveRecordAnonymizer.eager_load!
|
@@ -0,0 +1,10 @@
|
|
1
|
+
Description:
|
2
|
+
This generator creates a migration to anonymize a model's fields.
|
3
|
+
If the generator is used to generate migration for a model with field "name",
|
4
|
+
it will create a migration to create a new field "anonymized_name"
|
5
|
+
|
6
|
+
Example:
|
7
|
+
bin/rails generate anonymize Model field1 field2
|
8
|
+
|
9
|
+
This will create:
|
10
|
+
db/migrate/20160101000000_anonymize_model.rb
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module ActiveRecordAnonymizer
|
7
|
+
class AnonymizeGenerator < ActiveRecord::Generators::Base
|
8
|
+
include Rails::Generators::Migration
|
9
|
+
|
10
|
+
class InvalidArguments < Thor::Error; end
|
11
|
+
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
13
|
+
|
14
|
+
argument :attributes, type: :array, default: [], banner: "attribute attribute"
|
15
|
+
|
16
|
+
def generate_migration
|
17
|
+
validate_arguments
|
18
|
+
migration_template "migration.rb.erb", "db/migrate/anonymize_#{table_name}.rb"
|
19
|
+
output_instructions
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def output_instructions
|
25
|
+
log "\n\n"
|
26
|
+
log "Add the following to your model file:"
|
27
|
+
log "\n"
|
28
|
+
log anonymize_method_string
|
29
|
+
log "\n"
|
30
|
+
end
|
31
|
+
|
32
|
+
def anonymize_method_string
|
33
|
+
" anonymize :#{attributes.map(&:name).join(', :')}\n"
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_arguments
|
37
|
+
raise InvalidArguments, "Attributes are needed for the migration" if attributes.empty?
|
38
|
+
end
|
39
|
+
|
40
|
+
def migration_version
|
41
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails.version.start_with? "5"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/base"
|
4
|
+
|
5
|
+
module ActiveRecordAnonymizer
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
8
|
+
|
9
|
+
desc "Creates an anonymizer initializer file"
|
10
|
+
def copy_initializer
|
11
|
+
template "anonymizer.rb", "config/initializers/anonymizer.rb"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
ActiveRecordAnonymizer.configure do |config|
|
4
|
+
# Configure the environments in which anonymization is allowed.
|
5
|
+
config.environments = %i[staging]
|
6
|
+
|
7
|
+
# Uncomment the following line to skip updating anonymized_columns when updating the original columns.
|
8
|
+
# config.skip_update = true
|
9
|
+
|
10
|
+
# Uncomment the following line to alias the original columns.
|
11
|
+
# config.alias_original_columns = true
|
12
|
+
|
13
|
+
# This will only work if config.alias_original_columns is set to true.
|
14
|
+
# Change the alias column name. (original by default)
|
15
|
+
# Model.original_column_name will provide the original value of the column.
|
16
|
+
config.alias_column_name = "original"
|
17
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
4
|
+
def self.up
|
5
|
+
<% attributes.each do |column| -%>
|
6
|
+
add_column :<%= table_name %>, :<%= "anonymized_#{column.name}" %>, :<%= column.type %>, index: <%= column.has_index? %>, unique: <%= column.has_uniq_index? %>
|
7
|
+
<% end -%>
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.down
|
11
|
+
<%- attributes.each do |column| -%>
|
12
|
+
remove_column :<%= table_name %>, :<%= "anonymized_#{column.name}" %>
|
13
|
+
<% end -%>
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_anonymizer"
|
4
|
+
|
5
|
+
namespace :anonymizer do
|
6
|
+
desc "populate anonymize columns (specify CLASS)"
|
7
|
+
task populate: :environment do
|
8
|
+
klass_name = ENV["CLASS"]
|
9
|
+
abort "USAGE: rake anonymize:populate CLASS=User" unless klass_name
|
10
|
+
|
11
|
+
model =
|
12
|
+
begin
|
13
|
+
ActiveRecordAnonymizer.load_model(klass_name)
|
14
|
+
rescue ActiveRecordAnonymizer::Error => e
|
15
|
+
abort e.message
|
16
|
+
end
|
17
|
+
|
18
|
+
puts "Anonymize columns for #{klass_name}..."
|
19
|
+
model.each(&:save!)
|
20
|
+
puts "Anonymize columns for #{klass_name} done!"
|
21
|
+
end
|
22
|
+
|
23
|
+
namespace :populate do
|
24
|
+
desc "populate anonymize columns for all models"
|
25
|
+
task all: :environment do
|
26
|
+
ActiveRecordAnonymizer.models.each do |model|
|
27
|
+
puts "Anonymizing #{model.name}..."
|
28
|
+
model.find_each(&:save!)
|
29
|
+
end
|
30
|
+
puts "Anonymize columns for all models done!"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: active_record_anonymizer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Keshav Biswa
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-03-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 5.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 5.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 5.2.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 5.2.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: faker
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.9'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.9'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: zeitwerk
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.4'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '2.4'
|
69
|
+
description: " A Rails gem to anonymize ActiveRecord attributes using Faker and
|
70
|
+
other strategies.\n"
|
71
|
+
email:
|
72
|
+
- keshavbiswa21@gmail.com
|
73
|
+
executables: []
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- ".rubocop.yml"
|
78
|
+
- Gemfile
|
79
|
+
- LICENSE.txt
|
80
|
+
- README.md
|
81
|
+
- Rakefile
|
82
|
+
- lib/active_record_anonymizer.rb
|
83
|
+
- lib/active_record_anonymizer/anonymizer.rb
|
84
|
+
- lib/active_record_anonymizer/configuration.rb
|
85
|
+
- lib/active_record_anonymizer/encryptor.rb
|
86
|
+
- lib/active_record_anonymizer/extensions.rb
|
87
|
+
- lib/active_record_anonymizer/fake_value.rb
|
88
|
+
- lib/active_record_anonymizer/railtie.rb
|
89
|
+
- lib/active_record_anonymizer/version.rb
|
90
|
+
- lib/generators/active_record_anonymizer/USAGE
|
91
|
+
- lib/generators/active_record_anonymizer/anonymize_generator.rb
|
92
|
+
- lib/generators/active_record_anonymizer/install_generator.rb
|
93
|
+
- lib/generators/active_record_anonymizer/templates/anonymizer.rb
|
94
|
+
- lib/generators/active_record_anonymizer/templates/migration.rb.erb
|
95
|
+
- lib/tasks/active_record_anonymizer.rake
|
96
|
+
- sig/active_record_anonymizer.rbs
|
97
|
+
homepage: https://github.com/keshavbiswa/active_record_anonymizer
|
98
|
+
licenses:
|
99
|
+
- MIT
|
100
|
+
metadata:
|
101
|
+
homepage_uri: https://github.com/keshavbiswa/active_record_anonymizer
|
102
|
+
source_code_uri: https://github.com/keshavbiswa/active_record_anonymizer
|
103
|
+
changelog_uri: https://github.com/keshavbiswa/active_record_anonymizer
|
104
|
+
post_install_message:
|
105
|
+
rdoc_options: []
|
106
|
+
require_paths:
|
107
|
+
- lib
|
108
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 2.7.7
|
113
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
requirements: []
|
119
|
+
rubygems_version: 3.4.10
|
120
|
+
signing_key:
|
121
|
+
specification_version: 4
|
122
|
+
summary: Anonymize your ActiveRecord attributes with ease.
|
123
|
+
test_files: []
|