Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# v0.8.0 2026-09-16
- Added `NxtSupport::EncryptedJsonAttrs`, `NxtSupport::IndifferentJsonType` and `NxtSupport::EncryptedJsonPathsType`
Comment thread
nsommer marked this conversation as resolved.
- 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`

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

##### 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.
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
1 change: 1 addition & 0 deletions lib/nxt_support/rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require "nxt_support/rspec/encryption"
103 changes: 103 additions & 0 deletions lib/nxt_support/rspec/encryption.rb
Original file line number Diff line number Diff line change
@@ -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
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
Loading