feat(orm): extend encryption with encryption_key_for
This commit is contained in:
1 parent
2525025d09
commit
8e5611b6e3
3 files changed
+193
No files matched your search
@@ -165,3 +165,68 @@ module Prism
|
||||
end
|
||||
end
|
||||
|
||||
# Keys are derived (PBKDF2, deliberately expensive) once per class
|
||||
# per attribute and cached - without this every single read/write of
|
||||
# an encrypted attribute would re-run PBKDF2 from scratch.
|
||||
def encryption_key_for(attribute)
|
||||
attribute = attribute.to_s
|
||||
@encryption_keys ||= {}
|
||||
@encryption_keys[attribute] ||= begin
|
||||
secret = encryption_config_for(attribute)[:key] || Encryption.primary_key
|
||||
raise Error, 'Prism::Orm::Encryption.primary_key= (or Application#enable_attribute_encryption) must be set before using `encrypts`, unless a key: is given' unless secret
|
||||
derive_encryption_key(attribute, secret)
|
||||
end
|
||||
end
|
||||
|
||||
# Same PBKDF2-once-and-cache treatment as encryption_key_for, for the
|
||||
# retired secrets a rotated column still needs to be able to decrypt.
|
||||
# Falls back to the app-wide Encryption.previous_keys unless this
|
||||
# attribute named its own rotation schedule via `previous_keys:`.
|
||||
def encryption_previous_keys_for(attribute)
|
||||
attribute = attribute.to_s
|
||||
@encryption_previous_keys ||= {}
|
||||
@encryption_previous_keys[attribute] ||= begin
|
||||
secrets = encryption_config_for(attribute)[:previous_keys] || Encryption.previous_keys
|
||||
secrets.map { |secret| derive_encryption_key(attribute, secret) }
|
||||
end
|
||||
end
|
||||
|
||||
# Lets ConditionNode translate `where(ssn: "123-45-6789")` into the
|
||||
# matching ciphertext, the same shape of problem Enum#enum_cast
|
||||
# already solves for Symbol labels. Refuses on a non-deterministic
|
||||
# column instead of silently building a condition that can never
|
||||
# match anything (a fresh random IV means encrypting the same
|
||||
# plaintext twice never produces the same ciphertext).
|
||||
#
|
||||
# Mid-rotation, a matching row may still be sitting on disk
|
||||
# encrypted under a previous key, so this returns every candidate
|
||||
# ciphertext (current key first) as an Array once previous_keys are
|
||||
# configured - ConditionNode's caller already treats an Array as an
|
||||
# IN-list (Predicates.evaluate), so a plain `where(ssn: plaintext)`
|
||||
# keeps finding not-yet-re-encrypted rows without callers needing to
|
||||
# know rotation is even happening. Returns a bare String (as before)
|
||||
# when there is nothing to rotate through, so the common case never
|
||||
# pays for the Array wrapping or an IN scan of one.
|
||||
def encrypt_for_query(attribute, value)
|
||||
raise Error, "#{attribute} is not deterministic, cannot query #{name} by its plaintext value" unless encryption_config_for(attribute)[:deterministic]
|
||||
return nil if value.nil?
|
||||
candidates = [Encryption.encrypt(encryption_key_for(attribute), value, deterministic: true)]
|
||||
encryption_previous_keys_for(attribute).each do |previous_key|
|
||||
candidates << Encryption.encrypt(previous_key, value, deterministic: true)
|
||||
end
|
||||
candidates.size == 1 ? candidates.first : candidates
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encryption_config_for(attribute)
|
||||
encrypted_attributes.fetch(attribute.to_s) { raise Error, "#{attribute} is not an encrypted attribute on #{name}" }
|
||||
end
|
||||
|
||||
def derive_encryption_key(attribute, secret)
|
||||
MessageEncryptor.derive_key(secret, salt: "prism-orm-encryption/#{table_name}/#{attribute}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
module Prism
|
||||
module Orm
|
||||
# ActiveRecord::Enum-lite: maps a column's raw stored value (usually an
|
||||
# Integer, but any comparable value works) to a String label, Rails'
|
||||
# dual call syntax supported both ways:
|
||||
#
|
||||
# enum :status, { draft: 0, published: 1, archived: 2 }
|
||||
# enum status: { draft: 0, published: 1 }, role: { user: 0, admin: 1 }
|
||||
#
|
||||
# Declaring `enum :status, {...}` on a model defines, per mapping key:
|
||||
# - the attribute reader/writer (`post.status` => "published" String,
|
||||
# `post.status = :published`/`"published"`/the raw Integer all set
|
||||
# the same underlying column)
|
||||
# - a predicate (`post.published?`)
|
||||
# - a persisting bang setter (`post.published!`, sets then `save!`)
|
||||
# - a class-level scope built on the existing `scope` DSL
|
||||
# (`Post.published` => `Post.where(status: 1)`, so it composes with
|
||||
# default_scope/where/order/includes like any hand-written scope)
|
||||
# - the reverse mapping accessor (`Post.statuses` =>
|
||||
# {"draft"=>0, "published"=>1, "archived"=>2})
|
||||
#
|
||||
# Assigning a value outside the mapping raises ArgumentError immediately
|
||||
# (matches Rails - a typo'd enum value fails loudly at the call site,
|
||||
# not as a mysterious later validation error or silently stored garbage).
|
||||
module Enum
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Same superclass-plus-own fallback as Callbacks#callbacks_for and
|
||||
# Serialization#json_hidden_attributes - a plain `@ivar ||= {}` per
|
||||
# class would drop a base model's enums in every subclass.
|
||||
def enum_definitions
|
||||
inherited = superclass.respond_to?(:enum_definitions) ? superclass.enum_definitions : {}
|
||||
inherited.merge(own_enum_definitions)
|
||||
end
|
||||
|
||||
def own_enum_definitions
|
||||
@own_enum_definitions ||= {}
|
||||
end
|
||||
|
||||
def enum(name = nil, mapping = nil, prefix: nil, suffix: nil, **columns)
|
||||
define_enum(name, mapping, prefix: prefix, suffix: suffix) if name
|
||||
columns.each { |column, column_mapping| define_enum(column, column_mapping, prefix: prefix, suffix: suffix) }
|
||||
end
|
||||
|
||||
# Translates a value assigned to an enum attribute (Symbol, String,
|
||||
# or the raw stored value itself) into the raw value to persist.
|
||||
def enum_cast(attribute, value)
|
||||
mapping = enum_definitions.fetch(attribute.to_s) { raise ArgumentError, "unknown enum #{attribute}" }
|
||||
return nil if value.nil?
|
||||
key = value.is_a?(Symbol) ? value.to_s : value
|
||||
return mapping[key] if key.is_a?(String) && mapping.key?(key)
|
||||
return value if mapping.value?(value)
|
||||
raise ArgumentError, "#{value.inspect} is not a valid #{attribute}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def define_enum(name, mapping, prefix:, suffix:)
|
||||
name = name.to_s
|
||||
mapping = mapping.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
|
||||
raise ArgumentError, "duplicate raw value in enum #{name}" if mapping.values.uniq.length != mapping.length
|
||||
|
||||
own_enum_definitions[name] = mapping
|
||||
reverse = mapping.invert
|
||||
|
||||
define_method(name) { reverse[self[name]] }
|
||||
define_method("#{name}=") { |value| self[name] = self.class.enum_cast(name, value) }
|
||||
|
||||
mapping.each_key do |key|
|
||||
method_base = enum_method_base(name, key, prefix, suffix)
|
||||
define_method("#{method_base}?") { self[name] == mapping[key] }
|
||||
define_method("#{method_base}!") do
|
||||
send("#{name}=", key)
|
||||
save!
|
||||
end
|
||||
scope(method_base, -> { where(name => mapping[key]) })
|
||||
end
|
||||
|
||||
define_singleton_method(Inflector.pluralize(name)) { mapping.dup }
|
||||
end
|
||||
|
||||
def enum_method_base(name, key, prefix, suffix)
|
||||
base = key
|
||||
base = "#{prefix == true ? name : prefix}_#{base}" if prefix
|
||||
base = "#{base}_#{suffix == true ? name : suffix}" if suffix
|
||||
base
|
||||
end
|
||||
end
|
||||
|
||||
# Enum columns serialize to their String label (Rails behavior), not
|
||||
# the raw stored value - relies on Enum being included after
|
||||
# Serialization in Model, so it sits closer to Model in the ancestor
|
||||
# chain and `super` reaches Serialization#serializable_hash.
|
||||
def serializable_hash(**options)
|
||||
hash = super
|
||||
self.class.enum_definitions.each do |column, mapping|
|
||||
next unless hash.key?(column)
|
||||
hash[column] = mapping.invert[self[column]]
|
||||
end
|
||||
hash
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -38,6 +38,27 @@ module Prism
|
||||
def full_messages
|
||||
@messages.flat_map { |attribute, messages| messages.map { |message| "#{attribute} #{message}" } }
|
||||
end
|
||||
|
||||
# JSON:API error object array (https://jsonapi.org/format/#error-objects):
|
||||
# one entry per failed validation, `source.pointer` naming the exact
|
||||
# attribute so a client can highlight the right form field without
|
||||
# parsing `detail` text. `title` is the raw message (stable across
|
||||
# occurrences, e.g. "can't be blank"); `detail` reuses the exact
|
||||
# `"#{attribute} #{message}"` wording `full_messages` produces, so the
|
||||
# two representations never drift apart.
|
||||
def to_jsonapi(status: 422)
|
||||
status_string = status.to_s
|
||||
@messages.flat_map do |attribute, messages|
|
||||
messages.map do |message|
|
||||
{
|
||||
status: status_string,
|
||||
source: { pointer: "/data/attributes/#{attribute}" },
|
||||
title: message,
|
||||
detail: "#{attribute} #{message}"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user