|
| 1 | +# PII Encryption Design — User Microservice |
| 2 | +**Date:** 2026-05-07 |
| 3 | +**Compliance:** DPDPA (Digital Personal Data Protection Act) |
| 4 | +**Scope:** `Users` table — Phase 1 |
| 5 | +**Status:** Decisions finalised ✅ |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. Objective |
| 10 | + |
| 11 | +Encrypt all Personally Identifiable Information (PII) stored in the `Users` table at rest. Only ciphertext will persist in the DB. Encryption/decryption happens transparently in the service layer using AES-256 with a key from an environment variable (`PII_ENCRYPTION_KEY`). |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 2. PII Fields in Scope (Users Table) |
| 16 | + |
| 17 | +Under DPDPA, **name is classified as Personal Data** — `firstName`, `lastName`, `middleName` are all required to be encrypted. |
| 18 | + |
| 19 | +| Column | DB Column Name (after migration) | Encryption Mode | Searchable in DB? | |
| 20 | +|---|---|---|---| |
| 21 | +| `email` | `email` (same, value encrypted) | Deterministic (AES-256-CBC) | YES | |
| 22 | +| `mobile` | `mobile` (same, value encrypted) | Deterministic (AES-256-CBC) | YES | |
| 23 | +| `dob` | `dob` (same, value encrypted) | Random-IV (AES-256-GCM) | NO | |
| 24 | +| `firstName` | `firstName` (same, value encrypted) | Deterministic (AES-256-CBC) | YES | |
| 25 | +| `lastName` | `lastName` (same, value encrypted) | Deterministic (AES-256-CBC) | YES | |
| 26 | +| `middleName` | `middleName` (same, value encrypted) | Random-IV (AES-256-GCM) | NO | |
| 27 | +| `address` | `address` (same, value encrypted) | Random-IV (AES-256-GCM) | NO | |
| 28 | +| `pincode` | `pincode` (same, value encrypted) | Random-IV (AES-256-GCM) | NO | |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## 3. Column Strategy — Keep Same Names, Store Encrypted Values ✅ |
| 33 | + |
| 34 | +**Decision:** Keep existing column names (`email`, `mobile`, `dob`, `firstName`, etc.) in the `Users` table. The encrypted ciphertext is stored directly in these columns. The separate `encryptedEmail`, `encryptedMobile`, `encryptedDob` columns (currently in DB) will be **dropped**. |
| 35 | + |
| 36 | +**Why this is better:** |
| 37 | +- No entity renames or response field mapping changes needed |
| 38 | +- Reporting/analytics tools that query the DB directly will receive encrypted values — which is the correct DPDPA behaviour (they should not see plaintext PII) |
| 39 | +- Simpler migration — encrypt in-place, drop the redundant `encryptedXxx` columns |
| 40 | + |
| 41 | +### Final DB Column State After Migration |
| 42 | + |
| 43 | +| Column | Type | Value | |
| 44 | +|---|---|---| |
| 45 | +| `email` | `TEXT` | AES encrypted ciphertext | |
| 46 | +| `mobile` | `TEXT` | AES encrypted ciphertext | |
| 47 | +| `dob` | `TEXT` | AES encrypted ciphertext | |
| 48 | +| `firstName` | `TEXT` | AES encrypted ciphertext | |
| 49 | +| `lastName` | `TEXT` | AES encrypted ciphertext | |
| 50 | +| `middleName` | `TEXT` | AES encrypted ciphertext | |
| 51 | +| `address` | `TEXT` | AES encrypted ciphertext (was already TEXT) | |
| 52 | +| `pincode` | `TEXT` | AES encrypted ciphertext | |
| 53 | +| `encryptedEmail` | — | **DROPPED** | |
| 54 | +| `encryptedMobile` | — | **DROPPED** | |
| 55 | +| `encryptedDob` | — | **DROPPED** | |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## 4. Encryption Approach |
| 60 | + |
| 61 | +### Two Modes |
| 62 | + |
| 63 | +**Deterministic (AES-256-CBC, fixed IV derived from key)** |
| 64 | +- Used for: `email`, `mobile`, `firstName`, `lastName` |
| 65 | +- Same plaintext → same ciphertext every time |
| 66 | +- Allows DB-level `WHERE email = encryptDeterministic(input)` |
| 67 | +- Slightly lower security than random-IV (no IV randomness) — acceptable trade-off for searchability |
| 68 | + |
| 69 | +**Random-IV (AES-256-GCM, authenticated)** |
| 70 | +- Used for: `dob`, `middleName`, `address`, `pincode` |
| 71 | +- Same plaintext → different ciphertext each time |
| 72 | +- Maximum security, tamper-proof (GCM auth tag) |
| 73 | +- Not searchable at DB level — no need to search by these fields |
| 74 | + |
| 75 | +### Key Configuration |
| 76 | +- **Env var:** `PII_ENCRYPTION_KEY` — 32-byte hex string (256-bit) |
| 77 | +- **Deterministic IV:** first 16 bytes of `SHA-256(PII_ENCRYPTION_KEY)` — fixed, derived from key |
| 78 | +- **Random IV:** `crypto.randomBytes(12)` — regenerated per encryption |
| 79 | +- **Storage format (GCM):** `iv_base64:authTag_base64:ciphertext_base64` |
| 80 | +- **Storage format (CBC):** `ciphertext_base64` (IV is fixed/derived, no need to store) |
| 81 | +- **Key Rotation:** requires a data migration script to re-encrypt all rows |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## 5. Database Migration Steps |
| 86 | + |
| 87 | +``` |
| 88 | +Step 1: Widen column types to TEXT (to fit ciphertext) |
| 89 | +Step 2: Encrypt all existing rows in-place (via migration script) |
| 90 | +Step 3: Drop redundant encryptedXxx columns |
| 91 | +Step 4: Deploy new service code |
| 92 | +``` |
| 93 | + |
| 94 | +**Step 1 — SQL:** |
| 95 | +```sql |
| 96 | +ALTER TABLE "Users" ALTER COLUMN "email" TYPE TEXT; |
| 97 | +ALTER TABLE "Users" ALTER COLUMN "mobile" TYPE TEXT; |
| 98 | +ALTER TABLE "Users" ALTER COLUMN "dob" TYPE TEXT; |
| 99 | +ALTER TABLE "Users" ALTER COLUMN "firstName" TYPE TEXT; |
| 100 | +ALTER TABLE "Users" ALTER COLUMN "lastName" TYPE TEXT; |
| 101 | +ALTER TABLE "Users" ALTER COLUMN "middleName" TYPE TEXT; |
| 102 | +ALTER TABLE "Users" ALTER COLUMN "pincode" TYPE TEXT; |
| 103 | +-- address is already TEXT |
| 104 | +``` |
| 105 | + |
| 106 | +**Step 3 — SQL (after data encryption script completes):** |
| 107 | +```sql |
| 108 | +ALTER TABLE "Users" DROP COLUMN IF EXISTS "encryptedEmail"; |
| 109 | +ALTER TABLE "Users" DROP COLUMN IF EXISTS "encryptedMobile"; |
| 110 | +ALTER TABLE "Users" DROP COLUMN IF EXISTS "encryptedDob"; |
| 111 | +``` |
| 112 | + |
| 113 | +> **Critical:** Steps 1-3 must complete and be verified **before** deploying new service code. The service code assumes all PII columns contain ciphertext. |
| 114 | +
|
| 115 | +--- |
| 116 | + |
| 117 | +## 6. New Service: `EncryptionService` |
| 118 | + |
| 119 | +**Location:** `src/common/services/encryption.service.ts` |
| 120 | + |
| 121 | +```typescript |
| 122 | +@Injectable() |
| 123 | +export class EncryptionService { |
| 124 | + // AES-256-CBC — fixed IV derived from key. Same input → same output. |
| 125 | + encryptDeterministic(value: string): string |
| 126 | + |
| 127 | + // AES-256-GCM — random IV. Same input → different output each time. |
| 128 | + encryptRandom(value: string): string |
| 129 | + |
| 130 | + // Detects mode from ciphertext format and decrypts accordingly. |
| 131 | + decrypt(value: string): string |
| 132 | + |
| 133 | + // Convenience: encrypts all PII fields on a user object before DB write. |
| 134 | + encryptUserPII(user: Partial<User>): Partial<User> |
| 135 | + |
| 136 | + // Convenience: decrypts all PII fields on a user object after DB read. |
| 137 | + decryptUserPII(user: Partial<User>): Partial<User> |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +**PII field → encryption mode mapping (inside EncryptionService):** |
| 142 | +``` |
| 143 | +email → encryptDeterministic |
| 144 | +mobile → encryptDeterministic |
| 145 | +firstName → encryptDeterministic |
| 146 | +lastName → encryptDeterministic |
| 147 | +dob → encryptRandom |
| 148 | +middleName → encryptRandom |
| 149 | +address → encryptRandom |
| 150 | +pincode → encryptRandom |
| 151 | +``` |
| 152 | + |
| 153 | +--- |
| 154 | + |
| 155 | +## 7. API Impact Analysis |
| 156 | + |
| 157 | +### 7.1 CREATE — `POST /user/create` |
| 158 | + |
| 159 | +| Aspect | Change | |
| 160 | +|---|---| |
| 161 | +| **Request body** | No change — consumer sends plaintext | |
| 162 | +| **Service layer** | Call `encryptUserPII()` before `usersRepository.save()` | |
| 163 | +| **Keycloak** | Receives plaintext before encryption — no change | |
| 164 | +| **Response** | Call `decryptUserPII()` before returning | |
| 165 | + |
| 166 | +--- |
| 167 | + |
| 168 | +### 7.2 GET — `GET /user/read/:userId` |
| 169 | + |
| 170 | +| Aspect | Change | |
| 171 | +|---|---| |
| 172 | +| **DB fetch** | Fetches ciphertext from all PII columns | |
| 173 | +| **Service layer** | Call `decryptUserPII()` after fetch | |
| 174 | +| **Response** | Plaintext PII returned to consumer — field names unchanged | |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +### 7.3 UPDATE — `PATCH /user/update/:userid` |
| 179 | + |
| 180 | +| Aspect | Change | |
| 181 | +|---|---| |
| 182 | +| **Request body** | No change — consumer sends plaintext | |
| 183 | +| **Service layer** | Call `encryptUserPII()` on updated fields before save | |
| 184 | +| **Keycloak** | Receives plaintext — no change | |
| 185 | +| **Response** | `decryptUserPII()` before returning | |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +### 7.4 LIST / SEARCH — `POST /user/list` ⚠️ MOST IMPACTED |
| 190 | + |
| 191 | +Current PII filters: |
| 192 | +```json |
| 193 | +{ "filters": { "email": ["user@example.com"], "mobile": "9876543210" } } |
| 194 | +``` |
| 195 | + |
| 196 | +**Changes:** |
| 197 | +| Filter | Old Behaviour | New Behaviour | |
| 198 | +|---|---|---| |
| 199 | +| `email` | `WHERE email ILIKE ?` | `encryptDeterministic(input)` → `WHERE email = ?` (exact match) | |
| 200 | +| `mobile` | `WHERE mobile = ?` | `encryptDeterministic(input)` → `WHERE mobile = ?` (exact match) | |
| 201 | +| `firstName` | `WHERE firstName ILIKE ?` | `encryptDeterministic(input)` → `WHERE firstName = ?` (exact match) | |
| 202 | +| `lastName` | `WHERE lastName ILIKE ?` | `encryptDeterministic(input)` → `WHERE lastName = ?` (exact match) | |
| 203 | + |
| 204 | +> **Breaking change for partial/wildcard search:** `ILIKE '%rahul%'` is no longer possible. Search must be exact. This is an accepted trade-off for DPDPA compliance. |
| 205 | +
|
| 206 | +After DB fetch → call `decryptUserPII()` on each result before building response. |
| 207 | + |
| 208 | +--- |
| 209 | + |
| 210 | +### 7.5 CHECK USER — `POST /user/check` |
| 211 | + |
| 212 | +`ExistUserDto` has `email`, `mobile`, `firstName`, `lastName`. |
| 213 | + |
| 214 | +| Field | Change | |
| 215 | +|---|---| |
| 216 | +| `email` | `encryptDeterministic(input)` → `WHERE email = ?` | |
| 217 | +| `mobile` | `encryptDeterministic(input)` → `WHERE mobile = ?` | |
| 218 | +| `firstName` | `encryptDeterministic(input)` → `WHERE firstName = ?` | |
| 219 | +| `lastName` | `encryptDeterministic(input)` → `WHERE lastName = ?` | |
| 220 | + |
| 221 | +--- |
| 222 | + |
| 223 | +### 7.6 FORGOT PASSWORD — `POST /user/forgot-password` |
| 224 | + |
| 225 | +| Aspect | Change | |
| 226 | +|---|---| |
| 227 | +| Input email lookup | `encryptDeterministic(email)` → `WHERE email = ?` | |
| 228 | +| Response | No PII exposed | |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +### 7.7 SEND OTP — `POST /user/send-otp` |
| 233 | + |
| 234 | +| Aspect | Change | |
| 235 | +|---|---| |
| 236 | +| Mobile lookup | `encryptDeterministic(mobile)` → `WHERE mobile = ?` | |
| 237 | +| Sending SMS | `decrypt(user.mobile)` → pass plaintext to SMS provider | |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +### 7.8 SEND OTP ON MAIL — `POST /user/send-otp-mail` |
| 242 | + |
| 243 | +| Aspect | Change | |
| 244 | +|---|---| |
| 245 | +| Email lookup | `encryptDeterministic(email)` → `WHERE email = ?` | |
| 246 | +| Sending email | `decrypt(user.email)` → pass plaintext to mail provider | |
| 247 | + |
| 248 | +--- |
| 249 | + |
| 250 | +### 7.9 PASSWORD RESET OTP — `POST /user/password-reset-otp` |
| 251 | + |
| 252 | +Same pattern as 7.7 / 7.8 — encrypt input for lookup, decrypt for sending. |
| 253 | + |
| 254 | +--- |
| 255 | + |
| 256 | +### 7.10 USER HIERARCHY VIEW — `POST /user/hierarchy-view` |
| 257 | + |
| 258 | +| Aspect | Change | |
| 259 | +|---|---| |
| 260 | +| Email lookup | `encryptDeterministic(email)` → `WHERE email = ?` | |
| 261 | +| Domain extraction | `decrypt(user.email)` → then extract domain | |
| 262 | + |
| 263 | +--- |
| 264 | + |
| 265 | +### 7.11 DELETE — `DELETE /user/delete/:userId` |
| 266 | + |
| 267 | +Lookup by UUID — **no impact.** Encrypted row deleted with no special handling. |
| 268 | + |
| 269 | +--- |
| 270 | + |
| 271 | +### 7.12 SUGGEST USERNAME — `POST /user/suggestUsername` |
| 272 | + |
| 273 | +Input `firstName`/`lastName` come from request body, not DB — **no impact on input.** |
| 274 | +If fetching existing users for uniqueness: call `decryptUserPII()` on fetched rows. |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +### 7.13 PASSWORD RESET LINK — `POST /user/password-reset-link` |
| 279 | + |
| 280 | +| Aspect | Change | |
| 281 | +|---|---| |
| 282 | +| Fetching user email to send link | `decrypt(user.email)` → pass to mail provider | |
| 283 | + |
| 284 | +--- |
| 285 | + |
| 286 | +## 8. Response Shape — No Contract Change |
| 287 | + |
| 288 | +API consumers always receive **decrypted plaintext**. Field names are unchanged. |
| 289 | + |
| 290 | +```json |
| 291 | +{ |
| 292 | + "userId": "uuid", |
| 293 | + "firstName": "Rahul", |
| 294 | + "lastName": "Sharma", |
| 295 | + "email": "rahul@example.com", |
| 296 | + "mobile": "9876543210", |
| 297 | + "dob": "1995-04-12", |
| 298 | + "address": "123 Main St", |
| 299 | + "pincode": "110001" |
| 300 | +} |
| 301 | +``` |
| 302 | + |
| 303 | +The DB stores ciphertext in these columns. The service decrypts transparently before responding. |
| 304 | + |
| 305 | +--- |
| 306 | + |
| 307 | +## 9. Files to Create / Modify |
| 308 | + |
| 309 | +| File | Action | What Changes | |
| 310 | +|---|---|---| |
| 311 | +| `src/common/services/encryption.service.ts` | **CREATE** | Core AES-256 service with deterministic + random-IV modes | |
| 312 | +| `src/common/services/encryption.service.spec.ts` | **CREATE** | Unit tests for both encryption modes | |
| 313 | +| `src/user/entities/user-entity.ts` | **MODIFY** | Remove `encryptedEmail/Mobile/Dob` columns; change PII column types to `text` | |
| 314 | +| `src/user/user.service.ts` | **MODIFY** | Inject `EncryptionService`; encrypt on write, decrypt on read; update all search filter logic | |
| 315 | +| `src/user/dto/user-search.dto.ts` | **MODIFY** | Remove `@IsEmail` from `filters.email` (string only now) | |
| 316 | +| `src/user/user.module.ts` | **MODIFY** | Add `EncryptionService` to providers | |
| 317 | +| `src/common/common.module.ts` | **MODIFY** | Export `EncryptionService` | |
| 318 | +| `migrations/YYYYMMDD-encrypt-pii-users.ts` | **CREATE** | Widen columns, encrypt existing rows, drop `encryptedXxx` columns | |
| 319 | +| `.env` / `.env.example` | **MODIFY** | Add `PII_ENCRYPTION_KEY=<32-byte-hex>` | |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +## 10. What Does NOT Change |
| 324 | + |
| 325 | +| Field | Reason | |
| 326 | +|---|---| |
| 327 | +| `username` | Not PII — used for login, stays plaintext | |
| 328 | +| `enrollmentId` | Unique identifier — stays plaintext | |
| 329 | +| `gender` | Not sensitive personal data under DPDPA | |
| 330 | +| `status`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` | Operational metadata | |
| 331 | +| `userId` | Primary key UUID | |
| 332 | +| Keycloak data | Outside service boundary — Keycloak stores its own copy | |
| 333 | +| Non-PII filters in `/user/list` | `status`, `role`, `cohortId`, `tenantId` — no change | |
| 334 | + |
| 335 | +--- |
| 336 | + |
| 337 | +## 11. Decisions Log |
| 338 | + |
| 339 | +| # | Decision | Rationale | |
| 340 | +|---|---|---| |
| 341 | +| 1 | Keep same column names, store encrypted values in-place | Cleaner — no entity renames, no response mapping changes, reporting tools correctly see ciphertext | |
| 342 | +| 2 | Drop `encryptedEmail`, `encryptedMobile`, `encryptedDob` columns | Redundant after in-place encryption strategy | |
| 343 | +| 3 | `firstName`, `lastName` are PII and must be encrypted | DPDPA classifies full name as Personal Data | |
| 344 | +| 4 | `firstName`, `lastName`, `email`, `mobile` use deterministic encryption | Required for DB-level search (exact match) | |
| 345 | +| 5 | `dob`, `middleName`, `address`, `pincode` use random-IV | No search requirement — maximum security | |
| 346 | +| 6 | `ILIKE` / partial search on PII fields removed | Impossible after encryption — exact match only | |
| 347 | +| 7 | Data export (Phase 2) | Not in scope for Phase 1 | |
| 348 | +| 8 | `FieldValues` encryption | Phase 2 — after Phase 1 is stable | |
0 commit comments