lib/lockbox/model.rb in lockbox-1.3.3 vs lib/lockbox/model.rb in lockbox-1.4.0

- old
+ new

@@ -135,17 +135,20 @@ def attributes # load attributes # essentially a no-op if already loaded # an exception is thrown if decryption fails self.class.lockbox_attributes.each do |_, lockbox_attribute| - # don't try to decrypt if no decryption key given - next if lockbox_attribute[:algorithm] == "hybrid" && lockbox_attribute[:decryption_key].nil? - # it is possible that the encrypted attribute is not loaded, eg. # if the record was fetched partially (`User.select(:id).first`). # accessing a not loaded attribute raises an `ActiveModel::MissingAttributeError`. - send(lockbox_attribute[:attribute]) if has_attribute?(lockbox_attribute[:encrypted_attribute]) + if has_attribute?(lockbox_attribute[:encrypted_attribute]) + begin + send(lockbox_attribute[:attribute]) + rescue ArgumentError => e + raise e if e.message != "No decryption key set" + end + end end super end # needed for in-place modifications @@ -228,10 +231,24 @@ result end if ActiveRecord::VERSION::MAJOR >= 6 + if ActiveRecord::VERSION::STRING.to_f >= 7.2 + def self.insert(attributes, **options) + super(lockbox_map_record_attributes(attributes), **options) + end + + def self.insert!(attributes, **options) + super(lockbox_map_record_attributes(attributes), **options) + end + + def self.upsert(attributes, **options) + super(lockbox_map_record_attributes(attributes, check_readonly: true), **options) + end + end + def self.insert_all(attributes, **options) super(lockbox_map_attributes(attributes), **options) end def self.insert_all!(attributes, **options) @@ -246,34 +263,41 @@ # does not try to handle :returning option for simplicity def self.lockbox_map_attributes(records, check_readonly: false) return records unless records.is_a?(Array) records.map do |attributes| - # transform keys like Active Record - attributes = attributes.transform_keys do |key| - n = key.to_s - attribute_aliases[n] || n - end + lockbox_map_record_attributes(attributes, check_readonly: false) + end + end - lockbox_attributes = self.lockbox_attributes.slice(*attributes.keys.map(&:to_sym)) - lockbox_attributes.each do |key, lockbox_attribute| - attribute = key.to_s - # check read only - # users should mark both plaintext and ciphertext columns - if check_readonly && readonly_attributes.include?(attribute) && !readonly_attributes.include?(lockbox_attribute[:encrypted_attribute].to_s) - warn "[lockbox] WARNING: Mark attribute as readonly: #{lockbox_attribute[:encrypted_attribute]}" - end + # private + def self.lockbox_map_record_attributes(attributes, check_readonly: false) + return attributes unless attributes.is_a?(Hash) - message = attributes[attribute] - attributes.delete(attribute) unless lockbox_attribute[:migrating] - encrypted_attribute = lockbox_attribute[:encrypted_attribute] - ciphertext = send("generate_#{encrypted_attribute}", message) - attributes[encrypted_attribute] = ciphertext + # transform keys like Active Record + attributes = attributes.transform_keys do |key| + n = key.to_s + attribute_aliases[n] || n + end + + lockbox_attributes = self.lockbox_attributes.slice(*attributes.keys.map(&:to_sym)) + lockbox_attributes.each do |key, lockbox_attribute| + attribute = key.to_s + # check read only + # users should mark both plaintext and ciphertext columns + if check_readonly && readonly_attributes.include?(attribute) && !readonly_attributes.include?(lockbox_attribute[:encrypted_attribute].to_s) + warn "[lockbox] WARNING: Mark attribute as readonly: #{lockbox_attribute[:encrypted_attribute]}" end - attributes + message = attributes[attribute] + attributes.delete(attribute) unless lockbox_attribute[:migrating] + encrypted_attribute = lockbox_attribute[:encrypted_attribute] + ciphertext = send("generate_#{encrypted_attribute}", message) + attributes[encrypted_attribute] = ciphertext end + + attributes end end else def reload self.class.lockbox_attributes.each do |_, v| @@ -293,11 +317,16 @@ if stored_attributes.any? { |k, v| v.include?(name) } warn "[lockbox] WARNING: encrypting store accessors is not supported. Encrypt the column instead." end # warn on default attributes - if attributes_to_define_after_schema_loads.key?(name.to_s) + if ActiveRecord::VERSION::STRING.to_f >= 7.2 + # TODO improve + if pending_attribute_modifications.any? { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingDefault) && v.name == name.to_s } + warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead." + end + elsif attributes_to_define_after_schema_loads.key?(name.to_s) opt = attributes_to_define_after_schema_loads[name.to_s][1] has_default = if ActiveRecord::VERSION::MAJOR >= 7 # not ideal, since NO_DEFAULT_PROVIDED is private @@ -345,10 +374,30 @@ serialize name, Hash when :array serialize name, Array end end + elsif ActiveRecord::VERSION::STRING.to_f >= 7.2 + decorate_attributes([name]) do |attr_name, cast_type| + if cast_type.instance_of?(ActiveRecord::Type::Value) + original_type = pending_attribute_modifications.find { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingType) && v.name == original_name.to_s && !v.type.nil? }&.type + if original_type + original_type + elsif options[:migrating] + cast_type + else + ActiveRecord::Type::String.new + end + elsif cast_type.is_a?(ActiveRecord::Type::Serialized) && cast_type.subtype.instance_of?(ActiveModel::Type::Value) + # hack to set string type after serialize + # otherwise, type gets set to ActiveModel::Type::Value + # which always returns false for changed_in_place? + ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, cast_type.coder) + else + cast_type + end + end elsif !attributes_to_define_after_schema_loads.key?(name.to_s) # when migrating it's best to specify the type directly # however, we can try to use the original type if its already defined if attributes_to_define_after_schema_loads.key?(original_name.to_s) attribute name, attributes_to_define_after_schema_loads[original_name.to_s].first @@ -358,12 +407,11 @@ attribute name, ActiveRecord::Type::Value.new else attribute name, :string end else - # hack for Active Record 6.1 - # to set string type after serialize + # hack for Active Record 6.1+ to set string type after serialize # otherwise, type gets set to ActiveModel::Type::Value # which always returns false for changed_in_place? # earlier versions of Active Record take the previous code path if ActiveRecord::VERSION::STRING.to_f >= 7.0 && attributes_to_define_after_schema_loads[name.to_s].first.is_a?(Proc) attribute_type = attributes_to_define_after_schema_loads[name.to_s].first.call(nil) @@ -445,16 +493,16 @@ define_method("#{name}=") do |message| # decrypt first for dirty tracking # don't raise error if can't decrypt previous # don't try to decrypt if no decryption key given - unless options[:algorithm] == "hybrid" && options[:decryption_key].nil? - begin - send(name) - rescue Lockbox::DecryptionError - warn "[lockbox] Decrypting previous value failed" - end + begin + send(name) + rescue Lockbox::DecryptionError + warn "[lockbox] Decrypting previous value failed" + rescue ArgumentError => e + raise e if e.message != "No decryption key set" end send("lockbox_direct_#{name}=", message) # warn every time, as this should be addressed @@ -667,10 +715,11 @@ end end end def lockbox_encrypts(*attributes, **options) - ActiveSupport::Deprecation.warn("`#{__callee__}` is deprecated in favor of `has_encrypted`") + deprecator = ActiveSupport::VERSION::STRING.to_f >= 7.2 ? ActiveSupport.deprecator : ActiveSupport::Deprecation + deprecator.warn("`#{__callee__}` is deprecated in favor of `has_encrypted`") has_encrypted(*attributes, **options) end module Attached def encrypts_attached(*attributes, **options)