From 8ae5cefa7e48b65392fdb17bd9fec02238a83e34 Mon Sep 17 00:00:00 2001 From: Eugenio Augusto Jimenes Date: Fri, 3 Jul 2026 16:40:13 -0300 Subject: [PATCH] feat: encrypt sensitive PII at rest - encrypt date_of_birth, guardian_phone, and product drive phone/email via Active Record encryption - migrate date_of_birth columns from date to text to hold the ciphertext - sort children by decrypted date_of_birth in Ruby since SQL cannot order ciphertext - backfill existing rows in place and add encryption key config (ENV in prod, committed non-prod keys for dev/test) --- .env.example | 7 ++++++ .../partners/children_controller.rb | 8 ++++++ .../partners/authorized_family_member.rb | 5 +++- app/models/partners/child.rb | 5 +++- app/models/partners/family.rb | 1 + app/models/product_drive_participant.rb | 1 + .../initializers/active_record_encryption.rb | 15 +++++++++++ .../20260703191550_encrypt_dates_of_birth.rb | 16 ++++++++++++ .../20260703191552_backfill_encrypted_pii.rb | 25 +++++++++++++++++++ db/schema.rb | 6 ++--- spec/factories/partners/children.rb | 2 +- .../partners/authorized_family_member_spec.rb | 15 ++++++++++- spec/models/partners/child_spec.rb | 12 ++++++++- spec/models/partners/family_spec.rb | 9 +++++++ spec/models/product_drive_participant_spec.rb | 11 ++++++++ .../partners/children_requests_spec.rb | 21 ++++++++++++++++ 16 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 config/initializers/active_record_encryption.rb create mode 100644 db/migrate/20260703191550_encrypt_dates_of_birth.rb create mode 100644 db/migrate/20260703191552_backfill_encrypted_pii.rb diff --git a/.env.example b/.env.example index 5df0115d4d..a936a69eb0 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,10 @@ RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe # [OPTIONAL] - Used to fetch copies of production DB AZURE_STORAGE_ACCOUNT_NAME= AZURE_STORAGE_ACCESS_KEY= + +# Active Record encryption keys — PRODUCTION/STAGING ONLY. +# Dev/test use committed non-prod keys in config/initializers/active_record_encryption.rb, +# so these are ignored locally. Generate with: bin/rails db:encryption:init +# Losing these keys = permanent, unrecoverable loss of the encrypted PII. +AR_ENCRYPTION_PRIMARY_KEY= +AR_ENCRYPTION_KEY_DERIVATION_SALT= diff --git a/app/controllers/partners/children_controller.rb b/app/controllers/partners/children_controller.rb index 1c9ab499dc..a63869075b 100644 --- a/app/controllers/partners/children_controller.rb +++ b/app/controllers/partners/children_controller.rb @@ -13,6 +13,13 @@ def index @children = @filterrific.find + # date_of_birth is encrypted => can't ORDER BY it in SQL. Sort the decrypted value in Ruby + # (safe: this index isn't paginated, whole set is already loaded). + if params[:sort] == "date_of_birth" + @children = @children.sort_by { |c| [c.date_of_birth || Date.new(9999, 12, 31), c.last_name.to_s, c.id] } + @children = @children.reverse if sort_direction == "desc" + end + respond_to do |format| format.js format.html @@ -91,6 +98,7 @@ def child_params end def sort_order + return "last_name, id" if params[:sort] == "date_of_birth" # encrypted; sorted in Ruby, not SQL sort_column + ' ' + sort_direction end diff --git a/app/models/partners/authorized_family_member.rb b/app/models/partners/authorized_family_member.rb index efa60b2626..f9045195c4 100644 --- a/app/models/partners/authorized_family_member.rb +++ b/app/models/partners/authorized_family_member.rb @@ -4,7 +4,7 @@ # # id :bigint not null, primary key # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # last_name :string @@ -18,6 +18,9 @@ class AuthorizedFamilyMember < Base belongs_to :family has_many :child_item_requests, dependent: :nullify + attribute :date_of_birth, :date + encrypts :date_of_birth + def display_name "#{first_name} #{last_name}" end diff --git a/app/models/partners/child.rb b/app/models/partners/child.rb index d34def9f8b..5879e858fc 100644 --- a/app/models/partners/child.rb +++ b/app/models/partners/child.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb @@ -27,6 +27,9 @@ class Child < Base has_many :child_item_requests, dependent: :destroy has_and_belongs_to_many :requested_items, class_name: 'Item' + attribute :date_of_birth, :date + encrypts :date_of_birth + include Filterable include Exportable diff --git a/app/models/partners/family.rb b/app/models/partners/family.rb index 11d73da765..b208902224 100644 --- a/app/models/partners/family.rb +++ b/app/models/partners/family.rb @@ -28,6 +28,7 @@ module Partners class Family < Base has_paper_trail + encrypts :guardian_phone belongs_to :partner, class_name: '::Partner' has_many :children, dependent: :destroy has_many :authorized_family_members, dependent: :destroy diff --git a/app/models/product_drive_participant.rb b/app/models/product_drive_participant.rb index dc1ab6823e..92f515233a 100644 --- a/app/models/product_drive_participant.rb +++ b/app/models/product_drive_participant.rb @@ -18,6 +18,7 @@ class ProductDriveParticipant < ApplicationRecord has_paper_trail + encrypts :phone, :email include Provideable include Geocodable diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb new file mode 100644 index 0000000000..5d38a60f4d --- /dev/null +++ b/config/initializers/active_record_encryption.rb @@ -0,0 +1,15 @@ +if Rails.env.local? # development + test: committed non-prod keys (like secret_key_base in secrets.yml) + primary_key = "YCEmkDIf91rP301UjZSXb8QwB43wU9qK" + key_derivation_salt = "fZVRM9z52VcYVlFG1UikohNjZgcqFX8z" +else # production/staging: ENV.fetch (no default) => boot fails loudly if unset + primary_key = ENV.fetch("AR_ENCRYPTION_PRIMARY_KEY") + key_derivation_salt = ENV.fetch("AR_ENCRYPTION_KEY_DERIVATION_SALT") +end + +ActiveRecord::Encryption.configure( + primary_key: primary_key, + key_derivation_salt: key_derivation_salt, + # Bridge for migrating existing plaintext rows: lets reads of not-yet-backfilled + # columns work instead of raising. Flip to false (follow-up) after prod backfill. + support_unencrypted_data: true +) diff --git a/db/migrate/20260703191550_encrypt_dates_of_birth.rb b/db/migrate/20260703191550_encrypt_dates_of_birth.rb new file mode 100644 index 0000000000..34f7a8099d --- /dev/null +++ b/db/migrate/20260703191550_encrypt_dates_of_birth.rb @@ -0,0 +1,16 @@ +class EncryptDatesOfBirth < ActiveRecord::Migration[8.0] + def up + # date -> text: encrypted values are string blobs and won't fit a date column. + safety_assured do + change_column :children, :date_of_birth, :text + change_column :authorized_family_members, :date_of_birth, :text + end + end + + def down + safety_assured do + change_column :children, :date_of_birth, :date, using: "date_of_birth::date" + change_column :authorized_family_members, :date_of_birth, :date, using: "date_of_birth::date" + end + end +end diff --git a/db/migrate/20260703191552_backfill_encrypted_pii.rb b/db/migrate/20260703191552_backfill_encrypted_pii.rb new file mode 100644 index 0000000000..2f8458e27d --- /dev/null +++ b/db/migrate/20260703191552_backfill_encrypted_pii.rb @@ -0,0 +1,25 @@ +class BackfillEncryptedPii < ActiveRecord::Migration[8.0] + # `encrypts` only encrypts new writes, so existing rows stay plaintext until re-saved. + # This re-encrypts them in place. `record.encrypt` uses update_columns (no validations, + # callbacks, timestamps or paper_trail), and relies on + # config.active_record.encryption.support_unencrypted_data = true to read the plaintext. + # + # No wrapping transaction: each row commits on its own, so a big table doesn't hold one + # long transaction/lock and a failed run keeps its progress (encrypt is idempotent). + disable_ddl_transaction! + + MODELS = [Partners::Child, Partners::AuthorizedFamilyMember, Partners::Family, ProductDriveParticipant].freeze + + def up + MODELS.each do |model| + model.find_in_batches do |batch| + batch.each(&:encrypt) + sleep(0.1) # throttle prod DB load (~40-50k rows); matches BackfillItemReportingCategoryField + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/schema.rb b/db/schema.rb index 6763e951c6..7579e2a730 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_13_201123) do +ActiveRecord::Schema[8.0].define(version: 2026_07_03_191552) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -112,7 +112,7 @@ create_table "authorized_family_members", force: :cascade do |t| t.string "first_name" t.string "last_name" - t.date "date_of_birth" + t.text "date_of_birth" t.string "gender" t.text "comments" t.bigint "family_id" @@ -173,7 +173,7 @@ create_table "children", force: :cascade do |t| t.string "first_name" t.string "last_name" - t.date "date_of_birth" + t.text "date_of_birth" t.string "gender" t.jsonb "child_lives_with" t.jsonb "race" diff --git a/spec/factories/partners/children.rb b/spec/factories/partners/children.rb index 76329c7226..9859b521a4 100644 --- a/spec/factories/partners/children.rb +++ b/spec/factories/partners/children.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb diff --git a/spec/models/partners/authorized_family_member_spec.rb b/spec/models/partners/authorized_family_member_spec.rb index 8da9bbcb49..7740783a64 100644 --- a/spec/models/partners/authorized_family_member_spec.rb +++ b/spec/models/partners/authorized_family_member_spec.rb @@ -4,7 +4,7 @@ # # id :bigint not null, primary key # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # last_name :string @@ -19,6 +19,19 @@ it { should have_many(:child_item_requests).dependent(:nullify) } end + describe "encrypts date_of_birth at rest" do + let(:family) { create(:partners_family) } + let(:member) do + family.authorized_family_members.create!(first_name: "A", last_name: "B", date_of_birth: Date.new(2020, 1, 1)) + end + + it "stores ciphertext but round-trips as a Date" do + expect(member.reload.date_of_birth).to eq(Date.new(2020, 1, 1)) + expect(member.date_of_birth).to be_a(Date) + expect(member.ciphertext_for(:date_of_birth)).not_to include("2020-01-01") + end + end + describe "#display_name" do let(:partners_family) { create(:partners_family) } let(:authorized_family_member) { partners_family.create_authorized } diff --git a/spec/models/partners/child_spec.rb b/spec/models/partners/child_spec.rb index 7934f19a8e..92c85a8b83 100644 --- a/spec/models/partners/child_spec.rb +++ b/spec/models/partners/child_spec.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb @@ -27,6 +27,16 @@ it { should have_and_belong_to_many(:requested_items).class_name('Item') } end + describe "encrypts date_of_birth at rest" do + let(:child) { create(:partners_child, date_of_birth: Date.new(2020, 1, 1)) } + + it "stores ciphertext but round-trips as a Date" do + expect(child.reload.date_of_birth).to eq(Date.new(2020, 1, 1)) + expect(child.date_of_birth).to be_a(Date) + expect(child.ciphertext_for(:date_of_birth)).not_to include("2020-01-01") + end + end + describe "#display_name" do subject { partners_child } let(:partners_child) { create(:partners_child) } diff --git a/spec/models/partners/family_spec.rb b/spec/models/partners/family_spec.rb index f039189178..cceac0e628 100644 --- a/spec/models/partners/family_spec.rb +++ b/spec/models/partners/family_spec.rb @@ -33,6 +33,15 @@ it { should have_many(:authorized_family_members).dependent(:destroy) } end + describe "encrypts guardian_phone at rest" do + let(:family) { create(:partners_family, guardian_phone: "555-123-4567") } + + it "stores ciphertext but round-trips the value" do + expect(family.reload.guardian_phone).to eq("555-123-4567") + expect(family.ciphertext_for(:guardian_phone)).not_to include("555-123-4567") + end + end + describe "validations" do subject { partners_family } let(:partners_family) { FactoryBot.build(:partners_family) } diff --git a/spec/models/product_drive_participant_spec.rb b/spec/models/product_drive_participant_spec.rb index 94abe444bd..45ab6610f2 100644 --- a/spec/models/product_drive_participant_spec.rb +++ b/spec/models/product_drive_participant_spec.rb @@ -19,6 +19,17 @@ RSpec.describe ProductDriveParticipant, type: :model do it_behaves_like "provideable" + describe "encrypts phone and email at rest" do + let(:participant) { create(:product_drive_participant, phone: "555-123-4567", email: "person@example.com") } + + it "stores phone and email as ciphertext but round-trips them" do + expect(participant.reload.phone).to eq("555-123-4567") + expect(participant.email).to eq("person@example.com") + expect(participant.ciphertext_for(:phone)).not_to include("555-123-4567") + expect(participant.ciphertext_for(:email)).not_to include("person@example.com") + end + end + context "Validations" do it "is invalid unless it has either a phone number or an email" do expect(build(:product_drive_participant, :no_contact_name_or_email, contact_name: "George Henry")).not_to be_valid diff --git a/spec/requests/partners/children_requests_spec.rb b/spec/requests/partners/children_requests_spec.rb index 8547c91a30..227a90f1fb 100644 --- a/spec/requests/partners/children_requests_spec.rb +++ b/spec/requests/partners/children_requests_spec.rb @@ -57,5 +57,26 @@ CSV expect(response.body).to eq(csv) end + + # date_of_birth is encrypted (non-deterministic) => SQL ORDER BY sorts ciphertext. + # The controller must sort by the decrypted date in Ruby instead. + describe "sorting by the encrypted date_of_birth" do + let!(:child_no_dob) do + create(:partners_child, first_name: "Nodob", last_name: "Smith", date_of_birth: nil, family: family) + end + + it "sorts ascending by actual date, with nils last" do + get partners_children_path(sort: "date_of_birth", direction: "asc") + body = response.body + expect(body.index("Jane")).to be < body.index("John") # 2018 before 2019 + expect(body.index("John")).to be < body.index("Nodob") # nil date last + end + + it "sorts descending by actual date" do + get partners_children_path(sort: "date_of_birth", direction: "desc") + body = response.body + expect(body.index("John")).to be < body.index("Jane") # 2019 before 2018 + end + end end end