Skip to content

#5609 Encrypt sensitive Personally Identifiable Information (PII)#5626

Open
eugeniojimenes wants to merge 1 commit into
rubyforgood:mainfrom
eugeniojimenes:5609-encrypt-sensitive-pii
Open

#5609 Encrypt sensitive Personally Identifiable Information (PII)#5626
eugeniojimenes wants to merge 1 commit into
rubyforgood:mainfrom
eugeniojimenes:5609-encrypt-sensitive-pii

Conversation

@eugeniojimenes

@eugeniojimenes eugeniojimenes commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Resolves #5609

Description

This encrypts sensitive personal data at rest using the built-in Active Record Encryption (encrypts).

Encrypted columns (all non-deterministic):

  • Partners::Child#date_of_birth
  • Partners::AuthorizedFamilyMember#date_of_birth
  • Partners::Family#guardian_phone
  • ProductDriveParticipant#phone, #email

Out of scope (because organization and business data is not personal data): partner, vendor, donation site and organization contact emails and phones, the Devise users.email, address fields, and free text comments.

Alternatives and tradeoffs:

  • Non-deterministic (the more secure option) was chosen because none of these columns are queried by value, validated for uniqueness, or searched.
  • Encryption makes date_of_birth impossible to sort in SQL, because the stored ciphertext does not follow date order (this is also true for deterministic encryption). Instead of removing the "sort children by date of birth" feature, the controller sorts the decrypted value in Ruby. This is safe because that page is not paginated and loads only one partner's children.
  • date_of_birth was changed from date to text so the column can hold the ciphertext. The models re-declare attribute :date_of_birth, :date so the value still behaves as a date in forms, JSON and views.
  • Existing rows are re-encrypted in place by a data migration (find_in_batches + each(&:encrypt), with disable_ddl_transaction! and a small throttle). It is idempotent, so it can be re-run safely.

Key management: two keys (primary_key and key_derivation_salt) are set in config/initializers/active_record_encryption.rb, following the same pattern the project already uses for secret_key_base. Development and test use committed non-production keys. Production and staging read them from the environment and refuse to boot if they are missing. The variable names are documented in .env.example.

Dependencies: none. This uses the built-in Rails encryption, no new gems.

Deploy notes:

  1. Set AR_ENCRYPTION_PRIMARY_KEY and AR_ENCRYPTION_KEY_DERIVATION_SALT in production and staging (generate them with bin/rails db:encryption:init). Losing these keys means the encrypted data cannot be recovered.
  2. The backfill migration re-encrypts existing rows. Existing date rows keep reading correctly after the switch to text thanks to support_unencrypted_data: true.
  3. ⚠️ Follow-up: support_unencrypted_data is set to true for now, so reads of rows that are not yet backfilled do not raise an error. After the production backfill is done, a follow-up should set it to false to fully enforce encryption.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Model specs for the four models check that each value is saved as ciphertext and still reads back correctly. The date of birth also keeps its date type.
  • A request spec checks that the children page sorts by date of birth in both directions, with empty dates last.
  • The backfill was checked by hand: a plaintext row written with raw SQL becomes encrypted after the migration, and the value is kept.

Note: when I ran the full suite locally, one system test failed (kit_system_spec.rb, the duplicate items modal from #5538). It is unrelated to this change and looks like an existing flaky test. Rubocop, Brakeman and factory_bot:lint all pass.

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

Consider in-db encryption of sensitive data

1 participant