attr_redactor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,318 @@
1
+ require 'hash_redactor'
2
+
3
+ # Adds attr_accessors that redact an object's attributes
4
+ module AttrRedactor
5
+ autoload :Version, 'attr_redactor/version'
6
+
7
+ def self.extended(base) # :nodoc:
8
+ base.class_eval do
9
+ include InstanceMethods
10
+ attr_writer :attr_redactor_options
11
+ @attr_redactor_options, @redacted_attributes = {}, {}
12
+ end
13
+ end
14
+
15
+ # Generates attr_accessors that remove, digest or encrypt values in an attribute that is a hash transparently
16
+ #
17
+ # Options
18
+ # Any other options you specify are passed on to hash_redactor which is used for
19
+ # redacting, and in turn onto attr_encrypted which it uses for encryption)
20
+ #
21
+ # redact: Hash that describes the values to redact in the hash. Should be a
22
+ # map of the form { key => method }, where key is the key to redact
23
+ # and method is one of :remove, :digest, :encrypt
24
+ # eg {
25
+ # :ssn => :remove,
26
+ # :email => :digest
27
+ # :medical_notes => :encrypt,
28
+ # }
29
+ #
30
+ # attribute: The name of the referenced encrypted attribute. For example
31
+ # <tt>attr_accessor :data, attribute: :safe_data</tt> would generate
32
+ # an attribute named 'safe_data' to store the redacted data.
33
+ # This is useful when defining one attribute to encrypt at a time
34
+ # or when the :prefix and :suffix options aren't enough.
35
+ # Defaults to nil.
36
+ #
37
+ # prefix: A prefix used to generate the name of the referenced redacted attributes.
38
+ # For example <tt>attr_accessor :data, prefix: 'safe_'</tt> would
39
+ # generate attributes named 'safe_data' to store the redacted
40
+ # data hash.
41
+ # Defaults to 'redacted_'.
42
+ #
43
+ # suffix: A suffix used to generate the name of the referenced redacted attributes.
44
+ # For example <tt>attr_accessor :data, prefix: '', suffix: '_cleaned'</tt>
45
+ # would generate attributes named 'data_cleaned' to store the
46
+ # cleaned up data.
47
+ # Defaults to ''.
48
+ #
49
+ # encryption_key: The encryption key to use for encrypted fields.
50
+ # Defaults to nil. Required if you are using encryption.
51
+ #
52
+ # digest_salt: The salt to use for digests
53
+ # Defaults to ""
54
+ #
55
+ #
56
+ # You can specify your own default options
57
+ #
58
+ # class User
59
+ # attr_redactor_options.merge!(redact: { :ssn => :remove })
60
+ # attr_redactor :data
61
+ # end
62
+ #
63
+ #
64
+ # Example
65
+ #
66
+ # class User
67
+ # attr_redactor_options.merge!(encryption_key: 'some secret key')
68
+ # attr_redactor :data, redact: {
69
+ # :ssn => :remove,
70
+ # :email => :digest
71
+ # :medical_notes => :encrypt,
72
+ # }
73
+ # end
74
+ #
75
+ # @user = User.new
76
+ # @user.redacted_data # nil
77
+ # @user.data? # false
78
+ # @user.data = { ssn: 'private', email: 'mail@email.com', medical_notes: 'private' }
79
+ # @user.data? # true
80
+ # @user.redacted_data # { email_digest: 'XXXXXX', encrypted_medical_notes: 'XXXXXX', encrypted_medical_notes_iv: 'XXXXXXX' }
81
+ # @user.save!
82
+ # @user = User.last
83
+ #
84
+ # @user.data # { email_digest: 'XXXXXX', medical_notes: 'private' }
85
+ #
86
+ # See README for more examples
87
+ def attr_redactor(*attributes)
88
+ options = attributes.last.is_a?(Hash) ? attributes.pop : {}
89
+ options = attr_redactor_default_options.dup.merge!(attr_redactor_options).merge!(options)
90
+
91
+ attributes.each do |attribute|
92
+ redacted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym
93
+
94
+ instance_methods_as_symbols = attribute_instance_methods_as_symbols
95
+ attr_reader redacted_attribute_name unless instance_methods_as_symbols.include?(redacted_attribute_name)
96
+ attr_writer redacted_attribute_name unless instance_methods_as_symbols.include?(:"#{redacted_attribute_name}=")
97
+
98
+ # Create a redactor for the attribute
99
+ options[:redactor] = HashRedactor::HashRedactor.new(options)
100
+
101
+ define_method(attribute) do
102
+ instance_variable_get("@#{attribute}") || instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
103
+ end
104
+
105
+ define_method("#{attribute}=") do |value|
106
+ send("#{redacted_attribute_name}=", redact(attribute, value))
107
+ instance_variable_set("@#{attribute}", value)
108
+ # replace with redacted/unredacted value immediately
109
+ instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
110
+ end
111
+
112
+ define_method("#{attribute}?") do
113
+ value = send(attribute)
114
+ value.respond_to?(:empty?) ? !value.empty? : !!value
115
+ end
116
+
117
+ define_method("#{attribute}_redact_hash") do
118
+ options = redacted_attributes[attribute]
119
+ options[:redact]
120
+ end
121
+
122
+ redacted_attributes[attribute.to_sym] = options.merge(attribute: redacted_attribute_name)
123
+ end
124
+ end
125
+
126
+ # Default options to use with calls to <tt>attr_redactor</tt>
127
+ #
128
+ # It will inherit existing options from its superclass
129
+ def attr_redactor_options
130
+ @attr_redactor_options ||= superclass.attr_redactor_options.dup
131
+ end
132
+
133
+ def attr_redactor_default_options
134
+ {
135
+ prefix: 'redacted_',
136
+ suffix: '',
137
+ if: true,
138
+ unless: false,
139
+ marshal: false,
140
+ marshaler: Marshal,
141
+ dump_method: 'dump',
142
+ load_method: 'load',
143
+ }
144
+ end
145
+
146
+ private :attr_redactor_default_options
147
+
148
+ # Checks if an attribute is configured with <tt>attr_redactor</tt>
149
+ #
150
+ # Example
151
+ #
152
+ # class User
153
+ # attr_accessor :name
154
+ # attr_redactor :email
155
+ # end
156
+ #
157
+ # User.attr_redacted?(:name) # false
158
+ # User.attr_redacted?(:email) # true
159
+ def attr_redacted?(attribute)
160
+ redacted_attributes.has_key?(attribute.to_sym)
161
+ end
162
+
163
+ # Decrypts values in the attribute specified
164
+ #
165
+ # Example
166
+ #
167
+ # class User
168
+ # attr_redactor :data
169
+ # end
170
+ #
171
+ # data = User.redact(:data, SOME_REDACTED_HASH)
172
+ def unredact(attribute, redacted_value, options = {})
173
+ options = redacted_attributes[attribute.to_sym].merge(options)
174
+ if options[:if] && !options[:unless] && !redacted_value.nil?
175
+ value = options[:redactor].decrypt(redacted_value, options)
176
+ value
177
+ else
178
+ redacted_value
179
+ end
180
+ end
181
+
182
+ # Redacts for the attribute specified
183
+ #
184
+ # Example
185
+ #
186
+ # class User
187
+ # attr_redactor :data
188
+ # end
189
+ #
190
+ # redacted_data = User.redact(:data, { email: 'test@example.com' })
191
+ def redact(attribute, value, options = {})
192
+ options = redacted_attributes[attribute.to_sym].merge(options)
193
+ if options[:if] && !options[:unless] && !value.nil?
194
+ redacted_value = options[:redactor].redact(value, options)
195
+ else
196
+ value
197
+ end
198
+ end
199
+
200
+ # Contains a hash of redacted attributes with virtual attribute names as keys
201
+ # and their corresponding options as values
202
+ #
203
+ # Example
204
+ #
205
+ # class User
206
+ # attr_redactor :data, key: 'my secret key'
207
+ # end
208
+ #
209
+ # User.redacted_attributes # { data: { attribute: 'redacted_data', encryption_key: 'my secret key' } }
210
+ def redacted_attributes
211
+ @redacted_attributes ||= superclass.redacted_attributes.dup
212
+ end
213
+
214
+ # Forwards calls to :redact_#{attribute} or :unredact_#{attribute} to the corresponding redact or unredact method
215
+ # if attribute was configured with attr_redactor
216
+ #
217
+ # Example
218
+ #
219
+ # class User
220
+ # attr_redactor :data, key: 'my secret key'
221
+ # end
222
+ #
223
+ # User.redact_data('SOME_ENCRYPTED_EMAIL_STRING')
224
+ def method_missing(method, *arguments, &block)
225
+ if method.to_s =~ /^(redact|unredact)_(.+)$/ && attr_redacted?($2)
226
+ send($1, $2, *arguments)
227
+ else
228
+ super
229
+ end
230
+ end
231
+
232
+ module InstanceMethods
233
+ # Decrypts a value for the attribute specified using options evaluated in the current object's scope
234
+ #
235
+ # Example
236
+ #
237
+ # class User
238
+ # attr_accessor :secret_key
239
+ # attr_redactor :data, key: :secret_key
240
+ #
241
+ # def initialize(secret_key)
242
+ # self.secret_key = secret_key
243
+ # end
244
+ # end
245
+ #
246
+ # @user = User.new('some-secret-key')
247
+ # @user.unredact(:data, SOME_REDACTED_HASH)
248
+ def unredact(attribute, redacted_value)
249
+ redacted_attributes[attribute.to_sym][:operation] = :unredacting
250
+ self.class.unredact(attribute, redacted_value, evaluated_attr_redacted_options_for(attribute))
251
+ end
252
+
253
+ # Redacts a value for the attribute specified using options evaluated in the current object's scope
254
+ #
255
+ # Example
256
+ #
257
+ # class User
258
+ # attr_accessor :secret_key
259
+ # attr_redactor :data, key: :secret_key
260
+ #
261
+ # def initialize(secret_key)
262
+ # self.secret_key = secret_key
263
+ # end
264
+ # end
265
+ #
266
+ # @user = User.new('some-secret-key')
267
+ # @user.redact(:data, 'test@example.com')
268
+ def redact(attribute, value)
269
+ redacted_attributes[attribute.to_sym][:operation] = :redacting
270
+ self.class.redact(attribute, value, evaluated_attr_redacted_options_for(attribute))
271
+ end
272
+
273
+ # Copies the class level hash of redacted attributes with virtual attribute names as keys
274
+ # and their corresponding options as values to the instance
275
+ #
276
+ def redacted_attributes
277
+ @redacted_attributes ||= self.class.redacted_attributes.dup
278
+ end
279
+
280
+ protected
281
+
282
+ # Returns attr_redactor options evaluated in the current object's scope for the attribute specified
283
+ def evaluated_attr_redacted_options_for(attribute)
284
+ evaluated_options = Hash.new
285
+ attribute_option_value = redacted_attributes[attribute.to_sym][:attribute]
286
+ redacted_attributes[attribute.to_sym].map do |option, value|
287
+ evaluated_options[option] = evaluate_attr_redactor_option(value)
288
+ end
289
+
290
+ evaluated_options[:attribute] = attribute_option_value
291
+
292
+ evaluated_options
293
+ end
294
+
295
+ # Evaluates symbol (method reference) or proc (responds to call) options
296
+ #
297
+ # If the option is not a symbol or proc then the original option is returned
298
+ def evaluate_attr_redactor_option(option)
299
+ if option.is_a?(Symbol) && respond_to?(option)
300
+ send(option)
301
+ elsif option.respond_to?(:call)
302
+ option.call(self)
303
+ else
304
+ option
305
+ end
306
+ end
307
+ end
308
+
309
+ protected
310
+
311
+ def attribute_instance_methods_as_symbols
312
+ instance_methods.collect { |method| method.to_sym }
313
+ end
314
+
315
+ end
316
+
317
+
318
+ Dir[File.join(File.dirname(__FILE__), 'attr_redactor', 'adapters', '*.rb')].each { |adapter| require adapter }
@@ -0,0 +1,187 @@
1
+ require_relative 'test_helper'
2
+
3
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
4
+
5
+ def create_tables
6
+ silence_stream(STDOUT) do
7
+ ActiveRecord::Schema.define(version: 1) do
8
+ create_table :people do |t|
9
+ t.string :redacted_data
10
+ t.string :login
11
+ t.boolean :is_admin
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ # The table needs to exist before defining the class
18
+ create_tables
19
+
20
+ ActiveRecord::MissingAttributeError = ActiveModel::MissingAttributeError unless defined?(ActiveRecord::MissingAttributeError)
21
+
22
+ if ::ActiveRecord::VERSION::STRING > "4.0"
23
+ module Rack
24
+ module Test
25
+ class UploadedFile; end
26
+ end
27
+ end
28
+
29
+ require 'action_controller/metal/strong_parameters'
30
+ end
31
+
32
+ class Person < ActiveRecord::Base
33
+ self.attr_redactor_options[:encryption_key] = "a very very very long secure, very secure key"
34
+ self.attr_redactor_options[:redact] = {
35
+ "ssn" => :remove,
36
+ "email" => :digest,
37
+ "history" => :encrypt
38
+ }
39
+
40
+ serialize :redacted_data, Hash
41
+ attr_redactor :data
42
+
43
+ attr_protected :login if ::ActiveRecord::VERSION::STRING < "4.0"
44
+ end
45
+
46
+ class PersonWithValidation < Person
47
+ validates_presence_of :data
48
+ end
49
+
50
+ class ActiveRecordTest < Minitest::Test
51
+
52
+ def setup
53
+ ActiveRecord::Base.connection.tables.each { |table| ActiveRecord::Base.connection.drop_table(table) }
54
+ create_tables
55
+ end
56
+
57
+ def test_should_marshal_and_redact_data
58
+ @person = Person.create :data => { "ssn" => '12345', "email" => 'some@address.com',
59
+ "history" => 'A big secret' }
60
+ refute_nil @person.redacted_data
61
+ refute_equal @person.data, @person.redacted_data
62
+ assert_equal @person.data, Person.first.data
63
+ end
64
+
65
+ def test_should_validate_presence_of_data
66
+ @person = PersonWithValidation.new
67
+ assert !@person.valid?
68
+ assert !@person.errors[:data].empty? || @person.errors.on(:data)
69
+ end
70
+
71
+ def test_should_create_changed_predicate
72
+ data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
73
+ "history" => 'A big secret' }
74
+ alternate_data = { "ssn" => '54321', "email" => 'some@address.com',
75
+ "history" => 'A really big secret' }
76
+
77
+ person = Person.create!(data: data_to_redact)
78
+ refute person.data_changed?
79
+ person.data = data_to_redact
80
+ refute person.data_changed?
81
+ person.data = alternate_data
82
+ assert person.data_changed?
83
+ person.save!
84
+ person.data = alternate_data
85
+ refute person.data_changed?
86
+ person.data = nil
87
+ assert person.data_changed?
88
+ end
89
+
90
+ def test_changing_hash_should_not_cause_changed
91
+ # It's misleading to do so as the change won't be saved
92
+ data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
93
+ "history" => 'A big secret' }
94
+
95
+ person = Person.create!(data: data_to_redact)
96
+ refute person.data_changed?
97
+ person.data["email"] = 'anew@email.com'
98
+ refute person.data_changed?
99
+ end
100
+
101
+ def test_should_create_was_predicate
102
+ data_to_redact = { "ssn" => '12345', "email" => 'some@address.com',
103
+ "history" => 'A big secret' }
104
+
105
+ alternate_data = { "ssn" => '54321', "email" => 'some@address.com',
106
+ "history" => 'A really big secret' }
107
+
108
+ person = Person.create!(data: data_to_redact)
109
+ person.data = alternate_data
110
+ assert_equal person.unredact(:data, person.redact(:data, data_to_redact)), person.data_was
111
+ end
112
+
113
+ if ::ActiveRecord::VERSION::STRING > "4.0"
114
+ def test_should_assign_attributes
115
+ @user = Person.new(login: 'login', is_admin: false)
116
+ @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login)
117
+ assert_equal 'modified', @user.login
118
+ end
119
+
120
+ def test_should_not_assign_protected_attributes
121
+ @user = Person.new(login: 'login', is_admin: false)
122
+ @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true).permit(:login)
123
+ assert !@user.is_admin?
124
+ end
125
+
126
+ def test_should_raise_exception_if_not_permitted
127
+ @user = Person.new(login: 'login', is_admin: false)
128
+ assert_raises ActiveModel::ForbiddenAttributesError do
129
+ @user.attributes = ActionController::Parameters.new(login: 'modified', is_admin: true)
130
+ end
131
+ end
132
+
133
+ def test_should_raise_exception_on_init_if_not_permitted
134
+ assert_raises ActiveModel::ForbiddenAttributesError do
135
+ @user = Person.new ActionController::Parameters.new(login: 'modified', is_admin: true)
136
+ end
137
+ end
138
+ else
139
+ def test_should_assign_attributes
140
+ @user = Person.new(login: 'login', is_admin: false)
141
+ @user.attributes = { login: 'modified', is_admin: true }
142
+ assert @user.is_admin
143
+ end
144
+
145
+ def test_should_not_assign_protected_attributes
146
+ @user = Person.new(login: 'login', is_admin: false)
147
+ @user.attributes = { login: 'modified', is_admin: true }
148
+ assert_nil @user.login
149
+ end
150
+
151
+ def test_should_assign_protected_attributes
152
+ @user = Person.new(login: 'login', is_admin: false)
153
+ if ::ActiveRecord::VERSION::STRING > "3.1"
154
+ @user.send(:assign_attributes, { login: 'modified', is_admin: true }, without_protection: true)
155
+ else
156
+ @user.send(:attributes=, { login: 'modified', is_admin: true }, false)
157
+ end
158
+ assert_equal 'modified', @user.login
159
+ end
160
+ end
161
+
162
+ def test_should_allow_assignment_of_nil_attributes
163
+ @person = Person.new
164
+ assert_nil(@person.attributes = nil)
165
+ end
166
+
167
+ if ::ActiveRecord::VERSION::STRING > "3.1"
168
+ def test_should_allow_assign_attributes_with_nil
169
+ @person = Person.new
170
+ assert_nil(@person.assign_attributes nil)
171
+ end
172
+ end
173
+
174
+ # See https://github.com/attr-encrypted/attr_encrypted/issues/68
175
+ def test_should_invalidate_virtual_attributes_on_reload
176
+ old_data = { "history" => 'Itself' }
177
+ new_data = { "history" => 'Repeating itself' }
178
+ p = Person.create!(data: old_data)
179
+ assert_equal p.data["history"], old_data["history"]
180
+ p.data = new_data
181
+ assert_equal p.data["history"], new_data["history"]
182
+
183
+ result = p.reload
184
+ assert_equal p, result
185
+ assert_equal p.data["history"], old_data["history"]
186
+ end
187
+ end