@friggframework/core 2.0.0-next.53 → 2.0.0-next.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CLAUDE.md +2 -1
  2. package/application/commands/credential-commands.js +1 -1
  3. package/application/commands/integration-commands.js +1 -1
  4. package/application/index.js +1 -1
  5. package/core/create-handler.js +12 -0
  6. package/credential/repositories/credential-repository-documentdb.js +304 -0
  7. package/credential/repositories/credential-repository-factory.js +8 -1
  8. package/credential/repositories/credential-repository-mongo.js +16 -54
  9. package/credential/repositories/credential-repository-postgres.js +14 -41
  10. package/credential/use-cases/get-credential-for-user.js +7 -3
  11. package/database/config.js +4 -4
  12. package/database/documentdb-encryption-service.js +330 -0
  13. package/database/documentdb-utils.js +136 -0
  14. package/database/encryption/README.md +50 -1
  15. package/database/encryption/documentdb-encryption-service.md +3270 -0
  16. package/database/encryption/encryption-schema-registry.js +46 -0
  17. package/database/prisma.js +7 -47
  18. package/database/repositories/health-check-repository-documentdb.js +134 -0
  19. package/database/repositories/health-check-repository-factory.js +6 -1
  20. package/database/repositories/health-check-repository-interface.js +29 -34
  21. package/database/repositories/health-check-repository-mongodb.js +1 -3
  22. package/database/use-cases/check-database-state-use-case.js +3 -3
  23. package/database/use-cases/run-database-migration-use-case.js +6 -4
  24. package/database/use-cases/trigger-database-migration-use-case.js +2 -2
  25. package/database/utils/mongodb-schema-init.js +5 -5
  26. package/database/utils/prisma-runner.js +15 -9
  27. package/errors/client-safe-error.js +26 -0
  28. package/errors/fetch-error.js +2 -1
  29. package/errors/index.js +2 -0
  30. package/generated/prisma-mongodb/edge.js +3 -3
  31. package/generated/prisma-mongodb/index.d.ts +10 -4
  32. package/generated/prisma-mongodb/index.js +3 -3
  33. package/generated/prisma-mongodb/package.json +1 -1
  34. package/generated/prisma-mongodb/schema.prisma +1 -3
  35. package/generated/prisma-mongodb/wasm.js +2 -2
  36. package/generated/prisma-postgresql/edge.js +3 -3
  37. package/generated/prisma-postgresql/index.d.ts +10 -4
  38. package/generated/prisma-postgresql/index.js +3 -3
  39. package/generated/prisma-postgresql/package.json +1 -1
  40. package/generated/prisma-postgresql/schema.prisma +1 -3
  41. package/generated/prisma-postgresql/wasm.js +2 -2
  42. package/handlers/routers/db-migration.js +2 -3
  43. package/handlers/routers/health.js +0 -3
  44. package/handlers/workers/db-migration.js +8 -8
  45. package/integrations/integration-router.js +6 -6
  46. package/integrations/repositories/integration-mapping-repository-documentdb.js +280 -0
  47. package/integrations/repositories/integration-mapping-repository-factory.js +8 -1
  48. package/integrations/repositories/integration-repository-documentdb.js +210 -0
  49. package/integrations/repositories/integration-repository-factory.js +8 -1
  50. package/integrations/repositories/process-repository-documentdb.js +243 -0
  51. package/integrations/repositories/process-repository-factory.js +8 -1
  52. package/modules/repositories/module-repository-documentdb.js +307 -0
  53. package/modules/repositories/module-repository-factory.js +8 -1
  54. package/package.json +5 -5
  55. package/prisma-mongodb/schema.prisma +1 -3
  56. package/prisma-postgresql/migrations/20251112195422_update_user_unique_constraints/migration.sql +69 -0
  57. package/prisma-postgresql/schema.prisma +1 -3
  58. package/syncs/repositories/sync-repository-documentdb.js +240 -0
  59. package/syncs/repositories/sync-repository-factory.js +6 -1
  60. package/token/repositories/token-repository-documentdb.js +137 -0
  61. package/token/repositories/token-repository-factory.js +8 -1
  62. package/token/repositories/token-repository-mongo.js +10 -3
  63. package/token/repositories/token-repository-postgres.js +10 -3
  64. package/user/repositories/user-repository-documentdb.js +432 -0
  65. package/user/repositories/user-repository-factory.js +6 -1
  66. package/user/repositories/user-repository-mongo.js +3 -2
  67. package/user/repositories/user-repository-postgres.js +3 -2
  68. package/user/use-cases/login-user.js +1 -1
  69. package/websocket/repositories/websocket-connection-repository-documentdb.js +119 -0
  70. package/websocket/repositories/websocket-connection-repository-factory.js +8 -1
