Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
8 changes: 8 additions & 0 deletions app/controllers/partners/children_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion app/models/partners/authorized_family_member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/models/partners/child.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions app/models/partners/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/product_drive_participant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

class ProductDriveParticipant < ApplicationRecord
has_paper_trail
encrypts :phone, :email
include Provideable
include Geocodable

Expand Down
15 changes: 15 additions & 0 deletions config/initializers/active_record_encryption.rb
Original file line number Diff line number Diff line change
@@ -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
)
16 changes: 16 additions & 0 deletions db/migrate/20260703191550_encrypt_dates_of_birth.rb
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions db/migrate/20260703191552_backfill_encrypted_pii.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion spec/factories/partners/children.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion spec/models/partners/authorized_family_member_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down
12 changes: 11 additions & 1 deletion spec/models/partners/child_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
Expand Down
9 changes: 9 additions & 0 deletions spec/models/partners/family_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
11 changes: 11 additions & 0 deletions spec/models/product_drive_participant_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions spec/requests/partners/children_requests_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading