attr_redactor 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,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