@@ -0,0 +1,3270 @@
1
+ # DocumentDB Encryption Service Implementation Guide
2
+
3
+ **Status**: 🔴 **CRITICAL** - Security Vulnerability
4
+ **Priority**: P0 - Immediate Action Required
5
+ **Created**: 2025-01-13
6
+ **Last Updated**: 2025-01-13
7
+
8
+ ---
9
+
10
+ ## Table of Contents
11
+
12
+ 1. [Executive Summary](#executive-summary)
13
+ 2. [Problem Statement](#problem-statement)
14
+ 3. [Architecture & Design](#architecture--design)
15
+ 4. [Technical Specification](#technical-specification)
16
+ 5. [Implementation Plan](#implementation-plan)
17
+ 6. [Code Examples](#code-examples)
18
+ 7. [Testing Strategy](#testing-strategy)
19
+ 8. [Migration Guide](#migration-guide)
20
+ 9. [Security Considerations](#security-considerations)
21
+ 10. [Maintenance & Future Work](#maintenance--future-work)
22
+ 11. [References](#references)
23
+
24
+ ---
25
+
26
+ ## Executive Summary
27
+
28
+ ### The Problem
29
+
30
+ DocumentDB repositories use `$runCommandRaw()` for MongoDB protocol compatibility, which **bypasses Prisma Client Extensions**, including the encryption extension. This results in a **critical security vulnerability** where:
31
+
32
+ - ✅ **MongoDB/PostgreSQL**: Automatic encryption via Prisma Extension
33
+ - ❌ **DocumentDB**: OAuth credentials stored in **plain text**
34
+
35
+ ### The Solution
36
+
37
+ Create `DocumentDBEncryptionService` - a centralized encryption service specifically designed for DocumentDB repositories that:
38
+
39
+ - Provides document-level encryption/decryption
40
+ - Handles nested field paths (e.g., `data.access_token`)
41
+ - Uses the same Cryptor and schema registry as Prisma Extension
42
+ - Maintains consistency with existing encryption architecture
43
+
44
+ ### Impact
45
+
46
+ - **Security**: OAuth credentials encrypted at rest in DocumentDB
47
+ - **Architecture**: DRY principle - single source of encryption logic
48
+ - **Consistency**: All DocumentDB repos use same encryption pattern
49
+ - **Compliance**: Meets production encryption requirements
50
+
51
+ ---
52
+
53
+ ## Problem Statement
54
+
55
+ ### Current Architecture (MongoDB/PostgreSQL)
56
+
57
+ ```
58
+ Application Code (Use Cases)
59
+ ↓ works with plain data
60
+ Repositories
61
+ ↓ uses Prisma queries
62
+ Prisma Client + Extension (AUTOMATIC ENCRYPTION)
63
+ ↓ intercepts all queries
64
+ FieldEncryptionService
65
+ ↓ encrypts/decrypts per field
66
+ Cryptor (KMS or AES)
67
+
68
+ Database (encrypted storage)
69
+ ```
70
+
71
+ **How it works**:
72
+ ```javascript
73
+ // MongoDB Repository - Automatic encryption
74
+ await prisma.credential.create({
75
+ data: {
76
+ access_token: "plain_secret" // ← Plain text in
77
+ }
78
+ });
79
+ // → Prisma Extension intercepts
80
+ // → FieldEncryptionService.encryptField() called
81
+ // → Stored as "keyId:iv:cipher:encKey" in database
82
+
83
+ const cred = await prisma.credential.findFirst({ where: { id } });
84
+ // ← Database returns "keyId:iv:cipher:encKey"
85
+ // ← Prisma Extension intercepts
86
+ // ← FieldEncryptionService.decryptField() called
87
+ // ← Application receives { access_token: "plain_secret" }
88
+ ```
89
+
90
+ ### DocumentDB Architecture (Current - BROKEN)
91
+
92
+ ```
93
+ Application Code (Use Cases)
94
+ ↓ works with plain data
95
+ DocumentDB Repositories
96
+ ↓ uses $runCommandRaw
97
+ Prisma Client (NO EXTENSION INTERCEPTION)
98
+ ↓ raw command bypasses all extensions
99
+ Database (PLAIN TEXT STORAGE) ⚠️ SECURITY VULNERABILITY
100
+ ```
101
+
102
+ **Why it's broken**:
103
+ ```javascript
104
+ // DocumentDB Repository - NO encryption
105
+ const oauthData = {
106
+ access_token: "ya29.actual_google_token", // Plain text!
107
+ refresh_token: "1//0secret_refresh_token" // Plain text!
108
+ };
109
+
110
+ await prisma.$runCommandRaw({
111
+ insert: 'Credential',
112
+ documents: [{ data: oauthData }]
113
+ });
114
+ // ❌ Prisma Extension NEVER sees this command
115
+ // ❌ FieldEncryptionService NEVER invoked
116
+ // ❌ Stored in database as PLAIN TEXT
117
+ ```
118
+
119
+ ### Root Cause
120
+
121
+ From Prisma documentation:
122
+
123
+ > "$runCommandRaw is a low-level database access method. Prisma Client extensions do not apply to raw database access."
124
+
125
+ **Why DocumentDB needs raw commands**:
126
+ - DocumentDB has MongoDB compatibility limitations
127
+ - Certain Prisma features don't work (transactions, some aggregations)
128
+ - Raw commands provide direct MongoDB protocol access
129
+
130
+ ### Current Repository Status
131
+
132
+ | Repository | Encryption Status | Security Risk |
133
+ |-----------|------------------|---------------|
134
+ | **UserRepositoryDocumentDB** | ✅ Has manual encryption for `hashword` | Low - passwords protected |
135
+ | **ModuleRepositoryDocumentDB** | ⚠️ Has manual decryption for reads only | Medium - assumes credentials encrypted |
136
+ | **CredentialRepositoryDocumentDB** | ❌ **NO encryption on writes, NO decryption on reads** | 🔴 **CRITICAL - OAuth tokens in plain text** |
137
+ | **IntegrationRepositoryDocumentDB** | ✅ No encrypted fields, OK | None |
138
+
139
+ ---
140
+
141
+ ## Architecture & Design
142
+
143
+ ### Comparison: FieldEncryptionService vs DocumentDBEncryptionService
144
+
145
+ | Aspect | FieldEncryptionService | DocumentDBEncryptionService |
146
+ |--------|------------------------|----------------------------|
147
+ | **Purpose** | Encrypt individual fields for Prisma Extension | Encrypt entire documents for raw queries |
148
+ | **Invocation** | Automatic (Prisma intercepts queries) | Manual (repository calls explicitly) |
149
+ | **Scope** | Single field at a time | Entire document with multiple fields |
150
+ | **Nested Fields** | Handled by Prisma Extension traversal | Must manually traverse field paths |
151
+ | **Integration** | Via Prisma Client Extension | Direct import in repositories |
152
+ | **Query Types** | `create()`, `update()`, `findFirst()`, etc. | `$runCommandRaw()`, via documentdb-utils |
153
+ | **Database Support** | MongoDB, PostgreSQL (via Prisma) | DocumentDB (raw MongoDB protocol) |
154
+ | **Schema Registry** | Used by Prisma Extension | Directly queries registry |
155
+ | **Error Handling** | Prisma transaction rollback | Must handle in repository |
156
+ | **Testing** | Integration tests with Prisma | Unit tests + repository tests |
157
+
158
+ ### Proposed Architecture (DocumentDB - FIXED)
159
+
160
+ ```
161
+ Application Code (Use Cases)
162
+ ↓ works with plain data
163
+ DocumentDB Repositories
164
+ ↓ MANUALLY calls encryptFields()/decryptFields()
165
+ DocumentDBEncryptionService
166
+ ↓ traverses field paths based on schema registry
167
+ ↓ encrypts/decrypts each field
168
+ Cryptor (KMS or AES)
169
+
170
+ Database (ENCRYPTED STORAGE) ✅ SECURE
171
+ ```
172
+
173
+ ### Architecture Flow Diagram
174
+
175
+ ```
176
+ ┌─────────────────────────────────────────────────────────────────┐
177
+ │ Application Layer (Use Cases) │
178
+ │ - Works with plain text data │
179
+ │ - Never sees encrypted values │
180
+ └──────────────────┬──────────────────────────────────────────────┘
181
+
182
+ ┌────────────┴──────────────┐
183
+ │ │
184
+ ▼ MongoDB/PostgreSQL ▼ DocumentDB
185
+ ┌─────────────────────┐ ┌──────────────────────────┐
186
+ │ Repository │ │ Repository │
187
+ │ (plain text) │ │ (plain text) │
188
+ └──────┬──────────────┘ └───┬──────────────────────┘
189
+ │ │ Manually calls
190
+ │ Uses Prisma queries │ encryptFields()/
191
+ ▼ │ decryptFields()
192
+ ┌─────────────────────┐ ▼
193
+ │ Prisma Client │ ┌──────────────────────────────┐
194
+ │ + Extension │ │ DocumentDBEncryptionService │
195
+ │ (automatic) │ │ - Traverses field paths │
196
+ └──────┬──────────────┘ │ - Calls Cryptor per field │
197
+ │ Intercepts └───┬──────────────────────────┘
198
+ │ queries │
199
+ ▼ │
200
+ ┌─────────────────────┐ │
201
+ │ FieldEncryptionSvc │◄───────┘ Both use Cryptor
202
+ │ - Per-field logic │
203
+ └──────┬──────────────┘
204
+
205
+
206
+ ┌─────────────────────────────────────┐
207
+ │ Cryptor (AWS KMS or AES) │
208
+ │ - Envelope encryption │
209
+ │ - Returns: "keyId:iv:cipher:encKey"│
210
+ └──────┬──────────────────────────────┘
211
+
212
+
213
+ ┌─────────────────────────────────────┐
214
+ │ Database (MongoDB/PostgreSQL/ │
215
+ │ DocumentDB) │
216
+ │ - Stores encrypted strings │
217
+ └─────────────────────────────────────┘
218
+ ```
219
+
220
+ ### Design Principles
221
+
222
+ 1. **Consistency**: Same encryption format and Cryptor as Prisma Extension
223
+ 2. **Reusability**: Single service used by all DocumentDB repositories
224
+ 3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma)
225
+ 4. **Environment-Aware**: Respects STAGE-based bypass (dev/test/local)
226
+ 5. **Error-Tolerant**: Graceful handling of decryption failures
227
+ 6. **Testable**: Can be unit tested independently of repositories
228
+
229
+ ---
230
+
231
+ ## Technical Specification
232
+
233
+ ### Class Design
234
+
235
+ ```javascript
236
+ /**
237
+ * Encryption service specifically for DocumentDB repositories
238
+ * that use $runCommandRaw and bypass Prisma Extensions.
239
+ *
240
+ * Provides document-level encryption/decryption,
241
+ * handling nested fields according to the encryption schema registry.
242
+ */
243
+ class DocumentDBEncryptionService {
244
+ constructor()
245
+ _initializeCryptor()
246
+ async encryptFields(modelName, document)
247
+ async decryptFields(modelName, document)
248
+ async _encryptFieldPath(document, fieldPath, modelName)
249
+ async _decryptFieldPath(document, fieldPath, modelName)
250
+ _isEncryptedValue(value)
251
+ }
252
+ ```
253
+
254
+ ### Method Specifications
255
+
256
+ #### `constructor()`
257
+
258
+ **Purpose**: Initialize the service and configure Cryptor
259
+
260
+ **Behavior**:
261
+ - Calls `_initializeCryptor()` immediately
262
+ - Sets up `this.cryptor` and `this.enabled` properties
263
+
264
+ **No parameters**
265
+
266
+ ---
267
+
268
+ #### `_initializeCryptor()`
269
+
270
+ **Purpose**: Initialize Cryptor with environment-based configuration
271
+
272
+ **Logic**:
273
+ ```javascript
274
+ 1. Get STAGE from environment (default: 'development')
275
+ 2. If STAGE in ['dev', 'test', 'local']:
276
+ - Set this.cryptor = null
277
+ - Set this.enabled = false
278
+ - Return (bypass encryption)
279
+ 3. Check for KMS_KEY_ARN environment variable
280
+ 4. Check for AES_KEY_ID environment variable
281
+ 5. If neither present:
282
+ - Warn "No encryption keys configured"
283
+ - Set this.cryptor = null
284
+ - Set this.enabled = false
285
+ - Return
286
+ 6. Create Cryptor({ shouldUseAws: hasKMS })
287
+ 7. Set this.enabled = true
288
+ ```
289
+
290
+ **Environment Variables Used**:
291
+ - `STAGE` or `NODE_ENV`: Determines bypass behavior
292
+ - `KMS_KEY_ARN`: AWS KMS key ARN (enables KMS encryption)
293
+ - `AES_KEY_ID`: AES key identifier (enables AES encryption)
294
+ - `AES_KEY`: AES encryption key (required if AES_KEY_ID present)
295
+
296
+ **Matches**: Logic from `packages/core/database/prisma.js` lines 76-96
297
+
298
+ ---
299
+
300
+ #### `async encryptFields(modelName, document)`
301
+
302
+ **Purpose**: Encrypt fields in a document before storing to DocumentDB
303
+
304
+ **Parameters**:
305
+ - `modelName` (string): Model name from schema registry (e.g., 'User', 'Credential')
306
+ - `document` (Object): Document to encrypt
307
+
308
+ **Returns**: `Promise<Object>` - Document with encrypted fields
309
+
310
+ **Algorithm**:
311
+ ```javascript
312
+ 1. If !this.enabled or !this.cryptor:
313
+ - Return document unchanged (bypass)
314
+ 2. If !document or typeof document !== 'object':
315
+ - Return document unchanged (invalid input)
316
+ 3. Get encrypted fields config from registry:
317
+ - encryptedFieldsConfig = getEncryptedFields(modelName)
318
+ 4. If no config or no fields defined:
319
+ - Return document unchanged (no encryption needed)
320
+ 5. Create shallow copy: result = { ...document }
321
+ 6. For each fieldPath in encryptedFieldsConfig.fields:
322
+ - await this._encryptFieldPath(result, fieldPath, modelName)
323
+ 7. Return result
324
+ ```
325
+
326
+ **Error Handling**:
327
+ - Invalid inputs: Return unchanged
328
+ - Encryption errors: Propagate to caller (repository must handle)
329
+
330
+ **Example**:
331
+ ```javascript
332
+ const plainDoc = {
333
+ userId: "123",
334
+ data: {
335
+ access_token: "plain_secret",
336
+ refresh_token: "plain_refresh"
337
+ }
338
+ };
339
+
340
+ const encrypted = await service.encryptFields('Credential', plainDoc);
341
+ // encrypted.data.access_token = "aes-key-1:iv:cipher:enckey"
342
+ // encrypted.data.refresh_token = "aes-key-1:iv:cipher:enckey"
343
+ ```
344
+
345
+ ---
346
+
347
+ #### `async decryptFields(modelName, document)`
348
+
349
+ **Purpose**: Decrypt fields in a document after reading from DocumentDB
350
+
351
+ **Parameters**:
352
+ - `modelName` (string): Model name from schema registry
353
+ - `document` (Object): Document to decrypt
354
+
355
+ **Returns**: `Promise<Object>` - Document with decrypted fields
356
+
357
+ **Algorithm**:
358
+ ```javascript
359
+ 1. If !this.enabled or !this.cryptor:
360
+ - Return document unchanged (bypass)
361
+ 2. If !document or typeof document !== 'object':
362
+ - Return document unchanged (invalid input)
363
+ 3. Get encrypted fields config from registry:
364
+ - encryptedFieldsConfig = getEncryptedFields(modelName)
365
+ 4. If no config or no fields defined:
366
+ - Return document unchanged (no decryption needed)
367
+ 5. Create shallow copy: result = { ...document }
368
+ 6. For each fieldPath in encryptedFieldsConfig.fields:
369
+ - await this._decryptFieldPath(result, fieldPath, modelName)
370
+ 7. Return result
371
+ ```
372
+
373
+ **Error Handling**:
374
+ - Decryption failures: Set field to null (don't expose encrypted data)
375
+ - Log error with context
376
+
377
+ **Example**:
378
+ ```javascript
379
+ const encryptedDoc = {
380
+ userId: "123",
381
+ data: {
382
+ access_token: "aes-key-1:iv:cipher:enckey",
383
+ refresh_token: "aes-key-1:iv:cipher:enckey"
384
+ }
385
+ };
386
+
387
+ const decrypted = await service.decryptFields('Credential', encryptedDoc);
388
+ // decrypted.data.access_token = "plain_secret"
389
+ // decrypted.data.refresh_token = "plain_refresh"
390
+ ```
391
+
392
+ ---
393
+
394
+ #### `async _encryptFieldPath(document, fieldPath, modelName)`
395
+
396
+ **Purpose**: Encrypt a specific field path in a document (handles nested fields)
397
+
398
+ **Parameters**:
399
+ - `document` (Object): Document to modify (mutated in place)
400
+ - `fieldPath` (string): Field path from schema registry (e.g., 'data.access_token')
401
+ - `modelName` (string): For error logging context
402
+
403
+ **Algorithm**:
404
+ ```javascript
405
+ 1. Split fieldPath by '.': parts = fieldPath.split('.')
406
+ 2. Navigate to parent object:
407
+ - current = document
408
+ - For i from 0 to parts.length - 2:
409
+ - If !current[parts[i]]: return (path doesn't exist)
410
+ - current = current[parts[i]]
411
+ 3. Get field name: fieldName = parts[parts.length - 1]
412
+ 4. Get value: value = current[fieldName]
413
+ 5. Skip if already encrypted or empty:
414
+ - If !value or this._isEncryptedValue(value): return
415
+ 6. Convert to string if needed:
416
+ - stringValue = (typeof value === 'string') ? value : JSON.stringify(value)
417
+ 7. Encrypt using Cryptor:
418
+ - current[fieldName] = await this.cryptor.encrypt(stringValue)
419
+ 8. Catch errors:
420
+ - Log: "Failed to encrypt {modelName}.{fieldPath}: {error}"
421
+ - Throw error (repository must handle)
422
+ ```
423
+
424
+ **Example Field Paths**:
425
+ - `hashword` → Encrypts `document.hashword`
426
+ - `data.access_token` → Encrypts `document.data.access_token`
427
+ - `data.refresh_token` → Encrypts `document.data.refresh_token`
428
+
429
+ ---
430
+
431
+ #### `async _decryptFieldPath(document, fieldPath, modelName)`
432
+
433
+ **Purpose**: Decrypt a specific field path in a document
434
+
435
+ **Parameters**:
436
+ - `document` (Object): Document to modify (mutated in place)
437
+ - `fieldPath` (string): Field path from schema registry
438
+ - `modelName` (string): For error logging context
439
+
440
+ **Algorithm**:
441
+ ```javascript
442
+ 1. Split fieldPath by '.': parts = fieldPath.split('.')
443
+ 2. Navigate to parent object:
444
+ - current = document
445
+ - For i from 0 to parts.length - 2:
446
+ - If !current[parts[i]]: return (path doesn't exist)
447
+ - current = current[parts[i]]
448
+ 3. Get field name: fieldName = parts[parts.length - 1]
449
+ 4. Get encrypted value: encryptedValue = current[fieldName]
450
+ 5. Skip if not encrypted format:
451
+ - If !encryptedValue or !this._isEncryptedValue(encryptedValue): return
452
+ 6. Decrypt using Cryptor:
453
+ - decryptedString = await this.cryptor.decrypt(encryptedValue)
454
+ 7. Try to parse as JSON:
455
+ - Try: current[fieldName] = JSON.parse(decryptedString)
456
+ - Catch: current[fieldName] = decryptedString (not JSON, return as string)
457
+ 8. Catch decryption errors:
458
+ - Log: "Failed to decrypt {modelName}.{fieldPath}: {error}"
459
+ - Set current[fieldName] = null (don't expose potentially corrupted data)
460
+ ```
461
+
462
+ **Error Tolerance**:
463
+ - If decryption fails, set field to `null` instead of throwing
464
+ - Prevents exposing encrypted strings to application
465
+ - Logs error for debugging
466
+
467
+ ---
468
+
469
+ #### `_isEncryptedValue(value)`
470
+
471
+ **Purpose**: Check if a value is in encrypted format
472
+
473
+ **Parameters**:
474
+ - `value` (any): Value to check
475
+
476
+ **Returns**: `boolean` - True if value is encrypted
477
+
478
+ **Logic**:
479
+ ```javascript
480
+ 1. If typeof value !== 'string': return false
481
+ 2. Split by ':': parts = value.split(':')
482
+ 3. Return parts.length >= 4
483
+ ```
484
+
485
+ **Encrypted Format**: `"keyId:iv:cipher:encKey"` (envelope encryption)
486
+
487
+ **Examples**:
488
+ ```javascript
489
+ _isEncryptedValue("plain_text") // false
490
+ _isEncryptedValue("aes-key-1:iv123:cipher456:enckey789") // true
491
+ _isEncryptedValue(null) // false
492
+ _isEncryptedValue({}) // false
493
+ ```
494
+
495
+ ---
496
+
497
+ ### Dependencies
498
+
499
+ ```javascript
500
+ const { Cryptor } = require('../encrypt/Cryptor');
501
+ const { getEncryptedFields } = require('./encryption/encryption-schema-registry');
502
+ ```
503
+
504
+ **Cryptor**: Handles actual encryption/decryption (KMS or AES)
505
+ **getEncryptedFields**: Returns encrypted field paths for a model
506
+
507
+ ---
508
+
509
+ ### Encrypted Fields (from Schema Registry)
510
+
511
+ ```javascript
512
+ // From packages/core/database/encryption/encryption-schema-registry.js
513
+
514
+ const ENCRYPTED_FIELDS = {
515
+ User: ['hashword'],
516
+ Credential: [
517
+ 'data.access_token',
518
+ 'data.refresh_token',
519
+ 'data.id_token',
520
+ 'data.domain'
521
+ ],
522
+ IntegrationMapping: ['mapping'],
523
+ Token: ['token']
524
+ };
525
+ ```
526
+
527
+ **DocumentDBEncryptionService** will automatically encrypt/decrypt these fields when `encryptFields()`/`decryptFields()` is called with the corresponding model name.
528
+
529
+ ---
530
+
531
+ ## Implementation Plan
532
+
533
+ ### Phase 1: Create DocumentDBEncryptionService (New File)
534
+
535
+ **Files to Create**:
536
+ 1. `packages/core/database/documentdb-encryption-service.js`
537
+ 2. `packages/core/database/__tests__/documentdb-encryption-service.test.js`
538
+
539
+ **Implementation Checklist**:
540
+
541
+ #### 1.1 Service Class (`documentdb-encryption-service.js`)
542
+
543
+ - [ ] Create file with standard file header comment
544
+ - [ ] Import dependencies: `Cryptor`, `getEncryptedFields`
545
+ - [ ] Create `DocumentDBEncryptionService` class
546
+ - [ ] Implement `constructor()` - calls `_initializeCryptor()`
547
+ - [ ] Implement `_initializeCryptor()` - matches `prisma.js` logic
548
+ - [ ] Check STAGE environment variable
549
+ - [ ] Implement bypass for dev/test/local
550
+ - [ ] Check for KMS_KEY_ARN
551
+ - [ ] Check for AES_KEY_ID
552
+ - [ ] Create Cryptor with shouldUseAws flag
553
+ - [ ] Set this.enabled flag
554
+ - [ ] Implement `encryptFields(modelName, document)`
555
+ - [ ] Early returns for disabled/invalid input
556
+ - [ ] Get encrypted fields from registry
557
+ - [ ] Loop through field paths
558
+ - [ ] Call `_encryptFieldPath()` for each
559
+ - [ ] Implement `decryptFields(modelName, document)`
560
+ - [ ] Early returns for disabled/invalid input
561
+ - [ ] Get encrypted fields from registry
562
+ - [ ] Loop through field paths
563
+ - [ ] Call `_decryptFieldPath()` for each
564
+ - [ ] Implement `_encryptFieldPath(document, fieldPath, modelName)`
565
+ - [ ] Parse field path (split by '.')
566
+ - [ ] Navigate to parent object
567
+ - [ ] Check if already encrypted
568
+ - [ ] Convert to string if needed
569
+ - [ ] Call `this.cryptor.encrypt()`
570
+ - [ ] Error handling with context
571
+ - [ ] Implement `_decryptFieldPath(document, fieldPath, modelName)`
572
+ - [ ] Parse field path
573
+ - [ ] Navigate to parent object
574
+ - [ ] Check if encrypted format
575
+ - [ ] Call `this.cryptor.decrypt()`
576
+ - [ ] Try to parse as JSON
577
+ - [ ] Error handling (set to null on failure)
578
+ - [ ] Implement `_isEncryptedValue(value)`
579
+ - [ ] Type check (must be string)
580
+ - [ ] Split by ':'
581
+ - [ ] Check for 4+ parts
582
+ - [ ] Add JSDoc comments for all public methods
583
+ - [ ] Export: `module.exports = { DocumentDBEncryptionService };`
584
+
585
+ #### 1.2 Service Tests (`__tests__/documentdb-encryption-service.test.js`)
586
+
587
+ - [ ] Create test file with describe block
588
+ - [ ] Mock dependencies: `Cryptor`, `getEncryptedFields`
589
+ - [ ] **Test Group: Initialization**
590
+ - [ ] Test bypass in dev stage
591
+ - [ ] Test bypass in test stage
592
+ - [ ] Test bypass in local stage
593
+ - [ ] Test enabled with KMS_KEY_ARN in production
594
+ - [ ] Test enabled with AES_KEY_ID in production
595
+ - [ ] Test disabled with no keys in production
596
+ - [ ] Test KMS takes precedence over AES
597
+ - [ ] **Test Group: encryptFields()**
598
+ - [ ] Test returns unchanged when disabled (dev stage)
599
+ - [ ] Test returns unchanged for null document
600
+ - [ ] Test returns unchanged for non-object document
601
+ - [ ] Test returns unchanged when no encrypted fields in registry
602
+ - [ ] Test encrypts User.hashword
603
+ - [ ] Test encrypts Credential.data.access_token
604
+ - [ ] Test encrypts Credential.data.refresh_token
605
+ - [ ] Test encrypts multiple nested fields
606
+ - [ ] Test skips already encrypted values
607
+ - [ ] Test skips null values
608
+ - [ ] Test skips non-existent paths
609
+ - [ ] Test encrypts objects (JSON.stringify)
610
+ - [ ] Test error handling (propagates error)
611
+ - [ ] **Test Group: decryptFields()**
612
+ - [ ] Test returns unchanged when disabled
613
+ - [ ] Test returns unchanged for null document
614
+ - [ ] Test returns unchanged for non-object document
615
+ - [ ] Test returns unchanged when no encrypted fields in registry
616
+ - [ ] Test decrypts User.hashword
617
+ - [ ] Test decrypts Credential.data.access_token
618
+ - [ ] Test decrypts multiple nested fields
619
+ - [ ] Test skips plain text values
620
+ - [ ] Test skips null values
621
+ - [ ] Test skips non-existent paths
622
+ - [ ] Test parses JSON objects after decryption
623
+ - [ ] Test handles non-JSON strings
624
+ - [ ] Test error handling (sets field to null)
625
+ - [ ] **Test Group: _isEncryptedValue()**
626
+ - [ ] Test returns false for plain text
627
+ - [ ] Test returns false for null
628
+ - [ ] Test returns false for numbers
629
+ - [ ] Test returns false for objects
630
+ - [ ] Test returns false for short strings (< 4 parts)
631
+ - [ ] Test returns true for encrypted format (4+ parts with colons)
632
+ - [ ] **Test Coverage Target**: >90% line coverage
633
+
634
+ **Estimated Time**: 2-3 hours
635
+
636
+ ---
637
+
638
+ ### Phase 1.5: Fix Critical Issues from Code Review
639
+
640
+ **Status**: ⚠️ CRITICAL - Must complete before Phase 2
641
+
642
+ **Context**: After Phase 1 implementation and code review, three critical issues were identified that must be fixed before integrating the service into repositories. These issues address data corruption, silent failures, and testability concerns.
643
+
644
+ **Code Review Summary**: Overall assessment 6/10 → 8/10 after fixes
645
+
646
+ ---
647
+
648
+ #### Critical Issue #1: JSON.parse Corrupts Date Objects
649
+
650
+ **Problem**:
651
+ ```javascript
652
+ // Current implementation (lines 101, 147)
653
+ const result = JSON.parse(JSON.stringify(document));
654
+ ```
655
+
656
+ **Why it's critical**:
657
+ - `JSON.stringify()` converts Date objects to ISO strings
658
+ - `JSON.parse()` does NOT convert them back to Date objects
659
+ - OAuth tokens often have `expires_at` as Date objects
660
+ - This causes **silent data corruption** in production
661
+
662
+ **Example of corruption**:
663
+ ```javascript
664
+ const credential = {
665
+ data: { access_token: 'secret' },
666
+ expires_at: new Date('2025-12-31') // Date object
667
+ };
668
+
669
+ const encrypted = await service.encryptFields('Credential', credential);
670
+ // encrypted.expires_at is now "2025-12-31T00:00:00.000Z" (STRING, not Date)
671
+ // This breaks any code expecting Date.getTime(), Date.toISOString(), etc.
672
+ ```
673
+
674
+ **Fix**:
675
+ ```javascript
676
+ // Use structuredClone (Node.js 17+)
677
+ const result = structuredClone(document);
678
+ ```
679
+
680
+ **Benefits of structuredClone**:
681
+ - ✅ Preserves Date objects
682
+ - ✅ Preserves RegExp objects
683
+ - ✅ Preserves Buffer objects
684
+ - ✅ Handles circular references
685
+ - ✅ Native Node.js function (no dependencies)
686
+
687
+ **Files to Update**:
688
+ - `documentdb-encryption-service.js` lines 101, 147
689
+
690
+ **Checklist**:
691
+ - [ ] Replace `JSON.parse(JSON.stringify(document))` in `encryptFields()` (line 101)
692
+ - [ ] Replace `JSON.parse(JSON.stringify(document))` in `decryptFields()` (line 147)
693
+ - [ ] Add test case: `it('preserves Date objects in documents')`
694
+ - [ ] Verify Node.js version supports structuredClone (>=17)
695
+
696
+ **Estimated Time**: 5 minutes
697
+
698
+ ---
699
+
700
+ #### Critical Issue #2: Decryption Failures Set to Null
701
+
702
+ **Problem**:
703
+ ```javascript
704
+ // Current implementation (_decryptFieldPath, line 258)
705
+ catch (error) {
706
+ console.error('[DocumentDBEncryptionService] Failed to decrypt...', errorContext);
707
+ current[fieldName] = null; // ❌ Silent data loss
708
+ }
709
+ ```
710
+
711
+ **Why it's critical**:
712
+ - **Silent credential loss** - Application continues with null tokens
713
+ - **Hard to debug** - Error logged but not propagated
714
+ - **Security risk** - Could mask key rotation issues or corrupted data
715
+ - **Cascade failures** - Null propagates until crash elsewhere
716
+
717
+ **Real-world scenario**:
718
+ ```javascript
719
+ // Encrypted credential in database (key rotated or corrupted)
720
+ const credential = await findCredential(userId);
721
+ // Decryption silently fails, field set to null
722
+
723
+ // Application continues
724
+ const api = new AsanaAPI({ token: credential.access_token });
725
+ // ❌ Later crashes with "Cannot use null as token" far from root cause
726
+ ```
727
+
728
+ **Why this is wrong**:
729
+ - Violates fail-fast principle (errors should be discovered immediately)
730
+ - Inconsistent with `encryptFields()` which throws errors
731
+ - Repository can't distinguish null data from decryption failure
732
+
733
+ **Fix**:
734
+ ```javascript
735
+ // Throw error immediately (fail fast)
736
+ catch (error) {
737
+ console.error('[DocumentDBEncryptionService] Failed to decrypt...', errorContext);
738
+ throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
739
+ }
740
+ ```
741
+
742
+ **Files to Update**:
743
+ - `documentdb-encryption-service.js` line 258
744
+ - `documentdb-encryption-service.test.js` update test "sets field to null on decryption error"
745
+
746
+ **Checklist**:
747
+ - [ ] Remove `current[fieldName] = null;` from `_decryptFieldPath()` (line 258)
748
+ - [ ] Add `throw new Error(...)` with context
749
+ - [ ] Update test: change from `expect(result.hashword).toBeNull()` to `expect(...).rejects.toThrow()`
750
+ - [ ] Update test name: "throws error on decryption failure" (not "sets field to null")
751
+ - [ ] Verify all 56+ tests still pass
752
+
753
+ **Estimated Time**: 10 minutes
754
+
755
+ ---
756
+
757
+ #### Critical Issue #3: No Cryptor Dependency Injection
758
+
759
+ **Problem**:
760
+ ```javascript
761
+ // Current implementation (constructor, lines 24-26)
762
+ constructor() {
763
+ this._initializeCryptor(); // ❌ Creates Cryptor internally
764
+ }
765
+
766
+ _initializeCryptor() {
767
+ this.cryptor = new Cryptor({ shouldUseAws }); // ❌ Hard-coded
768
+ }
769
+ ```
770
+
771
+ **Why it's critical**:
772
+ - **Repository tests break** - Can't mock encryption in Phase 2-4
773
+ - **Requires real keys** - Tests need AWS credentials or AES keys
774
+ - **Slower tests** - Real encryption is slower than mocks
775
+ - **Can't test error scenarios** - Can't simulate Cryptor failures
776
+
777
+ **Impact on Phase 2 (UserRepositoryDocumentDB tests)**:
778
+ ```javascript
779
+ describe('UserRepositoryDocumentDB', () => {
780
+ it('encrypts hashword before saving', async () => {
781
+ // ❌ PROBLEM: Can't mock DocumentDBEncryptionService's Cryptor
782
+ const service = new DocumentDBEncryptionService();
783
+ // Tries to create real Cryptor - tests fail without keys
784
+
785
+ const repo = new UserRepositoryDocumentDB({ encryptionService: service });
786
+ await repo.createUser({ hashword: 'password' });
787
+ // ❌ Real KMS/AES encryption happens in tests
788
+ });
789
+ });
790
+ ```
791
+
792
+ **Fix**:
793
+ ```javascript
794
+ class DocumentDBEncryptionService {
795
+ constructor({ cryptor = null } = {}) {
796
+ if (cryptor) {
797
+ // Dependency injection - use provided Cryptor (for testing)
798
+ this.cryptor = cryptor;
799
+ this.enabled = true;
800
+ } else {
801
+ // Default behavior - create Cryptor from environment
802
+ this._initializeCryptor();
803
+ }
804
+ }
805
+ }
806
+ ```
807
+
808
+ **Usage**:
809
+ ```javascript
810
+ // In tests (with mock)
811
+ const mockCryptor = {
812
+ encrypt: jest.fn().mockResolvedValue('encrypted'),
813
+ decrypt: jest.fn().mockResolvedValue('decrypted')
814
+ };
815
+ const service = new DocumentDBEncryptionService({ cryptor: mockCryptor });
816
+
817
+ // In production (uses environment config)
818
+ const service = new DocumentDBEncryptionService();
819
+ ```
820
+
821
+ **Files to Update**:
822
+ - `documentdb-encryption-service.js` constructor
823
+ - `documentdb-encryption-service.test.js` add dependency injection test
824
+
825
+ **Checklist**:
826
+ - [ ] Change constructor signature: `constructor({ cryptor = null } = {})`
827
+ - [ ] Add conditional logic: if cryptor provided, use it; else call `_initializeCryptor()`
828
+ - [ ] Set `this.enabled = true` when cryptor injected
829
+ - [ ] Add test: `it('accepts injected Cryptor for testing')`
830
+ - [ ] Verify injection test passes
831
+ - [ ] Verify all existing tests still pass
832
+
833
+ **Estimated Time**: 15 minutes
834
+
835
+ ---
836
+
837
+ #### Phase 1.5 Summary
838
+
839
+ **Total Changes**:
840
+ - 3 files modified
841
+ - 5 lines of code changed (service implementation)
842
+ - 3 new/updated test cases
843
+ - 0 breaking changes (backward compatible)
844
+
845
+ **Total Time**: ~30 minutes
846
+
847
+ **Success Criteria**:
848
+ - ✅ All 56+ tests pass
849
+ - ✅ Date objects preserved in documents
850
+ - ✅ Decryption failures throw errors
851
+ - ✅ Cryptor can be injected for testing
852
+ - ✅ 100% code coverage maintained
853
+ - ✅ Code review assessment improves from 6/10 to 8/10
854
+
855
+ **Validation**:
856
+ ```javascript
857
+ // Test 1: Date preservation
858
+ const doc = { data: { token: 'secret' }, createdAt: new Date() };
859
+ const encrypted = await service.encryptFields('Model', doc);
860
+ expect(encrypted.createdAt).toBeInstanceOf(Date); // ✅ Must pass
861
+
862
+ // Test 2: Decryption error throws
863
+ const corrupted = { data: { token: 'corrupted_encrypted_value' } };
864
+ await expect(service.decryptFields('Model', corrupted))
865
+ .rejects.toThrow('Decryption failed'); // ✅ Must pass
866
+
867
+ // Test 3: Dependency injection
868
+ const mockCryptor = { encrypt: jest.fn(), decrypt: jest.fn() };
869
+ const service = new DocumentDBEncryptionService({ cryptor: mockCryptor });
870
+ expect(service.cryptor).toBe(mockCryptor); // ✅ Must pass
871
+ ```
872
+
873
+ **Next Step**: After Phase 1.5 completion, proceed to Phase 2 (Refactor UserRepositoryDocumentDB)
874
+
875
+ ---
876
+
877
+ ### Phase 2: Refactor UserRepositoryDocumentDB
878
+
879
+ **File**: `packages/core/user/repositories/user-repository-documentdb.js`
880
+
881
+ **Changes Checklist**:
882
+
883
+ - [ ] Import DocumentDBEncryptionService at top of file
884
+ - [ ] **Remove existing encryption methods** (lines 24-148):
885
+ - [ ] Remove `_initializeCryptor()` method
886
+ - [ ] Remove `_encryptField()` method
887
+ - [ ] Remove `_decryptField()` method
888
+ - [ ] Remove `_isEncryptedValue()` method
889
+ - [ ] Remove `_encryptHashword()` method
890
+ - [ ] Remove `_decryptHashword()` method
891
+ - [ ] **Update constructor**:
892
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
893
+ - [ ] Remove: `this._initializeCryptor();`
894
+ - [ ] **Update `createIndividualUser()` method** (around line 183):
895
+ - [ ] After building document, before insertOne():
896
+ ```javascript
897
+ const encryptedDocument = await this.encryptionService.encryptFields('User', document);
898
+ const insertedId = await insertOne(this.prisma, 'User', encryptedDocument);
899
+ ```
900
+ - [ ] After findOne(), before _mapUser():
901
+ ```javascript
902
+ const decryptedUser = await this.encryptionService.decryptFields('User', created);
903
+ return this._mapUser(decryptedUser);
904
+ ```
905
+ - [ ] **Update `createOrganizationUser()` method**:
906
+ - [ ] No changes needed (no encrypted fields for organization users)
907
+ - [ ] **Update `findIndividualUserById()` method** (around line 165):
908
+ - [ ] After findOne():
909
+ ```javascript
910
+ const decryptedUser = await this.encryptionService.decryptFields('User', doc);
911
+ return this._mapUser(decryptedUser);
912
+ ```
913
+ - [ ] **Update `findIndividualUserByUsername()` method**:
914
+ - [ ] Same pattern: decrypt after findOne()
915
+ - [ ] **Update `findIndividualUserByEmail()` method**:
916
+ - [ ] Same pattern: decrypt after findOne()
917
+ - [ ] **Update `findIndividualUserByAppUserId()` method**:
918
+ - [ ] Same pattern: decrypt after findOne()
919
+ - [ ] **Update `findUserById()` method**:
920
+ - [ ] Same pattern: decrypt after findOne()
921
+ - [ ] **Update `updateIndividualUser()` method** (around line 303):
922
+ - [ ] After preparing update payload, encrypt before updateOne():
923
+ ```javascript
924
+ const encryptedPayload = await this.encryptionService.encryptFields('User', payload);
925
+ await updateOne(this.prisma, 'User', { _id: objectId, type: 'INDIVIDUAL' },
926
+ { $set: encryptedPayload });
927
+ ```
928
+ - [ ] After findOne(), decrypt before _mapUser():
929
+ ```javascript
930
+ const decryptedUser = await this.encryptionService.decryptFields('User', updated);
931
+ return this._mapUser(decryptedUser);
932
+ ```
933
+ - [ ] **Update `updateOrganizationUser()` method**:
934
+ - [ ] No changes needed (no encrypted fields)
935
+ - [ ] Verify no references to old encryption methods remain
936
+ - [ ] Run linter to check for issues
937
+ - [ ] Test locally
938
+
939
+ **Estimated Time**: 1 hour
940
+
941
+ ---
942
+
943
+ ### Phase 3: Refactor ModuleRepositoryDocumentDB
944
+
945
+ **File**: `packages/core/modules/repositories/module-repository-documentdb.js`
946
+
947
+ **Changes Checklist**:
948
+
949
+ - [ ] Import DocumentDBEncryptionService at top of file
950
+ - [ ] **Remove existing encryption methods** (lines 22-117):
951
+ - [ ] Remove `_initializeCryptor()` method
952
+ - [ ] Remove `_encryptField()` method
953
+ - [ ] Remove `_decryptField()` method
954
+ - [ ] Remove `_isEncryptedValue()` method
955
+ - [ ] Remove `_decryptCredentialData()` method
956
+ - [ ] **Update constructor**:
957
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
958
+ - [ ] Remove: `this._initializeCryptor();`
959
+ - [ ] **Update `_fetchCredential()` method** (around line 241):
960
+ - [ ] After findOne(), before returning:
961
+ ```javascript
962
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', rawCredential);
963
+ return {
964
+ id: fromObjectId(decryptedCredential._id),
965
+ userId: fromObjectId(decryptedCredential.userId),
966
+ externalId: decryptedCredential.externalId ?? null,
967
+ authIsValid: decryptedCredential.authIsValid ?? null,
968
+ createdAt: decryptedCredential.createdAt,
969
+ updatedAt: decryptedCredential.updatedAt,
970
+ data: decryptedCredential.data
971
+ };
972
+ ```
973
+ - [ ] **Update `_fetchCredentialsBulk()` method** (around line 280):
974
+ - [ ] Inside the map function for each credential:
975
+ ```javascript
976
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', rawCredential);
977
+ return this._convertCredentialIds({
978
+ id: fromObjectId(decryptedCredential._id),
979
+ // ... rest of mapping
980
+ data: decryptedCredential.data
981
+ });
982
+ ```
983
+ - [ ] Verify no references to old encryption methods remain
984
+ - [ ] Run linter to check for issues
985
+ - [ ] Test locally
986
+
987
+ **Note**: ModuleRepository doesn't create/update credentials, only reads them. It relies on CredentialRepository for writes.
988
+
989
+ **Estimated Time**: 1 hour
990
+
991
+ ---
992
+
993
+ ### Phase 4: Fix CredentialRepositoryDocumentDB (CRITICAL)
994
+
995
+ **File**: `packages/core/credential/repositories/credential-repository-documentdb.js`
996
+
997
+ **Critical Priority**: This is the security vulnerability fix
998
+
999
+ **Changes Checklist**:
1000
+
1001
+ - [ ] Import DocumentDBEncryptionService at top of file
1002
+ - [ ] **Update constructor**:
1003
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
1004
+ - [ ] **Fix `upsertCredential()` method** (around line 50):
1005
+ - [ ] **Current problematic code**:
1006
+ ```javascript
1007
+ const { user, userId, authIsValid, externalId, ...oauthData } = details || {};
1008
+ // oauthData contains PLAIN TEXT: access_token, refresh_token, etc.
1009
+
1010
+ const document = {
1011
+ data: oauthData // ❌ STORED AS PLAIN TEXT
1012
+ };
1013
+ await insertOne(this.prisma, 'Credential', document);
1014
+ ```
1015
+ - [ ] **Replace with ENCRYPTED version**:
1016
+ ```javascript
1017
+ const { user, userId, authIsValid, externalId, ...oauthData } = details || {};
1018
+
1019
+ // Build plain text document
1020
+ const plainDocument = {
1021
+ userId: toObjectId(userId || user),
1022
+ externalId: externalId ?? null,
1023
+ authIsValid: authIsValid ?? true,
1024
+ data: oauthData, // Still plain text at this point
1025
+ createdAt: now,
1026
+ updatedAt: now
1027
+ };
1028
+
1029
+ // ✅ ENCRYPT before storing
1030
+ const encryptedDocument = await this.encryptionService.encryptFields(
1031
+ 'Credential',
1032
+ plainDocument
1033
+ );
1034
+
1035
+ const insertedId = await insertOne(this.prisma, 'Credential', encryptedDocument);
1036
+
1037
+ // Read back and decrypt
1038
+ const created = await findOne(this.prisma, 'Credential', { _id: insertedId });
1039
+ const decryptedCredential = await this.encryptionService.decryptFields(
1040
+ 'Credential',
1041
+ created
1042
+ );
1043
+
1044
+ return this._mapCredential(decryptedCredential);
1045
+ ```
1046
+ - [ ] **For UPDATE case** (when credential exists):
1047
+ ```javascript
1048
+ // Merge existing data with new data
1049
+ const existingData = existing.data || {};
1050
+ const mergedData = { ...existingData, ...oauthData };
1051
+
1052
+ // Build update document
1053
+ const updateDocument = {
1054
+ data: mergedData,
1055
+ authIsValid: authIsValid ?? existing.authIsValid,
1056
+ updatedAt: now
1057
+ };
1058
+
1059
+ // ✅ ENCRYPT before storing
1060
+ const encryptedUpdate = await this.encryptionService.encryptFields(
1061
+ 'Credential',
1062
+ { data: updateDocument.data } // Only encrypt the data field
1063
+ );
1064
+
1065
+ await updateOne(
1066
+ this.prisma,
1067
+ 'Credential',
1068
+ { _id: existing._id },
1069
+ {
1070
+ $set: {
1071
+ data: encryptedUpdate.data,
1072
+ authIsValid: updateDocument.authIsValid,
1073
+ updatedAt: updateDocument.updatedAt
1074
+ }
1075
+ }
1076
+ );
1077
+
1078
+ // Read back and decrypt
1079
+ const updated = await findOne(this.prisma, 'Credential', { _id: existing._id });
1080
+ const decryptedCredential = await this.encryptionService.decryptFields(
1081
+ 'Credential',
1082
+ updated
1083
+ );
1084
+
1085
+ return this._mapCredential(decryptedCredential);
1086
+ ```
1087
+ - [ ] **Fix `_mapCredential()` method** (around line 192):
1088
+ - [ ] **Current problematic code**:
1089
+ ```javascript
1090
+ _mapCredential(doc) {
1091
+ const data = doc?.data || {};
1092
+ return {
1093
+ id: fromObjectId(doc._id),
1094
+ userId: fromObjectId(doc.userId),
1095
+ externalId: doc.externalId ?? null,
1096
+ authIsValid: doc.authIsValid ?? null,
1097
+ ...data // ❌ Could be encrypted strings
1098
+ };
1099
+ }
1100
+ ```
1101
+ - [ ] **Note**: If we decrypt in `upsertCredential()` before calling `_mapCredential()`, this method doesn't need changes. But for safety:
1102
+ ```javascript
1103
+ _mapCredential(doc) {
1104
+ // Assume doc is already decrypted by caller
1105
+ // (upsertCredential, findCredential should decrypt before calling this)
1106
+ const data = doc?.data || {};
1107
+ return {
1108
+ id: fromObjectId(doc._id),
1109
+ userId: fromObjectId(doc.userId),
1110
+ externalId: doc.externalId ?? null,
1111
+ authIsValid: doc.authIsValid ?? null,
1112
+ ...data // Already decrypted
1113
+ };
1114
+ }
1115
+ ```
1116
+ - [ ] **Fix `findCredential()` method** (if exists):
1117
+ - [ ] After findOne(), decrypt:
1118
+ ```javascript
1119
+ const doc = await findOne(this.prisma, 'Credential', filter);
1120
+ if (!doc) return null;
1121
+
1122
+ const decryptedDoc = await this.encryptionService.decryptFields('Credential', doc);
1123
+ return this._mapCredential(decryptedDoc);
1124
+ ```
1125
+ - [ ] **Fix `findManyCredentials()` method** (if exists):
1126
+ - [ ] After findMany(), decrypt each:
1127
+ ```javascript
1128
+ const docs = await findMany(this.prisma, 'Credential', filter);
1129
+
1130
+ const decryptedDocs = await Promise.all(
1131
+ docs.map(doc => this.encryptionService.decryptFields('Credential', doc))
1132
+ );
1133
+
1134
+ return decryptedDocs.map(doc => this._mapCredential(doc));
1135
+ ```
1136
+ - [ ] Add JSDoc comments explaining encryption
1137
+ - [ ] Verify all credential read/write operations are covered
1138
+ - [ ] Run linter
1139
+ - [ ] Test locally with real OAuth flow
1140
+
1141
+ **Security Verification**:
1142
+ - [ ] Create test credential with `access_token: "test_secret"`
1143
+ - [ ] Query database directly (bypass repository)
1144
+ - [ ] Verify stored value is encrypted format: `"keyId:iv:cipher:encKey"`
1145
+ - [ ] Verify repository returns decrypted value: `"test_secret"`
1146
+
1147
+ **Estimated Time**: 1.5 hours
1148
+
1149
+ ---
1150
+
1151
+ ### Phase 5: Add Comprehensive Tests
1152
+
1153
+ #### 5.1 User Repository Encryption Tests
1154
+
1155
+ **File**: `packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js`
1156
+
1157
+ **Test Coverage Checklist**:
1158
+
1159
+ - [ ] Create test file with describe block
1160
+ - [ ] Mock DocumentDBEncryptionService
1161
+ - [ ] **Test Group: Encryption on Write**
1162
+ - [ ] Test `createIndividualUser()` encrypts hashword before insert
1163
+ - [ ] Test `updateIndividualUser()` encrypts hashword before update
1164
+ - [ ] Verify encrypted format in database (use direct query)
1165
+ - [ ] Verify plain text never stored
1166
+ - [ ] **Test Group: Decryption on Read**
1167
+ - [ ] Test `findIndividualUserById()` returns decrypted hashword
1168
+ - [ ] Test `findIndividualUserByUsername()` returns decrypted hashword
1169
+ - [ ] Test `findIndividualUserByEmail()` returns decrypted hashword
1170
+ - [ ] Verify application receives plain text
1171
+ - [ ] **Test Group: Stage-Based Bypass**
1172
+ - [ ] Test encryption bypassed in dev stage
1173
+ - [ ] Test encryption bypassed in test stage
1174
+ - [ ] Test encryption bypassed in local stage
1175
+ - [ ] Test encryption enabled in production stage
1176
+ - [ ] **Test Group: Edge Cases**
1177
+ - [ ] Test null hashword handling
1178
+ - [ ] Test undefined hashword handling
1179
+ - [ ] Test empty string hashword
1180
+ - [ ] Test already encrypted hashword (idempotent)
1181
+ - [ ] **Test Group: Error Handling**
1182
+ - [ ] Test encryption service throws error
1183
+ - [ ] Test decryption service throws error
1184
+ - [ ] Verify error propagation to use case
1185
+ - [ ] Run tests: `npm test user-repository-documentdb-encryption.test.js`
1186
+
1187
+ **Estimated Time**: 1.5 hours
1188
+
1189
+ ---
1190
+
1191
+ #### 5.2 Module Repository Encryption Tests
1192
+
1193
+ **File**: `packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js`
1194
+
1195
+ **Test Coverage Checklist**:
1196
+
1197
+ - [ ] Create test file with describe block
1198
+ - [ ] Mock DocumentDBEncryptionService
1199
+ - [ ] Mock credential data in database (pre-encrypted)
1200
+ - [ ] **Test Group: Credential Decryption**
1201
+ - [ ] Test `_fetchCredential()` decrypts credential data
1202
+ - [ ] Test `_fetchCredentialsBulk()` decrypts multiple credentials
1203
+ - [ ] Verify nested field decryption (data.access_token)
1204
+ - [ ] Verify multiple field decryption (access_token, refresh_token, id_token)
1205
+ - [ ] **Test Group: Integration with Entities**
1206
+ - [ ] Test `findEntityById()` returns entity with decrypted credential
1207
+ - [ ] Test `findEntitiesByUserId()` returns entities with decrypted credentials
1208
+ - [ ] Test `findEntitiesByUserIdAndModuleName()` decrypts credentials
1209
+ - [ ] **Test Group: Error Handling**
1210
+ - [ ] Test corrupted encrypted data (decryption fails)
1211
+ - [ ] Test missing credential (null credential)
1212
+ - [ ] Verify graceful degradation
1213
+ - [ ] **Test Group: Performance**
1214
+ - [ ] Test bulk decryption of 10 credentials
1215
+ - [ ] Verify parallel decryption (not sequential)
1216
+ - [ ] Run tests: `npm test module-repository-documentdb-encryption.test.js`
1217
+
1218
+ **Estimated Time**: 1.5 hours
1219
+
1220
+ ---
1221
+
1222
+ #### 5.3 Credential Repository Encryption Tests (NEW - CRITICAL)
1223
+
1224
+ **File**: `packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js`
1225
+
1226
+ **Test Coverage Checklist**:
1227
+
1228
+ - [ ] Create test file with describe block
1229
+ - [ ] Mock DocumentDBEncryptionService
1230
+ - [ ] Setup DocumentDB test database
1231
+ - [ ] **Test Group: Encryption on Upsert (INSERT)**
1232
+ - [ ] Test encrypts access_token before insert
1233
+ - [ ] Test encrypts refresh_token before insert
1234
+ - [ ] Test encrypts id_token before insert
1235
+ - [ ] Test encrypts domain before insert
1236
+ - [ ] **Verify encrypted format in database**:
1237
+ ```javascript
1238
+ // Direct database query (bypass repository)
1239
+ const rawDoc = await prisma.$runCommandRaw({
1240
+ find: 'Credential',
1241
+ filter: { userId: toObjectId(userId) }
1242
+ });
1243
+ const storedToken = rawDoc.cursor.firstBatch[0].data.access_token;
1244
+
1245
+ // Must match encrypted format
1246
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
1247
+ expect(storedToken).not.toBe('plain_secret');
1248
+ ```
1249
+ - [ ] **Test Group: Encryption on Upsert (UPDATE)**
1250
+ - [ ] Test existing credential update encrypts new tokens
1251
+ - [ ] Test merges existing encrypted data with new encrypted data
1252
+ - [ ] Test updates preserve other credential fields
1253
+ - [ ] **Test Group: Decryption on Read**
1254
+ - [ ] Test `upsertCredential()` returns decrypted credential
1255
+ - [ ] Test `findCredential()` returns decrypted credential (if exists)
1256
+ - [ ] Test `_mapCredential()` receives decrypted data
1257
+ - [ ] **Verify plain text returned to application**:
1258
+ ```javascript
1259
+ const credential = await repository.upsertCredential({
1260
+ userId, externalId,
1261
+ access_token: 'plain_secret',
1262
+ refresh_token: 'plain_refresh'
1263
+ });
1264
+
1265
+ expect(credential.access_token).toBe('plain_secret');
1266
+ expect(credential.refresh_token).toBe('plain_refresh');
1267
+ ```
1268
+ - [ ] **Test Group: Integration Flow**
1269
+ - [ ] Test full flow: insert → read → verify
1270
+ - [ ] Test full flow: insert → update → read → verify
1271
+ - [ ] Test multiple credentials per user
1272
+ - [ ] Test credential retrieval by externalId
1273
+ - [ ] **Test Group: Security Validation**
1274
+ - [ ] Test KMS encryption in production stage
1275
+ - [ ] Test AES encryption when KMS unavailable
1276
+ - [ ] Test bypass in dev/test/local stages
1277
+ - [ ] Test plain text never exposed in logs
1278
+ - [ ] **Test Group: Error Handling**
1279
+ - [ ] Test encryption service throws error on insert
1280
+ - [ ] Test decryption service throws error on read
1281
+ - [ ] Test partial credential data (missing fields)
1282
+ - [ ] Test null values for optional fields
1283
+ - [ ] **Test Group: Edge Cases**
1284
+ - [ ] Test empty oauth data
1285
+ - [ ] Test very large token values (>1KB)
1286
+ - [ ] Test special characters in tokens
1287
+ - [ ] Test unicode in tokens
1288
+ - [ ] Run tests: `npm test credential-repository-documentdb-encryption.test.js`
1289
+
1290
+ **Security Test Example**:
1291
+ ```javascript
1292
+ describe('Security - Encryption Verification', () => {
1293
+ it('stores access_token in encrypted format in database', async () => {
1294
+ const userId = new ObjectId();
1295
+ const externalId = 'test-external-123';
1296
+ const plainToken = 'ya29.actual_google_token_here';
1297
+
1298
+ // Create credential via repository
1299
+ await credentialRepo.upsertCredential({
1300
+ userId: fromObjectId(userId),
1301
+ externalId,
1302
+ access_token: plainToken
1303
+ });
1304
+
1305
+ // Query database directly (bypass repository and encryption)
1306
+ const rawResult = await prisma.$runCommandRaw({
1307
+ find: 'Credential',
1308
+ filter: { userId, externalId }
1309
+ });
1310
+
1311
+ const storedCredential = rawResult.cursor.firstBatch[0];
1312
+ const storedToken = storedCredential.data.access_token;
1313
+
1314
+ // CRITICAL: Verify encrypted format
1315
+ expect(storedToken).not.toBe(plainToken); // Must not be plain text
1316
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Must be "keyId:iv:cipher:encKey"
1317
+
1318
+ // Verify repository returns decrypted value
1319
+ const retrieved = await credentialRepo.findCredential({ userId, externalId });
1320
+ expect(retrieved.access_token).toBe(plainToken); // Must be decrypted
1321
+ });
1322
+ });
1323
+ ```
1324
+
1325
+ **Estimated Time**: 2 hours
1326
+
1327
+ ---
1328
+
1329
+ ### Phase 6: Apply to Both Locations
1330
+
1331
+ **Dual Location Rule**: All changes must be applied to BOTH:
1332
+ 1. **Development**: `/Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg/packages/core/`
1333
+ 2. **Runtime**: `/Users/danielklotz/projects/lefthook/frontify--frigg/backend/node_modules/@friggframework/core/`
1334
+
1335
+ **Files to Update in Both Locations**:
1336
+
1337
+ - [ ] `database/documentdb-encryption-service.js` (NEW)
1338
+ - [ ] `database/__tests__/documentdb-encryption-service.test.js` (NEW)
1339
+ - [ ] `user/repositories/user-repository-documentdb.js`
1340
+ - [ ] `user/repositories/__tests__/user-repository-documentdb-encryption.test.js` (NEW)
1341
+ - [ ] `modules/repositories/module-repository-documentdb.js`
1342
+ - [ ] `modules/repositories/__tests__/module-repository-documentdb-encryption.test.js` (NEW)
1343
+ - [ ] `credential/repositories/credential-repository-documentdb.js`
1344
+ - [ ] `credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js` (NEW)
1345
+
1346
+ **Verification Steps**:
1347
+
1348
+ For each file:
1349
+ - [ ] Copy from `/tmp/frigg/` to `/backend/node_modules/@friggframework/`
1350
+ - [ ] Verify file checksums match
1351
+ - [ ] Run `diff` to confirm identical content
1352
+ - [ ] Check file permissions
1353
+
1354
+ **Script to Automate** (optional):
1355
+ ```bash
1356
+ #!/bin/bash
1357
+ # sync-documentdb-encryption.sh
1358
+
1359
+ SOURCE="/Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg/packages/core"
1360
+ DEST="/Users/danielklotz/projects/lefthook/frontify--frigg/backend/node_modules/@friggframework/core"
1361
+
1362
+ FILES=(
1363
+ "database/documentdb-encryption-service.js"
1364
+ "database/__tests__/documentdb-encryption-service.test.js"
1365
+ "user/repositories/user-repository-documentdb.js"
1366
+ "user/repositories/__tests__/user-repository-documentdb-encryption.test.js"
1367
+ "modules/repositories/module-repository-documentdb.js"
1368
+ "modules/repositories/__tests__/module-repository-documentdb-encryption.test.js"
1369
+ "credential/repositories/credential-repository-documentdb.js"
1370
+ "credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js"
1371
+ )
1372
+
1373
+ for file in "${FILES[@]}"; do
1374
+ cp "$SOURCE/$file" "$DEST/$file"
1375
+ echo "✅ Synced: $file"
1376
+ done
1377
+
1378
+ echo "🎉 All files synced successfully"
1379
+ ```
1380
+
1381
+ **Estimated Time**: 30 minutes
1382
+
1383
+ ---
1384
+
1385
+ ### Phase 7: Validation & Testing
1386
+
1387
+ #### 7.1 Run Test Suites
1388
+
1389
+ **Test Execution Checklist**:
1390
+
1391
+ - [ ] **Run DocumentDB encryption service tests**:
1392
+ ```bash
1393
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg
1394
+ npm test packages/core/database/__tests__/documentdb-encryption-service.test.js
1395
+ ```
1396
+ - [ ] Verify all tests pass
1397
+ - [ ] Check coverage >90%
1398
+
1399
+ - [ ] **Run User repository encryption tests**:
1400
+ ```bash
1401
+ npm test packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js
1402
+ ```
1403
+ - [ ] Verify all tests pass
1404
+
1405
+ - [ ] **Run Module repository encryption tests**:
1406
+ ```bash
1407
+ npm test packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js
1408
+ ```
1409
+ - [ ] Verify all tests pass
1410
+
1411
+ - [ ] **Run Credential repository encryption tests** (CRITICAL):
1412
+ ```bash
1413
+ npm test packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js
1414
+ ```
1415
+ - [ ] Verify all tests pass
1416
+ - [ ] Verify security test passes (encrypted format verification)
1417
+
1418
+ - [ ] **Run all repository tests**:
1419
+ ```bash
1420
+ npm test -- --testPathPattern=documentdb
1421
+ ```
1422
+ - [ ] Verify no regressions
1423
+
1424
+ - [ ] **Run full test suite**:
1425
+ ```bash
1426
+ npm test
1427
+ ```
1428
+ - [ ] Verify all tests pass
1429
+ - [ ] Check for no unexpected failures
1430
+
1431
+ ---
1432
+
1433
+ #### 7.2 Manual Verification
1434
+
1435
+ **Local Environment Setup**:
1436
+
1437
+ - [ ] Start MongoDB (DocumentDB simulation):
1438
+ ```bash
1439
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/backend
1440
+ npm run docker:start
1441
+ ```
1442
+
1443
+ - [ ] Verify MongoDB is running:
1444
+ ```bash
1445
+ docker ps | grep mongo
1446
+ ```
1447
+
1448
+ - [ ] Set environment variables for encryption:
1449
+ ```bash
1450
+ export STAGE=production
1451
+ export AES_KEY_ID=local-test-key
1452
+ export AES_KEY=01234567890123456789012345678901 # 32 chars
1453
+ ```
1454
+
1455
+ - [ ] Start backend:
1456
+ ```bash
1457
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/backend
1458
+ npm run frigg:start
1459
+ ```
1460
+
1461
+ **Manual Test: Credential Creation**
1462
+
1463
+ - [ ] Create user and get token:
1464
+ ```bash
1465
+ curl -X POST http://localhost:3000/user/create \
1466
+ -H "Content-Type: application/json" \
1467
+ -d '{"username":"test@test.com","password":"test"}' \
1468
+ -o /tmp/token.json
1469
+
1470
+ TOKEN=$(jq -r '.token' /tmp/token.json)
1471
+ echo "Token: $TOKEN"
1472
+ ```
1473
+
1474
+ - [ ] Create OAuth credential (if endpoint exists, else use Asana OAuth flow):
1475
+ ```bash
1476
+ # Trigger OAuth flow through application
1477
+ # Then verify credential was created encrypted
1478
+ ```
1479
+
1480
+ **Manual Test: Database Verification**
1481
+
1482
+ - [ ] Connect to MongoDB:
1483
+ ```bash
1484
+ docker exec -it $(docker ps -q -f name=mongo) mongosh
1485
+ ```
1486
+
1487
+ - [ ] Query credential:
1488
+ ```javascript
1489
+ use frigg
1490
+ db.Credential.findOne()
1491
+ ```
1492
+
1493
+ - [ ] **CRITICAL VERIFICATION**:
1494
+ ```javascript
1495
+ // Check data.access_token format
1496
+ const cred = db.Credential.findOne({ externalId: "google-user-123" });
1497
+ print("access_token:", cred.data.access_token);
1498
+
1499
+ // Expected format: "keyId:iv:cipher:encKey"
1500
+ // Example: "aes-key-1:1234567890abcdef:a1b2c3d4e5f6...:9876543210fedcba"
1501
+
1502
+ // MUST NOT be plain text like "ya29.a0AfH6SMCX..."
1503
+ ```
1504
+
1505
+ - [ ] Verify encrypted format:
1506
+ ```javascript
1507
+ // Should have 4+ colon-separated parts
1508
+ const parts = cred.data.access_token.split(':');
1509
+ print("Parts count:", parts.length); // Should be >= 4
1510
+ ```
1511
+
1512
+ **Manual Test: API Usage**
1513
+
1514
+ - [ ] Use credential through API:
1515
+ ```bash
1516
+ # Make API request that uses the credential
1517
+ # Example: Fetch Asana user info
1518
+ curl -X GET http://localhost:3000/api/asana/me \
1519
+ -H "Authorization: Bearer $TOKEN"
1520
+ ```
1521
+
1522
+ - [ ] Verify API call succeeds (credential was decrypted correctly)
1523
+
1524
+ **Manual Test: Stage Bypass**
1525
+
1526
+ - [ ] Stop backend
1527
+
1528
+ - [ ] Change to dev stage:
1529
+ ```bash
1530
+ export STAGE=dev
1531
+ unset AES_KEY_ID
1532
+ unset AES_KEY
1533
+ ```
1534
+
1535
+ - [ ] Start backend
1536
+
1537
+ - [ ] Create credential
1538
+
1539
+ - [ ] Verify credential stored as plain text (bypass worked):
1540
+ ```javascript
1541
+ // In mongosh:
1542
+ const devCred = db.Credential.findOne({ userId: ObjectId("...") });
1543
+ print("access_token:", devCred.data.access_token);
1544
+ // Should be plain text (not encrypted) in dev stage
1545
+ ```
1546
+
1547
+ ---
1548
+
1549
+ #### 7.3 Integration Testing
1550
+
1551
+ **OAuth Flow Testing**:
1552
+
1553
+ - [ ] **Asana OAuth Flow**:
1554
+ - [ ] Start OAuth flow via Asana integration
1555
+ - [ ] Complete OAuth authorization
1556
+ - [ ] Verify credential created in database
1557
+ - [ ] Check credential is encrypted in database
1558
+ - [ ] Verify Asana API calls work (credential decrypted)
1559
+
1560
+ - [ ] **Frontify OAuth Flow**:
1561
+ - [ ] Start OAuth flow via Frontify integration
1562
+ - [ ] Complete OAuth authorization
1563
+ - [ ] Verify credential created in database
1564
+ - [ ] Check credential is encrypted in database
1565
+ - [ ] Verify Frontify API calls work
1566
+
1567
+ **Credential Refresh Testing**:
1568
+
1569
+ - [ ] Trigger token refresh (if implemented)
1570
+ - [ ] Verify new tokens are encrypted
1571
+ - [ ] Verify old tokens are overwritten (not duplicated)
1572
+ - [ ] Verify refresh token itself is encrypted
1573
+
1574
+ **Multi-User Testing**:
1575
+
1576
+ - [ ] Create credentials for 3 different users
1577
+ - [ ] Verify each credential is independently encrypted
1578
+ - [ ] Verify users can only access their own credentials
1579
+ - [ ] Check for no credential leakage between users
1580
+
1581
+ ---
1582
+
1583
+ #### 7.4 Performance Testing
1584
+
1585
+ **Encryption Performance**:
1586
+
1587
+ - [ ] Measure encryption time for single credential:
1588
+ ```javascript
1589
+ const start = Date.now();
1590
+ const encrypted = await service.encryptFields('Credential', credential);
1591
+ const encryptTime = Date.now() - start;
1592
+ console.log(`Encryption time: ${encryptTime}ms`);
1593
+ // Should be < 50ms for KMS, < 10ms for AES
1594
+ ```
1595
+
1596
+ - [ ] Measure decryption time for single credential
1597
+
1598
+ **Bulk Operations**:
1599
+
1600
+ - [ ] Test bulk credential retrieval (10 credentials):
1601
+ ```javascript
1602
+ const start = Date.now();
1603
+ const entities = await moduleRepo.findEntitiesByUserId(userId);
1604
+ const bulkTime = Date.now() - start;
1605
+ console.log(`Bulk retrieval time: ${bulkTime}ms`);
1606
+ // Should be reasonable (< 500ms for 10 credentials)
1607
+ ```
1608
+
1609
+ - [ ] Verify parallel decryption is used (not sequential)
1610
+
1611
+ ---
1612
+
1613
+ #### 7.5 Security Validation
1614
+
1615
+ **Encryption Format Verification**:
1616
+
1617
+ - [ ] Create credential with known value
1618
+ - [ ] Query database directly
1619
+ - [ ] Verify format matches: `keyId:iv:cipher:encKey`
1620
+ - [ ] Verify at least 4 colon-separated parts
1621
+ - [ ] Verify base64-like characters in each part
1622
+
1623
+ **Decryption Verification**:
1624
+
1625
+ - [ ] Create credential with known value
1626
+ - [ ] Retrieve via repository
1627
+ - [ ] Verify decrypted value matches original
1628
+ - [ ] Verify no corruption or truncation
1629
+
1630
+ **Negative Tests**:
1631
+
1632
+ - [ ] Manually corrupt encrypted value in database
1633
+ - [ ] Attempt to retrieve credential
1634
+ - [ ] Verify graceful handling (field set to null, logged error)
1635
+ - [ ] Verify application doesn't crash
1636
+
1637
+ **Key Rotation Simulation** (if time permits):
1638
+
1639
+ - [ ] Create credential with key1
1640
+ - [ ] Rotate to key2 (change AES_KEY_ID)
1641
+ - [ ] Verify old credentials still decrypt (backward compatible)
1642
+ - [ ] Verify new credentials use key2
1643
+
1644
+ **Estimated Time**: 1.5 hours
1645
+
1646
+ ---
1647
+
1648
+ ### Phase 8: Documentation Updates
1649
+
1650
+ #### 8.1 Update Main Encryption README
1651
+
1652
+ **File**: `packages/core/database/encryption/README.md`
1653
+
1654
+ **Sections to Add**:
1655
+
1656
+ - [ ] **Add "DocumentDB Encryption" section** (after "How It Works"):
1657
+ ```markdown
1658
+ ## DocumentDB Encryption
1659
+
1660
+ ### Why DocumentDB Needs Manual Encryption
1661
+
1662
+ DocumentDB repositories use `$runCommandRaw()` for MongoDB protocol compatibility,
1663
+ which bypasses Prisma Client Extensions. This means the automatic encryption
1664
+ extension does not apply.
1665
+
1666
+ ### DocumentDBEncryptionService
1667
+
1668
+ For DocumentDB repositories, use `DocumentDBEncryptionService` to manually
1669
+ encrypt/decrypt documents before/after database operations.
1670
+
1671
+ #### Usage Example
1672
+
1673
+ \`\`\`javascript
1674
+ const { DocumentDBEncryptionService } = require('../documentdb-encryption-service');
1675
+ const { insertOne, findOne } = require('../documentdb-utils');
1676
+
1677
+ class MyRepositoryDocumentDB {
1678
+ constructor() {
1679
+ this.encryptionService = new DocumentDBEncryptionService();
1680
+ }
1681
+
1682
+ async create(data) {
1683
+ // Encrypt before write
1684
+ const encrypted = await this.encryptionService.encryptFields('ModelName', data);
1685
+ const id = await insertOne(this.prisma, 'CollectionName', encrypted);
1686
+
1687
+ // Decrypt after read
1688
+ const doc = await findOne(this.prisma, 'CollectionName', { _id: id });
1689
+ const decrypted = await this.encryptionService.decryptFields('ModelName', doc);
1690
+
1691
+ return decrypted;
1692
+ }
1693
+ }
1694
+ \`\`\`
1695
+
1696
+ #### Configuration
1697
+
1698
+ Uses the same environment variables and Cryptor as the Prisma Extension:
1699
+ - `STAGE`: Bypasses encryption for dev/test/local
1700
+ - `KMS_KEY_ARN`: AWS KMS encryption (production)
1701
+ - `AES_KEY_ID` + `AES_KEY`: AES encryption (fallback)
1702
+
1703
+ #### Implementation Details
1704
+
1705
+ See: [documentdb-encryption-service.md](./documentdb-encryption-service.md)
1706
+ ```
1707
+
1708
+ - [ ] **Update "Adding Encrypted Fields" section**:
1709
+ ```markdown
1710
+ After adding fields to `encryption-schema-registry.js`:
1711
+
1712
+ 1. **For MongoDB/PostgreSQL**: No code changes needed (automatic)
1713
+ 2. **For DocumentDB**: Encryption is automatic via DocumentDBEncryptionService
1714
+ (service reads from same registry)
1715
+ ```
1716
+
1717
+ ---
1718
+
1719
+ #### 8.2 Repository JSDoc Comments
1720
+
1721
+ **UserRepositoryDocumentDB**:
1722
+
1723
+ - [ ] Add class-level JSDoc:
1724
+ ```javascript
1725
+ /**
1726
+ * User repository for DocumentDB.
1727
+ * Uses DocumentDBEncryptionService for field-level encryption.
1728
+ *
1729
+ * Encrypted fields: User.hashword
1730
+ *
1731
+ * @see DocumentDBEncryptionService
1732
+ * @see encryption-schema-registry.js
1733
+ */
1734
+ class UserRepositoryDocumentDB extends UserRepositoryInterface {
1735
+ ```
1736
+
1737
+ **ModuleRepositoryDocumentDB**:
1738
+
1739
+ - [ ] Add class-level JSDoc:
1740
+ ```javascript
1741
+ /**
1742
+ * Module/Entity repository for DocumentDB.
1743
+ * Uses DocumentDBEncryptionService for credential decryption.
1744
+ *
1745
+ * Encrypted fields: Credential.data.*
1746
+ *
1747
+ * Note: This repository only reads credentials. CredentialRepository
1748
+ * handles credential creation/updates with encryption.
1749
+ *
1750
+ * @see DocumentDBEncryptionService
1751
+ * @see CredentialRepositoryDocumentDB
1752
+ */
1753
+ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
1754
+ ```
1755
+
1756
+ **CredentialRepositoryDocumentDB**:
1757
+
1758
+ - [ ] Add class-level JSDoc:
1759
+ ```javascript
1760
+ /**
1761
+ * Credential repository for DocumentDB.
1762
+ * Uses DocumentDBEncryptionService for field-level encryption.
1763
+ *
1764
+ * Encrypted fields:
1765
+ * - Credential.data.access_token
1766
+ * - Credential.data.refresh_token
1767
+ * - Credential.data.id_token
1768
+ * - Credential.data.domain
1769
+ *
1770
+ * SECURITY CRITICAL: All OAuth credentials must be encrypted at rest.
1771
+ *
1772
+ * @see DocumentDBEncryptionService
1773
+ * @see encryption-schema-registry.js
1774
+ */
1775
+ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
1776
+ ```
1777
+
1778
+ **Estimated Time**: 30 minutes
1779
+
1780
+ ---
1781
+
1782
+ ## Total Implementation Time Estimate
1783
+
1784
+ | Phase | Description | Time |
1785
+ |-------|-------------|------|
1786
+ | Phase 1 | Create DocumentDBEncryptionService + tests | 2-3 hours |
1787
+ | Phase 2 | Refactor UserRepositoryDocumentDB | 1 hour |
1788
+ | Phase 3 | Refactor ModuleRepositoryDocumentDB | 1 hour |
1789
+ | Phase 4 | Fix CredentialRepositoryDocumentDB | 1.5 hours |
1790
+ | Phase 5 | Add comprehensive tests (3 repos) | 5 hours |
1791
+ | Phase 6 | Apply to both locations | 30 minutes |
1792
+ | Phase 7 | Validation and integration testing | 1.5 hours |
1793
+ | Phase 8 | Documentation updates | 30 minutes |
1794
+ | **Total** | | **~13 hours** |
1795
+
1796
+ ---
1797
+
1798
+ ## Code Examples
1799
+
1800
+ ### Example 1: Before & After - CredentialRepositoryDocumentDB
1801
+
1802
+ **BEFORE (Vulnerable - Plain Text Storage)**:
1803
+
1804
+ ```javascript
1805
+ class CredentialRepositoryDocumentDB {
1806
+ constructor() {
1807
+ this.prisma = prisma;
1808
+ // ❌ No encryption service
1809
+ }
1810
+
1811
+ async upsertCredential(credentialDetails) {
1812
+ const { identifiers, details } = credentialDetails;
1813
+ const { user, userId, authIsValid, externalId, ...oauthData } = details || {};
1814
+
1815
+ // ❌ oauthData contains PLAIN TEXT tokens
1816
+ const document = {
1817
+ userId: toObjectId(userId || user),
1818
+ externalId,
1819
+ data: oauthData, // ❌ { access_token: "plain_secret", ... }
1820
+ createdAt: new Date(),
1821
+ updatedAt: new Date()
1822
+ };
1823
+
1824
+ // ❌ STORED AS PLAIN TEXT
1825
+ const insertedId = await insertOne(this.prisma, 'Credential', document);
1826
+
1827
+ const created = await findOne(this.prisma, 'Credential', { _id: insertedId });
1828
+ // ❌ Returns encrypted string (if previously encrypted) or plain text
1829
+ return this._mapCredential(created);
1830
+ }
1831
+ }
1832
+ ```
1833
+
1834
+ **AFTER (Secure - Encrypted Storage)**:
1835
+
1836
+ ```javascript
1837
+ const { DocumentDBEncryptionService } = require('../database/documentdb-encryption-service');
1838
+
1839
+ class CredentialRepositoryDocumentDB {
1840
+ constructor() {
1841
+ this.prisma = prisma;
1842
+ // ✅ Initialize encryption service
1843
+ this.encryptionService = new DocumentDBEncryptionService();
1844
+ }
1845
+
1846
+ async upsertCredential(credentialDetails) {
1847
+ const { identifiers, details } = credentialDetails;
1848
+ const { user, userId, authIsValid, externalId, ...oauthData } = details || {};
1849
+
1850
+ // Build plain text document
1851
+ const plainDocument = {
1852
+ userId: toObjectId(userId || user),
1853
+ externalId,
1854
+ data: oauthData, // Still plain text: { access_token: "plain_secret", ... }
1855
+ createdAt: new Date(),
1856
+ updatedAt: new Date()
1857
+ };
1858
+
1859
+ // ✅ ENCRYPT before storing
1860
+ const encryptedDocument = await this.encryptionService.encryptFields(
1861
+ 'Credential',
1862
+ plainDocument
1863
+ );
1864
+ // encryptedDocument.data = { access_token: "keyId:iv:cipher:encKey", ... }
1865
+
1866
+ // ✅ STORED AS ENCRYPTED
1867
+ const insertedId = await insertOne(this.prisma, 'Credential', encryptedDocument);
1868
+
1869
+ const created = await findOne(this.prisma, 'Credential', { _id: insertedId });
1870
+
1871
+ // ✅ DECRYPT before returning
1872
+ const decryptedCredential = await this.encryptionService.decryptFields(
1873
+ 'Credential',
1874
+ created
1875
+ );
1876
+ // decryptedCredential.data = { access_token: "plain_secret", ... }
1877
+
1878
+ return this._mapCredential(decryptedCredential);
1879
+ }
1880
+ }
1881
+ ```
1882
+
1883
+ ---
1884
+
1885
+ ### Example 2: DocumentDBEncryptionService Usage Patterns
1886
+
1887
+ **Pattern 1: Single Field Encryption (User.hashword)**:
1888
+
1889
+ ```javascript
1890
+ class UserRepositoryDocumentDB {
1891
+ async createIndividualUser(params) {
1892
+ const document = {
1893
+ type: 'INDIVIDUAL',
1894
+ username: params.username,
1895
+ hashword: await bcrypt.hash(params.hashword, 10), // Bcrypt hash
1896
+ createdAt: new Date()
1897
+ };
1898
+
1899
+ // Encrypt bcrypt hash before storage
1900
+ const encrypted = await this.encryptionService.encryptFields('User', document);
1901
+ // encrypted.hashword = "keyId:iv:cipher:encKey"
1902
+
1903
+ const id = await insertOne(this.prisma, 'User', encrypted);
1904
+ const created = await findOne(this.prisma, 'User', { _id: id });
1905
+
1906
+ // Decrypt before returning
1907
+ const decrypted = await this.encryptionService.decryptFields('User', created);
1908
+ // decrypted.hashword = "$2b$10$..." (bcrypt hash)
1909
+
1910
+ return this._mapUser(decrypted);
1911
+ }
1912
+ }
1913
+ ```
1914
+
1915
+ **Pattern 2: Nested Fields Encryption (Credential.data.*)**:
1916
+
1917
+ ```javascript
1918
+ class CredentialRepositoryDocumentDB {
1919
+ async upsertCredential(details) {
1920
+ const document = {
1921
+ data: {
1922
+ access_token: "ya29.actual_token",
1923
+ refresh_token: "1//0refresh",
1924
+ id_token: "eyJhbGci...",
1925
+ expires_at: 1234567890, // Not encrypted (not in registry)
1926
+ scope: "openid profile" // Not encrypted
1927
+ }
1928
+ };
1929
+
1930
+ // Encrypts only fields defined in encryption-schema-registry.js
1931
+ const encrypted = await this.encryptionService.encryptFields('Credential', document);
1932
+ // encrypted.data = {
1933
+ // access_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
1934
+ // refresh_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
1935
+ // id_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
1936
+ // expires_at: 1234567890, ← PLAIN (not in registry)
1937
+ // scope: "openid profile" ← PLAIN (not in registry)
1938
+ // }
1939
+ }
1940
+ }
1941
+ ```
1942
+
1943
+ **Pattern 3: Bulk Decryption (Multiple Credentials)**:
1944
+
1945
+ ```javascript
1946
+ class ModuleRepositoryDocumentDB {
1947
+ async _fetchCredentialsBulk(credentialIds) {
1948
+ const objectIds = credentialIds.map(id => toObjectId(id)).filter(Boolean);
1949
+
1950
+ // Fetch all credentials (encrypted)
1951
+ const rawCredentials = await findMany(this.prisma, 'Credential', {
1952
+ _id: { $in: objectIds }
1953
+ });
1954
+
1955
+ // Decrypt in parallel
1956
+ const decryptionPromises = rawCredentials.map(async (rawCredential) => {
1957
+ const decrypted = await this.encryptionService.decryptFields(
1958
+ 'Credential',
1959
+ rawCredential
1960
+ );
1961
+ return this._mapCredential(decrypted);
1962
+ });
1963
+
1964
+ return await Promise.all(decryptionPromises);
1965
+ }
1966
+ }
1967
+ ```
1968
+
1969
+ ---
1970
+
1971
+ ### Example 3: Complete Flow - OAuth Credential Creation
1972
+
1973
+ ```javascript
1974
+ // 1. User completes OAuth flow, application receives tokens
1975
+ const oauthTokens = {
1976
+ access_token: "ya29.a0AfH6SMCXyz...",
1977
+ refresh_token: "1//0gFz6TRvwUm...",
1978
+ id_token: "eyJhbGciOiJSUzI1...",
1979
+ expires_in: 3600,
1980
+ token_type: "Bearer"
1981
+ };
1982
+
1983
+ // 2. Use case calls repository
1984
+ const credential = await credentialRepository.upsertCredential({
1985
+ identifiers: { userId: "user123", externalId: "google-user-456" },
1986
+ details: oauthTokens
1987
+ });
1988
+
1989
+ // 3. Inside repository: Build plain document
1990
+ const plainDocument = {
1991
+ userId: toObjectId("user123"),
1992
+ externalId: "google-user-456",
1993
+ data: {
1994
+ access_token: "ya29.a0AfH6SMCXyz...",
1995
+ refresh_token: "1//0gFz6TRvwUm...",
1996
+ id_token: "eyJhbGciOiJSUzI1...",
1997
+ expires_in: 3600,
1998
+ token_type: "Bearer"
1999
+ }
2000
+ };
2001
+
2002
+ // 4. DocumentDBEncryptionService encrypts sensitive fields
2003
+ const encryptedDocument = await this.encryptionService.encryptFields('Credential', plainDocument);
2004
+ // Result:
2005
+ // {
2006
+ // userId: ObjectId("..."),
2007
+ // externalId: "google-user-456",
2008
+ // data: {
2009
+ // access_token: "aes-key-1:a1b2c3:d4e5f6:g7h8i9", ← ENCRYPTED
2010
+ // refresh_token: "aes-key-1:j1k2l3:m4n5o6:p7q8r9", ← ENCRYPTED
2011
+ // id_token: "aes-key-1:s1t2u3:v4w5x6:y7z8a9", ← ENCRYPTED
2012
+ // expires_in: 3600, ← PLAIN (not in registry)
2013
+ // token_type: "Bearer" ← PLAIN (not in registry)
2014
+ // }
2015
+ // }
2016
+
2017
+ // 5. Store in DocumentDB
2018
+ await insertOne(this.prisma, 'Credential', encryptedDocument);
2019
+
2020
+ // 6. Read back from DocumentDB
2021
+ const rawDocument = await findOne(this.prisma, 'Credential', { userId: objectId });
2022
+ // Returns encrypted data as stored
2023
+
2024
+ // 7. DocumentDBEncryptionService decrypts sensitive fields
2025
+ const decryptedDocument = await this.encryptionService.decryptFields('Credential', rawDocument);
2026
+ // Result:
2027
+ // {
2028
+ // data: {
2029
+ // access_token: "ya29.a0AfH6SMCXyz...", ← DECRYPTED
2030
+ // refresh_token: "1//0gFz6TRvwUm...", ← DECRYPTED
2031
+ // id_token: "eyJhbGciOiJSUzI1...", ← DECRYPTED
2032
+ // expires_in: 3600,
2033
+ // token_type: "Bearer"
2034
+ // }
2035
+ // }
2036
+
2037
+ // 8. Use case receives plain text credential
2038
+ return credential; // { access_token: "ya29...", refresh_token: "1//0...", ... }
2039
+
2040
+ // 9. Application makes API call
2041
+ await fetch('https://www.googleapis.com/oauth2/v1/userinfo', {
2042
+ headers: { Authorization: `Bearer ${credential.access_token}` }
2043
+ });
2044
+ // ✅ Works! Token is usable
2045
+ ```
2046
+
2047
+ ---
2048
+
2049
+ ## Testing Strategy
2050
+
2051
+ ### Unit Tests: DocumentDBEncryptionService
2052
+
2053
+ **Coverage Goals**:
2054
+ - 100% line coverage
2055
+ - All branches covered
2056
+ - All error paths tested
2057
+
2058
+ **Key Test Cases**:
2059
+
2060
+ ```javascript
2061
+ describe('DocumentDBEncryptionService', () => {
2062
+ describe('Initialization', () => {
2063
+ it('bypasses encryption in dev stage', () => {
2064
+ process.env.STAGE = 'dev';
2065
+ const service = new DocumentDBEncryptionService();
2066
+ expect(service.enabled).toBe(false);
2067
+ expect(service.cryptor).toBeNull();
2068
+ });
2069
+
2070
+ it('enables KMS encryption in production with KMS_KEY_ARN', () => {
2071
+ process.env.STAGE = 'production';
2072
+ process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123456789012:key/abc123';
2073
+ const service = new DocumentDBEncryptionService();
2074
+ expect(service.enabled).toBe(true);
2075
+ expect(service.cryptor.shouldUseAws).toBe(true);
2076
+ });
2077
+
2078
+ it('enables AES encryption in production with AES_KEY_ID', () => {
2079
+ process.env.STAGE = 'production';
2080
+ process.env.AES_KEY_ID = 'local-key';
2081
+ process.env.AES_KEY = '01234567890123456789012345678901';
2082
+ const service = new DocumentDBEncryptionService();
2083
+ expect(service.enabled).toBe(true);
2084
+ expect(service.cryptor.shouldUseAws).toBe(false);
2085
+ });
2086
+ });
2087
+
2088
+ describe('encryptFields()', () => {
2089
+ it('encrypts User.hashword', async () => {
2090
+ const document = {
2091
+ username: 'test@example.com',
2092
+ hashword: '$2b$10$plain_bcrypt_hash'
2093
+ };
2094
+
2095
+ const encrypted = await service.encryptFields('User', document);
2096
+
2097
+ expect(encrypted.username).toBe('test@example.com'); // Not encrypted
2098
+ expect(encrypted.hashword).not.toBe('$2b$10$plain_bcrypt_hash'); // Encrypted
2099
+ expect(encrypted.hashword).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Format check
2100
+ });
2101
+
2102
+ it('encrypts Credential.data.access_token', async () => {
2103
+ const document = {
2104
+ userId: '123',
2105
+ data: {
2106
+ access_token: 'ya29.token_here',
2107
+ scope: 'openid profile' // Not in registry
2108
+ }
2109
+ };
2110
+
2111
+ const encrypted = await service.encryptFields('Credential', document);
2112
+
2113
+ expect(encrypted.data.access_token).not.toBe('ya29.token_here');
2114
+ expect(encrypted.data.access_token).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2115
+ expect(encrypted.data.scope).toBe('openid profile'); // Not encrypted
2116
+ });
2117
+
2118
+ it('skips already encrypted values', async () => {
2119
+ const alreadyEncrypted = 'keyId:iv123:cipher456:enckey789';
2120
+ const document = { hashword: alreadyEncrypted };
2121
+
2122
+ const result = await service.encryptFields('User', document);
2123
+
2124
+ expect(result.hashword).toBe(alreadyEncrypted); // Unchanged
2125
+ });
2126
+
2127
+ it('returns unchanged for unknown model', async () => {
2128
+ const document = { field: 'value' };
2129
+ const result = await service.encryptFields('UnknownModel', document);
2130
+ expect(result).toEqual(document);
2131
+ });
2132
+ });
2133
+
2134
+ describe('decryptFields()', () => {
2135
+ it('decrypts User.hashword', async () => {
2136
+ const encryptedDoc = {
2137
+ username: 'test@example.com',
2138
+ hashword: 'keyId:iv:cipher:enckey' // Mock encrypted
2139
+ };
2140
+
2141
+ // Mock Cryptor to return known value
2142
+ mockCryptor.decrypt.mockResolvedValue('$2b$10$plain_bcrypt_hash');
2143
+
2144
+ const decrypted = await service.decryptFields('User', encryptedDoc);
2145
+
2146
+ expect(decrypted.hashword).toBe('$2b$10$plain_bcrypt_hash');
2147
+ expect(mockCryptor.decrypt).toHaveBeenCalledWith('keyId:iv:cipher:enckey');
2148
+ });
2149
+
2150
+ it('handles decryption failures gracefully', async () => {
2151
+ const encryptedDoc = { hashword: 'corrupted:data:here:error' };
2152
+ mockCryptor.decrypt.mockRejectedValue(new Error('Decryption failed'));
2153
+
2154
+ const result = await service.decryptFields('User', encryptedDoc);
2155
+
2156
+ expect(result.hashword).toBeNull(); // Set to null on error
2157
+ });
2158
+
2159
+ it('parses JSON objects after decryption', async () => {
2160
+ const encryptedDoc = { data: { config: 'keyId:iv:cipher:enckey' } };
2161
+ const jsonObject = { nested: 'value', array: [1, 2, 3] };
2162
+ mockCryptor.decrypt.mockResolvedValue(JSON.stringify(jsonObject));
2163
+
2164
+ const result = await service.decryptFields('CustomModel', encryptedDoc);
2165
+
2166
+ expect(result.data.config).toEqual(jsonObject); // Parsed as object
2167
+ });
2168
+ });
2169
+ });
2170
+ ```
2171
+
2172
+ ---
2173
+
2174
+ ### Integration Tests: Repository Level
2175
+
2176
+ **CredentialRepositoryDocumentDB Security Tests**:
2177
+
2178
+ ```javascript
2179
+ describe('CredentialRepositoryDocumentDB - Security', () => {
2180
+ let repository;
2181
+ let prisma;
2182
+
2183
+ beforeAll(async () => {
2184
+ // Setup DocumentDB test database
2185
+ process.env.STAGE = 'production';
2186
+ process.env.AES_KEY_ID = 'test-key';
2187
+ process.env.AES_KEY = '01234567890123456789012345678901';
2188
+
2189
+ prisma = await connectPrisma();
2190
+ repository = new CredentialRepositoryDocumentDB({ prisma });
2191
+ });
2192
+
2193
+ afterAll(async () => {
2194
+ await disconnectPrisma();
2195
+ });
2196
+
2197
+ describe('CRITICAL: OAuth Token Encryption', () => {
2198
+ it('stores access_token encrypted in database', async () => {
2199
+ const userId = new ObjectId();
2200
+ const externalId = 'google-user-123';
2201
+ const plainToken = 'ya29.actual_google_token_here';
2202
+
2203
+ // Create credential via repository
2204
+ await repository.upsertCredential({
2205
+ identifiers: { userId: fromObjectId(userId), externalId },
2206
+ details: { access_token: plainToken, token_type: 'Bearer' }
2207
+ });
2208
+
2209
+ // Query database directly (bypass repository)
2210
+ const rawResult = await prisma.$runCommandRaw({
2211
+ find: 'Credential',
2212
+ filter: { userId, externalId }
2213
+ });
2214
+
2215
+ const storedCredential = rawResult.cursor.firstBatch[0];
2216
+ const storedToken = storedCredential.data.access_token;
2217
+
2218
+ // CRITICAL ASSERTIONS
2219
+ expect(storedToken).not.toBe(plainToken); // NOT plain text
2220
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Encrypted format
2221
+ expect(storedToken.split(':').length).toBeGreaterThanOrEqual(4); // 4+ parts
2222
+
2223
+ // Verify repository returns decrypted
2224
+ const retrieved = await repository.findCredential({
2225
+ userId: fromObjectId(userId),
2226
+ externalId
2227
+ });
2228
+ expect(retrieved.access_token).toBe(plainToken); // Decrypted
2229
+ });
2230
+
2231
+ it('encrypts refresh_token', async () => {
2232
+ const userId = new ObjectId();
2233
+ const plainRefresh = '1//0secret_refresh_token';
2234
+
2235
+ await repository.upsertCredential({
2236
+ identifiers: { userId: fromObjectId(userId), externalId: 'test-456' },
2237
+ details: { refresh_token: plainRefresh }
2238
+ });
2239
+
2240
+ const rawResult = await prisma.$runCommandRaw({
2241
+ find: 'Credential',
2242
+ filter: { userId }
2243
+ });
2244
+
2245
+ const stored = rawResult.cursor.firstBatch[0].data.refresh_token;
2246
+ expect(stored).not.toBe(plainRefresh);
2247
+ expect(stored).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2248
+ });
2249
+
2250
+ it('encrypts id_token', async () => {
2251
+ const userId = new ObjectId();
2252
+ const plainIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...';
2253
+
2254
+ await repository.upsertCredential({
2255
+ identifiers: { userId: fromObjectId(userId), externalId: 'test-789' },
2256
+ details: { id_token: plainIdToken }
2257
+ });
2258
+
2259
+ const rawResult = await prisma.$runCommandRaw({
2260
+ find: 'Credential',
2261
+ filter: { userId }
2262
+ });
2263
+
2264
+ const stored = rawResult.cursor.firstBatch[0].data.id_token;
2265
+ expect(stored).not.toBe(plainIdToken);
2266
+ expect(stored).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2267
+ });
2268
+
2269
+ it('does NOT encrypt non-sensitive fields', async () => {
2270
+ const userId = new ObjectId();
2271
+
2272
+ await repository.upsertCredential({
2273
+ identifiers: { userId: fromObjectId(userId), externalId: 'test-000' },
2274
+ details: {
2275
+ access_token: 'token123',
2276
+ expires_in: 3600, // Not in encrypted fields registry
2277
+ token_type: 'Bearer', // Not in registry
2278
+ scope: 'openid profile' // Not in registry
2279
+ }
2280
+ });
2281
+
2282
+ const rawResult = await prisma.$runCommandRaw({
2283
+ find: 'Credential',
2284
+ filter: { userId }
2285
+ });
2286
+
2287
+ const stored = rawResult.cursor.firstBatch[0].data;
2288
+
2289
+ // These should NOT be encrypted
2290
+ expect(stored.expires_in).toBe(3600);
2291
+ expect(stored.token_type).toBe('Bearer');
2292
+ expect(stored.scope).toBe('openid profile');
2293
+
2294
+ // But access_token should be encrypted
2295
+ expect(stored.access_token).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2296
+ });
2297
+ });
2298
+
2299
+ describe('Full Integration Flow', () => {
2300
+ it('encrypts on insert, decrypts on read', async () => {
2301
+ const userId = new ObjectId();
2302
+ const plainData = {
2303
+ access_token: 'test_access_123',
2304
+ refresh_token: 'test_refresh_456',
2305
+ expires_in: 7200
2306
+ };
2307
+
2308
+ // Insert
2309
+ const created = await repository.upsertCredential({
2310
+ identifiers: { userId: fromObjectId(userId), externalId: 'flow-test' },
2311
+ details: plainData
2312
+ });
2313
+
2314
+ // Verify returned data is plain text
2315
+ expect(created.access_token).toBe('test_access_123');
2316
+ expect(created.refresh_token).toBe('test_refresh_456');
2317
+
2318
+ // Read via repository
2319
+ const retrieved = await repository.findCredential({
2320
+ userId: fromObjectId(userId),
2321
+ externalId: 'flow-test'
2322
+ });
2323
+
2324
+ // Verify decrypted correctly
2325
+ expect(retrieved.access_token).toBe('test_access_123');
2326
+ expect(retrieved.refresh_token).toBe('test_refresh_456');
2327
+
2328
+ // Verify database has encrypted values
2329
+ const rawResult = await prisma.$runCommandRaw({
2330
+ find: 'Credential',
2331
+ filter: { userId }
2332
+ });
2333
+ const stored = rawResult.cursor.firstBatch[0].data;
2334
+ expect(stored.access_token).not.toBe('test_access_123');
2335
+ expect(stored.refresh_token).not.toBe('test_refresh_456');
2336
+ });
2337
+ });
2338
+
2339
+ describe('Stage-Based Bypass', () => {
2340
+ it('bypasses encryption in dev stage', async () => {
2341
+ // Re-initialize with dev stage
2342
+ process.env.STAGE = 'dev';
2343
+ const devRepo = new CredentialRepositoryDocumentDB({ prisma });
2344
+
2345
+ const userId = new ObjectId();
2346
+ const plainToken = 'dev_token_plain';
2347
+
2348
+ await devRepo.upsertCredential({
2349
+ identifiers: { userId: fromObjectId(userId), externalId: 'dev-test' },
2350
+ details: { access_token: plainToken }
2351
+ });
2352
+
2353
+ // In dev, should be stored as plain text
2354
+ const rawResult = await prisma.$runCommandRaw({
2355
+ find: 'Credential',
2356
+ filter: { userId }
2357
+ });
2358
+ const stored = rawResult.cursor.firstBatch[0].data.access_token;
2359
+ expect(stored).toBe(plainToken); // Plain text in dev!
2360
+
2361
+ // Reset to production
2362
+ process.env.STAGE = 'production';
2363
+ });
2364
+ });
2365
+ });
2366
+ ```
2367
+
2368
+ ---
2369
+
2370
+ ### Manual Test Script
2371
+
2372
+ ```bash
2373
+ #!/bin/bash
2374
+ # manual-encryption-test.sh
2375
+ # Tests DocumentDB encryption manually
2376
+
2377
+ set -e
2378
+
2379
+ echo "🔐 DocumentDB Encryption Manual Test"
2380
+ echo "===================================="
2381
+
2382
+ # Setup
2383
+ export STAGE=production
2384
+ export AES_KEY_ID=test-manual-key
2385
+ export AES_KEY=01234567890123456789012345678901
2386
+
2387
+ echo "✅ Environment configured (production, AES encryption)"
2388
+
2389
+ # Start MongoDB
2390
+ echo "📦 Starting MongoDB..."
2391
+ docker-compose up -d mongo
2392
+ sleep 5
2393
+
2394
+ # Start backend
2395
+ echo "🚀 Starting backend..."
2396
+ cd backend
2397
+ npm run frigg:start &
2398
+ BACKEND_PID=$!
2399
+ sleep 10
2400
+
2401
+ # Create user
2402
+ echo "👤 Creating test user..."
2403
+ TOKEN=$(curl -s -X POST http://localhost:3000/user/create \
2404
+ -H "Content-Type: application/json" \
2405
+ -d '{"username":"test@encryption.com","password":"testpass"}' \
2406
+ | jq -r '.token')
2407
+
2408
+ echo "✅ User created, token: ${TOKEN:0:20}..."
2409
+
2410
+ # Trigger OAuth flow (simulated)
2411
+ echo "🔑 Simulating OAuth credential creation..."
2412
+ # Note: This would normally be done through OAuth flow
2413
+ # For testing, we can directly call credential creation endpoint if it exists
2414
+
2415
+ # Verify encryption in database
2416
+ echo "🔍 Verifying encryption in database..."
2417
+ docker exec -it $(docker ps -q -f name=mongo) mongosh --eval "
2418
+ use frigg;
2419
+ var cred = db.Credential.findOne();
2420
+ if (cred) {
2421
+ print('Found credential:');
2422
+ print(' ID: ' + cred._id);
2423
+ print(' access_token format: ' + cred.data.access_token);
2424
+
2425
+ var parts = cred.data.access_token.split(':');
2426
+ if (parts.length >= 4) {
2427
+ print(' ✅ ENCRYPTED (4+ parts)');
2428
+ } else {
2429
+ print(' ❌ NOT ENCRYPTED (plain text)');
2430
+ quit(1);
2431
+ }
2432
+ } else {
2433
+ print('⚠️ No credentials found');
2434
+ }
2435
+ "
2436
+
2437
+ echo "✅ Manual test complete"
2438
+
2439
+ # Cleanup
2440
+ kill $BACKEND_PID
2441
+ docker-compose down
2442
+ ```
2443
+
2444
+ ---
2445
+
2446
+ ## Migration Guide
2447
+
2448
+ ### For Existing Deployments with Plain Text Credentials
2449
+
2450
+ **⚠️ WARNING**: If DocumentDB repositories are already deployed and storing plain text credentials, follow this migration plan.
2451
+
2452
+ ---
2453
+
2454
+ ### Step 1: Assess the Damage
2455
+
2456
+ **Query Database for Plain Text Credentials**:
2457
+
2458
+ ```javascript
2459
+ // Run in mongosh on DocumentDB
2460
+
2461
+ use frigg;
2462
+
2463
+ // Check total credentials
2464
+ var totalCreds = db.Credential.countDocuments();
2465
+ print('Total credentials:', totalCreds);
2466
+
2467
+ // Sample credentials to check format
2468
+ var sampleCreds = db.Credential.find().limit(10).toArray();
2469
+
2470
+ sampleCreds.forEach(function(cred) {
2471
+ var token = cred.data?.access_token;
2472
+ if (!token) {
2473
+ print('Credential', cred._id, ': No access_token');
2474
+ return;
2475
+ }
2476
+
2477
+ var parts = token.split(':');
2478
+ if (parts.length >= 4) {
2479
+ print('Credential', cred._id, ': ENCRYPTED ✅');
2480
+ } else {
2481
+ print('Credential', cred._id, ': PLAIN TEXT ❌', token.substring(0, 20) + '...');
2482
+ }
2483
+ });
2484
+ ```
2485
+
2486
+ **Estimate Impact**:
2487
+ - Number of affected credentials
2488
+ - Number of affected users
2489
+ - Third-party services (Asana, Frontify, etc.)
2490
+
2491
+ ---
2492
+
2493
+ ### Step 2: Immediate Security Response
2494
+
2495
+ **Priority Actions**:
2496
+
2497
+ 1. **Deploy Fix Immediately**:
2498
+ ```bash
2499
+ # Deploy encryption fix to stop new plain text storage
2500
+ cd backend
2501
+ npm install @friggframework/core@latest # With encryption fix
2502
+ npm run deploy -- --stage production
2503
+ ```
2504
+
2505
+ 2. **Rotate All Affected Tokens**:
2506
+ - Force OAuth re-authentication for all users
2507
+ - Revoke old tokens on third-party services
2508
+ - Generate new encrypted tokens
2509
+
2510
+ 3. **Audit Access**:
2511
+ - Review database access logs
2512
+ - Identify who had access to plain text credentials
2513
+ - Check for unauthorized API usage
2514
+
2515
+ ---
2516
+
2517
+ ### Step 3: Data Migration
2518
+
2519
+ **Migration Script** (`migrate-encrypt-credentials.js`):
2520
+
2521
+ ```javascript
2522
+ const { prisma, connectPrisma, disconnectPrisma } = require('@friggframework/core/database/prisma');
2523
+ const { DocumentDBEncryptionService } = require('@friggframework/core/database/documentdb-encryption-service');
2524
+ const { toObjectId, fromObjectId } = require('@friggframework/core/database/documentdb-utils');
2525
+
2526
+ /**
2527
+ * Migrate plain text credentials to encrypted format.
2528
+ *
2529
+ * This script:
2530
+ * 1. Identifies plain text credentials
2531
+ * 2. Encrypts them using DocumentDBEncryptionService
2532
+ * 3. Updates database with encrypted values
2533
+ * 4. Verifies encryption
2534
+ */
2535
+ async function migrateCredentials() {
2536
+ console.log('🔐 Starting credential encryption migration...');
2537
+
2538
+ // Initialize
2539
+ await connectPrisma();
2540
+ const encryptionService = new DocumentDBEncryptionService();
2541
+
2542
+ if (!encryptionService.enabled) {
2543
+ console.error('❌ Encryption not enabled! Check environment variables.');
2544
+ process.exit(1);
2545
+ }
2546
+
2547
+ // Fetch all credentials
2548
+ const result = await prisma.$runCommandRaw({
2549
+ find: 'Credential',
2550
+ filter: {}
2551
+ });
2552
+
2553
+ const credentials = result.cursor.firstBatch;
2554
+ console.log(`📊 Found ${credentials.length} credentials`);
2555
+
2556
+ let encryptedCount = 0;
2557
+ let alreadyEncryptedCount = 0;
2558
+ let errorCount = 0;
2559
+
2560
+ for (const cred of credentials) {
2561
+ const credId = fromObjectId(cred._id);
2562
+
2563
+ try {
2564
+ // Check if already encrypted
2565
+ const token = cred.data?.access_token;
2566
+ if (!token) {
2567
+ console.log(`⏭️ Skipping credential ${credId} (no access_token)`);
2568
+ continue;
2569
+ }
2570
+
2571
+ const parts = token.split(':');
2572
+ if (parts.length >= 4) {
2573
+ console.log(`✅ Credential ${credId} already encrypted`);
2574
+ alreadyEncryptedCount++;
2575
+ continue;
2576
+ }
2577
+
2578
+ // Encrypt credential data
2579
+ console.log(`🔐 Encrypting credential ${credId}...`);
2580
+ const encryptedData = await encryptionService.encryptFields('Credential', {
2581
+ data: cred.data
2582
+ });
2583
+
2584
+ // Update database
2585
+ await prisma.$runCommandRaw({
2586
+ update: 'Credential',
2587
+ updates: [{
2588
+ q: { _id: cred._id },
2589
+ u: {
2590
+ $set: {
2591
+ data: encryptedData.data,
2592
+ updatedAt: new Date()
2593
+ }
2594
+ }
2595
+ }]
2596
+ });
2597
+
2598
+ console.log(`✅ Encrypted credential ${credId}`);
2599
+ encryptedCount++;
2600
+
2601
+ } catch (error) {
2602
+ console.error(`❌ Failed to encrypt credential ${credId}:`, error.message);
2603
+ errorCount++;
2604
+ }
2605
+ }
2606
+
2607
+ console.log('\n📊 Migration Summary:');
2608
+ console.log(` Total credentials: ${credentials.length}`);
2609
+ console.log(` Encrypted: ${encryptedCount}`);
2610
+ console.log(` Already encrypted: ${alreadyEncryptedCount}`);
2611
+ console.log(` Errors: ${errorCount}`);
2612
+
2613
+ await disconnectPrisma();
2614
+ console.log('✅ Migration complete');
2615
+ }
2616
+
2617
+ // Run migration
2618
+ migrateCredentials().catch(error => {
2619
+ console.error('💥 Migration failed:', error);
2620
+ process.exit(1);
2621
+ });
2622
+ ```
2623
+
2624
+ **Run Migration**:
2625
+
2626
+ ```bash
2627
+ # Set production environment variables
2628
+ export STAGE=production
2629
+ export KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/abc123
2630
+
2631
+ # Run migration
2632
+ node migrate-encrypt-credentials.js
2633
+
2634
+ # Verify
2635
+ node verify-encryption.js # See verification script below
2636
+ ```
2637
+
2638
+ ---
2639
+
2640
+ ### Step 4: Verification
2641
+
2642
+ **Verification Script** (`verify-encryption.js`):
2643
+
2644
+ ```javascript
2645
+ const { prisma, connectPrisma, disconnectPrisma } = require('@friggframework/core/database/prisma');
2646
+
2647
+ async function verifyEncryption() {
2648
+ console.log('🔍 Verifying credential encryption...');
2649
+
2650
+ await connectPrisma();
2651
+
2652
+ const result = await prisma.$runCommandRaw({
2653
+ find: 'Credential',
2654
+ filter: {}
2655
+ });
2656
+
2657
+ const credentials = result.cursor.firstBatch;
2658
+ let passCount = 0;
2659
+ let failCount = 0;
2660
+
2661
+ for (const cred of credentials) {
2662
+ const token = cred.data?.access_token;
2663
+ if (!token) continue;
2664
+
2665
+ const parts = token.split(':');
2666
+ if (parts.length >= 4) {
2667
+ passCount++;
2668
+ } else {
2669
+ console.error(`❌ Plain text found in credential ${cred._id}`);
2670
+ failCount++;
2671
+ }
2672
+ }
2673
+
2674
+ await disconnectPrisma();
2675
+
2676
+ console.log('\n📊 Verification Results:');
2677
+ console.log(` Encrypted: ${passCount}`);
2678
+ console.log(` Plain text: ${failCount}`);
2679
+
2680
+ if (failCount > 0) {
2681
+ console.error('\n❌ Verification failed! Plain text credentials still exist.');
2682
+ process.exit(1);
2683
+ } else {
2684
+ console.log('\n✅ Verification passed! All credentials encrypted.');
2685
+ }
2686
+ }
2687
+
2688
+ verifyEncryption().catch(error => {
2689
+ console.error('💥 Verification failed:', error);
2690
+ process.exit(1);
2691
+ });
2692
+ ```
2693
+
2694
+ ---
2695
+
2696
+ ### Step 5: Post-Migration Cleanup
2697
+
2698
+ 1. **Delete Migration Scripts**:
2699
+ ```bash
2700
+ rm migrate-encrypt-credentials.js
2701
+ rm verify-encryption.js
2702
+ ```
2703
+
2704
+ 2. **Update Documentation**:
2705
+ - Document the incident
2706
+ - Document lessons learned
2707
+ - Update security procedures
2708
+
2709
+ 3. **Monitor**:
2710
+ - Set up alerts for plain text detection
2711
+ - Monitor API error rates (in case decryption fails)
2712
+ - Watch for OAuth re-authentication requests
2713
+
2714
+ ---
2715
+
2716
+ ### Rollback Procedures
2717
+
2718
+ **If Migration Fails**:
2719
+
2720
+ 1. **Stop the migration script**
2721
+
2722
+ 2. **Restore from backup**:
2723
+ ```bash
2724
+ # Restore MongoDB backup from before migration
2725
+ mongorestore --uri="mongodb://..." --archive=backup-before-migration.archive
2726
+ ```
2727
+
2728
+ 3. **Revert code deployment**:
2729
+ ```bash
2730
+ # Rollback to previous version
2731
+ cd backend
2732
+ npm install @friggframework/core@<previous-version>
2733
+ npm run deploy -- --stage production
2734
+ ```
2735
+
2736
+ 4. **Investigate and fix issues**
2737
+
2738
+ 5. **Re-attempt migration with fixes**
2739
+
2740
+ ---
2741
+
2742
+ ### Zero-Downtime Migration Strategy
2743
+
2744
+ For large deployments:
2745
+
2746
+ 1. **Phase 1: Deploy encryption fix** (don't migrate yet)
2747
+ - New credentials will be encrypted
2748
+ - Old credentials remain as-is
2749
+ - Application handles both encrypted and plain text
2750
+
2751
+ 2. **Phase 2: Migrate in batches**
2752
+ ```javascript
2753
+ // Migrate 100 credentials at a time
2754
+ const batchSize = 100;
2755
+ for (let skip = 0; skip < totalCredentials; skip += batchSize) {
2756
+ await migrateBatch(skip, batchSize);
2757
+ await sleep(1000); // 1 second between batches
2758
+ }
2759
+ ```
2760
+
2761
+ 3. **Phase 3: Verify**
2762
+ - Check random samples
2763
+ - Monitor error rates
2764
+ - Verify API calls still work
2765
+
2766
+ 4. **Phase 4: Complete**
2767
+ - Remove backward compatibility code
2768
+ - Update monitoring alerts
2769
+
2770
+ ---
2771
+
2772
+ ## Security Considerations
2773
+
2774
+ ### Encryption Format
2775
+
2776
+ **Envelope Encryption Pattern**:
2777
+ ```
2778
+ keyId:iv:cipher:encKey
2779
+ ```
2780
+
2781
+ **Components**:
2782
+ - `keyId`: Identifier for the encryption key (e.g., "aes-key-1", KMS key ID)
2783
+ - `iv`: Initialization vector (base64-encoded)
2784
+ - `cipher`: Encrypted data (base64-encoded)
2785
+ - `encKey`: Encrypted data encryption key (base64-encoded)
2786
+
2787
+ **Example**:
2788
+ ```
2789
+ aes-key-1:MTIzNDU2Nzg5MGFiY2RlZg==:ZW5jcnlwdGVkX2RhdGFfaGVyZQ==:ZGVrX2VuY3J5cHRlZA==
2790
+ ```
2791
+
2792
+ ---
2793
+
2794
+ ### Key Management
2795
+
2796
+ **Production (KMS - Recommended)**:
2797
+ ```bash
2798
+ # AWS KMS key is auto-discovered by Frigg infrastructure
2799
+ # Or set explicitly:
2800
+ export KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/abc-123-def-456
2801
+
2802
+ # Stage must be production
2803
+ export STAGE=production
2804
+ ```
2805
+
2806
+ **Benefits**:
2807
+ - ✅ AWS-managed key rotation
2808
+ - ✅ Audit trail via CloudTrail
2809
+ - ✅ Fine-grained IAM permissions
2810
+ - ✅ Hardware security module (HSM) backed
2811
+ - ✅ Compliance-ready (HIPAA, PCI-DSS, etc.)
2812
+
2813
+ **Alternative (AES - Any Environment)**:
2814
+ ```bash
2815
+ # Generate a 32-character key
2816
+ export AES_KEY_ID=my-app-key-v1
2817
+ export AES_KEY=$(openssl rand -hex 16) # 32 hex chars = 16 bytes
2818
+
2819
+ # Can be used in production
2820
+ export STAGE=production
2821
+ ```
2822
+
2823
+ **Benefits**:
2824
+ - ✅ Works in any environment (no AWS required)
2825
+ - ✅ Faster than KMS (no network calls)
2826
+ - ✅ No AWS costs
2827
+
2828
+ **Drawbacks**:
2829
+ - ⚠️ Must securely manage key yourself
2830
+ - ⚠️ No automatic key rotation
2831
+ - ⚠️ Key stored in environment/config
2832
+
2833
+ ---
2834
+
2835
+ ### Stage-Based Bypass
2836
+
2837
+ **Purpose**: Skip encryption in local development for easier debugging
2838
+
2839
+ **Bypassed Stages**:
2840
+ - `dev`
2841
+ - `test`
2842
+ - `local`
2843
+
2844
+ **Production Stages** (encryption enabled):
2845
+ - `production`
2846
+ - `prod`
2847
+ - `staging`
2848
+ - `stage`
2849
+ - Any other value
2850
+
2851
+ **Configuration**:
2852
+ ```bash
2853
+ # Bypass encryption (dev)
2854
+ export STAGE=dev
2855
+ # DocumentDBEncryptionService.enabled = false
2856
+ # Data stored as plain text
2857
+
2858
+ # Enable encryption (production)
2859
+ export STAGE=production
2860
+ export KMS_KEY_ARN=...
2861
+ # DocumentDBEncryptionService.enabled = true
2862
+ # Data stored encrypted
2863
+ ```
2864
+
2865
+ **Security Note**: Never use `STAGE=dev` in production environments!
2866
+
2867
+ ---
2868
+
2869
+ ### Encrypted Fields Registry
2870
+
2871
+ **Location**: `packages/core/database/encryption/encryption-schema-registry.js`
2872
+
2873
+ **Current Encrypted Fields**:
2874
+ ```javascript
2875
+ const ENCRYPTED_FIELDS = {
2876
+ User: ['hashword'],
2877
+ Credential: [
2878
+ 'data.access_token',
2879
+ 'data.refresh_token',
2880
+ 'data.id_token',
2881
+ 'data.domain'
2882
+ ],
2883
+ IntegrationMapping: ['mapping'],
2884
+ Token: ['token']
2885
+ };
2886
+ ```
2887
+
2888
+ **Adding New Encrypted Fields**:
2889
+
2890
+ 1. Open `encryption-schema-registry.js`
2891
+ 2. Add field path to appropriate model:
2892
+ ```javascript
2893
+ Credential: [
2894
+ 'data.access_token',
2895
+ 'data.refresh_token',
2896
+ 'data.id_token',
2897
+ 'data.domain',
2898
+ 'data.client_secret' // ← NEW
2899
+ ]
2900
+ ```
2901
+ 3. Deploy - encryption applied automatically (no code changes needed)
2902
+
2903
+ **Field Path Examples**:
2904
+ - Top-level: `hashword` → encrypts `document.hashword`
2905
+ - Nested: `data.access_token` → encrypts `document.data.access_token`
2906
+ - Deep nesting supported: `config.secrets.apiKey`
2907
+
2908
+ ---
2909
+
2910
+ ### Compliance & Best Practices
2911
+
2912
+ **GDPR Compliance**:
2913
+ - ✅ Data encrypted at rest
2914
+ - ✅ Encryption keys managed securely
2915
+ - ✅ User data can be deleted (right to erasure)
2916
+
2917
+ **PCI-DSS Compliance** (if storing payment data):
2918
+ - ✅ Encryption of cardholder data
2919
+ - ✅ Key management procedures
2920
+ - ✅ Audit logging (via CloudTrail with KMS)
2921
+
2922
+ **HIPAA Compliance** (if storing health data):
2923
+ - ✅ Encryption at rest (required)
2924
+ - ✅ Access controls (AWS KMS IAM)
2925
+ - ✅ Audit trail (CloudTrail)
2926
+
2927
+ **Best Practices**:
2928
+ 1. **Use KMS in production** - Better security, compliance, key rotation
2929
+ 2. **Rotate keys periodically** - Even with KMS, review and rotate annually
2930
+ 3. **Monitor decryption failures** - Alert on >1% failure rate
2931
+ 4. **Test encryption in CI/CD** - Automated tests verify encryption works
2932
+ 5. **Secure key storage** - Never commit keys to version control
2933
+ 6. **Least privilege access** - Limit who can decrypt data
2934
+
2935
+ ---
2936
+
2937
+ ### Security Audit Checklist
2938
+
2939
+ Before going to production:
2940
+
2941
+ - [ ] Verify `STAGE=production` in environment
2942
+ - [ ] Verify encryption keys configured (`KMS_KEY_ARN` or `AES_KEY_ID`)
2943
+ - [ ] Run security tests (verify encrypted format in database)
2944
+ - [ ] Test credential creation and retrieval end-to-end
2945
+ - [ ] Verify OAuth flows work (tokens decrypted correctly)
2946
+ - [ ] Check logs for decryption errors
2947
+ - [ ] Review IAM permissions (if using KMS)
2948
+ - [ ] Test key rotation procedure (if using KMS)
2949
+ - [ ] Document encryption architecture for auditors
2950
+ - [ ] Set up monitoring alerts (decryption failures, plain text detection)
2951
+
2952
+ ---
2953
+
2954
+ ## Maintenance & Future Work
2955
+
2956
+ ### Adding New DocumentDB Repositories
2957
+
2958
+ When creating a new DocumentDB repository that handles encrypted data:
2959
+
2960
+ 1. **Import DocumentDBEncryptionService**:
2961
+ ```javascript
2962
+ const { DocumentDBEncryptionService } = require('../database/documentdb-encryption-service');
2963
+ ```
2964
+
2965
+ 2. **Initialize in constructor**:
2966
+ ```javascript
2967
+ constructor() {
2968
+ this.prisma = prisma;
2969
+ this.encryptionService = new DocumentDBEncryptionService();
2970
+ }
2971
+ ```
2972
+
2973
+ 3. **Encrypt before writes**:
2974
+ ```javascript
2975
+ async create(data) {
2976
+ const encrypted = await this.encryptionService.encryptFields('ModelName', data);
2977
+ const id = await insertOne(this.prisma, 'CollectionName', encrypted);
2978
+ // ...
2979
+ }
2980
+ ```
2981
+
2982
+ 4. **Decrypt after reads**:
2983
+ ```javascript
2984
+ async findById(id) {
2985
+ const doc = await findOne(this.prisma, 'CollectionName', { _id: toObjectId(id) });
2986
+ const decrypted = await this.encryptionService.decryptFields('ModelName', doc);
2987
+ return this._mapModel(decrypted);
2988
+ }
2989
+ ```
2990
+
2991
+ 5. **Add encrypted fields to registry** (if new model):
2992
+ ```javascript
2993
+ // packages/core/database/encryption/encryption-schema-registry.js
2994
+ const ENCRYPTED_FIELDS = {
2995
+ // ... existing models
2996
+ NewModel: ['sensitiveField1', 'nested.field2']
2997
+ };
2998
+ ```
2999
+
3000
+ 6. **Add tests** (see Phase 5 for test patterns)
3001
+
3002
+ ---
3003
+
3004
+ ### Adding New Encrypted Fields
3005
+
3006
+ To encrypt a new field in an existing model:
3007
+
3008
+ 1. **Update encryption-schema-registry.js**:
3009
+ ```javascript
3010
+ const ENCRYPTED_FIELDS = {
3011
+ Credential: [
3012
+ 'data.access_token',
3013
+ 'data.refresh_token',
3014
+ 'data.id_token',
3015
+ 'data.domain',
3016
+ 'data.client_secret' // ← NEW FIELD
3017
+ ]
3018
+ };
3019
+ ```
3020
+
3021
+ 2. **No code changes needed** - DocumentDBEncryptionService reads from registry
3022
+
3023
+ 3. **Deploy** - new field will be encrypted automatically
3024
+
3025
+ 4. **Migrate existing data** (if field already has plain text values):
3026
+ ```javascript
3027
+ // Run migration script to encrypt existing plain text values
3028
+ // Similar to credential migration script
3029
+ ```
3030
+
3031
+ ---
3032
+
3033
+ ### Known Limitations
3034
+
3035
+ 1. **Performance**: Encryption/decryption adds latency
3036
+ - KMS: ~50ms per field (network call to AWS)
3037
+ - AES: ~5-10ms per field (local crypto)
3038
+ - **Mitigation**: Use bulk operations, consider caching decrypted values
3039
+
3040
+ 2. **DocumentDB-specific**: Only needed for DocumentDB
3041
+ - MongoDB/PostgreSQL use automatic Prisma Extension
3042
+ - Duplicate logic unavoidable (Prisma raw queries bypass extensions)
3043
+
3044
+ 3. **Manual encryption required**: Developers must remember to call service
3045
+ - **Mitigation**: Code reviews, tests, linting rules
3046
+
3047
+ 4. **No transactional encryption**: Encryption happens outside transactions
3048
+ - **Risk**: If encryption fails mid-operation, could leave inconsistent state
3049
+ - **Mitigation**: Encrypt before transaction starts, handle errors
3050
+
3051
+ 5. **Field-level only**: Doesn't encrypt entire documents or collections
3052
+ - **Alternative**: Use database-level encryption (AWS DocumentDB encryption at rest)
3053
+
3054
+ ---
3055
+
3056
+ ### Future Improvements
3057
+
3058
+ 1. **Automatic Repository Decorator**:
3059
+ ```javascript
3060
+ // Potential future API
3061
+ @encryptDocumentDB(['User', 'Credential'])
3062
+ class MyRepositoryDocumentDB {
3063
+ // Encryption applied automatically by decorator
3064
+ }
3065
+ ```
3066
+
3067
+ 2. **Encryption Caching**:
3068
+ - Cache decrypted values for frequently accessed credentials
3069
+ - Invalidate cache on credential update
3070
+ - Reduce KMS API calls
3071
+
3072
+ 3. **Field Compression**:
3073
+ - Compress large fields before encryption
3074
+ - Reduce storage and transfer costs
3075
+ - Especially useful for `IntegrationMapping.mapping`
3076
+
3077
+ 4. **Key Versioning**:
3078
+ - Support multiple active keys
3079
+ - Gradual key rotation without migration
3080
+ - Store key version with encrypted data
3081
+
3082
+ 5. **Encryption Metrics**:
3083
+ - Track encryption/decryption performance
3084
+ - Monitor failure rates
3085
+ - Alert on anomalies
3086
+
3087
+ 6. **Integration with Prisma Extension**:
3088
+ - Potential future Prisma feature: Extension support for raw queries
3089
+ - Would eliminate need for DocumentDBEncryptionService
3090
+ - Track: https://github.com/prisma/prisma/issues/...
3091
+
3092
+ ---
3093
+
3094
+ ### Monitoring & Alerts
3095
+
3096
+ **Recommended Metrics**:
3097
+
3098
+ 1. **Encryption Failures**:
3099
+ ```javascript
3100
+ // Log when encryption fails
3101
+ console.error('Encryption failed', { modelName, fieldPath, error });
3102
+ // Alert if >1% of operations fail
3103
+ ```
3104
+
3105
+ 2. **Decryption Failures**:
3106
+ ```javascript
3107
+ // Log when decryption fails
3108
+ console.error('Decryption failed', { modelName, fieldPath, error });
3109
+ // Alert immediately (could indicate data corruption)
3110
+ ```
3111
+
3112
+ 3. **Plain Text Detection**:
3113
+ ```javascript
3114
+ // Periodic scan of database
3115
+ // Alert if any plain text credentials found
3116
+ ```
3117
+
3118
+ 4. **Performance Metrics**:
3119
+ ```javascript
3120
+ // Track encryption/decryption time
3121
+ const start = Date.now();
3122
+ await service.encryptFields(...);
3123
+ const duration = Date.now() - start;
3124
+ metrics.histogram('encryption_duration_ms', duration);
3125
+ ```
3126
+
3127
+ **CloudWatch Dashboards** (for AWS deployments):
3128
+ - Encryption operation count
3129
+ - Average encryption duration
3130
+ - Decryption failure rate
3131
+ - KMS API call count (if using KMS)
3132
+
3133
+ ---
3134
+
3135
+ ### Support & Troubleshooting
3136
+
3137
+ **Common Issues**:
3138
+
3139
+ 1. **"No encryption keys configured"**
3140
+ - **Cause**: Missing `KMS_KEY_ARN` or `AES_KEY_ID` in production
3141
+ - **Fix**: Set environment variables, restart application
3142
+
3143
+ 2. **"Decryption failed"**
3144
+ - **Cause**: Wrong key, corrupted data, or key rotation
3145
+ - **Fix**: Check key configuration, verify data integrity, check key version
3146
+
3147
+ 3. **"Cannot read property 'access_token' of undefined"**
3148
+ - **Cause**: Credential data is null or decryption returned null
3149
+ - **Fix**: Check if credential exists, verify encryption didn't fail on write
3150
+
3151
+ 4. **"Encryption too slow"**
3152
+ - **Cause**: Using KMS with high latency
3153
+ - **Fix**: Switch to AES for non-production, optimize KMS calls (batching)
3154
+
3155
+ 5. **"Credentials not encrypted after deployment"**
3156
+ - **Cause**: `STAGE=dev` in production, or missing encryption keys
3157
+ - **Fix**: Set `STAGE=production`, configure keys, redeploy
3158
+
3159
+ **Getting Help**:
3160
+ - Check logs for error details
3161
+ - Review encryption-schema-registry.js configuration
3162
+ - Verify environment variables
3163
+ - Run health check: `curl http://localhost:3000/health/detailed`
3164
+ - Check encryption status in health response
3165
+
3166
+ ---
3167
+
3168
+ ## References
3169
+
3170
+ ### Related Files
3171
+
3172
+ **Core Encryption**:
3173
+ - `packages/core/database/encryption/README.md` - Main encryption documentation
3174
+ - `packages/core/database/encryption/encryption-schema-registry.js` - Encrypted fields definition
3175
+ - `packages/core/database/encryption/field-encryption-service.js` - Field-level encryption (Prisma Extension)
3176
+ - `packages/core/database/encryption/prisma-encryption-extension.js` - Prisma Client Extension
3177
+ - `packages/core/encrypt/Cryptor.js` - Encryption adapter (KMS/AES)
3178
+
3179
+ **DocumentDB**:
3180
+ - `packages/core/database/documentdb-utils.js` - Raw query utilities
3181
+ - `packages/core/database/prisma.js` - Prisma client initialization
3182
+
3183
+ **Repositories**:
3184
+ - `packages/core/user/repositories/user-repository-documentdb.js` - User repository
3185
+ - `packages/core/modules/repositories/module-repository-documentdb.js` - Module/Entity repository
3186
+ - `packages/core/credential/repositories/credential-repository-documentdb.js` - Credential repository
3187
+ - `packages/core/integrations/repositories/integration-repository-documentdb.js` - Integration repository
3188
+
3189
+ **Tests**:
3190
+ - `packages/core/database/encryption/*.test.js` - Encryption unit tests
3191
+ - `packages/core/**/repositories/__tests__/*.test.js` - Repository tests
3192
+
3193
+ ---
3194
+
3195
+ ### External Documentation
3196
+
3197
+ **Prisma**:
3198
+ - [Prisma Client Extensions](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions)
3199
+ - [Raw Database Access](https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access)
3200
+ - [MongoDB Support](https://www.prisma.io/docs/concepts/database-connectors/mongodb)
3201
+
3202
+ **AWS DocumentDB**:
3203
+ - [AWS DocumentDB Documentation](https://docs.aws.amazon.com/documentdb/)
3204
+ - [MongoDB Compatibility](https://docs.aws.amazon.com/documentdb/latest/developerguide/functional-differences.html)
3205
+
3206
+ **AWS KMS**:
3207
+ - [AWS KMS Developer Guide](https://docs.aws.amazon.com/kms/latest/developerguide/)
3208
+ - [Envelope Encryption](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping)
3209
+
3210
+ **Encryption Best Practices**:
3211
+ - [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html)
3212
+ - [NIST Encryption Standards](https://csrc.nist.gov/projects/cryptographic-standards-and-guidelines)
3213
+
3214
+ ---
3215
+
3216
+ ### Frigg Framework
3217
+
3218
+ **Core Documentation**:
3219
+ - [Frigg Framework Docs](https://docs.friggframework.org)
3220
+ - [GitHub Repository](https://github.com/friggframework/frigg)
3221
+ - [Community Slack](https://friggframework.org/#contact)
3222
+
3223
+ **Related Issues**:
3224
+ - GitHub Issue: DocumentDB encryption support [#TBD]
3225
+ - GitHub PR: Implement DocumentDBEncryptionService [#TBD]
3226
+
3227
+ ---
3228
+
3229
+ ## Appendix
3230
+
3231
+ ### Glossary
3232
+
3233
+ **Terms**:
3234
+ - **DocumentDB**: AWS DocumentDB, a MongoDB-compatible database service
3235
+ - **Prisma Extension**: Prisma feature that intercepts and modifies queries
3236
+ - **Raw Query**: Low-level database command that bypasses Prisma ORM
3237
+ - **Envelope Encryption**: Encryption pattern using data keys encrypted by master keys
3238
+ - **KMS**: AWS Key Management Service
3239
+ - **AES**: Advanced Encryption Standard (symmetric encryption)
3240
+ - **Field-Level Encryption**: Encrypting individual fields within documents
3241
+
3242
+ **Acronyms**:
3243
+ - **DRY**: Don't Repeat Yourself
3244
+ - **IAM**: Identity and Access Management
3245
+ - **HSM**: Hardware Security Module
3246
+ - **GDPR**: General Data Protection Regulation
3247
+ - **PCI-DSS**: Payment Card Industry Data Security Standard
3248
+ - **HIPAA**: Health Insurance Portability and Accountability Act
3249
+
3250
+ ---
3251
+
3252
+ ### Changelog
3253
+
3254
+ | Version | Date | Author | Changes |
3255
+ |---------|------|--------|---------|
3256
+ | 1.0 | 2025-01-13 | System | Initial documentation |
3257
+
3258
+ ---
3259
+
3260
+ ## Conclusion
3261
+
3262
+ This document provides a complete specification and implementation guide for the DocumentDBEncryptionService. Follow the phases sequentially, run all tests, and verify encryption at each step.
3263
+
3264
+ **Remember**: This is a **CRITICAL SECURITY** implementation. OAuth credentials MUST be encrypted at rest. Take the time to implement correctly and test thoroughly.
3265
+
3266
+ For questions or support, contact the Frigg team via GitHub issues or community Slack.
3267
+
3268
+ ---
3269
+
3270
+ **Document Status**: ✅ Ready for Implementation