From 2abd2064db657bbac9c54ee6015640cea3698ba2 Mon Sep 17 00:00:00 2001 From: Nils Sommer Date: Wed, 16 Sep 2026 14:38:59 +0200 Subject: [PATCH 1/5] Add NxtSupport::EncryptedJsonAttrs Upstreamed from application-service (IX-3471), where IBANs had to be encrypted in three shapes: a plain string column, a raw partner payload in a jsonb column and a single key inside another jsonb column. Rails' encrypts covers the first shape only, so the service grew a small DSL on top of Active Record Encryption for the other two. encrypts_json_entirely wraps a whole JSON column in encrypts while still reading it back as a HashWithIndifferentAccess. encrypts_json_attrs encrypts only the leaves at the given JSONPath expressions, so the remaining keys stay queryable, and where_encrypted_json finds rows by the deterministic ciphertext of such a leaf through jsonb_path_query. Both build on NxtSupport::IndifferentJsonType, which also decodes the double encoded rows that indifferently_accessible_json_attrs writes to json columns, so services can switch a column over without a data migration. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 + Gemfile.lock | 6 +- README.md | 67 ++++++ lib/nxt_support.rb | 1 + lib/nxt_support/models.rb | 1 + .../models/encrypted_json_attrs.rb | 41 ++++ lib/nxt_support/types.rb | 2 + .../types/encrypted_json_paths_type.rb | 61 +++++ .../types/indifferent_json_type.rb | 32 +++ lib/nxt_support/version.rb | 2 +- nxt_support.gemspec | 1 + spec/models/encrypted_json_attrs_spec.rb | 217 ++++++++++++++++++ spec/spec_helper.rb | 8 + spec/types/encrypted_json_paths_type_spec.rb | 171 ++++++++++++++ spec/types/indifferent_json_type_spec.rb | 64 ++++++ 15 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 lib/nxt_support/models/encrypted_json_attrs.rb create mode 100644 lib/nxt_support/types.rb create mode 100644 lib/nxt_support/types/encrypted_json_paths_type.rb create mode 100644 lib/nxt_support/types/indifferent_json_type.rb create mode 100644 spec/models/encrypted_json_attrs_spec.rb create mode 100644 spec/types/encrypted_json_paths_type_spec.rb create mode 100644 spec/types/indifferent_json_type_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a32b912..cdc96d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# v0.8.0 2026-09-16 +- Added `NxtSupport::EncryptedJsonAttrs`, `NxtSupport::IndifferentJsonType` and `NxtSupport::EncryptedJsonPathsType` + # v0.7.0 2026-09-16 - Added `NxtSupport::Iban` diff --git a/Gemfile.lock b/Gemfile.lock index d9a345d..f17347b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,10 @@ PATH remote: . specs: - nxt_support (0.7.0) + nxt_support (0.8.0) activerecord activesupport + jsonpath nxt_init nxt_registry @@ -40,12 +41,15 @@ GEM concurrent-ruby (~> 1.0) io-console (0.8.2) json (2.21.1) + jsonpath (1.1.5) + multi_json logger (1.7.0) method_source (1.1.0) mini_portile2 (2.8.9) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) + multi_json (1.21.2) nxt_init (0.1.5) activesupport nxt_registry (0.3.10) diff --git a/README.md b/README.md index dbcd9c7..68bc8a4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,73 @@ class MyModel < ApplicationRecord end ``` +#### NxtSupport::EncryptedJsonAttrs + +This mixin builds on [Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html) to encrypt JSON columns, either as a whole or only selected fields inside them. Both variants read the column back as `ActiveSupport::HashWithIndifferentAccess`. + +Your application has to configure Active Record Encryption first: + +```ruby +config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') +config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') +config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') +``` + +`encrypts_json_entirely` encrypts the whole column. It is plain `encrypts` on top of `NxtSupport::IndifferentJsonType`, so all `encrypts` options (`deterministic:`, `key_provider:`, ...) are accepted. Nothing inside the column is queryable afterwards, and Rails recommends a `text` column for encrypted attributes. + +```ruby +class Application < ApplicationRecord + include NxtSupport::EncryptedJsonAttrs + + encrypts_json_entirely :data +end + +application = Application.create!(data: { payment: { bank_data: { iban: 'DE89370400440532013000' } } }) +application.data.dig(:payment, :bank_data, :iban) # => "DE89370400440532013000" +``` + +`encrypts_json_attrs` encrypts only the leaves at the given paths and leaves the rest of the JSON as it is, so the other keys stay queryable in SQL. Paths are [JSONPath](https://goessner.net/articles/JsonPath/) expressions evaluated with the [`jsonpath`](https://github.com/joshbuddy/jsonpath) gem. A bare key path such as `payment.bank_data.iban` is shorthand for `$.payment.bank_data.iban`. Only string leaves are encrypted, an already encrypted leaf is left alone, and plaintext leaves are read transparently, so existing rows keep working until they are re-saved. + +```ruby +class Application::PaymentMethod < ApplicationRecord + include NxtSupport::EncryptedJsonAttrs + + encrypts_json_attrs column: :data, paths: %w[iban], deterministic: true +end + +payment_method = Application::PaymentMethod.create!(data: { iban: 'DE89370400440532013000', account_holder: 'John' }) +payment_method.data[:iban] # => "DE89370400440532013000" +``` + +More path examples: + +```ruby +encrypts_json_attrs column: :data, paths: %w[payment.bank_data.iban] +encrypts_json_attrs column: :data, paths: %w[$.accounts[*].iban] +encrypts_json_attrs column: :data, paths: %w[$..iban] +encrypts_json_attrs column: :data, paths: ["$.accounts[?(@.type == 'sepa')].iban"] +``` + +With `deterministic: true` the ciphertext is stable, so records can be found by the value of an encrypted field. `where_encrypted_json` resolves the same path with `jsonb_path_query` and therefore requires a PostgreSQL `jsonb` column. `encrypted_json_value_for` returns the ciphertext to use in your own queries. + +```ruby +Application::PaymentMethod.where_encrypted_json(:data, path: 'iban', value: 'DE89370400440532013000') +Application::PaymentMethod.where_encrypted_json(:data, path: '$..iban', value: 'DE89370400440532013000') +Application::PaymentMethod.encrypted_json_value_for(:data, 'DE89370400440532013000') # => "{\"p\":\"...\",\"h\":{...}}" +``` + +Both variants default the attribute to an empty `HashWithIndifferentAccess`, which can be changed with `default:`. + +##### Migrating from `indifferently_accessible_json_attrs` + +`indifferently_accessible_json_attrs` on a `json` or `jsonb` column stores the value double encoded, as a JSON string literal that contains JSON. `NxtSupport::IndifferentJsonType` (and with it both encryption variants) decodes such rows transparently, so a column can be switched over without a data migration. Re-saving the records encrypts them; with `config.active_record.encryption.support_unencrypted_data = true` unencrypted rows stay readable in the meantime when using `encrypts_json_entirely`. + +`NxtSupport::IndifferentJsonType` can also be used on its own as a drop in replacement for `indifferently_accessible_json_attrs`: + +```ruby +attribute :data, NxtSupport::IndifferentJsonType.new +``` + #### NxtSupport::SafelyFindOrCreateable The `NxtSupport::Models::SafelyFindOrCreateable` concern is aimed at ActiveRecord models with a uniqueness database constraint. If you use `find_or_create_by` from ActiveRecord, it can happen that the `find_by` call returns `nil` (because no record for the given conditions exists), but in the small timeframe between the `find_by` and the `create` call, another thread inserts a record, so that the `create` call raises an error. diff --git a/lib/nxt_support.rb b/lib/nxt_support.rb index 419fa9f..1ca2e16 100644 --- a/lib/nxt_support.rb +++ b/lib/nxt_support.rb @@ -3,6 +3,7 @@ require "nxt_support/version" require "nxt_support/preprocessor" +require "nxt_support/types" require "nxt_support/models" require "nxt_support/serializers" require "nxt_support/util" diff --git a/lib/nxt_support/models.rb b/lib/nxt_support/models.rb index ae916b3..3f63c75 100644 --- a/lib/nxt_support/models.rb +++ b/lib/nxt_support/models.rb @@ -5,3 +5,4 @@ require "nxt_support/models/assignable_values" require "nxt_support/models/preprocess_attributes" require "nxt_support/models/duration_attribute_accessor" +require "nxt_support/models/encrypted_json_attrs" diff --git a/lib/nxt_support/models/encrypted_json_attrs.rb b/lib/nxt_support/models/encrypted_json_attrs.rb new file mode 100644 index 0000000..a77b9c2 --- /dev/null +++ b/lib/nxt_support/models/encrypted_json_attrs.rb @@ -0,0 +1,41 @@ +module NxtSupport + module EncryptedJsonAttrs + extend ActiveSupport::Concern + + class_methods do + def encrypts_json_entirely(*attrs, default: -> { {}.with_indifferent_access }, **encryption_options) + attrs.each do |attr| + attribute attr, IndifferentJsonType.new, default: default + encrypts attr, **encryption_options + end + end + + def encrypts_json_attrs(column:, paths:, deterministic: false, default: -> { {}.with_indifferent_access }) + attribute column, EncryptedJsonPathsType.new(*paths, deterministic: deterministic), default: default + end + + def encrypted_json_value_for(attr, value) + type_for_attribute(attr).encrypt_for_query(value) + end + + def where_encrypted_json(attr, path:, value:) + column = "#{quoted_table_name}.#{connection.quote_column_name(attr)}" + matches = sanitize_sql_array( + [ + "SELECT 1 FROM jsonb_path_query(#{column}, ?) AS match WHERE match #>> '{}' = ?", + sql_json_path(path), + encrypted_json_value_for(attr, value) + ] + ) + + where("EXISTS (#{matches})") + end + + private + + def sql_json_path(path) + EncryptedJsonPathsType.normalize_path(path).gsub('..', '.**.') + end + end + end +end diff --git a/lib/nxt_support/types.rb b/lib/nxt_support/types.rb new file mode 100644 index 0000000..d914ca2 --- /dev/null +++ b/lib/nxt_support/types.rb @@ -0,0 +1,2 @@ +require "nxt_support/types/indifferent_json_type" +require "nxt_support/types/encrypted_json_paths_type" diff --git a/lib/nxt_support/types/encrypted_json_paths_type.rb b/lib/nxt_support/types/encrypted_json_paths_type.rb new file mode 100644 index 0000000..2c6c478 --- /dev/null +++ b/lib/nxt_support/types/encrypted_json_paths_type.rb @@ -0,0 +1,61 @@ +require 'jsonpath' + +module NxtSupport + class EncryptedJsonPathsType < IndifferentJsonType + attr_reader :paths, :scheme + + def self.normalize_path(path) + path = path.to_s + path.start_with?('$') ? path : "$.#{path}" + end + + def initialize(*paths, deterministic: false) + @paths = paths.map { |path| self.class.normalize_path(path) } + @scheme = ActiveRecord::Encryption::Scheme.new(deterministic: deterministic) + super() + end + + def deserialize(value) + map_leaves(super) { |leaf| decrypt(leaf) } + end + + def serialize(value) + super(map_leaves(indifferent(value)) { |leaf| encrypt(leaf) }) + end + + def encrypt_for_query(value) + raise ArgumentError, 'querying encrypted fields requires deterministic: true' unless scheme.deterministic? + + encrypt(value) + end + + private + + def map_leaves(object, &block) + return object unless object.is_a?(Hash) || object.is_a?(Array) + + paths.reduce(object) { |result, path| JsonPath.for(result).gsub(path, &block).to_hash } + end + + def encrypt(value) + return value unless value.is_a?(::String) + return value if encrypted?(value) + + encryptor.encrypt(value, key_provider: scheme.key_provider, cipher_options: { deterministic: scheme.deterministic? }) + end + + def decrypt(value) + return value unless encrypted?(value) + + encryptor.decrypt(value, key_provider: scheme.key_provider) + end + + def encrypted?(value) + value.is_a?(::String) && encryptor.encrypted?(value) + end + + def encryptor + ActiveRecord::Encryption.encryptor + end + end +end diff --git a/lib/nxt_support/types/indifferent_json_type.rb b/lib/nxt_support/types/indifferent_json_type.rb new file mode 100644 index 0000000..c8a1403 --- /dev/null +++ b/lib/nxt_support/types/indifferent_json_type.rb @@ -0,0 +1,32 @@ +require 'active_record' + +module NxtSupport + class IndifferentJsonType < ActiveRecord::Type::Json + def deserialize(value) + indifferent(decode_legacy_double_encoding(super)) + end + + def cast(value) + indifferent(value) + end + + private + + def decode_legacy_double_encoding(value) + return value unless value.is_a?(::String) && value.start_with?('{', '[') + + ActiveSupport::JSON.decode(value) + rescue JSON::ParserError + value + end + + def indifferent(value) + case value + when Hash then value.with_indifferent_access + when Array then value.map { |element| indifferent(element) } + when nil then nil + else raise ArgumentError, "Cant deserialize '#{value}'" + end + end + end +end diff --git a/lib/nxt_support/version.rb b/lib/nxt_support/version.rb index a798197..5f0a5f0 100644 --- a/lib/nxt_support/version.rb +++ b/lib/nxt_support/version.rb @@ -1,3 +1,3 @@ module NxtSupport - VERSION = "0.7.0".freeze + VERSION = "0.8.0".freeze end diff --git a/nxt_support.gemspec b/nxt_support.gemspec index b030caa..050a262 100644 --- a/nxt_support.gemspec +++ b/nxt_support.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| spec.add_dependency "activerecord" spec.add_dependency "activesupport" + spec.add_dependency "jsonpath" spec.add_dependency "nxt_init" spec.add_dependency "nxt_registry" spec.add_development_dependency "bundler" diff --git a/spec/models/encrypted_json_attrs_spec.rb b/spec/models/encrypted_json_attrs_spec.rb new file mode 100644 index 0000000..14b511c --- /dev/null +++ b/spec/models/encrypted_json_attrs_spec.rb @@ -0,0 +1,217 @@ +RSpec.describe NxtSupport::EncryptedJsonAttrs do + let(:iban) { 'DE89370400440532013000' } + let(:encryptor) { ActiveRecord::Encryption.encryptor } + let(:connection) { ActiveRecord::Base.connection } + + let!(:db_schema) do + ActiveRecord::Schema.define do + self.verbose = false + + create_table :encrypted_documents, force: true do |t| + t.text :data + end + + create_table :encrypted_payment_methods, force: true do |t| + t.json :data + end + end + end + + def raw_column(sql, id) + connection.select_value(ActiveRecord::Base.sanitize_sql_array([sql, id])) + end + + def update_raw_column(table, value, id) + connection.execute(ActiveRecord::Base.sanitize_sql_array(["UPDATE #{table} SET data = ? WHERE id = ?", value, id])) + end + + describe '.encrypts_json_entirely' do + let(:document_class) do + Class.new(ActiveRecord::Base) do + include NxtSupport::EncryptedJsonAttrs + + self.table_name = 'encrypted_documents' + + encrypts_json_entirely :data + end + end + + let(:document) { document_class.create!(data: { payment: { bank_data: { iban: iban } } }) } + + it 'stores the whole column as an encrypted envelope' do + raw = raw_column('SELECT data FROM encrypted_documents WHERE id = ?', document.id) + + expect(encryptor.encrypted?(raw)).to be(true) + expect(raw).not_to include(iban) + end + + it 'reads the column back as an indifferently accessible hash' do + data = document.reload.data + + expect(data).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(data.dig(:payment, :bank_data, :iban)).to eq(iban) + expect(data.dig('payment', 'bank_data', 'iban')).to eq(iban) + end + + it 'persists in place mutations' do + document.data.deep_merge!(payment: { bank_data: { bic: 'COBADEFF' } }) + document.save! + + expect(document.reload.data.dig(:payment, :bank_data, :bic)).to eq('COBADEFF') + end + + it 'does not mark an unchanged record as changed' do + expect(document.reload).not_to be_changed + end + + it 'defaults to an empty indifferently accessible hash' do + expect(document_class.new.data).to eq({}) + expect(document_class.new.data).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'passes encryption options on to encrypts' do + deterministic_class = Class.new(ActiveRecord::Base) do + include NxtSupport::EncryptedJsonAttrs + + self.table_name = 'encrypted_documents' + + encrypts_json_entirely :data, deterministic: true + end + + expect(deterministic_class.type_for_attribute('data')).to be_deterministic + end + + context 'when unencrypted data is supported' do + around do |example| + ActiveRecord::Encryption.config.support_unencrypted_data = true + example.run + ensure + ActiveRecord::Encryption.config.support_unencrypted_data = false + end + + it 'reads legacy plaintext rows' do + update_raw_column('encrypted_documents', { iban: iban }.to_json, document.id) + + expect(document.reload.data[:iban]).to eq(iban) + end + + it 'reads legacy double encoded rows written by indifferently_accessible_json_attrs' do + update_raw_column('encrypted_documents', { iban: iban }.to_json.to_json, document.id) + + expect(document.reload.data[:iban]).to eq(iban) + end + end + end + + describe '.encrypts_json_attrs' do + let(:payment_method_class) do + Class.new(ActiveRecord::Base) do + include NxtSupport::EncryptedJsonAttrs + + self.table_name = 'encrypted_payment_methods' + + encrypts_json_attrs column: :data, paths: %w[iban], deterministic: true + end + end + + let(:payment_method) { payment_method_class.create!(data: { iban: iban, account_holder_first_name: 'John' }) } + + it 'stores only the configured field encrypted' do + raw_iban = raw_column("SELECT json_extract(data, '$.iban') FROM encrypted_payment_methods WHERE id = ?", payment_method.id) + raw_first_name = raw_column( + "SELECT json_extract(data, '$.account_holder_first_name') FROM encrypted_payment_methods WHERE id = ?", payment_method.id + ) + + expect(encryptor.encrypted?(raw_iban)).to be(true) + expect(raw_iban).not_to include(iban) + expect(raw_first_name).to eq('John') + end + + it 'reads the field back decrypted' do + data = payment_method.reload.data + + expect(data).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(data[:iban]).to eq(iban) + expect(data['account_holder_first_name']).to eq('John') + end + + it 'persists in place mutations' do + payment_method.data[:iban] = 'DE02120300000000202051' + payment_method.save! + + expect(payment_method.reload.data[:iban]).to eq('DE02120300000000202051') + end + + it 'does not mark an unchanged record as changed' do + expect(payment_method.reload).not_to be_changed + end + + it 'defaults to an empty indifferently accessible hash' do + expect(payment_method_class.new.data).to eq({}) + expect(payment_method_class.new.data).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'reads legacy plaintext rows' do + update_raw_column('encrypted_payment_methods', { iban: iban }.to_json, payment_method.id) + + expect(payment_method.reload.data[:iban]).to eq(iban) + end + + it 'reads legacy double encoded rows written by indifferently_accessible_json_attrs' do + update_raw_column('encrypted_payment_methods', { iban: iban }.to_json.to_json, payment_method.id) + + expect(payment_method.reload.data[:iban]).to eq(iban) + end + + describe '.encrypted_json_value_for' do + it 'returns the ciphertext that is stored for the field' do + raw_iban = raw_column("SELECT json_extract(data, '$.iban') FROM encrypted_payment_methods WHERE id = ?", payment_method.id) + + expect(payment_method_class.encrypted_json_value_for(:data, iban)).to eq(raw_iban) + end + end + + describe '.where_encrypted_json' do + subject(:sql) { payment_method_class.where_encrypted_json(:data, path: path, value: iban).to_sql } + + let(:path) { 'iban' } + + it 'matches the deterministic ciphertext at the json path' do + expect(sql).to include(%(jsonb_path_query("encrypted_payment_methods"."data", '$.iban'))) + expect(sql).to include("#>> '{}' = '#{payment_method_class.encrypted_json_value_for(:data, iban)}'") + end + + context 'with a json path' do + let(:path) { '$.accounts[*].iban' } + + it 'passes it through' do + expect(sql).to include("'$.accounts[*].iban'") + end + end + + context 'with a recursive descent path' do + let(:path) { '$..iban' } + + it 'translates it into the PostgreSQL jsonpath syntax' do + expect(sql).to include("'$.**.iban'") + end + end + + context 'when the field is not deterministic' do + let(:payment_method_class) do + Class.new(ActiveRecord::Base) do + include NxtSupport::EncryptedJsonAttrs + + self.table_name = 'encrypted_payment_methods' + + encrypts_json_attrs column: :data, paths: %w[iban] + end + end + + it 'raises' do + expect { sql }.to raise_error(ArgumentError, 'querying encrypted fields requires deterministic: true') + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9c7237c..4087321 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,14 @@ database: ':memory:' ) +ActiveRecord::Encryption.configure( + primary_key: 'nxt-support-primary-key', + deterministic_key: 'nxt-support-deterministic-key', + key_derivation_salt: 'nxt-support-key-derivation-salt', + hash_digest_class: OpenSSL::Digest::SHA256, + support_sha1_for_non_deterministic_encryption: false +) + RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest diff --git a/spec/types/encrypted_json_paths_type_spec.rb b/spec/types/encrypted_json_paths_type_spec.rb new file mode 100644 index 0000000..e6e8639 --- /dev/null +++ b/spec/types/encrypted_json_paths_type_spec.rb @@ -0,0 +1,171 @@ +RSpec.describe NxtSupport::EncryptedJsonPathsType do + subject(:type) { described_class.new(*paths, deterministic: deterministic) } + + let(:paths) { %w[iban] } + let(:deterministic) { false } + let(:iban) { 'DE89370400440532013000' } + let(:encryptor) { ActiveRecord::Encryption.encryptor } + + def stored_leaf(serialized, *keys) + JSON.parse(serialized).dig(*keys) + end + + describe '#serialize' do + it 'encrypts the configured leaf and leaves the rest untouched' do + serialized = type.serialize({ iban: iban, account_holder_first_name: 'John' }) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + expect(stored_leaf(serialized, 'iban')).not_to include(iban) + expect(stored_leaf(serialized, 'account_holder_first_name')).to eq('John') + end + + it 'encrypts leaves given with string keys' do + serialized = type.serialize({ 'iban' => iban }) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + end + + it 'encrypts leaves given with symbol keys' do + serialized = type.serialize({ iban: iban }) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + end + + it 'does not mutate the given value' do + value = { iban: iban }.with_indifferent_access + type.serialize(value) + + expect(value[:iban]).to eq(iban) + end + + it 'does not touch a missing path' do + expect(type.serialize({ other: 'value' })).to eq('{"other":"value"}') + end + + it 'does not encrypt non string leaves' do + expect(type.serialize({ iban: 123 })).to eq('{"iban":123}') + end + + it 'does not encrypt an already encrypted leaf twice' do + once = stored_leaf(type.serialize({ iban: iban }), 'iban') + twice = stored_leaf(type.serialize({ iban: once }), 'iban') + + expect(twice).to eq(once) + end + + it 'returns nil for nil' do + expect(type.serialize(nil)).to be_nil + end + + context 'with nested and array paths' do + let(:paths) { %w[payment.bank_data.iban $.accounts[*].iban] } + + it 'encrypts leaves inside nested hashes and inside arrays' do + serialized = type.serialize( + { payment: { bank_data: { iban: iban, bic: 'COBADEFF' } }, accounts: [{ iban: iban }, { iban: nil }] } + ) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'payment', 'bank_data', 'iban'))).to be(true) + expect(stored_leaf(serialized, 'payment', 'bank_data', 'bic')).to eq('COBADEFF') + expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + expect(stored_leaf(serialized, 'accounts', 1, 'iban')).to be_nil + end + end + + context 'with a recursive descent path' do + let(:paths) { %w[$..iban] } + + it 'encrypts every leaf with that key at any depth' do + serialized = type.serialize({ iban: iban, payment: { bank_data: { iban: iban } }, accounts: [{ iban: iban }] }) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + expect(encryptor.encrypted?(stored_leaf(serialized, 'payment', 'bank_data', 'iban'))).to be(true) + expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + end + end + + context 'with a filter expression' do + let(:paths) { ["$.accounts[?(@.type == 'sepa')].iban"] } + + it 'encrypts only the matching elements' do + serialized = type.serialize({ accounts: [{ type: 'sepa', iban: iban }, { type: 'paypal', iban: 'not-an-iban' }] }) + + expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + expect(stored_leaf(serialized, 'accounts', 1, 'iban')).to eq('not-an-iban') + end + end + + context 'when encryption is disabled' do + it 'stores the plaintext leaf' do + serialized = ActiveRecord::Encryption.without_encryption { type.serialize({ iban: iban }) } + + expect(stored_leaf(serialized, 'iban')).to eq(iban) + end + end + end + + describe '#deserialize' do + it 'round trips to an indifferently accessible hash with the plaintext leaf' do + result = type.deserialize(type.serialize({ iban: iban, account_holder_first_name: 'John' })) + + expect(result).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(result[:iban]).to eq(iban) + expect(result['account_holder_first_name']).to eq('John') + end + + it 'passes through a plaintext leaf' do + expect(type.deserialize('{"iban":"DE89370400440532013000"}')[:iban]).to eq(iban) + end + + it 'reads a legacy double encoded json string literal' do + expect(type.deserialize('"{\"iban\":\"DE89370400440532013000\"}"')[:iban]).to eq(iban) + end + + it 'returns nil for nil' do + expect(type.deserialize(nil)).to be_nil + end + end + + describe '#changed_in_place?' do + it 'compares the decrypted value' do + serialized = type.serialize({ iban: iban }) + + expect(type.changed_in_place?(serialized, { 'iban' => iban }.with_indifferent_access)).to be(false) + expect(type.changed_in_place?(serialized, { 'iban' => 'DE02120300000000202051' }.with_indifferent_access)).to be(true) + end + end + + describe '.normalize_path' do + it 'prefixes a bare key path with the root' do + expect(described_class.normalize_path('payment.bank_data.iban')).to eq('$.payment.bank_data.iban') + expect(described_class.normalize_path(:iban)).to eq('$.iban') + end + + it 'leaves a json path untouched' do + expect(described_class.normalize_path('$..iban')).to eq('$..iban') + end + end + + describe '#encrypt_for_query' do + context 'when deterministic' do + let(:deterministic) { true } + + it 'returns the same ciphertext that is stored for the leaf' do + expect(type.encrypt_for_query(iban)).to eq(stored_leaf(type.serialize({ iban: iban }), 'iban')) + end + end + + context 'when not deterministic' do + it 'produces different ciphertexts for the same value' do + first = stored_leaf(type.serialize({ iban: iban }), 'iban') + second = stored_leaf(type.serialize({ iban: iban }), 'iban') + + expect(first).not_to eq(second) + end + + it 'raises' do + expect { type.encrypt_for_query(iban) }.to raise_error(ArgumentError, 'querying encrypted fields requires deterministic: true') + end + end + end +end diff --git a/spec/types/indifferent_json_type_spec.rb b/spec/types/indifferent_json_type_spec.rb new file mode 100644 index 0000000..f85fcb8 --- /dev/null +++ b/spec/types/indifferent_json_type_spec.rb @@ -0,0 +1,64 @@ +RSpec.describe NxtSupport::IndifferentJsonType do + subject(:type) { described_class.new } + + describe '#deserialize' do + it 'returns a HashWithIndifferentAccess for a json object' do + result = type.deserialize('{"iban":"DE89370400440532013000"}') + + expect(result).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(result[:iban]).to eq('DE89370400440532013000') + end + + it 'converts hashes inside arrays' do + result = type.deserialize('[{"key":"value"}]') + + expect(result.first).to be_a(ActiveSupport::HashWithIndifferentAccess) + expect(result.first[:key]).to eq('value') + end + + it 'decodes a legacy double encoded json string literal' do + result = type.deserialize('"{\"iban\":\"DE89370400440532013000\"}"') + + expect(result).to eq('iban' => 'DE89370400440532013000') + expect(result).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'returns nil for nil' do + expect(type.deserialize(nil)).to be_nil + end + + it 'raises for a json scalar that is not a hash or array' do + expect { type.deserialize('"invalid value"') }.to raise_error(ArgumentError, "Cant deserialize 'invalid value'") + end + end + + describe '#cast' do + it 'returns a HashWithIndifferentAccess for a hash' do + expect(type.cast({ key: 'value' })).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'returns nil for nil' do + expect(type.cast(nil)).to be_nil + end + + it 'raises for anything that is not a hash, array or nil' do + expect { type.cast('invalid value') }.to raise_error(ArgumentError, "Cant deserialize 'invalid value'") + end + end + + describe '#serialize' do + it 'encodes the value once' do + expect(type.serialize({ 'key' => 'value' }.with_indifferent_access)).to eq('{"key":"value"}') + end + end + + describe '#changed_in_place?' do + it 'detects a changed hash' do + expect(type.changed_in_place?('{"key":"value"}', { 'key' => 'other' }.with_indifferent_access)).to be(true) + end + + it 'treats an equal hash as unchanged' do + expect(type.changed_in_place?('{"key":"value"}', { 'key' => 'value' }.with_indifferent_access)).to be(false) + end + end +end From 72048e03f6640872340a94dae17351b9376b54ef Mon Sep 17 00:00:00 2001 From: Nils Sommer Date: Wed, 16 Sep 2026 15:01:01 +0200 Subject: [PATCH 2/5] Require full JSONPath expressions for encrypted json paths The shorthand that prefixed a bare key path with "$." was a second syntax to document and test, and it decided between the two by looking at the first character, so a key that starts with "$" would have been read as a path expression. One syntax with no special case is worth the two extra characters per call site. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++++---- lib/nxt_support/models/encrypted_json_attrs.rb | 2 +- .../types/encrypted_json_paths_type.rb | 7 +------ spec/models/encrypted_json_attrs_spec.rb | 6 +++--- spec/types/encrypted_json_paths_type_spec.rb | 15 ++------------- 5 files changed, 11 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 68bc8a4..cdf9a12 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,13 @@ application = Application.create!(data: { payment: { bank_data: { iban: 'DE89370 application.data.dig(:payment, :bank_data, :iban) # => "DE89370400440532013000" ``` -`encrypts_json_attrs` encrypts only the leaves at the given paths and leaves the rest of the JSON as it is, so the other keys stay queryable in SQL. Paths are [JSONPath](https://goessner.net/articles/JsonPath/) expressions evaluated with the [`jsonpath`](https://github.com/joshbuddy/jsonpath) gem. A bare key path such as `payment.bank_data.iban` is shorthand for `$.payment.bank_data.iban`. Only string leaves are encrypted, an already encrypted leaf is left alone, and plaintext leaves are read transparently, so existing rows keep working until they are re-saved. +`encrypts_json_attrs` encrypts only the leaves at the given paths and leaves the rest of the JSON as it is, so the other keys stay queryable in SQL. Paths are [JSONPath](https://goessner.net/articles/JsonPath/) expressions evaluated with the [`jsonpath`](https://github.com/joshbuddy/jsonpath) gem. Only string leaves are encrypted, an already encrypted leaf is left alone, and plaintext leaves are read transparently, so existing rows keep working until they are re-saved. ```ruby class Application::PaymentMethod < ApplicationRecord include NxtSupport::EncryptedJsonAttrs - encrypts_json_attrs column: :data, paths: %w[iban], deterministic: true + encrypts_json_attrs column: :data, paths: %w[$.iban], deterministic: true end payment_method = Application::PaymentMethod.create!(data: { iban: 'DE89370400440532013000', account_holder: 'John' }) @@ -102,7 +102,7 @@ payment_method.data[:iban] # => "DE89370400440532013000" More path examples: ```ruby -encrypts_json_attrs column: :data, paths: %w[payment.bank_data.iban] +encrypts_json_attrs column: :data, paths: %w[$.payment.bank_data.iban] encrypts_json_attrs column: :data, paths: %w[$.accounts[*].iban] encrypts_json_attrs column: :data, paths: %w[$..iban] encrypts_json_attrs column: :data, paths: ["$.accounts[?(@.type == 'sepa')].iban"] @@ -111,7 +111,7 @@ encrypts_json_attrs column: :data, paths: ["$.accounts[?(@.type == 'sepa')].iban With `deterministic: true` the ciphertext is stable, so records can be found by the value of an encrypted field. `where_encrypted_json` resolves the same path with `jsonb_path_query` and therefore requires a PostgreSQL `jsonb` column. `encrypted_json_value_for` returns the ciphertext to use in your own queries. ```ruby -Application::PaymentMethod.where_encrypted_json(:data, path: 'iban', value: 'DE89370400440532013000') +Application::PaymentMethod.where_encrypted_json(:data, path: '$.iban', value: 'DE89370400440532013000') Application::PaymentMethod.where_encrypted_json(:data, path: '$..iban', value: 'DE89370400440532013000') Application::PaymentMethod.encrypted_json_value_for(:data, 'DE89370400440532013000') # => "{\"p\":\"...\",\"h\":{...}}" ``` diff --git a/lib/nxt_support/models/encrypted_json_attrs.rb b/lib/nxt_support/models/encrypted_json_attrs.rb index a77b9c2..3ff3f82 100644 --- a/lib/nxt_support/models/encrypted_json_attrs.rb +++ b/lib/nxt_support/models/encrypted_json_attrs.rb @@ -34,7 +34,7 @@ def where_encrypted_json(attr, path:, value:) private def sql_json_path(path) - EncryptedJsonPathsType.normalize_path(path).gsub('..', '.**.') + path.gsub('..', '.**.') end end end diff --git a/lib/nxt_support/types/encrypted_json_paths_type.rb b/lib/nxt_support/types/encrypted_json_paths_type.rb index 2c6c478..8b4429b 100644 --- a/lib/nxt_support/types/encrypted_json_paths_type.rb +++ b/lib/nxt_support/types/encrypted_json_paths_type.rb @@ -4,13 +4,8 @@ module NxtSupport class EncryptedJsonPathsType < IndifferentJsonType attr_reader :paths, :scheme - def self.normalize_path(path) - path = path.to_s - path.start_with?('$') ? path : "$.#{path}" - end - def initialize(*paths, deterministic: false) - @paths = paths.map { |path| self.class.normalize_path(path) } + @paths = paths @scheme = ActiveRecord::Encryption::Scheme.new(deterministic: deterministic) super() end diff --git a/spec/models/encrypted_json_attrs_spec.rb b/spec/models/encrypted_json_attrs_spec.rb index 14b511c..c09673d 100644 --- a/spec/models/encrypted_json_attrs_spec.rb +++ b/spec/models/encrypted_json_attrs_spec.rb @@ -110,7 +110,7 @@ def update_raw_column(table, value, id) self.table_name = 'encrypted_payment_methods' - encrypts_json_attrs column: :data, paths: %w[iban], deterministic: true + encrypts_json_attrs column: :data, paths: %w[$.iban], deterministic: true end end @@ -174,7 +174,7 @@ def update_raw_column(table, value, id) describe '.where_encrypted_json' do subject(:sql) { payment_method_class.where_encrypted_json(:data, path: path, value: iban).to_sql } - let(:path) { 'iban' } + let(:path) { '$.iban' } it 'matches the deterministic ciphertext at the json path' do expect(sql).to include(%(jsonb_path_query("encrypted_payment_methods"."data", '$.iban'))) @@ -204,7 +204,7 @@ def update_raw_column(table, value, id) self.table_name = 'encrypted_payment_methods' - encrypts_json_attrs column: :data, paths: %w[iban] + encrypts_json_attrs column: :data, paths: %w[$.iban] end end diff --git a/spec/types/encrypted_json_paths_type_spec.rb b/spec/types/encrypted_json_paths_type_spec.rb index e6e8639..ac572aa 100644 --- a/spec/types/encrypted_json_paths_type_spec.rb +++ b/spec/types/encrypted_json_paths_type_spec.rb @@ -1,7 +1,7 @@ RSpec.describe NxtSupport::EncryptedJsonPathsType do subject(:type) { described_class.new(*paths, deterministic: deterministic) } - let(:paths) { %w[iban] } + let(:paths) { %w[$.iban] } let(:deterministic) { false } let(:iban) { 'DE89370400440532013000' } let(:encryptor) { ActiveRecord::Encryption.encryptor } @@ -58,7 +58,7 @@ def stored_leaf(serialized, *keys) end context 'with nested and array paths' do - let(:paths) { %w[payment.bank_data.iban $.accounts[*].iban] } + let(:paths) { %w[$.payment.bank_data.iban $.accounts[*].iban] } it 'encrypts leaves inside nested hashes and inside arrays' do serialized = type.serialize( @@ -135,17 +135,6 @@ def stored_leaf(serialized, *keys) end end - describe '.normalize_path' do - it 'prefixes a bare key path with the root' do - expect(described_class.normalize_path('payment.bank_data.iban')).to eq('$.payment.bank_data.iban') - expect(described_class.normalize_path(:iban)).to eq('$.iban') - end - - it 'leaves a json path untouched' do - expect(described_class.normalize_path('$..iban')).to eq('$..iban') - end - end - describe '#encrypt_for_query' do context 'when deterministic' do let(:deterministic) { true } From 061a4f7f03df7b6dfe430f844c1a97a00a5bb457 Mon Sep 17 00:00:00 2001 From: Nils Sommer Date: Wed, 16 Sep 2026 15:06:56 +0200 Subject: [PATCH 3/5] Document encrypts_json_entirely on json and jsonb columns Co-Authored-By: Claude Fable 5.1 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cdf9a12..826c9f9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_E `encrypts_json_entirely` encrypts the whole column. It is plain `encrypts` on top of `NxtSupport::IndifferentJsonType`, so all `encrypts` options (`deterministic:`, `key_provider:`, ...) are accepted. Nothing inside the column is queryable afterwards, and Rails recommends a `text` column for encrypted attributes. +It does work on an existing `json` or `jsonb` column, because the default message serializer emits the ciphertext envelope as JSON (`{"p":"...","h":{...}}`), which PostgreSQL accepts as a valid document. Treat that as a transitional state rather than a design: the column no longer holds meaningful JSON, so `data->>'key'` returns `NULL` and indexes on it are useless, PostgreSQL parses and normalizes the envelope on every read and write for nothing, and it only holds as long as the message serializer produces JSON. A MessagePack serializer or a binary encryptor would break it. Plan to change the column type to `text` once the data is encrypted. + ```ruby class Application < ApplicationRecord include NxtSupport::EncryptedJsonAttrs From 1c3305a95c8f1c73fa597a9fa5441280453b2a6b Mon Sep 17 00:00:00 2001 From: Nils Sommer Date: Wed, 16 Sep 2026 15:28:32 +0200 Subject: [PATCH 4/5] Address review comments Name the two raise specs after the reason, cover the JSON::ParserError branch in IndifferentJsonType, use a per-attribute support_unencrypted_data instead of toggling the global config in a spec, and mention the jsonpath dependency in the changelog. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + spec/models/encrypted_json_attrs_spec.rb | 17 ++++++++++------- spec/types/encrypted_json_paths_type_spec.rb | 2 +- spec/types/indifferent_json_type_spec.rb | 4 ++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc96d2..6d47edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # v0.8.0 2026-09-16 - Added `NxtSupport::EncryptedJsonAttrs`, `NxtSupport::IndifferentJsonType` and `NxtSupport::EncryptedJsonPathsType` +- Added `jsonpath` as a runtime dependency # v0.7.0 2026-09-16 - Added `NxtSupport::Iban` diff --git a/spec/models/encrypted_json_attrs_spec.rb b/spec/models/encrypted_json_attrs_spec.rb index c09673d..438ccc6 100644 --- a/spec/models/encrypted_json_attrs_spec.rb +++ b/spec/models/encrypted_json_attrs_spec.rb @@ -82,11 +82,14 @@ def update_raw_column(table, value, id) end context 'when unencrypted data is supported' do - around do |example| - ActiveRecord::Encryption.config.support_unencrypted_data = true - example.run - ensure - ActiveRecord::Encryption.config.support_unencrypted_data = false + let(:document_class) do + Class.new(ActiveRecord::Base) do + include NxtSupport::EncryptedJsonAttrs + + self.table_name = 'encrypted_documents' + + encrypts_json_entirely :data, support_unencrypted_data: true + end end it 'reads legacy plaintext rows' do @@ -181,7 +184,7 @@ def update_raw_column(table, value, id) expect(sql).to include("#>> '{}' = '#{payment_method_class.encrypted_json_value_for(:data, iban)}'") end - context 'with a json path' do + context 'with an array wildcard path' do let(:path) { '$.accounts[*].iban' } it 'passes it through' do @@ -208,7 +211,7 @@ def update_raw_column(table, value, id) end end - it 'raises' do + it 'raises because a non deterministic ciphertext cannot be queried' do expect { sql }.to raise_error(ArgumentError, 'querying encrypted fields requires deterministic: true') end end diff --git a/spec/types/encrypted_json_paths_type_spec.rb b/spec/types/encrypted_json_paths_type_spec.rb index ac572aa..06ff4c8 100644 --- a/spec/types/encrypted_json_paths_type_spec.rb +++ b/spec/types/encrypted_json_paths_type_spec.rb @@ -152,7 +152,7 @@ def stored_leaf(serialized, *keys) expect(first).not_to eq(second) end - it 'raises' do + it 'raises because a non deterministic ciphertext cannot be queried' do expect { type.encrypt_for_query(iban) }.to raise_error(ArgumentError, 'querying encrypted fields requires deterministic: true') end end diff --git a/spec/types/indifferent_json_type_spec.rb b/spec/types/indifferent_json_type_spec.rb index f85fcb8..79a6e0f 100644 --- a/spec/types/indifferent_json_type_spec.rb +++ b/spec/types/indifferent_json_type_spec.rb @@ -30,6 +30,10 @@ it 'raises for a json scalar that is not a hash or array' do expect { type.deserialize('"invalid value"') }.to raise_error(ArgumentError, "Cant deserialize 'invalid value'") end + + it 'raises for a json string literal that looks like an object but is not valid json' do + expect { type.deserialize('"{not json"') }.to raise_error(ArgumentError, "Cant deserialize '{not json'") + end end describe '#cast' do From 7a2f7eefc229045651fdf7c5b1c3fb5f7bd5ab8b Mon Sep 17 00:00:00 2001 From: Nils Sommer Date: Wed, 16 Sep 2026 15:43:48 +0200 Subject: [PATCH 5/5] Add NxtSupport::RSpec::Encryption spec helpers Specs that prove a column is encrypted keep repeating the same steps: a hand-written SELECT for the raw value, a database specific JSON function to dig out a leaf, a call to the encryptor and a check that the plaintext is absent. raw_column_value, be_encrypted and be_encrypted_at fold that into assertions that read the same on sqlite and PostgreSQL. The two matchers deliberately do not overlap. A wholly encrypted column is one envelope and only be_encrypted fits it. A partially encrypted column is still a JSON document, so be_encrypted fails on it and be_encrypted_at speaks about its leaves. A path that matches nothing fails in both directions so a typo cannot pass silently. The file is opt in through require 'nxt_support/rspec' and is not loaded by the gem itself, so RSpec stays a development dependency. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 27 ++++ lib/nxt_support/rspec.rb | 1 + lib/nxt_support/rspec/encryption.rb | 103 ++++++++++++++++ spec/models/encrypted_json_attrs_spec.rb | 25 ++-- spec/rspec/encryption_spec.rb | 122 +++++++++++++++++++ spec/spec_helper.rb | 3 + spec/types/encrypted_json_paths_type_spec.rb | 25 ++-- 8 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 lib/nxt_support/rspec.rb create mode 100644 lib/nxt_support/rspec/encryption.rb create mode 100644 spec/rspec/encryption_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d47edb..e0eef33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # v0.8.0 2026-09-16 - Added `NxtSupport::EncryptedJsonAttrs`, `NxtSupport::IndifferentJsonType` and `NxtSupport::EncryptedJsonPathsType` - Added `jsonpath` as a runtime dependency +- Added `NxtSupport::RSpec::Encryption` with `raw_column_value`, `be_encrypted` and `be_encrypted_at` for specs, opt in via `require 'nxt_support/rspec'` # v0.7.0 2026-09-16 - Added `NxtSupport::Iban` diff --git a/README.md b/README.md index 826c9f9..960f25a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,33 @@ Both variants default the attribute to an empty `HashWithIndifferentAccess`, whi attribute :data, NxtSupport::IndifferentJsonType.new ``` +##### Testing encrypted columns + +`nxt_support/rspec` ships a helper and two matchers that assert on the value as it is stored in the database, so a spec can prove what is encrypted without writing SQL or knowing the database's JSON functions. + +```ruby +# spec_helper.rb +require 'nxt_support/rspec' + +RSpec.configure do |config| + config.include NxtSupport::RSpec::Encryption +end +``` + +`raw_column_value(record, :column)` returns the column exactly as stored, bypassing every attribute type. `be_encrypted` passes for an Active Record encryption envelope and nothing else. `be_encrypted_at(path)` parses the value as JSON, resolves the JSONPath and passes if it matches at least one leaf and every matched leaf is an envelope. Negated, it passes if every matched leaf is plaintext. A path that matches nothing fails in both directions. + +```ruby +raw = raw_column_value(application, :data) +expect(raw).to be_encrypted + +raw = raw_column_value(payment_method, :data) +expect(raw).not_to be_encrypted +expect(raw).to be_encrypted_at('$.iban') +expect(raw).not_to be_encrypted_at('$.account_holder') +``` + +The two matchers are not interchangeable. A wholly encrypted column is a single envelope, so `be_encrypted_at` finds none of the original keys in it. A partially encrypted column is still a JSON document, so `be_encrypted` fails on it. + #### NxtSupport::SafelyFindOrCreateable The `NxtSupport::Models::SafelyFindOrCreateable` concern is aimed at ActiveRecord models with a uniqueness database constraint. If you use `find_or_create_by` from ActiveRecord, it can happen that the `find_by` call returns `nil` (because no record for the given conditions exists), but in the small timeframe between the `find_by` and the `create` call, another thread inserts a record, so that the `create` call raises an error. diff --git a/lib/nxt_support/rspec.rb b/lib/nxt_support/rspec.rb new file mode 100644 index 0000000..9242238 --- /dev/null +++ b/lib/nxt_support/rspec.rb @@ -0,0 +1 @@ +require "nxt_support/rspec/encryption" diff --git a/lib/nxt_support/rspec/encryption.rb b/lib/nxt_support/rspec/encryption.rb new file mode 100644 index 0000000..5c67959 --- /dev/null +++ b/lib/nxt_support/rspec/encryption.rb @@ -0,0 +1,103 @@ +require 'active_record' +require 'jsonpath' + +module NxtSupport + module RSpec + module Encryption + def raw_column_value(record, column) + klass = record.class + connection = klass.connection + sql = klass.sanitize_sql_array( + [ + "SELECT #{connection.quote_column_name(column)} FROM #{klass.quoted_table_name} " \ + "WHERE #{connection.quote_column_name(klass.primary_key)} = ?", + record.id + ] + ) + + connection.select_value(sql) + end + + def be_encrypted + BeEncrypted.new + end + + def be_encrypted_at(path) + BeEncryptedAt.new(path) + end + + def self.envelope?(value) + value.is_a?(::String) && ActiveRecord::Encryption.encryptor.encrypted?(value) + end + + class BeEncrypted + def matches?(actual) + @actual = actual + Encryption.envelope?(actual) + end + + def failure_message + "expected #{@actual.inspect} to be an Active Record encryption envelope" + end + + def failure_message_when_negated + "expected #{@actual.inspect} not to be an Active Record encryption envelope" + end + + def description + 'be encrypted' + end + end + + class BeEncryptedAt + def initialize(path) + @path = path + end + + def matches?(actual) + resolve(actual) + @leaves.any? && @leaves.all? { |leaf| Encryption.envelope?(leaf) } + end + + def does_not_match?(actual) + resolve(actual) + @leaves.any? && @leaves.none? { |leaf| Encryption.envelope?(leaf) } + end + + def failure_message + return @parse_error if @parse_error + return "expected #{@path} to match at least one leaf in #{@actual.inspect}, but it matched none" if @leaves.empty? + + plaintext = @leaves.reject { |leaf| Encryption.envelope?(leaf) } + "expected every leaf at #{@path} to be an Active Record encryption envelope, but found #{plaintext.inspect}" + end + + def failure_message_when_negated + return @parse_error if @parse_error + return "expected #{@path} to match at least one leaf in #{@actual.inspect}, but it matched none" if @leaves.empty? + + encrypted = @leaves.select { |leaf| Encryption.envelope?(leaf) } + "expected no leaf at #{@path} to be an Active Record encryption envelope, but found #{encrypted.inspect}" + end + + def description + "be encrypted at #{@path}" + end + + private + + def resolve(actual) + @actual = actual + @leaves = JsonPath.new(@path).on(parse(actual)) + rescue JSON::ParserError => error + @parse_error = "expected #{actual.inspect} to be a JSON document, but it could not be parsed: #{error.message}" + @leaves = [] + end + + def parse(actual) + JSON.parse(actual.is_a?(::String) ? actual : actual.to_json) + end + end + end + end +end diff --git a/spec/models/encrypted_json_attrs_spec.rb b/spec/models/encrypted_json_attrs_spec.rb index 438ccc6..d2ec055 100644 --- a/spec/models/encrypted_json_attrs_spec.rb +++ b/spec/models/encrypted_json_attrs_spec.rb @@ -1,6 +1,5 @@ RSpec.describe NxtSupport::EncryptedJsonAttrs do let(:iban) { 'DE89370400440532013000' } - let(:encryptor) { ActiveRecord::Encryption.encryptor } let(:connection) { ActiveRecord::Base.connection } let!(:db_schema) do @@ -17,10 +16,6 @@ end end - def raw_column(sql, id) - connection.select_value(ActiveRecord::Base.sanitize_sql_array([sql, id])) - end - def update_raw_column(table, value, id) connection.execute(ActiveRecord::Base.sanitize_sql_array(["UPDATE #{table} SET data = ? WHERE id = ?", value, id])) end @@ -39,9 +34,9 @@ def update_raw_column(table, value, id) let(:document) { document_class.create!(data: { payment: { bank_data: { iban: iban } } }) } it 'stores the whole column as an encrypted envelope' do - raw = raw_column('SELECT data FROM encrypted_documents WHERE id = ?', document.id) + raw = raw_column_value(document, :data) - expect(encryptor.encrypted?(raw)).to be(true) + expect(raw).to be_encrypted expect(raw).not_to include(iban) end @@ -120,14 +115,12 @@ def update_raw_column(table, value, id) let(:payment_method) { payment_method_class.create!(data: { iban: iban, account_holder_first_name: 'John' }) } it 'stores only the configured field encrypted' do - raw_iban = raw_column("SELECT json_extract(data, '$.iban') FROM encrypted_payment_methods WHERE id = ?", payment_method.id) - raw_first_name = raw_column( - "SELECT json_extract(data, '$.account_holder_first_name') FROM encrypted_payment_methods WHERE id = ?", payment_method.id - ) + raw = raw_column_value(payment_method, :data) - expect(encryptor.encrypted?(raw_iban)).to be(true) - expect(raw_iban).not_to include(iban) - expect(raw_first_name).to eq('John') + expect(raw).not_to be_encrypted + expect(raw).to be_encrypted_at('$.iban') + expect(raw).not_to be_encrypted_at('$.account_holder_first_name') + expect(raw).not_to include(iban) end it 'reads the field back decrypted' do @@ -168,9 +161,9 @@ def update_raw_column(table, value, id) describe '.encrypted_json_value_for' do it 'returns the ciphertext that is stored for the field' do - raw_iban = raw_column("SELECT json_extract(data, '$.iban') FROM encrypted_payment_methods WHERE id = ?", payment_method.id) + stored_iban = JSON.parse(raw_column_value(payment_method, :data)).fetch('iban') - expect(payment_method_class.encrypted_json_value_for(:data, iban)).to eq(raw_iban) + expect(payment_method_class.encrypted_json_value_for(:data, iban)).to eq(stored_iban) end end diff --git a/spec/rspec/encryption_spec.rb b/spec/rspec/encryption_spec.rb new file mode 100644 index 0000000..3639d24 --- /dev/null +++ b/spec/rspec/encryption_spec.rb @@ -0,0 +1,122 @@ +RSpec.describe NxtSupport::RSpec::Encryption do + let(:encryptor) { ActiveRecord::Encryption.encryptor } + let(:envelope) { encryptor.encrypt('DE89370400440532013000') } + + def failure_of + yield + raise 'expected the expectation to fail' + rescue RSpec::Expectations::ExpectationNotMetError => error + error.message + end + + describe '#raw_column_value' do + let!(:db_schema) do + ActiveRecord::Schema.define do + self.verbose = false + + create_table :raw_values, force: true do |t| + t.text :payload + end + end + end + + let(:record_class) do + Class.new(ActiveRecord::Base) do + self.table_name = 'raw_values' + + attribute :payload, NxtSupport::IndifferentJsonType.new + end + end + + it 'returns the value as stored in the database' do + record = record_class.create!(payload: { key: 'value' }) + + expect(raw_column_value(record, :payload)).to eq('{"key":"value"}') + end + + it 'returns nil for an empty column' do + record = record_class.create! + + expect(raw_column_value(record, :payload)).to be_nil + end + end + + describe '#be_encrypted' do + it 'passes for an encryption envelope' do + expect(envelope).to be_encrypted + end + + it 'fails for plaintext' do + message = failure_of { expect('DE89370400440532013000').to be_encrypted } + + expect(message).to eq('expected "DE89370400440532013000" to be an Active Record encryption envelope') + end + + it 'fails for a json document that is not an envelope' do + expect('{"iban":"DE89370400440532013000"}').not_to be_encrypted + end + + it 'fails for nil' do + expect(nil).not_to be_encrypted + end + + it 'fails when negated for an envelope' do + message = failure_of { expect(envelope).not_to be_encrypted } + + expect(message).to eq("expected #{envelope.inspect} not to be an Active Record encryption envelope") + end + end + + describe '#be_encrypted_at' do + let(:document) { { iban: envelope, account_holder_first_name: 'John', accounts: [{ iban: envelope }, { iban: 'plain' }] } } + let(:json) { document.to_json } + + it 'passes when every leaf at the path is an envelope' do + expect(json).to be_encrypted_at('$.iban') + expect(json).to be_encrypted_at('$.accounts[0].iban') + end + + it 'accepts an already parsed document' do + expect(document).to be_encrypted_at('$.iban') + end + + it 'passes when negated and every leaf at the path is plaintext' do + expect(json).not_to be_encrypted_at('$.account_holder_first_name') + expect(json).not_to be_encrypted_at('$.accounts[1].iban') + end + + it 'fails when a leaf at the path is plaintext' do + message = failure_of { expect(json).to be_encrypted_at('$.account_holder_first_name') } + + expect(message).to eq('expected every leaf at $.account_holder_first_name to be an Active Record encryption envelope, but found ["John"]') + end + + it 'fails when negated and a leaf at the path is an envelope' do + message = failure_of { expect(json).not_to be_encrypted_at('$.iban') } + + expect(message).to eq("expected no leaf at $.iban to be an Active Record encryption envelope, but found [#{envelope.inspect}]") + end + + it 'fails in both directions when a wildcard path matches encrypted and plaintext leaves' do + expect(failure_of { expect(json).to be_encrypted_at('$.accounts[*].iban') }).to include('but found ["plain"]') + expect(failure_of { expect(json).not_to be_encrypted_at('$.accounts[*].iban') }).to include("but found [#{envelope.inspect}]") + end + + it 'fails in both directions when the path matches nothing' do + expect(failure_of { expect(json).to be_encrypted_at('$.missing') }) + .to eq("expected $.missing to match at least one leaf in #{json.inspect}, but it matched none") + expect(failure_of { expect(json).not_to be_encrypted_at('$.missing') }) + .to eq("expected $.missing to match at least one leaf in #{json.inspect}, but it matched none") + end + + it 'fails for a whole column envelope because it holds none of the original keys' do + expect(failure_of { expect(envelope).to be_encrypted_at('$.iban') }).to include('but it matched none') + end + + it 'fails for a value that is not json' do + message = failure_of { expect('not json').to be_encrypted_at('$.iban') } + + expect(message).to start_with('expected "not json" to be a JSON document, but it could not be parsed:') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4087321..12532b7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,7 @@ require 'bundler/setup' require 'pry' require 'nxt_support' +require 'nxt_support/rspec' require 'active_record' require 'active_support/testing/time_helpers' @@ -19,6 +20,8 @@ ) RSpec.configure do |config| + config.include NxtSupport::RSpec::Encryption + # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. diff --git a/spec/types/encrypted_json_paths_type_spec.rb b/spec/types/encrypted_json_paths_type_spec.rb index 06ff4c8..e01b839 100644 --- a/spec/types/encrypted_json_paths_type_spec.rb +++ b/spec/types/encrypted_json_paths_type_spec.rb @@ -4,7 +4,6 @@ let(:paths) { %w[$.iban] } let(:deterministic) { false } let(:iban) { 'DE89370400440532013000' } - let(:encryptor) { ActiveRecord::Encryption.encryptor } def stored_leaf(serialized, *keys) JSON.parse(serialized).dig(*keys) @@ -14,21 +13,17 @@ def stored_leaf(serialized, *keys) it 'encrypts the configured leaf and leaves the rest untouched' do serialized = type.serialize({ iban: iban, account_holder_first_name: 'John' }) - expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) - expect(stored_leaf(serialized, 'iban')).not_to include(iban) + expect(serialized).to be_encrypted_at('$.iban') + expect(serialized).not_to include(iban) expect(stored_leaf(serialized, 'account_holder_first_name')).to eq('John') end it 'encrypts leaves given with string keys' do - serialized = type.serialize({ 'iban' => iban }) - - expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + expect(type.serialize({ 'iban' => iban })).to be_encrypted_at('$.iban') end it 'encrypts leaves given with symbol keys' do - serialized = type.serialize({ iban: iban }) - - expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) + expect(type.serialize({ iban: iban })).to be_encrypted_at('$.iban') end it 'does not mutate the given value' do @@ -65,9 +60,9 @@ def stored_leaf(serialized, *keys) { payment: { bank_data: { iban: iban, bic: 'COBADEFF' } }, accounts: [{ iban: iban }, { iban: nil }] } ) - expect(encryptor.encrypted?(stored_leaf(serialized, 'payment', 'bank_data', 'iban'))).to be(true) - expect(stored_leaf(serialized, 'payment', 'bank_data', 'bic')).to eq('COBADEFF') - expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + expect(serialized).to be_encrypted_at('$.payment.bank_data.iban') + expect(serialized).not_to be_encrypted_at('$.payment.bank_data.bic') + expect(serialized).to be_encrypted_at('$.accounts[0].iban') expect(stored_leaf(serialized, 'accounts', 1, 'iban')).to be_nil end end @@ -78,9 +73,7 @@ def stored_leaf(serialized, *keys) it 'encrypts every leaf with that key at any depth' do serialized = type.serialize({ iban: iban, payment: { bank_data: { iban: iban } }, accounts: [{ iban: iban }] }) - expect(encryptor.encrypted?(stored_leaf(serialized, 'iban'))).to be(true) - expect(encryptor.encrypted?(stored_leaf(serialized, 'payment', 'bank_data', 'iban'))).to be(true) - expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + expect(serialized).to be_encrypted_at('$..iban') end end @@ -90,7 +83,7 @@ def stored_leaf(serialized, *keys) it 'encrypts only the matching elements' do serialized = type.serialize({ accounts: [{ type: 'sepa', iban: iban }, { type: 'paypal', iban: 'not-an-iban' }] }) - expect(encryptor.encrypted?(stored_leaf(serialized, 'accounts', 0, 'iban'))).to be(true) + expect(serialized).to be_encrypted_at('$.accounts[0].iban') expect(stored_leaf(serialized, 'accounts', 1, 'iban')).to eq('not-an-iban') end end