Skip to content

Add NxtSupport::EncryptedJsonAttrs - #173

Open
nsommer wants to merge 5 commits into
mainfrom
IX-3471-encrypted-json-attrs
Open

nsommer wants to merge 5 commits into
mainfrom
IX-3471-encrypted-json-attrs

Conversation

@nsommer

@nsommer nsommer commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

References

Upstreamed from application-service IX-3471 (application-service#3480)

Problem

Rails' encrypts covers plain columns only. Encrypting a whole JSON column, or single fields inside one, needs a small DSL on top of Active Record Encryption that every service storing personal data in JSON columns can use.

Solution

include NxtSupport::EncryptedJsonAttrs

encrypts_json_entirely :data
encrypts_json_attrs column: :data, paths: %w[$.iban], deterministic: true
PaymentMethod.where_encrypted_json(:data, path: '$.iban', value: iban)
  • encrypts_json_entirely: encrypts on top of NxtSupport::IndifferentJsonType.
  • encrypts_json_attrs: encrypts only the leaves at the given JSONPath expressions, the rest stays queryable. where_encrypted_json finds rows by deterministic ciphertext via jsonb_path_query (PostgreSQL only).
  • NxtSupport::IndifferentJsonType also reads the double encoded rows indifferently_accessible_json_attrs writes to json columns, so a column can be switched without a data migration.
  • NxtSupport::RSpec::Encryption (opt in via require 'nxt_support/rspec'): raw_column_value, be_encrypted, be_encrypted_at('$.iban').
  • New runtime dependency jsonpath. Paths must be full JSONPath expressions; the application-service shorthand (iban for $.iban) is dropped.

See the README for details.

🤖 Generated with Claude Code

nsommer and others added 3 commits September 16, 2026 14:38
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@nsommer nsommer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Inline comments from a standards pass; nothing blocking.

Comment thread spec/models/encrypted_json_attrs_spec.rb Outdated
Comment thread spec/types/encrypted_json_paths_type_spec.rb Outdated
Comment thread spec/types/indifferent_json_type_spec.rb
Comment thread spec/models/encrypted_json_attrs_spec.rb Outdated
Comment thread spec/models/encrypted_json_attrs_spec.rb Outdated
Comment thread CHANGELOG.md
nsommer and others added 2 commits September 16, 2026 15:28
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@nsommer

nsommer commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Tested for application-service (see nxt-insurance/application-service#3480) on staging:
Bildschirmfoto 2026-09-16 um 20 21 21

Comment thread README.md

##### 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants