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
-
.blind_fingerprint(rule, value) ⇒ Object
Deterministic blind-index fingerprint for a field's value, applying the field's normalization
expression:.
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 |