Encryption of sensitive data#194
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a database-layer encryption approach for selected user fields in CARE by moving the physical table behind a compatibility view/trigger layer and setting a per-connection PostgreSQL encryption key. The goal is to keep the existing application-facing user contract unchanged while encrypting data at rest.
Changes:
- Add a migration that renames
usertouser_secure, encrypts selected columns, creates helper SQL functions, and recreatesuseras a read/write view. - Route inserts/updates/deletes on the
userview through anINSTEAD OFtrigger. - Set
app.encryption_keyon Sequelize connections fromDB_ENCRYPTION_KEY.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| backend/db/migrations/20260505121500-transform-user-encryption-db-only.js | Implements the encrypted backing table, compatibility view, SQL helpers, and trigger-based write path for user records. |
| backend/db/index.js | Adds connection initialization logic to set the PostgreSQL session encryption key for application queries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| NEW.deleted, | ||
| NEW."createdAt", | ||
| NEW."updatedAt", | ||
| NEW."deletedAt", | ||
| NEW."acceptedAt", | ||
| NEW."acceptDataSharing", |
| IF EXISTS ( | ||
| SELECT 1 | ||
| FROM public.user_secure u | ||
| WHERE public.decrypt_text(u.email) = NEW.email | ||
| ) THEN | ||
| RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; |
| IF EXISTS ( | ||
| SELECT 1 | ||
| FROM public.user_secure u | ||
| WHERE u.id <> OLD.id | ||
| AND public.decrypt_text(u.email) = NEW.email | ||
| ) THEN | ||
| RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; |
| ALTER COLUMN email TYPE bytea USING public.encrypt_text(email), | ||
| ALTER COLUMN "initialPassword" TYPE bytea USING public.encrypt_text("initialPassword"); |
| "emailVerified", | ||
| "emailVerificationToken", | ||
| "resetToken", | ||
| "lastPasswordResetEmailSent", |
There was a problem hiding this comment.
can you ask dennis do we need to encrypt them as well and also for 2fa otp and totpsectret.
There was a problem hiding this comment.
okay. @dennis-zyska -> Do we need to encrypt these columns as well, as well as the 2fa and totp ones also?
| "twoFactorOtp", | ||
| "twoFactorOtpExpiresAt", | ||
| "twoFactorMethods", | ||
| "totpSecret", |
| } | ||
| const encryptionKey = process.env.DB_ENCRYPTION_KEY; | ||
| if (!encryptionKey) { | ||
| return; |
| `ALTER TABLE public."user" RENAME TO user_secure;`, | ||
| { transaction } | ||
| ); | ||
|
|
||
| await queryInterface.sequelize.query( | ||
| ` | ||
| ALTER TABLE public.user_secure | ||
| ALTER COLUMN "firstName" TYPE bytea USING public.encrypt_text("firstName"), | ||
| ALTER COLUMN "lastName" TYPE bytea USING public.encrypt_text("lastName"), | ||
| ALTER COLUMN email TYPE bytea USING public.encrypt_text(email), | ||
| ALTER COLUMN "initialPassword" TYPE bytea USING public.encrypt_text("initialPassword"); | ||
| `, | ||
| { transaction } | ||
| ); | ||
|
|
||
| await queryInterface.sequelize.query( | ||
| ` | ||
| CREATE OR REPLACE VIEW public."user" AS | ||
| SELECT | ||
| id, | ||
| public.decrypt_text("firstName") AS "firstName", | ||
| public.decrypt_text("lastName") AS "lastName", | ||
| "userName", | ||
| public.decrypt_text(email) AS email, | ||
| "passwordHash", | ||
| "acceptTerms", | ||
| "acceptStats", | ||
| salt, | ||
| "lastLoginAt", | ||
| deleted, | ||
| "createdAt", | ||
| "updatedAt", | ||
| "deletedAt", | ||
| "acceptedAt", | ||
| "acceptDataSharing", | ||
| "rolesUpdatedAt", | ||
| "extId", | ||
| public.decrypt_text("initialPassword") AS "initialPassword", | ||
| "emailVerified", | ||
| "emailVerificationToken", | ||
| "resetToken", | ||
| "lastPasswordResetEmailSent", | ||
| "lastVerificationEmailSent", | ||
| "twoFactorOtp", | ||
| "twoFactorOtpExpiresAt", | ||
| "twoFactorMethods", | ||
| "totpSecret", | ||
| "orcidId", | ||
| "ldapUsername", | ||
| "samlNameId" | ||
| FROM public.user_secure; |
There was a problem hiding this comment.
i think it is a good recommendation.
There was a problem hiding this comment.
Okay, thank you for the idea
| IF EXISTS ( | ||
| SELECT 1 | ||
| FROM public.user_secure u | ||
| WHERE public.decrypt_text(u.email) = NEW.email | ||
| ) THEN |
| @@ -1,3 +1,3 @@ | |||
| /** | |||
| * Declare all necessary dependencies to work with the database models | |||
| * | |||
There was a problem hiding this comment.
Add your name in the author
| } | ||
| const encryptionKey = process.env.DB_ENCRYPTION_KEY; | ||
| if (!encryptionKey) { | ||
| return; |
There was a problem hiding this comment.
add error msg the copilot suggestion.
| `ALTER TABLE public."user" RENAME TO user_secure;`, | ||
| { transaction } | ||
| ); | ||
|
|
||
| await queryInterface.sequelize.query( | ||
| ` | ||
| ALTER TABLE public.user_secure | ||
| ALTER COLUMN "firstName" TYPE bytea USING public.encrypt_text("firstName"), | ||
| ALTER COLUMN "lastName" TYPE bytea USING public.encrypt_text("lastName"), | ||
| ALTER COLUMN email TYPE bytea USING public.encrypt_text(email), | ||
| ALTER COLUMN "initialPassword" TYPE bytea USING public.encrypt_text("initialPassword"); | ||
| `, | ||
| { transaction } | ||
| ); | ||
|
|
||
| await queryInterface.sequelize.query( | ||
| ` | ||
| CREATE OR REPLACE VIEW public."user" AS | ||
| SELECT | ||
| id, | ||
| public.decrypt_text("firstName") AS "firstName", | ||
| public.decrypt_text("lastName") AS "lastName", | ||
| "userName", | ||
| public.decrypt_text(email) AS email, | ||
| "passwordHash", | ||
| "acceptTerms", | ||
| "acceptStats", | ||
| salt, | ||
| "lastLoginAt", | ||
| deleted, | ||
| "createdAt", | ||
| "updatedAt", | ||
| "deletedAt", | ||
| "acceptedAt", | ||
| "acceptDataSharing", | ||
| "rolesUpdatedAt", | ||
| "extId", | ||
| public.decrypt_text("initialPassword") AS "initialPassword", | ||
| "emailVerified", | ||
| "emailVerificationToken", | ||
| "resetToken", | ||
| "lastPasswordResetEmailSent", | ||
| "lastVerificationEmailSent", | ||
| "twoFactorOtp", | ||
| "twoFactorOtpExpiresAt", | ||
| "twoFactorMethods", | ||
| "totpSecret", | ||
| "orcidId", | ||
| "ldapUsername", | ||
| "samlNameId" | ||
| FROM public.user_secure; |
There was a problem hiding this comment.
i think it is a good recommendation.
| "emailVerified", | ||
| "emailVerificationToken", | ||
| "resetToken", | ||
| "lastPasswordResetEmailSent", |
There was a problem hiding this comment.
can you ask dennis do we need to encrypt them as well and also for 2fa otp and totpsectret.
Add DB-only migration to encrypt user table fields with pgcrypto and wire encryption helpers in db/index.js.
44d84ec to
c49c087
Compare
Main Description
This PR introduces phase-1 database encryption for sensitive
userfields using PostgreSQLpgcrypto, while keeping backend changes minimal and preserving existing Sequelize usage patterns.Implemented in this scope:
userfields:firstNamelastNameemailinitialPasswordDB_ENCRYPTION_KEYin Sequelize connection initialization.New User Features
New Dev Features
Bug Fixes
Known Limitations
DB_ENCRYPTION_KEYmust be configured in each environment.Future Steps