@friggframework/core 2.0.0-next.53 → 2.0.0-next.54
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.
- package/CLAUDE.md +2 -1
- package/application/commands/integration-commands.js +1 -1
- package/application/index.js +1 -1
- package/credential/repositories/credential-repository-documentdb.js +300 -0
- package/credential/repositories/credential-repository-factory.js +8 -1
- package/database/config.js +4 -4
- package/database/documentdb-encryption-service.js +330 -0
- package/database/documentdb-utils.js +136 -0
- package/database/encryption/README.md +50 -0
- package/database/encryption/documentdb-encryption-service.md +3270 -0
- package/database/encryption/encryption-schema-registry.js +46 -0
- package/database/prisma.js +7 -47
- package/database/repositories/health-check-repository-documentdb.js +134 -0
- package/database/repositories/health-check-repository-factory.js +6 -1
- package/database/repositories/health-check-repository-interface.js +29 -34
- package/database/repositories/health-check-repository-mongodb.js +1 -3
- package/database/use-cases/check-database-state-use-case.js +3 -3
- package/database/use-cases/run-database-migration-use-case.js +6 -4
- package/database/use-cases/trigger-database-migration-use-case.js +2 -2
- package/database/utils/mongodb-schema-init.js +5 -5
- package/database/utils/prisma-runner.js +15 -9
- package/generated/prisma-mongodb/edge.js +3 -3
- package/generated/prisma-mongodb/index.d.ts +10 -4
- package/generated/prisma-mongodb/index.js +3 -3
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +1 -3
- package/generated/prisma-mongodb/wasm.js +2 -2
- package/generated/prisma-postgresql/edge.js +3 -3
- package/generated/prisma-postgresql/index.d.ts +10 -4
- package/generated/prisma-postgresql/index.js +3 -3
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +1 -3
- package/generated/prisma-postgresql/wasm.js +2 -2
- package/handlers/routers/db-migration.js +2 -3
- package/handlers/routers/health.js +0 -3
- package/handlers/workers/db-migration.js +8 -8
- package/integrations/repositories/integration-mapping-repository-documentdb.js +135 -0
- package/integrations/repositories/integration-mapping-repository-factory.js +8 -1
- package/integrations/repositories/integration-repository-documentdb.js +189 -0
- package/integrations/repositories/integration-repository-factory.js +8 -1
- package/integrations/repositories/process-repository-documentdb.js +141 -0
- package/integrations/repositories/process-repository-factory.js +8 -1
- package/modules/repositories/module-repository-documentdb.js +307 -0
- package/modules/repositories/module-repository-factory.js +8 -1
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +1 -3
- package/prisma-postgresql/migrations/20251112195422_update_user_unique_constraints/migration.sql +69 -0
- package/prisma-postgresql/schema.prisma +1 -3
- package/syncs/repositories/sync-repository-documentdb.js +240 -0
- package/syncs/repositories/sync-repository-factory.js +6 -1
- package/token/repositories/token-repository-documentdb.js +125 -0
- package/token/repositories/token-repository-factory.js +8 -1
- package/user/repositories/user-repository-documentdb.js +292 -0
- package/user/repositories/user-repository-factory.js +6 -1
- package/websocket/repositories/websocket-connection-repository-documentdb.js +119 -0
- 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
|