Module: ConcernsOnRails::Models::Encryptable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/models/encryptable.rb

Overview

Transparent per-field encryption for sensitive columns (SSN, DOB, cards). Reads and writes stay plaintext; the DB column holds an authenticated AES-256-GCM envelope. Encryption is implemented as a custom ActiveModel::Type on the declared column, so it is invisible to the rest of the stack — sibling concerns that read self[:field] (Maskable, Normalizable) compose for free, and dirty tracking compares plaintext.

ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }

class Patient < ApplicationRecord
include ConcernsOnRails::Models::Encryptable

encryptable :ssn, :notes              # transparent string encryption
encryptable :dob, type: :date         # decrypts back to a Date
encryptable :email, blind_index: true # + a queryable fingerprint column
end

p = Patient.create!(ssn: "123-45-6789", dob: Date.new(1990, 1, 1))
p.ssn                 # => "123-45-6789"
p.reload.dob          # => Wed, 01 Jan 1990
p.ssn_ciphertext      # => "AQEA..." (Base64 envelope; no plaintext at rest)
p.ssn_encrypted?      # => true
Patient.find_by_email("a@b.com")  # exact-match lookup via the blind index

type: casts the decrypted value (reuses the Storable caster set: :string default, :integer, :float, :decimal, :boolean, :date, :datetime). key: overrides the gem-level key per field (a String or a Proc).

BLIND INDEX (blind_index: true or a Hash): because encryption is non-deterministic, encrypted columns are not directly queryable. Opt into a blind index and the concern maintains a deterministic keyed HMAC of the value in a companion <field>_bidx column (override with column:), and generates find_by_<field> / where_<field> / <field>_fingerprint class methods for exact-match lookups. Pass expression: (a callable) to normalize before hashing (e.g. ->(v) { v.to_s.downcase }) — it is applied on BOTH write and query so they stay symmetric. The index leaks equality (identical values share a digest); use it only for lookup keys.

Notes:

* The declared column must be `text` (or binary): it stores an opaque
Base64 envelope, not the logical type. `nil` stays `nil` (never an
encrypted blank). A blind-index column is `string`/`text` (a 64-char
hex digest) — add an index on it.
* The ciphertext itself is non-deterministic (random IV) — the same
plaintext yields different ciphertext every write, so `where(:ssn)`
matches nothing. Query through a blind index instead. Presence/NULL
checks (`where.not(ssn: nil)`) work normally.
* Never `update_column`/`update_columns` an encrypted field — those
bypass the type and write raw plaintext to the column (and skip the
blind-index refresh).
* Auditing an encrypted field would persist its plaintext to the audit
column, so declaring a field with BOTH `encryptable` and `auditable_by`
raises. Maskable masks the decrypted value; Normalizable normalizes the
plaintext before it is encrypted (order-independent).

Defined Under Namespace

Modules: ClassMethods Classes: EncryptedType

Constant Summary collapse

LABEL =
"ConcernsOnRails::Models::Encryptable".freeze
VALID_TYPES =
%i[string integer float decimal boolean date datetime].freeze
CASTERS =

Reusable ActiveModel casters. :decimal and :datetime round-trip through Strings and are handled explicitly (mirrors Models::Storable).

{
  string: ActiveModel::Type::String.new,
  integer: ActiveModel::Type::Integer.new,
  float: ActiveModel::Type::Float.new,
  boolean: ActiveModel::Type::Boolean.new,
  date: ActiveModel::Type::Date.new,
  datetime: ActiveModel::Type::DateTime.new
}.freeze

Class Method Summary collapse

Class Method Details

.blind_fingerprint(rule, value) ⇒ Object

Deterministic blind-index fingerprint for a field's value, applying the field's normalization expression:. Shared by the generated class finders and the before_save refresh. Returns nil for a nil value.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/concerns_on_rails/models/encryptable.rb', line 95

def self.blind_fingerprint(rule, value)
  bi = rule[:blind_index]
  return nil unless bi
  return nil if value.nil?

  normalized = bi[:expression] ? bi[:expression].call(value) : value
  return nil if normalized.nil?

  config = ConcernsOnRails.encryption
  material = config.resolve_material(rule[:key])
  return normalized.to_s if material == ConcernsOnRails::Encryption::PASSTHROUGH

  ConcernsOnRails::Support::Encryptor.blind_index(
    normalized, key: material, salt: config.key_derivation_salt
  )
end