Repository navigation
Add NxtSupport::EncryptedJsonAttrs #173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nsommer
wants to merge
5
commits into
main
Choose a base branch
from
IX-3471-encrypted-json-attrs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+912
−2
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2abd206
Add NxtSupport::EncryptedJsonAttrs
nsommer 72048e0
Require full JSONPath expressions for encrypted json paths
nsommer 061a4f7
Document encrypts_json_entirely on json and jsonb columns
nsommer 1c3305a
Address review comments
nsommer 7a2f7ee
Add NxtSupport::RSpec::Encryption spec helpers
nsommer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling |
||
|
|
||
| `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. | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| require "nxt_support/rspec/encryption" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.