attr_masker 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.
@@ -0,0 +1,227 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # Adds attr_accessors that mask an object's attributes
5
+ module AttrMasker
6
+ autoload :Version, "attr_masker/version"
7
+
8
+ autoload :Error, "attr_masker/error"
9
+ autoload :Performer, "attr_masker/performer"
10
+
11
+ module Maskers
12
+ autoload :Replacing, "attr_masker/maskers/replacing"
13
+ autoload :SIMPLE, "attr_masker/maskers/simple"
14
+ end
15
+
16
+ require "attr_masker/railtie" if defined?(Rails)
17
+ def self.extended(base) # :nodoc:
18
+ base.class_eval do
19
+
20
+ # Only include the dangerous instance methods during the Rake task!
21
+ include InstanceMethods
22
+ attr_writer :attr_masker_options
23
+ @attr_masker_options, @masker_attributes = {}, {}
24
+ end
25
+ end
26
+
27
+ # Generates attr_accessors that mask attributes transparently
28
+ #
29
+ # Options (any other options you specify are passed to the masker's mask
30
+ # methods)
31
+ #
32
+ # :marshal => If set to true, attributes will be marshaled as well as masker. This is useful if you're planning
33
+ # on masking something other than a string. Defaults to false unless you're using it with ActiveRecord
34
+ # or DataMapper.
35
+ #
36
+ # :marshaler => The object to use for marshaling. Defaults to Marshal.
37
+ #
38
+ # :dump_method => The dump method name to call on the <tt>:marshaler</tt> object to. Defaults to 'dump'.
39
+ #
40
+ # :load_method => The load method name to call on the <tt>:marshaler</tt> object. Defaults to 'load'.
41
+ #
42
+ # :masker => The object to use for masking. It must respond to +#mask+. Defaults to AttrMasker::Maskers::Simple.
43
+ #
44
+ # :if => Attributes are only masker if this option evaluates to true. If you pass a symbol representing an instance
45
+ # method then the result of the method will be evaluated. Any objects that respond to <tt>:call</tt> are evaluated as well.
46
+ # Defaults to true.
47
+ #
48
+ # :unless => Attributes are only masker if this option evaluates to false. If you pass a symbol representing an instance
49
+ # method then the result of the method will be evaluated. Any objects that respond to <tt>:call</tt> are evaluated as well.
50
+ # Defaults to false.
51
+ #
52
+ # You can specify your own default options
53
+ #
54
+ # class User
55
+ # # now all attributes will be encoded and marshaled by default
56
+ # attr_masker_options.merge!(:marshal => true, :some_other_option => true)
57
+ # attr_masker :configuration
58
+ # end
59
+ #
60
+ #
61
+ # Example
62
+ #
63
+ # class User
64
+ # attr_masker :email, :credit_card
65
+ # attr_masker :configuration, :marshal => true
66
+ # end
67
+ #
68
+ # @user = User.new
69
+ # @user.masker_email # nil
70
+ # @user.email? # false
71
+ # @user.email = 'test@example.com'
72
+ # @user.email? # true
73
+ # @user.masker_email # returns the masker version of 'test@example.com'
74
+ #
75
+ # @user.configuration = { :time_zone => 'UTC' }
76
+ # @user.masker_configuration # returns the masker version of configuration
77
+ #
78
+ # See README for more examples
79
+ def attr_masker(*attributes)
80
+ options = {
81
+ :if => true,
82
+ :unless => false,
83
+ :column_name => nil,
84
+ :marshal => false,
85
+ :marshaler => Marshal,
86
+ :dump_method => "dump",
87
+ :load_method => "load",
88
+ :masker => AttrMasker::Maskers::SIMPLE,
89
+ }.merge!(attr_masker_options).merge!(attributes.last.is_a?(Hash) ? attributes.pop : {})
90
+
91
+ attributes.each do |attribute|
92
+ masker_attributes[attribute.to_sym] = options.merge(attribute: attribute.to_sym)
93
+ end
94
+ end
95
+
96
+ # Default options to use with calls to <tt>attr_masker</tt>
97
+ # XXX:Keep
98
+ #
99
+ # It will inherit existing options from its superclass
100
+ def attr_masker_options
101
+ @attr_masker_options ||= superclass.attr_masker_options.dup
102
+ end
103
+
104
+ # Checks if an attribute is configured with <tt>attr_masker</tt>
105
+ # XXX:Keep
106
+ #
107
+ # Example
108
+ #
109
+ # class User
110
+ # attr_accessor :name
111
+ # attr_masker :email
112
+ # end
113
+ #
114
+ # User.attr_masker?(:name) # false
115
+ # User.attr_masker?(:email) # true
116
+ def attr_masker?(attribute)
117
+ masker_attributes.has_key?(attribute.to_sym)
118
+ end
119
+
120
+ # masks a value for the attribute specified
121
+ # XXX:modify
122
+ #
123
+ # Example
124
+ #
125
+ # class User
126
+ # attr_masker :email
127
+ # end
128
+ #
129
+ # masker_email = User.mask(:email, 'test@example.com')
130
+ def mask(attribute, value, options = {})
131
+ options = masker_attributes[attribute.to_sym].merge(options)
132
+ # if options[:if] && !options[:unless] && !value.nil? && !(value.is_a?(String) && value.empty?)
133
+ if options[:if] && !options[:unless]
134
+ value = options[:marshal] ? options[:marshaler].send(options[:dump_method], value) : value.to_s
135
+ masker_value = options[:masker].call(options.merge!(value: value))
136
+ masker_value
137
+ else
138
+ value
139
+ end
140
+ end
141
+
142
+ # Contains a hash of masker attributes with virtual attribute names as keys
143
+ # and their corresponding options as values
144
+ # XXX:Keep
145
+ #
146
+ # Example
147
+ #
148
+ # class User
149
+ # attr_masker :email
150
+ # end
151
+ #
152
+ # User.masker_attributes # { :email => { :attribute => 'masker_email' } }
153
+ def masker_attributes
154
+ @masker_attributes ||= superclass.masker_attributes.dup
155
+ end
156
+
157
+ # Forwards calls to :mask_#{attribute} to the corresponding mask method
158
+ # if attribute was configured with attr_masker
159
+ #
160
+ # Example
161
+ #
162
+ # class User
163
+ # attr_masker :email
164
+ # end
165
+ #
166
+ # User.mask_email('SOME_masker_EMAIL_STRING')
167
+ def method_missing(method, *arguments, &block)
168
+ if method.to_s =~ /^mask_(.+)$/ && attr_masker?($1)
169
+ send(:mask, $1, *arguments)
170
+ else
171
+ super
172
+ end
173
+ end
174
+
175
+ module InstanceMethods
176
+
177
+ # masks a value for the attribute specified using options evaluated in the current object's scope
178
+ #
179
+ # Example
180
+ #
181
+ # class User
182
+ # attr_accessor :secret_key
183
+ # attr_masker :email
184
+ #
185
+ # def initialize(secret_key)
186
+ # self.secret_key = secret_key
187
+ # end
188
+ # end
189
+ #
190
+ # @user = User.new('some-secret-key')
191
+ # @user.mask(:email, 'test@example.com')
192
+ def mask(attribute, value=nil)
193
+ value = self.send(attribute) if value.nil?
194
+ self.class.mask(attribute, value, evaluated_attr_masker_options_for(attribute))
195
+ end
196
+
197
+ protected
198
+
199
+ # Returns attr_masker options evaluated in the current object's scope for the attribute specified
200
+ # XXX:Keep
201
+ def evaluated_attr_masker_options_for(attribute)
202
+ self.class.masker_attributes[attribute.to_sym].inject({}) do |hash, (option, value)|
203
+ if %i[if unless].include?(option)
204
+ hash.merge!(option => evaluate_attr_masker_option(value))
205
+ else
206
+ hash.merge!(option => value)
207
+ end
208
+ end
209
+ end
210
+
211
+ # Evaluates symbol (method reference) or proc (responds to call) options
212
+ # XXX:Keep
213
+ #
214
+ # If the option is not a symbol or proc then the original option is returned
215
+ def evaluate_attr_masker_option(option)
216
+ if option.is_a?(Symbol) && respond_to?(option)
217
+ send(option)
218
+ elsif option.respond_to?(:call)
219
+ option.call(self)
220
+ else
221
+ option
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ Object.extend AttrMasker
data/lib/tasks/db.rake ADDED
@@ -0,0 +1,21 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # Hashrocket style looks better when describing task dependencies.
5
+ # rubocop:disable Style/HashSyntax
6
+
7
+ namespace :db do
8
+ desc "Mask every DB record according to rules set up in the respective " \
9
+ "ActiveRecord"
10
+
11
+ # If just:
12
+ # task :mask do ... end,
13
+ # then connection won't be established. Will need the '=> :environment'.
14
+ #
15
+ # URL:
16
+ # http://stackoverflow.com/questions/14163938/activerecordconnectionnotestablished-within-a-rake-task
17
+ #
18
+ task :mask => :environment do
19
+ AttrMasker::Performer::ActiveRecord.new.mask
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
@@ -0,0 +1,3 @@
1
+ Rails.application.routes.draw do
2
+ #
3
+ end
@@ -0,0 +1,8 @@
1
+ ActiveRecord::Schema.define do
2
+ create_table(:users, force: true) do |t|
3
+ t.string :first_name
4
+ t.string :last_name
5
+ t.string :email
6
+ t.timestamps null: false
7
+ end
8
+ end
File without changes
@@ -0,0 +1,203 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # No point in using ApplicationRecord here.
5
+ # rubocop:disable Rails/ApplicationRecord
6
+
7
+ # No point in ensuring a trailing comma in multiline argument lists here.
8
+ # rubocop:disable Style/TrailingCommaInArguments
9
+
10
+ require "spec_helper"
11
+
12
+ RSpec.describe "Attr Masker gem", :suppress_progressbar do
13
+ before do
14
+ stub_const "User", Class.new(ActiveRecord::Base)
15
+
16
+ User.class_eval do
17
+ def jedi?
18
+ email.ends_with? "@jedi.example.test"
19
+ end
20
+ end
21
+
22
+ allow(ActiveRecord::Base).to receive(:descendants).
23
+ and_return([ActiveRecord::SchemaMigration, User])
24
+ end
25
+
26
+ let!(:han) do
27
+ User.create!(
28
+ first_name: "Han",
29
+ last_name: "Solo",
30
+ email: "han@example.test",
31
+ )
32
+ end
33
+
34
+ let!(:luke) do
35
+ User.create!(
36
+ first_name: "Luke",
37
+ last_name: "Skywalker",
38
+ email: "luke@jedi.example.test",
39
+ )
40
+ end
41
+
42
+ example "Masking a single text attribute with default options" do
43
+ User.class_eval do
44
+ attr_masker :last_name
45
+ end
46
+
47
+ expect { run_rake_task }.not_to(change { User.count })
48
+
49
+ [han, luke].each do |record|
50
+ expect { record.reload }.to(
51
+ change { record.last_name }.to("(redacted)") &
52
+ preserve { record.first_name } &
53
+ preserve { record.email }
54
+ )
55
+ end
56
+ end
57
+
58
+ example "Specifying multiple attributes in an attr_masker declaration" do
59
+ User.class_eval do
60
+ attr_masker :first_name, :last_name
61
+ end
62
+
63
+ expect { run_rake_task }.not_to(change { User.count })
64
+
65
+ [han, luke].each do |record|
66
+ expect { record.reload }.to(
67
+ change { record.first_name }.to("(redacted)") &
68
+ change { record.last_name }.to("(redacted)") &
69
+ preserve { record.email }
70
+ )
71
+ end
72
+ end
73
+
74
+ example "Skipping some records when a symbol is passed to :if option" do
75
+ User.class_eval do
76
+ attr_masker :first_name, :last_name, if: :jedi?
77
+ end
78
+
79
+ expect { run_rake_task }.not_to(change { User.count })
80
+
81
+ expect { han.reload }.to(
82
+ preserve { han.first_name } &
83
+ preserve { han.last_name } &
84
+ preserve { han.email }
85
+ )
86
+
87
+ expect { luke.reload }.to(
88
+ change { luke.first_name }.to("(redacted)") &
89
+ change { luke.last_name }.to("(redacted)") &
90
+ preserve { luke.email }
91
+ )
92
+ end
93
+
94
+ example "Skipping some records when a lambda is passed to :if option" do
95
+ User.class_eval do
96
+ attr_masker :first_name, :last_name, if: ->(r) { r.jedi? }
97
+ end
98
+
99
+ expect { run_rake_task }.not_to(change { User.count })
100
+
101
+ expect { han.reload }.to(
102
+ preserve { han.first_name } &
103
+ preserve { han.last_name } &
104
+ preserve { han.email }
105
+ )
106
+
107
+ expect { luke.reload }.to(
108
+ change { luke.first_name }.to("(redacted)") &
109
+ change { luke.last_name }.to("(redacted)") &
110
+ preserve { luke.email }
111
+ )
112
+ end
113
+
114
+ example "Skipping some records when a symbol is passed to :unless option" do
115
+ User.class_eval do
116
+ attr_masker :first_name, :last_name, unless: :jedi?
117
+ end
118
+
119
+ expect { run_rake_task }.not_to(change { User.count })
120
+
121
+ expect { han.reload }.to(
122
+ change { han.first_name }.to("(redacted)") &
123
+ change { han.last_name }.to("(redacted)") &
124
+ preserve { han.email }
125
+ )
126
+
127
+ expect { luke.reload }.to(
128
+ preserve { luke.first_name } &
129
+ preserve { luke.last_name } &
130
+ preserve { luke.email }
131
+ )
132
+ end
133
+
134
+ example "Skipping some records when a lambda is passed to :unless option" do
135
+ User.class_eval do
136
+ attr_masker :first_name, :last_name, unless: ->(r) { r.jedi? }
137
+ end
138
+
139
+ expect { run_rake_task }.not_to(change { User.count })
140
+
141
+ expect { han.reload }.to(
142
+ change { han.first_name }.to("(redacted)") &
143
+ change { han.last_name }.to("(redacted)") &
144
+ preserve { han.email }
145
+ )
146
+
147
+ expect { luke.reload }.to(
148
+ preserve { luke.first_name } &
149
+ preserve { luke.last_name } &
150
+ preserve { luke.email }
151
+ )
152
+ end
153
+
154
+ example "Using a custom masker" do
155
+ reverse_masker = ->(value:, **_) do
156
+ value.reverse
157
+ end
158
+
159
+ upcase_masker = ->(value:, **_) do
160
+ value.upcase
161
+ end
162
+
163
+ User.class_eval do
164
+ attr_masker :first_name, masker: reverse_masker
165
+ attr_masker :last_name, masker: upcase_masker
166
+ end
167
+
168
+ expect { run_rake_task }.not_to(change { User.count })
169
+
170
+ expect { han.reload }.to(
171
+ change { han.first_name }.to("naH") &
172
+ change { han.last_name }.to("SOLO") &
173
+ preserve { han.email }
174
+ )
175
+
176
+ expect { luke.reload }.to(
177
+ change { luke.first_name }.to("ekuL") &
178
+ change { luke.last_name }.to("SKYWALKER") &
179
+ preserve { luke.email }
180
+ )
181
+ end
182
+
183
+ example "It is disabled in production environment" do
184
+ allow(Rails).to receive(:env) { "production".inquiry }
185
+
186
+ User.class_eval do
187
+ attr_masker :last_name
188
+ end
189
+
190
+ expect { run_rake_task }.to(
191
+ preserve { User.count } &
192
+ raise_exception(AttrMasker::Error)
193
+ )
194
+
195
+ [han, luke].each do |record|
196
+ expect { record.reload }.not_to(change { record })
197
+ end
198
+ end
199
+
200
+ def run_rake_task
201
+ Rake::Task["db:mask"].execute
202
+ end
203
+ end
@@ -0,0 +1,48 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # No point in using ApplicationRecord here.
5
+
6
+ require "spec_helper"
7
+
8
+ RSpec.describe AttrMasker::Maskers::Replacing do
9
+ subject { described_class.new **options }
10
+
11
+ let(:address) { "1 Pedder Street, Hong Kong" }
12
+
13
+ shared_examples "AttrMasker::Maskers::Replacing examples" do
14
+ example { expect(subject.(value: address)).to eq(expected_masked_address) }
15
+ example { expect(subject.(value: Math::PI)).to eq(Math::PI) }
16
+ example { expect(subject.(value: nil)).to eq(nil) }
17
+ end
18
+
19
+ context "with default options" do
20
+ let(:options) { {} }
21
+ let(:expected_masked_address) { "**************************" }
22
+ include_examples "AttrMasker::Maskers::Replacing examples"
23
+ end
24
+
25
+ context "with alphanum_only option set to true" do
26
+ let(:options) { { alphanum_only: true } }
27
+ let(:expected_masked_address) { "* ****** ******, **** ****" }
28
+ include_examples "AttrMasker::Maskers::Replacing examples"
29
+ end
30
+
31
+ context "with a custom replacement string" do
32
+ let(:options) { { replacement: "X" } }
33
+ let(:expected_masked_address) { "XXXXXXXXXXXXXXXXXXXXXXXXXX" }
34
+ include_examples "AttrMasker::Maskers::Replacing examples"
35
+ end
36
+
37
+ context "with an empty replacement string" do
38
+ let(:options) { { replacement: "" } }
39
+ let(:expected_masked_address) { "" }
40
+ include_examples "AttrMasker::Maskers::Replacing examples"
41
+ end
42
+
43
+ context "with alphanum_only and replacement options combined" do
44
+ let(:options) { { alphanum_only: true, replacement: "X" } }
45
+ let(:expected_masked_address) { "X XXXXXX XXXXXX, XXXX XXXX" }
46
+ include_examples "AttrMasker::Maskers::Replacing examples"
47
+ end
48
+ end
@@ -0,0 +1,14 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ # No point in using ApplicationRecord here.
5
+
6
+ require "spec_helper"
7
+
8
+ RSpec.describe AttrMasker::Maskers::SIMPLE do
9
+ subject { described_class }
10
+
11
+ example { expect(subject.(value: "Solo")).to eq("(redacted)") }
12
+ example { expect(subject.(value: Math::PI)).to eq("(redacted)") }
13
+ example { expect(subject.(value: nil)).to eq("(redacted)") }
14
+ end
@@ -0,0 +1,21 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ require "bundler"
5
+ Bundler.require :default, :development
6
+
7
+ Dir[File.expand_path "../support/**/*.rb", __FILE__].each { |f| require f }
8
+
9
+ RSpec.configure do |config|
10
+ # Enable flags like --only-failures and --next-failure
11
+ config.example_status_persistence_file_path = ".rspec_status"
12
+
13
+ # Disable RSpec exposing methods globally on `Module` and `main`
14
+ config.disable_monkey_patching!
15
+
16
+ config.expect_with :rspec do |c|
17
+ c.syntax = :expect
18
+ end
19
+ end
20
+
21
+ require "rails/all"
@@ -0,0 +1,5 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ Combustion.path = "spec/dummy"
5
+ Combustion.initialize! :all
@@ -0,0 +1,15 @@
1
+ require "database_cleaner"
2
+
3
+ RSpec.configure do |config|
4
+ config.before(:suite) do
5
+ DatabaseCleaner.clean_with(:truncation)
6
+
7
+ DatabaseCleaner.strategy = :truncation
8
+ end
9
+
10
+ config.around(:each) do |example|
11
+ DatabaseCleaner.cleaning do
12
+ example.run
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,2 @@
1
+ RSpec::Matchers.define_negated_matcher :exclude, :include
2
+ RSpec::Matchers.define_negated_matcher :preserve, :change
@@ -0,0 +1,6 @@
1
+ # (c) 2017 Ribose Inc.
2
+ #
3
+
4
+ require "rake"
5
+ Rails.application.load_tasks
6
+ load File.expand_path("../../../lib/tasks/db.rake", __FILE__)
@@ -0,0 +1,8 @@
1
+ RSpec.configure do |config|
2
+ config.before(:each, suppress_progressbar: true) do
3
+ stub_const(
4
+ "::ProgressBar::Output::DEFAULT_OUTPUT_STREAM",
5
+ StringIO.new,
6
+ )
7
+ end
8
+ end