# frozen_string_literal: true module ActiveRecord module Encryption # This is the concern mixed in Active Record models to make them encryptable. It adds the +encrypts+ # attribute declaration, as well as the API to encrypt and decrypt records. module EncryptableRecord extend ActiveSupport::Concern included do class_attribute :encrypted_attributes validate :cant_modify_encrypted_attributes_when_frozen, if: -> { has_encrypted_attributes? && ActiveRecord::Encryption.context.frozen_encryption? } end class_methods do # Encrypts the +name+ attribute. # # === Options # # * :key_provider - A key provider to provide encryption and decryption keys. Defaults to # +ActiveRecord::Encryption.key_provider+. # * :key - A password to derive the key from. It's a shorthand for a +:key_provider+ that # serves derivated keys. Both options can't be used at the same time. # * :deterministic - By default, encryption is not deterministic. It will use a random # initialization vector for each encryption operation. This means that encrypting the same content # with the same key twice will generate different ciphertexts. When set to +true+, it will generate the # initialization vector based on the encrypted content. This means that the same content will generate # the same ciphertexts. This enables querying encrypted text with Active Record. Deterministic encryption # will use the oldest encryption scheme to encrypt new data by default. You can change this by setting # +deterministic: { fixed: false }+. That will make it use the newest encryption scheme for encrypting new # data. # * :downcase - When true, it converts the encrypted content to downcase automatically. This allows to # effectively ignore case when querying data. Notice that the case is lost. Use +:ignore_case+ if you are interested # in preserving it. # * :ignore_case - When true, it behaves like +:downcase+ but, it also preserves the original case in a specially # designated column +original_+. When reading the encrypted content, the version with the original case is # served. But you can still execute queries that will ignore the case. This option can only be used when +:deterministic+ # is true. # * :context_properties - Additional properties that will override +Context+ settings when this attribute is # encrypted and decrypted. E.g: +encryptor:+, +cipher:+, +message_serializer:+, etc. # * :previous - List of previous encryption schemes. When provided, they will be used in order when trying to read # the attribute. Each entry of the list can contain the properties supported by #encrypts. Also, when deterministic # encryption is used, they will be used to generate additional ciphertexts to check in the queries. def encrypts(*names, key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, previous: [], **context_properties) self.encrypted_attributes ||= Set.new # not using :default because the instance would be shared across classes scheme = scheme_for key_provider: key_provider, key: key, deterministic: deterministic, downcase: downcase, \ ignore_case: ignore_case, previous: previous, **context_properties names.each do |name| encrypt_attribute name, scheme end end # Returns the list of deterministic encryptable attributes in the model class. def deterministic_encrypted_attributes @deterministic_encrypted_attributes ||= encrypted_attributes&.find_all do |attribute_name| type_for_attribute(attribute_name).deterministic? end end # Given a attribute name, it returns the name of the source attribute when it's a preserved one. def source_attribute_from_preserved_attribute(attribute_name) attribute_name.to_s.sub(ORIGINAL_ATTRIBUTE_PREFIX, "") if attribute_name.start_with?(ORIGINAL_ATTRIBUTE_PREFIX) end private def scheme_for(key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, previous: [], **context_properties) ActiveRecord::Encryption::Scheme.new(key_provider: key_provider, key: key, deterministic: deterministic, downcase: downcase, ignore_case: ignore_case, **context_properties).tap do |scheme| scheme.previous_schemes = global_previous_schemes_for(scheme) + Array.wrap(previous).collect { |scheme_config| ActiveRecord::Encryption::Scheme.new(**scheme_config) } end end def global_previous_schemes_for(scheme) ActiveRecord::Encryption.config.previous_schemes.collect do |previous_scheme| scheme.merge(previous_scheme) end end def encrypt_attribute(name, attribute_scheme) encrypted_attributes << name.to_sym attribute name, default: -> { columns_hash[name.to_s]&.default } do |cast_type| ActiveRecord::Encryption::EncryptedAttributeType.new scheme: attribute_scheme, cast_type: cast_type end preserve_original_encrypted(name) if attribute_scheme.ignore_case? ActiveRecord::Encryption.encrypted_attribute_was_declared(self, name) end def preserve_original_encrypted(name) original_attribute_name = "#{ORIGINAL_ATTRIBUTE_PREFIX}#{name}".to_sym if !ActiveRecord::Encryption.config.support_unencrypted_data && !column_names.include?(original_attribute_name.to_s) raise Errors::Configuration, "To use :ignore_case for '#{name}' you must create an additional column named '#{original_attribute_name}'" end encrypts original_attribute_name override_accessors_to_preserve_original name, original_attribute_name end def override_accessors_to_preserve_original(name, original_attribute_name) include(Module.new do define_method name do if ((value = super()) && encrypted_attribute?(name)) || !ActiveRecord::Encryption.config.support_unencrypted_data send(original_attribute_name) else value end end define_method "#{name}=" do |value| self.send "#{original_attribute_name}=", value super(value) end end) end def load_schema! super add_length_validation_for_encrypted_columns if ActiveRecord::Encryption.config.validate_column_size end def add_length_validation_for_encrypted_columns encrypted_attributes&.each do |attribute_name| validate_column_size attribute_name end end def validate_column_size(attribute_name) if limit = columns_hash[attribute_name.to_s]&.limit validates_length_of attribute_name, maximum: limit end end end # Returns whether a given attribute is encrypted or not. def encrypted_attribute?(attribute_name) ActiveRecord::Encryption.encryptor.encrypted? ciphertext_for(attribute_name) end # Returns the ciphertext for +attribute_name+. def ciphertext_for(attribute_name) read_attribute_before_type_cast(attribute_name) end # Encrypts all the encryptable attributes and saves the changes. def encrypt encrypt_attributes if has_encrypted_attributes? end # Decrypts all the encryptable attributes and saves the changes. def decrypt decrypt_attributes if has_encrypted_attributes? end private ORIGINAL_ATTRIBUTE_PREFIX = "original_" def encrypt_attributes validate_encryption_allowed update_columns build_encrypt_attribute_assignments end def decrypt_attributes validate_encryption_allowed decrypt_attribute_assignments = build_decrypt_attribute_assignments ActiveRecord::Encryption.without_encryption { update_columns decrypt_attribute_assignments } end def validate_encryption_allowed raise ActiveRecord::Encryption::Errors::Configuration, "can't be modified because it is encrypted" if ActiveRecord::Encryption.context.frozen_encryption? end def has_encrypted_attributes? self.class.encrypted_attributes.present? end def build_encrypt_attribute_assignments Array(self.class.encrypted_attributes).index_with do |attribute_name| self[attribute_name] end end def build_decrypt_attribute_assignments Array(self.class.encrypted_attributes).to_h do |attribute_name| type = type_for_attribute(attribute_name) encrypted_value = ciphertext_for(attribute_name) new_value = type.deserialize(encrypted_value) [attribute_name, new_value] end end def cant_modify_encrypted_attributes_when_frozen self.class&.encrypted_attributes.each do |attribute| errors.add(attribute.to_sym, "can't be modified because it is encrypted") if changed_attributes.include?(attribute) end end end end end