@friggframework/core 2.0.0--canary.395.ada1d9d.0 → 2.0.0--canary.395.4af60d3.0
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.
|
@@ -8,13 +8,13 @@ This module provides **transparent field-level encryption** for sensitive data i
|
|
|
8
8
|
|
|
9
9
|
### Key Features
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
11
|
+
- ✅ **Database-agnostic**: Works with MongoDB, PostgreSQL, and future databases
|
|
12
|
+
- ✅ **Transparent**: Repositories and use cases work with plain data
|
|
13
|
+
- ✅ **Hexagonal architecture**: Clean separation of concerns
|
|
14
|
+
- ✅ **AWS KMS support**: Enterprise-grade encryption with AWS Key Management Service
|
|
15
|
+
- ✅ **Local AES fallback**: Development mode using local encryption keys
|
|
16
|
+
- ✅ **Environment-based**: Automatic bypass in dev/test/local environments
|
|
17
|
+
- ✅ **Envelope encryption**: Secure key management pattern
|
|
18
18
|
|
|
19
19
|
## Architecture
|
|
20
20
|
|
|
@@ -49,14 +49,14 @@ Database type is configured in `backend/index.js` app definition:
|
|
|
49
49
|
const appDefinition = {
|
|
50
50
|
database: {
|
|
51
51
|
mongoDB: {
|
|
52
|
-
enable: true,
|
|
52
|
+
enable: true, // Use MongoDB
|
|
53
53
|
},
|
|
54
54
|
documentDB: {
|
|
55
|
-
enable: false,
|
|
55
|
+
enable: false, // Use DocumentDB (MongoDB-compatible)
|
|
56
56
|
tlsCAFile: './security/global-bundle.pem',
|
|
57
57
|
},
|
|
58
58
|
postgres: {
|
|
59
|
-
enable: false,
|
|
59
|
+
enable: false, // Use PostgreSQL
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
62
|
// ... other config
|
|
@@ -64,6 +64,7 @@ const appDefinition = {
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
**Important**: Only enable ONE database at a time. The framework will use the first enabled database in this priority order:
|
|
67
|
+
|
|
67
68
|
1. PostgreSQL (`postgres.enable = true`)
|
|
68
69
|
2. MongoDB (`mongoDB.enable = true`)
|
|
69
70
|
3. DocumentDB (`documentDB.enable = true`)
|
|
@@ -75,8 +76,8 @@ In `backend/index.js`:
|
|
|
75
76
|
```javascript
|
|
76
77
|
const appDefinition = {
|
|
77
78
|
encryption: {
|
|
78
|
-
fieldLevelEncryptionMethod: 'kms',
|
|
79
|
-
createResourceIfNoneFound: true,
|
|
79
|
+
fieldLevelEncryptionMethod: 'kms', // or 'aes'
|
|
80
|
+
createResourceIfNoneFound: true, // Auto-create KMS key if missing
|
|
80
81
|
},
|
|
81
82
|
// ... other config
|
|
82
83
|
};
|
|
@@ -93,16 +94,17 @@ STAGE=production
|
|
|
93
94
|
```
|
|
94
95
|
|
|
95
96
|
The `KMS_KEY_ARN` is usually auto-discovered by Frigg infrastructure:
|
|
96
|
-
- Set by AWS discovery: `AWS_DISCOVERY_KMS_KEY_ARN`
|
|
97
|
-
- Copied to `KMS_KEY_ARN` during deployment
|
|
98
97
|
|
|
99
|
-
|
|
98
|
+
- Set by AWS discovery: `AWS_DISCOVERY_KMS_KEY_ARN`
|
|
99
|
+
- Copied to `KMS_KEY_ARN` during deployment
|
|
100
|
+
|
|
101
|
+
#### AES Encryption
|
|
100
102
|
|
|
101
103
|
```bash
|
|
102
|
-
#
|
|
104
|
+
# AES encryption (can be used in any environment including production)
|
|
103
105
|
AES_KEY_ID=local-dev-key
|
|
104
106
|
AES_KEY=your-32-character-secret-key-here
|
|
105
|
-
STAGE=
|
|
107
|
+
STAGE=production # or development, staging, etc.
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
**⚠️ Important**: Encryption is automatically **disabled** when `STAGE` is set to `dev`, `test`, or `local`, regardless of key configuration.
|
|
@@ -116,7 +118,7 @@ To explicitly disable encryption:
|
|
|
116
118
|
STAGE=development # or dev, test, local
|
|
117
119
|
```
|
|
118
120
|
|
|
119
|
-
Or simply don't configure any encryption keys.
|
|
121
|
+
Or simply don't configure any encryption keys. In Production field level encryption **must** be enabled.
|
|
120
122
|
|
|
121
123
|
## Encrypted Fields
|
|
122
124
|
|
|
@@ -126,20 +128,20 @@ Fields are defined in `encryption-schema-registry.js`:
|
|
|
126
128
|
const ENCRYPTION_SCHEMA = {
|
|
127
129
|
Credential: {
|
|
128
130
|
fields: [
|
|
129
|
-
'data.access_token',
|
|
130
|
-
'data.refresh_token',
|
|
131
|
-
'data.domain',
|
|
132
|
-
'data.id_token',
|
|
131
|
+
'data.access_token', // OAuth access token
|
|
132
|
+
'data.refresh_token', // OAuth refresh token
|
|
133
|
+
'data.domain', // Service domain
|
|
134
|
+
'data.id_token', // OpenID Connect ID token
|
|
133
135
|
],
|
|
134
136
|
},
|
|
135
137
|
IntegrationMapping: {
|
|
136
|
-
fields: ['mapping'],
|
|
138
|
+
fields: ['mapping'], // Complete mapping object
|
|
137
139
|
},
|
|
138
140
|
User: {
|
|
139
|
-
fields: ['hashword'],
|
|
141
|
+
fields: ['hashword'], // Password hash
|
|
140
142
|
},
|
|
141
143
|
Token: {
|
|
142
|
-
fields: ['token'],
|
|
144
|
+
fields: ['token'], // Authentication token
|
|
143
145
|
},
|
|
144
146
|
};
|
|
145
147
|
```
|
|
@@ -162,14 +164,14 @@ const appDefinition = {
|
|
|
162
164
|
schema: {
|
|
163
165
|
// Your custom models
|
|
164
166
|
MyCustomModel: {
|
|
165
|
-
fields: ['secretData', 'data.apiKey']
|
|
167
|
+
fields: ['secretData', 'data.apiKey'],
|
|
166
168
|
},
|
|
167
169
|
|
|
168
170
|
// Extend core models with additional fields
|
|
169
171
|
Credential: {
|
|
170
|
-
fields: ['data.customToken'] // Merged with core fields
|
|
171
|
-
}
|
|
172
|
-
}
|
|
172
|
+
fields: ['data.customToken'], // Merged with core fields
|
|
173
|
+
},
|
|
174
|
+
},
|
|
173
175
|
},
|
|
174
176
|
integrations: [MyIntegration],
|
|
175
177
|
// ... rest of config
|
|
@@ -177,11 +179,12 @@ const appDefinition = {
|
|
|
177
179
|
```
|
|
178
180
|
|
|
179
181
|
**Features:**
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
182
|
+
|
|
183
|
+
- ✅ No framework file modifications needed
|
|
184
|
+
- ✅ Encryption for custom Prisma models
|
|
185
|
+
- ✅ Extends core models with additional fields
|
|
186
|
+
- ✅ Automatic validation on startup
|
|
187
|
+
- ✅ Protects against overriding core encrypted fields
|
|
185
188
|
|
|
186
189
|
**Example with Custom Model:**
|
|
187
190
|
|
|
@@ -220,11 +223,13 @@ await prisma.asanaTaskMapping.create({
|
|
|
220
223
|
```
|
|
221
224
|
|
|
222
225
|
**Validation:**
|
|
223
|
-
|
|
224
|
-
-
|
|
225
|
-
-
|
|
226
|
+
|
|
227
|
+
- Invalid field paths → Error on startup with clear message
|
|
228
|
+
- Attempting to override core fields → Error on startup
|
|
229
|
+
- Empty/null schema → Silently ignored
|
|
226
230
|
|
|
227
231
|
**Debug:**
|
|
232
|
+
|
|
228
233
|
```bash
|
|
229
234
|
# Enable debug logging to see custom schema loading
|
|
230
235
|
FRIGG_DEBUG=1 npm run frigg:start
|
|
@@ -243,7 +248,7 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
243
248
|
fields: [
|
|
244
249
|
'data.access_token',
|
|
245
250
|
'data.refresh_token',
|
|
246
|
-
'data.new_core_field',
|
|
251
|
+
'data.new_core_field', // New core field
|
|
247
252
|
],
|
|
248
253
|
},
|
|
249
254
|
};
|
|
@@ -252,13 +257,15 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
252
257
|
3. Deploy - encryption applied automatically to all integrations
|
|
253
258
|
|
|
254
259
|
**When to use:**
|
|
255
|
-
|
|
256
|
-
-
|
|
257
|
-
-
|
|
260
|
+
|
|
261
|
+
- Adding encryption for new framework-level sensitive fields
|
|
262
|
+
- Adding new core models (User, Token, etc.)
|
|
263
|
+
- Security baseline changes affecting all integrations
|
|
258
264
|
|
|
259
265
|
**When NOT to use:**
|
|
260
|
-
|
|
261
|
-
-
|
|
266
|
+
|
|
267
|
+
- Integration-specific sensitive data (use custom schema instead)
|
|
268
|
+
- Temporary/experimental encryption (use custom schema instead)
|
|
262
269
|
|
|
263
270
|
## How It Works
|
|
264
271
|
|
|
@@ -268,8 +275,8 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
268
275
|
// Application code (use case or repository)
|
|
269
276
|
await prisma.credential.create({
|
|
270
277
|
data: {
|
|
271
|
-
data: { access_token: 'secret123' }
|
|
272
|
-
}
|
|
278
|
+
data: { access_token: 'secret123' },
|
|
279
|
+
},
|
|
273
280
|
});
|
|
274
281
|
|
|
275
282
|
// What happens:
|
|
@@ -287,7 +294,7 @@ await prisma.credential.create({
|
|
|
287
294
|
```javascript
|
|
288
295
|
// Application code
|
|
289
296
|
const credential = await prisma.credential.findUnique({
|
|
290
|
-
where: { id: credentialId }
|
|
297
|
+
where: { id: credentialId },
|
|
291
298
|
});
|
|
292
299
|
|
|
293
300
|
// What happens:
|
|
@@ -309,10 +316,11 @@ Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
|
|
|
309
316
|
```
|
|
310
317
|
|
|
311
318
|
**Why Envelope Encryption?**
|
|
312
|
-
|
|
313
|
-
-
|
|
314
|
-
-
|
|
315
|
-
-
|
|
319
|
+
|
|
320
|
+
- Reduces KMS API calls (one DEK per field, cached)
|
|
321
|
+
- Master key never leaves KMS
|
|
322
|
+
- Enables key rotation without re-encrypting all data
|
|
323
|
+
- Better performance at scale
|
|
316
324
|
|
|
317
325
|
### Known Limitations
|
|
318
326
|
|
|
@@ -326,7 +334,7 @@ Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
|
|
|
326
334
|
// ❌ WRONG: Credential will NOT be decrypted
|
|
327
335
|
const entity = await prisma.entity.findUnique({
|
|
328
336
|
where: { id: entityId },
|
|
329
|
-
include: { credential: true }
|
|
337
|
+
include: { credential: true }, // Nested credential stays encrypted!
|
|
330
338
|
});
|
|
331
339
|
|
|
332
340
|
// entity.credential.data.access_token will be encrypted:
|
|
@@ -344,18 +352,18 @@ Always fetch relations with **separate queries**:
|
|
|
344
352
|
```javascript
|
|
345
353
|
// ✅ CORRECT: Fetch entity and credential separately
|
|
346
354
|
const entity = await prisma.entity.findUnique({
|
|
347
|
-
where: { id: entityId }
|
|
355
|
+
where: { id: entityId },
|
|
348
356
|
});
|
|
349
357
|
|
|
350
358
|
// Separate query ensures decryption
|
|
351
359
|
const credential = await prisma.credential.findUnique({
|
|
352
|
-
where: { id: entity.credentialId }
|
|
360
|
+
where: { id: entity.credentialId },
|
|
353
361
|
});
|
|
354
362
|
|
|
355
363
|
// Combine in application layer
|
|
356
364
|
return {
|
|
357
365
|
...entity,
|
|
358
|
-
credential
|
|
366
|
+
credential, // Now properly decrypted
|
|
359
367
|
};
|
|
360
368
|
```
|
|
361
369
|
|
|
@@ -366,32 +374,30 @@ For fetching multiple entities with credentials, use bulk fetching to avoid N+1
|
|
|
366
374
|
```javascript
|
|
367
375
|
// Fetch all entities
|
|
368
376
|
const entities = await prisma.entity.findMany({
|
|
369
|
-
where: { userId }
|
|
377
|
+
where: { userId },
|
|
370
378
|
});
|
|
371
379
|
|
|
372
380
|
// Bulk fetch credentials (single query)
|
|
373
|
-
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
381
|
+
const credentialIds = entities.map((e) => e.credentialId).filter(Boolean);
|
|
374
382
|
const credentials = await prisma.credential.findMany({
|
|
375
|
-
where: { id: { in: credentialIds } }
|
|
383
|
+
where: { id: { in: credentialIds } },
|
|
376
384
|
});
|
|
377
385
|
|
|
378
386
|
// Create lookup map
|
|
379
|
-
const credentialMap = new Map(
|
|
380
|
-
credentials.map(c => [c.id, c])
|
|
381
|
-
);
|
|
387
|
+
const credentialMap = new Map(credentials.map((c) => [c.id, c]));
|
|
382
388
|
|
|
383
389
|
// Combine in application layer
|
|
384
|
-
return entities.map(e => ({
|
|
390
|
+
return entities.map((e) => ({
|
|
385
391
|
...e,
|
|
386
|
-
credential: credentialMap.get(e.credentialId) || null
|
|
392
|
+
credential: credentialMap.get(e.credentialId) || null,
|
|
387
393
|
}));
|
|
388
394
|
```
|
|
389
395
|
|
|
390
396
|
**Verified:**
|
|
391
397
|
|
|
392
|
-
-
|
|
393
|
-
-
|
|
394
|
-
-
|
|
398
|
+
- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
|
|
399
|
+
- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
|
|
400
|
+
- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
|
|
395
401
|
|
|
396
402
|
**Implementation Examples:**
|
|
397
403
|
|
|
@@ -429,9 +435,9 @@ class AuthenticateUserUseCase {
|
|
|
429
435
|
identifiers: { userId },
|
|
430
436
|
details: {
|
|
431
437
|
data: {
|
|
432
|
-
access_token: accessToken // Plain text
|
|
433
|
-
}
|
|
434
|
-
}
|
|
438
|
+
access_token: accessToken, // Plain text
|
|
439
|
+
},
|
|
440
|
+
},
|
|
435
441
|
});
|
|
436
442
|
|
|
437
443
|
// Stored as encrypted, but we work with plain text
|
|
@@ -505,15 +511,16 @@ npm test -- database/encryption/
|
|
|
505
511
|
|
|
506
512
|
The encryption system uses **fail-fast error handling**:
|
|
507
513
|
|
|
508
|
-
-
|
|
509
|
-
-
|
|
510
|
-
-
|
|
511
|
-
-
|
|
514
|
+
- **Encryption failures**: Throw errors immediately (don't save corrupted/unencrypted sensitive data)
|
|
515
|
+
- **Decryption failures**: Throw errors immediately (prevents exposing invalid data)
|
|
516
|
+
- **Configuration errors**: Warn and disable encryption (graceful degradation for development)
|
|
517
|
+
- **Validation errors**: Throw errors on startup (catch issues before production)
|
|
512
518
|
|
|
513
519
|
**Why fail-fast?**
|
|
514
|
-
|
|
515
|
-
-
|
|
516
|
-
-
|
|
520
|
+
|
|
521
|
+
- Security-critical operations must not silently fail
|
|
522
|
+
- Better to expose issues during development than risk data breaches
|
|
523
|
+
- Prevents inconsistent database state (partially encrypted data)
|
|
517
524
|
|
|
518
525
|
### Logging Configuration
|
|
519
526
|
|
|
@@ -531,34 +538,39 @@ FRIGG_LOG_LEVEL=INFO
|
|
|
531
538
|
```
|
|
532
539
|
|
|
533
540
|
**Log Levels:**
|
|
534
|
-
|
|
535
|
-
-
|
|
536
|
-
-
|
|
537
|
-
-
|
|
541
|
+
|
|
542
|
+
- `DEBUG`: Detailed encryption operations (includes schema loading, key checks)
|
|
543
|
+
- `INFO`: High-level status (encryption enabled/disabled, custom schema registration)
|
|
544
|
+
- `WARN`: Configuration issues (missing keys, bypassed encryption)
|
|
545
|
+
- `ERROR`: Operation failures (encryption/decryption errors)
|
|
538
546
|
|
|
539
547
|
**Production Safety:**
|
|
540
|
-
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
548
|
+
|
|
549
|
+
- Sensitive data automatically sanitized in logs
|
|
550
|
+
- Long base64 strings truncated (prevents key leakage)
|
|
551
|
+
- Stack traces omitted in production (`STAGE=production`)
|
|
552
|
+
- Key IDs never logged
|
|
544
553
|
|
|
545
554
|
### Performance Optimizations
|
|
546
555
|
|
|
547
556
|
**Parallel field encryption:**
|
|
548
|
-
|
|
549
|
-
-
|
|
550
|
-
-
|
|
557
|
+
|
|
558
|
+
- Multiple fields encrypted concurrently using `Promise.all()`
|
|
559
|
+
- Significantly faster for models with many encrypted fields
|
|
560
|
+
- Example: 3 fields encrypted in ~30ms vs ~90ms (3x speedup)
|
|
551
561
|
|
|
552
562
|
**Deep cloning:**
|
|
553
|
-
|
|
554
|
-
-
|
|
555
|
-
-
|
|
563
|
+
|
|
564
|
+
- Uses native `structuredClone()` on Node.js 17+ (2-5x faster)
|
|
565
|
+
- Falls back to custom implementation for compatibility
|
|
566
|
+
- No external dependencies required
|
|
556
567
|
|
|
557
568
|
## Troubleshooting
|
|
558
569
|
|
|
559
570
|
### Encryption Not Working
|
|
560
571
|
|
|
561
572
|
**Check environment variables:**
|
|
573
|
+
|
|
562
574
|
```bash
|
|
563
575
|
echo $STAGE # Should be 'production' (not dev/test/local)
|
|
564
576
|
echo $KMS_KEY_ARN # Should be set (for KMS)
|
|
@@ -566,6 +578,7 @@ echo $AES_KEY_ID # Should be set (for AES)
|
|
|
566
578
|
```
|
|
567
579
|
|
|
568
580
|
**Check console logs:**
|
|
581
|
+
|
|
569
582
|
```
|
|
570
583
|
[Frigg] Field-level encryption enabled using KMS
|
|
571
584
|
```
|
|
@@ -581,13 +594,11 @@ or
|
|
|
581
594
|
**Error: "User is not authorized to perform: kms:GenerateDataKey"**
|
|
582
595
|
|
|
583
596
|
Solution: Add KMS permissions to Lambda execution role:
|
|
597
|
+
|
|
584
598
|
```json
|
|
585
599
|
{
|
|
586
600
|
"Effect": "Allow",
|
|
587
|
-
"Action": [
|
|
588
|
-
"kms:GenerateDataKey",
|
|
589
|
-
"kms:Decrypt"
|
|
590
|
-
],
|
|
601
|
+
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
|
|
591
602
|
"Resource": "arn:aws:kms:*:*:key/*"
|
|
592
603
|
}
|
|
593
604
|
```
|
|
@@ -595,6 +606,7 @@ Solution: Add KMS permissions to Lambda execution role:
|
|
|
595
606
|
**Error: "KMS key not found"**
|
|
596
607
|
|
|
597
608
|
Solution: Check `KMS_KEY_ARN` environment variable:
|
|
609
|
+
|
|
598
610
|
```bash
|
|
599
611
|
aws kms describe-key --key-id $KMS_KEY_ARN
|
|
600
612
|
```
|
|
@@ -604,6 +616,7 @@ aws kms describe-key --key-id $KMS_KEY_ARN
|
|
|
604
616
|
**Error: "No encryption key found with ID"**
|
|
605
617
|
|
|
606
618
|
Solution: Set both `AES_KEY_ID` and `AES_KEY`:
|
|
619
|
+
|
|
607
620
|
```bash
|
|
608
621
|
export AES_KEY_ID=local-dev-key
|
|
609
622
|
export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
@@ -613,9 +626,9 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
613
626
|
|
|
614
627
|
**Symptom: Slow queries with encryption**
|
|
615
628
|
|
|
616
|
-
-
|
|
617
|
-
-
|
|
618
|
-
-
|
|
629
|
+
- Check KMS API throttling (CloudWatch metrics)
|
|
630
|
+
- Consider data key caching (future enhancement)
|
|
631
|
+
- Verify proper field selection (don't encrypt unnecessary fields)
|
|
619
632
|
|
|
620
633
|
### Data Migration
|
|
621
634
|
|
|
@@ -630,7 +643,7 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
630
643
|
|
|
631
644
|
### DO
|
|
632
645
|
|
|
633
|
-
✅ Use AWS KMS
|
|
646
|
+
✅ Use AWS KMS for production (recommended) or AES encryption (valid alternative)
|
|
634
647
|
✅ Rotate KMS keys regularly (AWS handles automatically)
|
|
635
648
|
✅ Restrict KMS key access to Lambda execution role only
|
|
636
649
|
✅ Use VPC endpoints for KMS (reduce NAT costs)
|
|
@@ -639,7 +652,6 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
639
652
|
|
|
640
653
|
### DON'T
|
|
641
654
|
|
|
642
|
-
❌ Use AES keys in production (development only)
|
|
643
655
|
❌ Store AES keys in code or git (use environment variables)
|
|
644
656
|
❌ Disable encryption in production
|
|
645
657
|
❌ Skip encryption for PII data
|
|
@@ -650,22 +662,22 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
650
662
|
|
|
651
663
|
### Planned
|
|
652
664
|
|
|
653
|
-
-
|
|
654
|
-
-
|
|
655
|
-
-
|
|
656
|
-
-
|
|
657
|
-
-
|
|
665
|
+
- [ ] Data key caching (reduce KMS API calls)
|
|
666
|
+
- [ ] Key rotation automation
|
|
667
|
+
- [ ] Encryption metrics (CloudWatch)
|
|
668
|
+
- [ ] Field-level audit logging
|
|
669
|
+
- [ ] Support for queryable encryption (MongoDB CSFLE)
|
|
658
670
|
|
|
659
671
|
### Under Consideration
|
|
660
672
|
|
|
661
|
-
-
|
|
662
|
-
-
|
|
663
|
-
-
|
|
664
|
-
-
|
|
673
|
+
- [ ] Multi-region KMS replication
|
|
674
|
+
- [ ] Client-side field level encryption
|
|
675
|
+
- [ ] Encryption at rest + in transit
|
|
676
|
+
- [ ] Compliance reporting (GDPR, HIPAA)
|
|
665
677
|
|
|
666
678
|
## Related Documentation
|
|
667
679
|
|
|
668
|
-
-
|
|
669
|
-
-
|
|
670
|
-
-
|
|
671
|
-
-
|
|
680
|
+
- [Prisma Client Extensions](https://www.prisma.io/docs/orm/prisma-client/client-extensions)
|
|
681
|
+
- [AWS KMS Envelope Encryption](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping)
|
|
682
|
+
- [Frigg Infrastructure](../../../devtools/infrastructure/CLAUDE.md)
|
|
683
|
+
- [Hexagonal Architecture](../../CLAUDE.md#dddhexagonal-architecture-patterns)
|
|
@@ -34,7 +34,8 @@ class FieldEncryptionService {
|
|
|
34
34
|
const value = this._getNestedValue(encrypted, fieldPath);
|
|
35
35
|
|
|
36
36
|
if (this._shouldEncrypt(value)) {
|
|
37
|
-
const
|
|
37
|
+
const serializedValue = this._serializeForEncryption(value);
|
|
38
|
+
const encryptedValue = await this.cryptor.encrypt(serializedValue);
|
|
38
39
|
return { fieldPath, encryptedValue };
|
|
39
40
|
}
|
|
40
41
|
return null;
|
|
@@ -70,7 +71,8 @@ class FieldEncryptionService {
|
|
|
70
71
|
|
|
71
72
|
if (this._isEncrypted(value)) {
|
|
72
73
|
const decryptedValue = await this.cryptor.decrypt(value);
|
|
73
|
-
|
|
74
|
+
const deserializedValue = this._deserializeAfterDecryption(decryptedValue);
|
|
75
|
+
return { fieldPath, decryptedValue: deserializedValue };
|
|
74
76
|
}
|
|
75
77
|
return null;
|
|
76
78
|
});
|
|
@@ -186,6 +188,39 @@ class FieldEncryptionService {
|
|
|
186
188
|
|
|
187
189
|
return cloned;
|
|
188
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Serialize a value for encryption
|
|
194
|
+
* Objects/arrays are JSON stringified, primitives are converted to strings
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
_serializeForEncryption(value) {
|
|
198
|
+
if (typeof value === 'object' && value !== null) {
|
|
199
|
+
// JSON.stringify for objects and arrays
|
|
200
|
+
return JSON.stringify(value);
|
|
201
|
+
}
|
|
202
|
+
// For primitives (string, number, boolean), convert to string
|
|
203
|
+
return String(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Deserialize a value after decryption
|
|
208
|
+
* Attempts to parse as JSON, returns string if parsing fails
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_deserializeAfterDecryption(value) {
|
|
212
|
+
if (typeof value !== 'string') {
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Try to parse as JSON
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(value);
|
|
219
|
+
} catch {
|
|
220
|
+
// Not valid JSON, return as-is (likely was a plain string field)
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
189
224
|
}
|
|
190
225
|
|
|
191
226
|
module.exports = { FieldEncryptionService };
|
|
@@ -16,8 +16,25 @@ describe('FieldEncryptionService', () => {
|
|
|
16
16
|
decrypt: jest
|
|
17
17
|
.fn()
|
|
18
18
|
.mockImplementation((value) => {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Handle multiple encrypted formats
|
|
20
|
+
// Format 1: "encrypted:ORIGINAL:keydata:enckey"
|
|
21
|
+
// Format 2: "keyId:ORIGINAL:iv:enckey"
|
|
22
|
+
|
|
23
|
+
// Try format 1 (from our new tests)
|
|
24
|
+
const prefix1 = 'encrypted:';
|
|
25
|
+
const suffix1 = ':keydata:enckey';
|
|
26
|
+
if (value.startsWith(prefix1) && value.endsWith(suffix1)) {
|
|
27
|
+
return value.slice(prefix1.length, -suffix1.length);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Try format 2 (from existing tests)
|
|
31
|
+
const prefix2 = 'keyId:';
|
|
32
|
+
const suffix2 = ':iv:enckey';
|
|
33
|
+
if (value.startsWith(prefix2) && value.endsWith(suffix2)) {
|
|
34
|
+
return value.slice(prefix2.length, -suffix2.length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return value; // Fallback for non-standard format
|
|
21
38
|
}),
|
|
22
39
|
};
|
|
23
40
|
|
|
@@ -27,6 +44,7 @@ describe('FieldEncryptionService', () => {
|
|
|
27
44
|
const schemas = {
|
|
28
45
|
Credential: ['data.access_token', 'data.refresh_token'],
|
|
29
46
|
User: ['hashword'],
|
|
47
|
+
IntegrationMapping: ['mapping'],
|
|
30
48
|
EmptyModel: [],
|
|
31
49
|
};
|
|
32
50
|
return schemas[modelName] || [];
|
|
@@ -183,6 +201,48 @@ describe('FieldEncryptionService', () => {
|
|
|
183
201
|
expect(document).toEqual(original);
|
|
184
202
|
});
|
|
185
203
|
|
|
204
|
+
it('should properly encrypt object/JSON values (IntegrationMapping.mapping)', async () => {
|
|
205
|
+
// This test demonstrates the bug: objects are converted to "[object Object]"
|
|
206
|
+
// Expected behavior: object should be JSON.stringify'd before encryption
|
|
207
|
+
const mappingObject = {
|
|
208
|
+
action: 'upload',
|
|
209
|
+
formData: {
|
|
210
|
+
container: 'project_123',
|
|
211
|
+
folderId: '456',
|
|
212
|
+
attachments: ['att-1', 'att-2'],
|
|
213
|
+
},
|
|
214
|
+
taskId: 'task-789',
|
|
215
|
+
status: 'pending',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const document = {
|
|
219
|
+
id: 1,
|
|
220
|
+
integrationId: 1,
|
|
221
|
+
sourceId: 'task-789',
|
|
222
|
+
mapping: mappingObject,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const encrypted = await service.encryptFields('IntegrationMapping', document);
|
|
226
|
+
|
|
227
|
+
// The cryptor should receive JSON string, not "[object Object]"
|
|
228
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith(
|
|
229
|
+
JSON.stringify(mappingObject)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// The encrypted value should be the JSON string encrypted
|
|
233
|
+
expect(encrypted.mapping).toBe(
|
|
234
|
+
`encrypted:${JSON.stringify(mappingObject)}:keydata:enckey`
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Now decrypt and verify object is restored
|
|
238
|
+
const decrypted = await service.decryptFields('IntegrationMapping', encrypted);
|
|
239
|
+
|
|
240
|
+
// After decryption, the object should be fully restored
|
|
241
|
+
expect(decrypted.mapping).toEqual(mappingObject);
|
|
242
|
+
expect(decrypted.mapping.action).toBe('upload');
|
|
243
|
+
expect(decrypted.mapping.formData.attachments).toEqual(['att-1', 'att-2']);
|
|
244
|
+
});
|
|
245
|
+
|
|
186
246
|
it('should throw on encryption errors', async () => {
|
|
187
247
|
mockCryptor.encrypt.mockRejectedValueOnce(
|
|
188
248
|
new Error('Encryption failed')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.395.
|
|
4
|
+
"version": "2.0.0--canary.395.4af60d3.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@hapi/boom": "^10.0.1",
|
|
7
7
|
"@prisma/client": "^6.16.3",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"uuid": "^9.0.1"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@friggframework/eslint-config": "2.0.0--canary.395.
|
|
27
|
-
"@friggframework/prettier-config": "2.0.0--canary.395.
|
|
28
|
-
"@friggframework/test": "2.0.0--canary.395.
|
|
26
|
+
"@friggframework/eslint-config": "2.0.0--canary.395.4af60d3.0",
|
|
27
|
+
"@friggframework/prettier-config": "2.0.0--canary.395.4af60d3.0",
|
|
28
|
+
"@friggframework/test": "2.0.0--canary.395.4af60d3.0",
|
|
29
29
|
"@types/lodash": "4.17.15",
|
|
30
30
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
31
31
|
"chai": "^4.3.6",
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|
|
66
66
|
},
|
|
67
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "4af60d3d54fa6e29cb5e1693beff3d987a206c7c"
|
|
68
68
|
}
|