From 8e5611b6e3d97dc211707a0e49f3cf5faf855166 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 13:08:00 +0200 Subject: [PATCH] feat(orm): extend encryption with encryption_key_for --- lib/prism/orm/encryption.rb | 65 ++++++++++++++++++++++ lib/prism/orm/enum.rb | 107 ++++++++++++++++++++++++++++++++++++ lib/prism/orm/errors.rb | 21 +++++++ 3 files changed, 193 insertions(+) create mode 100644 lib/prism/orm/enum.rb diff --git a/lib/prism/orm/encryption.rb b/lib/prism/orm/encryption.rb index b0127b4..7dac036 100644 --- a/lib/prism/orm/encryption.rb +++ b/lib/prism/orm/encryption.rb @@ -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 diff --git a/lib/prism/orm/enum.rb b/lib/prism/orm/enum.rb new file mode 100644 index 0000000..e5b2f00 --- /dev/null +++ b/lib/prism/orm/enum.rb @@ -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 diff --git a/lib/prism/orm/errors.rb b/lib/prism/orm/errors.rb index eb78c6d..41a5514 100644 --- a/lib/prism/orm/errors.rb +++ b/lib/prism/orm/errors.rb @@ -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