@friggframework/core 2.0.0--canary.395.495dc7d.0 → 2.0.0--canary.395.ada1d9d.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.
- package/database/encryption/README.md +83 -0
- package/database/encryption/mongo-decryption-fix-verification.test.js +348 -0
- package/database/encryption/postgres-decryption-fix-verification.test.js +371 -0
- package/database/encryption/postgres-relation-decryption.test.js +245 -0
- package/database/prisma.js +2 -2
- package/index.js +9 -2
- package/modules/repositories/module-repository-mongo.js +71 -17
- package/modules/repositories/module-repository-postgres.js +80 -17
- package/package.json +9 -9
- package/{prisma-mongo → prisma-mongodb}/schema.prisma +1 -1
- package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
- package/{prisma-postgres → prisma-postgresql}/schema.prisma +1 -1
- /package/{prisma-postgres → prisma-postgresql}/migrations/20250930193005_init/migration.sql +0 -0
- /package/{prisma-postgres → prisma-postgresql}/migrations/migration_lock.toml +0 -0
|
@@ -314,6 +314,89 @@ Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
|
|
|
314
314
|
- Enables key rotation without re-encrypting all data
|
|
315
315
|
- Better performance at scale
|
|
316
316
|
|
|
317
|
+
### Known Limitations
|
|
318
|
+
|
|
319
|
+
#### Prisma Relations with `include` Bypass Decryption
|
|
320
|
+
|
|
321
|
+
**⚠️ Critical**: When using Prisma's `include` option to fetch related models, the encryption extension **cannot decrypt** nested relation data.
|
|
322
|
+
|
|
323
|
+
**Problem:**
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
// ❌ WRONG: Credential will NOT be decrypted
|
|
327
|
+
const entity = await prisma.entity.findUnique({
|
|
328
|
+
where: { id: entityId },
|
|
329
|
+
include: { credential: true } // Nested credential stays encrypted!
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// entity.credential.data.access_token will be encrypted:
|
|
333
|
+
// "keyId:iv:ciphertext:encKey" instead of plain text
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Root Cause:**
|
|
337
|
+
|
|
338
|
+
The Prisma encryption extension hooks into top-level model queries via `$allModels`. When you use `include`, Prisma internally fetches the nested relation, but the extension only sees the parent model name (`Entity`), not the nested model (`Credential`). Therefore, the `Credential` data bypasses the decryption logic.
|
|
339
|
+
|
|
340
|
+
**Solution:**
|
|
341
|
+
|
|
342
|
+
Always fetch relations with **separate queries**:
|
|
343
|
+
|
|
344
|
+
```javascript
|
|
345
|
+
// ✅ CORRECT: Fetch entity and credential separately
|
|
346
|
+
const entity = await prisma.entity.findUnique({
|
|
347
|
+
where: { id: entityId }
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Separate query ensures decryption
|
|
351
|
+
const credential = await prisma.credential.findUnique({
|
|
352
|
+
where: { id: entity.credentialId }
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Combine in application layer
|
|
356
|
+
return {
|
|
357
|
+
...entity,
|
|
358
|
+
credential // Now properly decrypted
|
|
359
|
+
};
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Best Practice (Bulk Operations):**
|
|
363
|
+
|
|
364
|
+
For fetching multiple entities with credentials, use bulk fetching to avoid N+1 queries:
|
|
365
|
+
|
|
366
|
+
```javascript
|
|
367
|
+
// Fetch all entities
|
|
368
|
+
const entities = await prisma.entity.findMany({
|
|
369
|
+
where: { userId }
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Bulk fetch credentials (single query)
|
|
373
|
+
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
374
|
+
const credentials = await prisma.credential.findMany({
|
|
375
|
+
where: { id: { in: credentialIds } }
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Create lookup map
|
|
379
|
+
const credentialMap = new Map(
|
|
380
|
+
credentials.map(c => [c.id, c])
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// Combine in application layer
|
|
384
|
+
return entities.map(e => ({
|
|
385
|
+
...e,
|
|
386
|
+
credential: credentialMap.get(e.credentialId) || null
|
|
387
|
+
}));
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Verified:**
|
|
391
|
+
|
|
392
|
+
- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
|
|
393
|
+
- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
|
|
394
|
+
- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
|
|
395
|
+
|
|
396
|
+
**Implementation Examples:**
|
|
397
|
+
|
|
398
|
+
See `modules/repositories/module-repository-postgres.js` and `module-repository-mongo.js` for complete implementation examples using `_fetchCredential()` and `_fetchCredentialsBulk()` helper methods.
|
|
399
|
+
|
|
317
400
|
## Usage Examples
|
|
318
401
|
|
|
319
402
|
### Repository Code (No Changes Needed!)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification Test: Repository Fix for MongoDB Decryption Bug
|
|
3
|
+
*
|
|
4
|
+
* This test verifies that the fix in ModuleRepositoryMongo successfully
|
|
5
|
+
* decrypts credentials when fetching entities (after removing `include`).
|
|
6
|
+
*
|
|
7
|
+
* Expected Behavior After Fix:
|
|
8
|
+
* - All repository methods should return decrypted credentials
|
|
9
|
+
* - No encrypted tokens should leak through to the application layer
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
process.env.DB_TYPE = 'mongodb';
|
|
13
|
+
process.env.DATABASE_URL = process.env.DATABASE_URL || 'mongodb://localhost:27017/frigg?replicaSet=rs0';
|
|
14
|
+
process.env.STAGE = 'integration-test';
|
|
15
|
+
process.env.AES_KEY_ID = 'test-key-id';
|
|
16
|
+
process.env.AES_KEY = 'test-aes-key-32-characters-long!';
|
|
17
|
+
|
|
18
|
+
jest.mock('../config', () => ({
|
|
19
|
+
DB_TYPE: 'mongodb',
|
|
20
|
+
getDatabaseType: jest.fn(() => 'mongodb'),
|
|
21
|
+
PRISMA_LOG_LEVEL: 'error,warn',
|
|
22
|
+
PRISMA_QUERY_LOGGING: false,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { prisma, connectPrisma, disconnectPrisma } = require('../prisma');
|
|
26
|
+
const { ModuleRepositoryMongo } = require('../../modules/repositories/module-repository-mongo');
|
|
27
|
+
|
|
28
|
+
describe('Repository Fix Verification - MongoDB Decryption', () => {
|
|
29
|
+
let repository;
|
|
30
|
+
let testCredentialId;
|
|
31
|
+
let testEntityId;
|
|
32
|
+
let testUserId;
|
|
33
|
+
const TEST_TOKEN = 'my-secret-access-token-12345';
|
|
34
|
+
const TEST_REFRESH_TOKEN = 'my-secret-refresh-token-67890';
|
|
35
|
+
const TEST_DOMAIN = 'example-test.com';
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
await connectPrisma();
|
|
39
|
+
repository = new ModuleRepositoryMongo();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (testEntityId) {
|
|
44
|
+
await prisma.entity.deleteMany({
|
|
45
|
+
where: { id: testEntityId }
|
|
46
|
+
}).catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
if (testCredentialId) {
|
|
49
|
+
await prisma.credential.deleteMany({
|
|
50
|
+
where: { id: testCredentialId }
|
|
51
|
+
}).catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
if (testUserId) {
|
|
54
|
+
await prisma.user.deleteMany({
|
|
55
|
+
where: { id: testUserId }
|
|
56
|
+
}).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await disconnectPrisma();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
if (testEntityId) {
|
|
64
|
+
await prisma.entity.deleteMany({
|
|
65
|
+
where: { id: testEntityId }
|
|
66
|
+
}).catch(() => {});
|
|
67
|
+
testEntityId = null;
|
|
68
|
+
}
|
|
69
|
+
if (testCredentialId) {
|
|
70
|
+
await prisma.credential.deleteMany({
|
|
71
|
+
where: { id: testCredentialId }
|
|
72
|
+
}).catch(() => {});
|
|
73
|
+
testCredentialId = null;
|
|
74
|
+
}
|
|
75
|
+
if (testUserId) {
|
|
76
|
+
await prisma.user.deleteMany({
|
|
77
|
+
where: { id: testUserId }
|
|
78
|
+
}).catch(() => {});
|
|
79
|
+
testUserId = null;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('✅ FIX VERIFICATION: findEntityById returns decrypted credential', async () => {
|
|
84
|
+
const user = await prisma.user.create({
|
|
85
|
+
data: {
|
|
86
|
+
type: 'INDIVIDUAL',
|
|
87
|
+
hashword: 'test-hash'
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
testUserId = user.id;
|
|
91
|
+
|
|
92
|
+
const credential = await prisma.credential.create({
|
|
93
|
+
data: {
|
|
94
|
+
userId: testUserId,
|
|
95
|
+
externalId: 'test-cred-findEntityById',
|
|
96
|
+
data: {
|
|
97
|
+
access_token: TEST_TOKEN,
|
|
98
|
+
refresh_token: TEST_REFRESH_TOKEN,
|
|
99
|
+
domain: TEST_DOMAIN,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
testCredentialId = credential.id;
|
|
104
|
+
|
|
105
|
+
const entity = await prisma.entity.create({
|
|
106
|
+
data: {
|
|
107
|
+
userId: testUserId,
|
|
108
|
+
credentialId: testCredentialId,
|
|
109
|
+
moduleName: 'test-module',
|
|
110
|
+
externalId: 'test-entity-findById',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
testEntityId = entity.id;
|
|
114
|
+
|
|
115
|
+
const result = await repository.findEntityById(testEntityId);
|
|
116
|
+
|
|
117
|
+
expect(result).toBeDefined();
|
|
118
|
+
expect(result.credential).toBeDefined();
|
|
119
|
+
expect(result.credential.data.access_token).toBe(TEST_TOKEN);
|
|
120
|
+
expect(result.credential.data.refresh_token).toBe(TEST_REFRESH_TOKEN);
|
|
121
|
+
expect(result.credential.data.domain).toBe(TEST_DOMAIN);
|
|
122
|
+
|
|
123
|
+
expect(result.credential.data.access_token).not.toContain(':');
|
|
124
|
+
|
|
125
|
+
console.log('✅ findEntityById: Credential successfully decrypted!');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('✅ FIX VERIFICATION: findEntitiesByUserId returns decrypted credentials', async () => {
|
|
129
|
+
const user = await prisma.user.create({
|
|
130
|
+
data: {
|
|
131
|
+
type: 'INDIVIDUAL',
|
|
132
|
+
hashword: 'test-hash'
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
testUserId = user.id;
|
|
136
|
+
|
|
137
|
+
const credential = await prisma.credential.create({
|
|
138
|
+
data: {
|
|
139
|
+
userId: testUserId,
|
|
140
|
+
externalId: 'test-cred-findByUserId',
|
|
141
|
+
data: {
|
|
142
|
+
access_token: TEST_TOKEN,
|
|
143
|
+
domain: TEST_DOMAIN,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
testCredentialId = credential.id;
|
|
148
|
+
|
|
149
|
+
const entity = await prisma.entity.create({
|
|
150
|
+
data: {
|
|
151
|
+
userId: testUserId,
|
|
152
|
+
credentialId: testCredentialId,
|
|
153
|
+
moduleName: 'test-module',
|
|
154
|
+
externalId: 'test-entity-findByUserId',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
testEntityId = entity.id;
|
|
158
|
+
|
|
159
|
+
const results = await repository.findEntitiesByUserId(testUserId);
|
|
160
|
+
|
|
161
|
+
expect(results).toBeDefined();
|
|
162
|
+
expect(results.length).toBeGreaterThan(0);
|
|
163
|
+
const firstEntity = results[0];
|
|
164
|
+
expect(firstEntity.credential).toBeDefined();
|
|
165
|
+
expect(firstEntity.credential.data.access_token).toBe(TEST_TOKEN);
|
|
166
|
+
expect(firstEntity.credential.data.access_token).not.toContain(':');
|
|
167
|
+
|
|
168
|
+
console.log('✅ findEntitiesByUserId: Credentials successfully decrypted!');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('✅ FIX VERIFICATION: findEntitiesByIds returns decrypted credentials', async () => {
|
|
172
|
+
const user = await prisma.user.create({
|
|
173
|
+
data: {
|
|
174
|
+
type: 'INDIVIDUAL',
|
|
175
|
+
hashword: 'test-hash'
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
testUserId = user.id;
|
|
179
|
+
|
|
180
|
+
const credential = await prisma.credential.create({
|
|
181
|
+
data: {
|
|
182
|
+
userId: testUserId,
|
|
183
|
+
externalId: 'test-cred-findByIds',
|
|
184
|
+
data: {
|
|
185
|
+
access_token: TEST_TOKEN,
|
|
186
|
+
domain: TEST_DOMAIN,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
testCredentialId = credential.id;
|
|
191
|
+
|
|
192
|
+
const entity = await prisma.entity.create({
|
|
193
|
+
data: {
|
|
194
|
+
userId: testUserId,
|
|
195
|
+
credentialId: testCredentialId,
|
|
196
|
+
moduleName: 'test-module',
|
|
197
|
+
externalId: 'test-entity-findByIds',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
testEntityId = entity.id;
|
|
201
|
+
|
|
202
|
+
const results = await repository.findEntitiesByIds([testEntityId]);
|
|
203
|
+
|
|
204
|
+
expect(results).toBeDefined();
|
|
205
|
+
expect(results.length).toBe(1);
|
|
206
|
+
expect(results[0].credential).toBeDefined();
|
|
207
|
+
expect(results[0].credential.data.access_token).toBe(TEST_TOKEN);
|
|
208
|
+
expect(results[0].credential.data.access_token).not.toContain(':');
|
|
209
|
+
|
|
210
|
+
console.log('✅ findEntitiesByIds: Credentials successfully decrypted!');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('✅ FIX VERIFICATION: createEntity returns decrypted credential', async () => {
|
|
214
|
+
const user = await prisma.user.create({
|
|
215
|
+
data: {
|
|
216
|
+
type: 'INDIVIDUAL',
|
|
217
|
+
hashword: 'test-hash'
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
testUserId = user.id;
|
|
221
|
+
|
|
222
|
+
const credential = await prisma.credential.create({
|
|
223
|
+
data: {
|
|
224
|
+
userId: testUserId,
|
|
225
|
+
externalId: 'test-cred-create',
|
|
226
|
+
data: {
|
|
227
|
+
access_token: TEST_TOKEN,
|
|
228
|
+
domain: TEST_DOMAIN,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
testCredentialId = credential.id;
|
|
233
|
+
|
|
234
|
+
const entity = await repository.createEntity({
|
|
235
|
+
userId: testUserId,
|
|
236
|
+
credentialId: testCredentialId,
|
|
237
|
+
moduleName: 'test-module',
|
|
238
|
+
externalId: 'test-entity-create',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
testEntityId = entity.id;
|
|
242
|
+
|
|
243
|
+
expect(entity).toBeDefined();
|
|
244
|
+
expect(entity.credential).toBeDefined();
|
|
245
|
+
expect(entity.credential.data.access_token).toBe(TEST_TOKEN);
|
|
246
|
+
expect(entity.credential.data.access_token).not.toContain(':');
|
|
247
|
+
|
|
248
|
+
console.log('✅ createEntity: Credential successfully decrypted!');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('✅ FIX VERIFICATION: updateEntity returns decrypted credential', async () => {
|
|
252
|
+
const user = await prisma.user.create({
|
|
253
|
+
data: {
|
|
254
|
+
type: 'INDIVIDUAL',
|
|
255
|
+
hashword: 'test-hash'
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
testUserId = user.id;
|
|
259
|
+
|
|
260
|
+
const credential = await prisma.credential.create({
|
|
261
|
+
data: {
|
|
262
|
+
userId: testUserId,
|
|
263
|
+
externalId: 'test-cred-update',
|
|
264
|
+
data: {
|
|
265
|
+
access_token: TEST_TOKEN,
|
|
266
|
+
domain: TEST_DOMAIN,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
testCredentialId = credential.id;
|
|
271
|
+
|
|
272
|
+
const entity = await prisma.entity.create({
|
|
273
|
+
data: {
|
|
274
|
+
userId: testUserId,
|
|
275
|
+
credentialId: testCredentialId,
|
|
276
|
+
moduleName: 'test-module',
|
|
277
|
+
externalId: 'test-entity-update',
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
testEntityId = entity.id;
|
|
281
|
+
|
|
282
|
+
const updated = await repository.updateEntity(testEntityId, {
|
|
283
|
+
name: 'Updated Name',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(updated).toBeDefined();
|
|
287
|
+
expect(updated.name).toBe('Updated Name');
|
|
288
|
+
expect(updated.credential).toBeDefined();
|
|
289
|
+
expect(updated.credential.data.access_token).toBe(TEST_TOKEN);
|
|
290
|
+
expect(updated.credential.data.access_token).not.toContain(':');
|
|
291
|
+
|
|
292
|
+
console.log('✅ updateEntity: Credential successfully decrypted!');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('📊 COMPARISON: Verify tokens are encrypted in database but decrypted in repository', async () => {
|
|
296
|
+
const user = await prisma.user.create({
|
|
297
|
+
data: {
|
|
298
|
+
type: 'INDIVIDUAL',
|
|
299
|
+
hashword: 'test-hash'
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
testUserId = user.id;
|
|
303
|
+
|
|
304
|
+
const credential = await prisma.credential.create({
|
|
305
|
+
data: {
|
|
306
|
+
userId: testUserId,
|
|
307
|
+
externalId: 'test-cred-comparison',
|
|
308
|
+
data: {
|
|
309
|
+
access_token: TEST_TOKEN,
|
|
310
|
+
domain: TEST_DOMAIN,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
testCredentialId = credential.id;
|
|
315
|
+
|
|
316
|
+
const entity = await prisma.entity.create({
|
|
317
|
+
data: {
|
|
318
|
+
userId: testUserId,
|
|
319
|
+
credentialId: testCredentialId,
|
|
320
|
+
moduleName: 'test-module',
|
|
321
|
+
externalId: 'test-entity-comparison',
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
testEntityId = entity.id;
|
|
325
|
+
|
|
326
|
+
const rawCred = await prisma.$runCommandRaw({
|
|
327
|
+
find: 'Credential',
|
|
328
|
+
filter: { _id: { $oid: testCredentialId } }
|
|
329
|
+
});
|
|
330
|
+
const rawDoc = rawCred.cursor.firstBatch[0];
|
|
331
|
+
const rawToken = rawDoc.data.access_token;
|
|
332
|
+
|
|
333
|
+
const repoEntity = await repository.findEntityById(testEntityId);
|
|
334
|
+
const repoToken = repoEntity.credential.data.access_token;
|
|
335
|
+
|
|
336
|
+
console.log('\n📊 COMPARISON RESULTS:');
|
|
337
|
+
console.log('Raw DB token (encrypted):', rawToken.substring(0, 50) + '...');
|
|
338
|
+
console.log('Repository token (decrypted):', repoToken);
|
|
339
|
+
|
|
340
|
+
expect(rawToken).toContain(':');
|
|
341
|
+
expect(rawToken.split(':')).toHaveLength(4);
|
|
342
|
+
|
|
343
|
+
expect(repoToken).toBe(TEST_TOKEN);
|
|
344
|
+
expect(repoToken).not.toContain(':');
|
|
345
|
+
|
|
346
|
+
console.log('✅ Database stores encrypted, repository returns decrypted - FIX WORKS!');
|
|
347
|
+
});
|
|
348
|
+
});
|