Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# v0.8.0 2026-09-16
- Added `NxtSupport::EncryptedJsonAttrs`, `NxtSupport::IndifferentJsonType` and `NxtSupport::EncryptedJsonPathsType`
Comment thread
nsommer marked this conversation as resolved.

# v0.7.0 2026-09-16
- Added `NxtSupport::Iban`

Expand Down
6 changes: 5 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
PATH
remote: .
specs:
nxt_support (0.7.0)
nxt_support (0.8.0)
activerecord
activesupport
jsonpath
nxt_init
nxt_registry

Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,75 @@ 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.

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

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. 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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling save! on an unchanged record would not be enough, right? We would need to run something like record.update_columns(data: record.data).


`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.
Expand Down
1 change: 1 addition & 0 deletions lib/nxt_support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lib/nxt_support/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
41 changes: 41 additions & 0 deletions lib/nxt_support/models/encrypted_json_attrs.rb
Original file line number Diff line number Diff line change
@@ -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)
path.gsub('..', '.**.')
end
end
end
end
2 changes: 2 additions & 0 deletions lib/nxt_support/types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require "nxt_support/types/indifferent_json_type"
require "nxt_support/types/encrypted_json_paths_type"
56 changes: 56 additions & 0 deletions lib/nxt_support/types/encrypted_json_paths_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
require 'jsonpath'

module NxtSupport
class EncryptedJsonPathsType < IndifferentJsonType
attr_reader :paths, :scheme

def initialize(*paths, deterministic: false)
@paths = paths
@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
32 changes: 32 additions & 0 deletions lib/nxt_support/types/indifferent_json_type.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/nxt_support/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module NxtSupport
VERSION = "0.7.0".freeze
VERSION = "0.8.0".freeze
end
1 change: 1 addition & 0 deletions nxt_support.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading