feat(orm): extend encryption with encryption_key_for

This commit is contained in:
dualfroz committed 2026-09-08 13:08:00 +02:00
1 parent 2525025d09
commit 8e5611b6e3
3 files changed
+193

No files matched your search

+65
View File
@@ -165,3 +165,68 @@ module Prism
end end
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
+107
View File
@@ -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
+21
View File
@@ -38,6 +38,27 @@ module Prism
def full_messages def full_messages
@messages.flat_map { |attribute, messages| messages.map { |message| "#{attribute} #{message}" } } @messages.flat_map { |attribute, messages| messages.map { |message| "#{attribute} #{message}" } }
end 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 end
end end