attr_masker 0.1.0

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