#5609 Encrypt sensitive Personally Identifiable Information (PII)#5626
#5609 Encrypt sensitive Personally Identifiable Information (PII)#5626eugeniojimenes wants to merge 2 commits into
Conversation
- 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)
dorner
left a comment
There was a problem hiding this comment.
the Devise users.email
is there a reason this wasn't covered?
| 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 |
There was a problem hiding this comment.
Have you confirmed that this won't result in any data loss? The date converts cleanly?
There was a problem hiding this comment.
Wrote a spec for it: spec/migrations/encrypt_dates_of_birth_spec.rb. It puts date_of_birth back to date, seeds plaintext rows with raw SQL, runs both migrations against real Postgres, and checks the dates come back unchanged. They do, so no data loss.
But it exposed a hidden dependency: a plain change_column ... :text serializes the date with whatever DateStyle the server has. The default is ISO, so we get 1990-03-04 and it works. Under SET DateStyle TO 'SQL, MDY' the spec fails: is 03/04/1990 April 3rd or March 4th? Ruby reads it day first and encrypts the wrong date. 02/29 and 12/31 have no valid month reading, so they end up nil.
Fixed by pinning the format in the migration: using: "to_char(date_of_birth, 'YYYY-MM-DD')". That ignores DateStyle, and the spec passes either way.
|
|
||
| def up | ||
| MODELS.each do |model| | ||
| model.find_in_batches do |batch| |
There was a problem hiding this comment.
Can we find only non-encrypted rows to handle the case where a run might fail?
There was a problem hiding this comment.
You're right, and my "idempotent" comment was wrong: idempotent in the value, not in the work. record.encrypt writes unconditionally (it just calls update_columns), and our encryption is non-deterministic, so re-encrypting an already-encrypted row rewrites it with new ciphertext. Same value, wasted write. A failed run redid the whole table.
There's no way to filter this in SQL. Ciphertext is opaque to Postgres, and Rails has no scope for it (extend_queries only helps deterministic attributes, and ours are all non-deterministic). Matching the payload prefix with NOT LIKE '{"p":%' would work, but it hardcodes Rails' internal JSON format into a migration.
So I used the public API instead, skipping the row before the write:
batch.reject { |record| encrypted?(record) }.each(&:encrypt)encrypted? uses record.encrypted_attribute?, and treats a blank value as done. We still read every row, which is one sequential scan, but we only write the ones that need it. That's the expensive part (WAL, replication), so a re-run after a failure only does what's left.
Covered in spec/migrations/backfill_encrypted_pii_spec.rb, including already-encrypted rows staying byte for byte unchanged.
| @@ -0,0 +1,15 @@ | |||
| if Rails.env.local? # development + test: committed non-prod keys (like secret_key_base in secrets.yml) | |||
| primary_key = "YCEmkDIf91rP301UjZSXb8QwB43wU9qK" | |||
There was a problem hiding this comment.
Maybe we should add this to .env.development instead?
There was a problem hiding this comment.
Done. The keys are in .env.development now, and the initializer is a plain ENV.fetch with no environment branch and no hardcoded values.
One catch: .env.development alone isn't enough. dotenv doesn't load it in the test environment (it loads .env.test instead, see dotenv/rails.rb), and CI never creates a .env, so the suite wouldn't boot. So I added .env.test too, with its own key pair. Both are throwaway non-production keys, and .gitignore only covers .env and .env.production, so they're safe to commit.
Checked all three paths:
- development reads
.env.development - test reads
.env.test, and the suite passes - production with no keys set fails the boot with
KeyError: key not found: "AR_ENCRYPTION_PRIMARY_KEY", instead of starting up and silently writing data nobody can read back
- pin the date -> text cast with to_char, so the conversion no longer depends on the server DateStyle - skip already-encrypted rows in the backfill, so a run that fails halfway resumes instead of rewriting everything - encrypt the partner profile contact emails and phones, which belong to named individuals - move the dev/test keys to committed .env.development and .env.test, and read ENV in every environment - add migration specs covering the date conversion and the backfill
You're right, "org data isn't personal" doesn't apply to
A bad deploy here locks everyone out, super admins included, so I'd rather do it as its own PR with a staged rollout. If separate, While we're drawing the line, two more scope calls for you:
|
|
NOTE: I also fixed a gap I'd missed here (the change is already in my latest commit) While addressing the review I re-checked the whole schema with the same "named individual" test. The They are encrypted now. No migration was needed since they are already string columns, and nothing queries or uniqueness-validates them, so they stay non-deterministic like the rest. |
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_birthPartners::AuthorizedFamilyMember#date_of_birthPartners::Family#guardian_phonePartners::Profile#executive_director_email,#executive_director_phone,#primary_contact_email,#primary_contact_phone,#primary_contact_mobile,#pick_up_email,#pick_up_phoneProductDriveParticipant#phone,#emailThe line is person vs organization: these all identify a specific individual.
Out of scope: contact details that belong to an organization rather than a person (
organizations.email,partners.email,donation_sites.email,vendors.email), address fields, and free textcomments. The Deviseusers.emailis personal data, but it is the login credential and would need deterministic encryption, which touches the auth path, the uniqueness validation and the admin email search. Discussed in the review thread.Alternatives and tradeoffs:
date_of_birthimpossible 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_birthwas changed fromdatetotextso the column can hold the ciphertext. The models re-declareattribute :date_of_birth, :dateso the value still behaves as a date in forms, JSON and views. The conversion pins its format withusing: "to_char(date_of_birth, 'YYYY-MM-DD')": a barechange_columnserializes the date with whateverDateStylethe server happens to run, and the rewrite is in place and irreversible.Partners::Profilecolumns needed no migration, since they are alreadystringcolumns.find_in_batches+encrypt, withdisable_ddl_transaction!and a small throttle). It skips rows that are already encrypted, so a run that fails halfway can be re-run and only does the rows that are left.Key management: two keys (
primary_keyandkey_derivation_salt) are read from the environment in every environment. Development and test use committed non-production keys, in.env.developmentand.env.test. Production and staging read them from the real environment and refuse to boot if they are missing.Dependencies: none. This uses the built-in Rails encryption, no new gems.
Deploy notes:
AR_ENCRYPTION_PRIMARY_KEYandAR_ENCRYPTION_KEY_DERIVATION_SALTin production and staging (generate them withbin/rails db:encryption:init). Losing these keys means the encrypted data cannot be recovered.support_unencrypted_data: true.support_unencrypted_datais set totruefor 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 tofalseto fully enforce encryption.Type of change
How Has This Been Tested?
spec/migrations/encrypt_dates_of_birth_spec.rbrewinds the schema todate, seeds legacy plaintext rows with raw SQL, runs both migrations against real Postgres, and checks that no date is lost. It covers leap days and ambiguous dates, and it asserts the result is identical under a non-ISODateStyle.spec/migrations/backfill_encrypted_pii_spec.rbchecks the backfill encrypts plaintext rows across all five models, leaves already-encrypted rows byte for byte unchanged, and picks up where a failed run stopped.