@friggframework/core 2.0.0--canary.667.a0620c7.0 → 2.0.0--canary.702.d5e6de1.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/CLAUDE.md +1 -41
- package/application/commands/README.md +11 -17
- package/application/commands/entity-commands.js +60 -18
- package/application/commands/integration-commands.js +0 -63
- package/database/documentdb-encryption-service.js +33 -2
- package/database/encryption/README.md +3 -3
- package/database/encryption/encryption-schema-registry.js +29 -109
- package/database/encryption/field-encryption-service.js +16 -13
- package/database/prisma.js +1 -10
- package/handlers/app-definition-loader.js +9 -2
- package/integrations/integration-router.js +22 -18
- package/integrations/repositories/integration-repository-documentdb.js +0 -32
- package/integrations/repositories/integration-repository-interface.js +0 -30
- package/integrations/repositories/integration-repository-mongo.js +0 -52
- package/integrations/repositories/integration-repository-postgres.js +0 -58
- package/integrations/use-cases/create-integration.js +19 -57
- package/jest-setup.js +2 -13
- package/jest-teardown.js +2 -5
- package/jest.config.js +0 -4
- package/modules/use-cases/get-entity-options-by-id.js +5 -7
- package/modules/use-cases/get-module.js +8 -29
- package/modules/use-cases/refresh-entity-options.js +5 -7
- package/modules/use-cases/test-module-auth.js +5 -7
- package/modules/utils/map-module-dto.js +3 -5
- package/package.json +8 -8
- package/queues/queuer-util.js +5 -6
- package/user/tests/doubles/test-user-repository.js +1 -1
- package/user/use-cases/get-user-from-bearer-token.js +2 -7
- package/database/encryption/ciphertext-format.js +0 -24
- package/integrations/use-cases/add-entities-to-integration.js +0 -92
- package/integrations/use-cases/remove-entities-from-integration.js +0 -70
- package/integrations/utils/assert-entity-ids-input.js +0 -42
- package/jest.db.config.js +0 -23
- package/modules/use-cases/create-entity-from-credential.js +0 -105
- package/modules/utils/find-module-definition.js +0 -23
- package/modules/utils/to-credential-id.js +0 -14
package/CLAUDE.md
CHANGED
|
@@ -206,51 +206,11 @@ packages/core/
|
|
|
206
206
|
- `delete-integration-for-user.js` - Remove integration
|
|
207
207
|
- `get-integration-instance.js` - Load integration with modules
|
|
208
208
|
- `load-integration-context.js` - Full integration context loading
|
|
209
|
-
- `add-entities-to-integration.js` / `remove-entities-from-integration.js` - Attach or detach entities on an existing integration
|
|
210
209
|
|
|
211
210
|
**Repositories**:
|
|
212
211
|
- `integration-repository-factory.js` - Creates database-specific repositories
|
|
213
212
|
- `integration-repository-mongo.js` - MongoDB implementation
|
|
214
213
|
- `integration-repository-postgres.js` - PostgreSQL implementation
|
|
215
|
-
- `addEntitiesToIntegration(integrationId, entityIds)` /
|
|
216
|
-
`removeEntitiesFromIntegration(integrationId, entityIds)` change the entity
|
|
217
|
-
set of an existing integration (all three adapters; `null` for an unknown
|
|
218
|
-
integration). Reach them through the `addEntitiesToIntegration` /
|
|
219
|
-
`removeEntitiesFromIntegration` commands. The add use case
|
|
220
|
-
(`add-entities-to-integration.js`) rejects unknown entities
|
|
221
|
-
(`ENTITIES_NOT_FOUND`, 404), entities of another user
|
|
222
|
-
(`ENTITY_USER_MISMATCH`, 403) and entities of a module the integration does
|
|
223
|
-
not already hold (`ENTITY_MODULE_MISMATCH`, 400). The remove use case
|
|
224
|
-
(`remove-entities-from-integration.js`) refuses to leave a module with no
|
|
225
|
-
entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
|
|
226
|
-
removing a detached one changes nothing; a detached entity is not deleted.
|
|
227
|
-
`integration-repository-entities-parity.db.test.js` runs every adapter against
|
|
228
|
-
real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
|
|
229
|
-
`INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
|
|
230
|
-
writes only `Integration.entityIds` (as its `createIntegration` does), not
|
|
231
|
-
`Entity.integrationIds`.
|
|
232
|
-
**One credential, many accounts**: when one credential covers several
|
|
233
|
-
provider accounts, give each account its own entity (externalId = account
|
|
234
|
-
id) and attach them all to one integration; webhooks then route with
|
|
235
|
-
`findIntegrationByEntityExternalId`. Traps:
|
|
236
|
-
(1) `IntegrationBase` keys modules by module name, so `this.<moduleKey>`
|
|
237
|
-
holds only one entity's module, and on the queue/webhook path
|
|
238
|
-
(`get-integration-instance.js`) `this.entities` holds entity **ids**, not
|
|
239
|
-
records. For per-account work (a webhook for one account, teardown of every
|
|
240
|
-
account in `onDelete`), load
|
|
241
|
-
`this.commands.loadIntegrationContextById(this.id)` and use
|
|
242
|
-
`context.modules`: one module per entity, each with its record on
|
|
243
|
-
`module.entity` (pick by `module.entity.externalId`).
|
|
244
|
-
(2) `findIntegrationByEntityExternalId` counts every entity with that
|
|
245
|
-
externalId, attached or not, across users: reuse an existing entity
|
|
246
|
-
(`findEntity`) when an account comes back, or delete it when it is revoked,
|
|
247
|
-
never create a second one.
|
|
248
|
-
(3) `createIntegration` reuses an existing integration of the same user
|
|
249
|
-
and type whose entities include all requested ones (an exact match wins),
|
|
250
|
-
so a reconnect that sends the original entities finds the integration that
|
|
251
|
-
now holds more.
|
|
252
|
-
(4) Every module's delegate is the integration: an auth failure on any
|
|
253
|
-
account marks the shared credential invalid and the integration ERROR.
|
|
254
214
|
- `integration-mapping-repository-*.js` - Mapping data persistence.
|
|
255
215
|
`queryMappings(integrationId, { where, orderBy, skip, take, omit })`
|
|
256
216
|
returns `{ mappings, total }`: one filtered, ordered page of an
|
|
@@ -266,7 +226,7 @@ packages/core/
|
|
|
266
226
|
mapping keys to leave out of the rows; never write such rows back. Path
|
|
267
227
|
segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
|
|
268
228
|
DocumentDB adapters give the same pages and totals;
|
|
269
|
-
`integration-mapping-repository-query-parity.
|
|
229
|
+
`integration-mapping-repository-query-parity.test.js` checks that against
|
|
270
230
|
real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
|
|
271
231
|
`QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
|
|
272
232
|
strings compare by the database collation on PostgreSQL and by code point
|
|
@@ -67,10 +67,9 @@ class MyIntegration extends IntegrationBase {
|
|
|
67
67
|
async hydrateFromExternalUser(externalUserId) {
|
|
68
68
|
// Find integration context by external entity ID
|
|
69
69
|
const result =
|
|
70
|
-
await this.commands.findIntegrationContextByExternalEntityId(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
70
|
+
await this.commands.findIntegrationContextByExternalEntityId(
|
|
71
|
+
externalUserId
|
|
72
|
+
);
|
|
74
73
|
|
|
75
74
|
if (result.error) {
|
|
76
75
|
return { error: result.error };
|
|
@@ -232,10 +231,9 @@ Manage integration records and load full integration contexts.
|
|
|
232
231
|
```javascript
|
|
233
232
|
// Find integration context by external entity ID
|
|
234
233
|
// Returns { context, error } where context includes record + hydrated modules
|
|
235
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
});
|
|
234
|
+
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
235
|
+
'external-user-or-workspace-id'
|
|
236
|
+
);
|
|
239
237
|
|
|
240
238
|
if (!result.error) {
|
|
241
239
|
integration.setIntegrationRecord(result.context);
|
|
@@ -301,10 +299,7 @@ try {
|
|
|
301
299
|
For integration context operations, errors are returned in the result:
|
|
302
300
|
|
|
303
301
|
```javascript
|
|
304
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
305
|
-
externalId: userId,
|
|
306
|
-
type: 'my-integration',
|
|
307
|
-
});
|
|
302
|
+
const result = await commands.findIntegrationContextByExternalEntityId(userId);
|
|
308
303
|
|
|
309
304
|
if (result.error) {
|
|
310
305
|
return { error: result.error };
|
|
@@ -354,10 +349,9 @@ const result =
|
|
|
354
349
|
const { createFriggCommands } = require('@friggframework/core');
|
|
355
350
|
|
|
356
351
|
this.commands = createFriggCommands({ integrationClass: MyIntegration });
|
|
357
|
-
const result = await this.commands.findIntegrationContextByExternalEntityId(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
352
|
+
const result = await this.commands.findIntegrationContextByExternalEntityId(
|
|
353
|
+
userId
|
|
354
|
+
);
|
|
361
355
|
```
|
|
362
356
|
|
|
363
357
|
## Best Practices
|
|
@@ -439,7 +433,7 @@ if (!user) {
|
|
|
439
433
|
| | `findEntitiesByIds(ids)` | Find multiple by IDs |
|
|
440
434
|
| | `updateEntity(id, updates)` | Update entity properties |
|
|
441
435
|
| | `deleteEntity(id)` | Delete entity |
|
|
442
|
-
| **Integration** | `findIntegrationContextByExternalEntityId(
|
|
436
|
+
| **Integration** | `findIntegrationContextByExternalEntityId(externalId)` | Load integration + modules by external ID |
|
|
443
437
|
| | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID |
|
|
444
438
|
|
|
445
439
|
## Support
|
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createModuleRepository,
|
|
3
3
|
} = require('../../modules/repositories/module-repository-factory');
|
|
4
|
-
const { toCredentialId } = require('../../modules/utils/to-credential-id');
|
|
5
4
|
|
|
6
5
|
const ERROR_CODE_MAP = {
|
|
7
6
|
ENTITY_NOT_FOUND: 404,
|
|
8
7
|
INVALID_ENTITY_DATA: 400,
|
|
9
8
|
};
|
|
10
9
|
|
|
11
|
-
function toEntityDto(entity) {
|
|
12
|
-
return {
|
|
13
|
-
id: entity.id,
|
|
14
|
-
userId: entity.userId,
|
|
15
|
-
externalId: entity.externalId,
|
|
16
|
-
name: entity.name,
|
|
17
|
-
moduleName: entity.moduleName,
|
|
18
|
-
credentialId: toCredentialId(entity.credential),
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
10
|
function mapErrorToResponse(error) {
|
|
23
11
|
const status = ERROR_CODE_MAP[error?.code] || 500;
|
|
24
12
|
return {
|
|
@@ -78,7 +66,16 @@ function createEntityCommands() {
|
|
|
78
66
|
|
|
79
67
|
const entity = await moduleRepo.createEntity(entityData);
|
|
80
68
|
|
|
81
|
-
return
|
|
69
|
+
return {
|
|
70
|
+
id: entity.id,
|
|
71
|
+
userId: entity.userId,
|
|
72
|
+
externalId: entity.externalId,
|
|
73
|
+
name: entity.name,
|
|
74
|
+
moduleName: entity.moduleName,
|
|
75
|
+
credentialId: entity.credential?._id
|
|
76
|
+
? entity.credential._id.toString()
|
|
77
|
+
: entity.credential,
|
|
78
|
+
};
|
|
82
79
|
} catch (error) {
|
|
83
80
|
return mapErrorToResponse(error);
|
|
84
81
|
}
|
|
@@ -112,7 +109,16 @@ function createEntityCommands() {
|
|
|
112
109
|
return null;
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
return
|
|
112
|
+
return {
|
|
113
|
+
id: entity.id,
|
|
114
|
+
userId: entity.userId,
|
|
115
|
+
externalId: entity.externalId,
|
|
116
|
+
name: entity.name,
|
|
117
|
+
moduleName: entity.moduleName,
|
|
118
|
+
credentialId: entity.credential?._id
|
|
119
|
+
? entity.credential._id.toString()
|
|
120
|
+
: entity.credential,
|
|
121
|
+
};
|
|
116
122
|
} catch (error) {
|
|
117
123
|
return mapErrorToResponse(error);
|
|
118
124
|
}
|
|
@@ -133,7 +139,16 @@ function createEntityCommands() {
|
|
|
133
139
|
|
|
134
140
|
const entities = await moduleRepo.findEntitiesByUserId(userId);
|
|
135
141
|
|
|
136
|
-
return entities.map((entity) =>
|
|
142
|
+
return entities.map((entity) => ({
|
|
143
|
+
id: entity.id,
|
|
144
|
+
userId: entity.userId,
|
|
145
|
+
externalId: entity.externalId,
|
|
146
|
+
name: entity.name,
|
|
147
|
+
moduleName: entity.moduleName,
|
|
148
|
+
credentialId: entity.credential?._id
|
|
149
|
+
? entity.credential._id.toString()
|
|
150
|
+
: entity.credential,
|
|
151
|
+
}));
|
|
137
152
|
} catch (error) {
|
|
138
153
|
if (error.code) {
|
|
139
154
|
return mapErrorToResponse(error);
|
|
@@ -165,7 +180,16 @@ function createEntityCommands() {
|
|
|
165
180
|
moduleName
|
|
166
181
|
);
|
|
167
182
|
|
|
168
|
-
return entities.map((entity) =>
|
|
183
|
+
return entities.map((entity) => ({
|
|
184
|
+
id: entity.id,
|
|
185
|
+
userId: entity.userId,
|
|
186
|
+
externalId: entity.externalId,
|
|
187
|
+
name: entity.name,
|
|
188
|
+
moduleName: entity.moduleName,
|
|
189
|
+
credentialId: entity.credential?._id
|
|
190
|
+
? entity.credential._id.toString()
|
|
191
|
+
: entity.credential,
|
|
192
|
+
}));
|
|
169
193
|
} catch (error) {
|
|
170
194
|
if (error.code) {
|
|
171
195
|
return mapErrorToResponse(error);
|
|
@@ -189,7 +213,16 @@ function createEntityCommands() {
|
|
|
189
213
|
|
|
190
214
|
const entity = await moduleRepo.findEntityById(entityId);
|
|
191
215
|
|
|
192
|
-
return
|
|
216
|
+
return {
|
|
217
|
+
id: entity.id,
|
|
218
|
+
userId: entity.userId,
|
|
219
|
+
externalId: entity.externalId,
|
|
220
|
+
name: entity.name,
|
|
221
|
+
moduleName: entity.moduleName,
|
|
222
|
+
credentialId: entity.credential?._id
|
|
223
|
+
? entity.credential._id.toString()
|
|
224
|
+
: entity.credential,
|
|
225
|
+
};
|
|
193
226
|
} catch (error) {
|
|
194
227
|
return mapErrorToResponse(error);
|
|
195
228
|
}
|
|
@@ -217,7 +250,16 @@ function createEntityCommands() {
|
|
|
217
250
|
throw error;
|
|
218
251
|
}
|
|
219
252
|
|
|
220
|
-
return
|
|
253
|
+
return {
|
|
254
|
+
id: entity.id,
|
|
255
|
+
userId: entity.userId,
|
|
256
|
+
externalId: entity.externalId,
|
|
257
|
+
name: entity.name,
|
|
258
|
+
moduleName: entity.moduleName,
|
|
259
|
+
credentialId: entity.credential?._id
|
|
260
|
+
? entity.credential._id.toString()
|
|
261
|
+
: entity.credential,
|
|
262
|
+
};
|
|
221
263
|
} catch (error) {
|
|
222
264
|
return mapErrorToResponse(error);
|
|
223
265
|
}
|
|
@@ -29,12 +29,6 @@ const {
|
|
|
29
29
|
const {
|
|
30
30
|
PatchIntegrationConfig,
|
|
31
31
|
} = require('../../integrations/use-cases/patch-integration-config');
|
|
32
|
-
const {
|
|
33
|
-
AddEntitiesToIntegration,
|
|
34
|
-
} = require('../../integrations/use-cases/add-entities-to-integration');
|
|
35
|
-
const {
|
|
36
|
-
RemoveEntitiesFromIntegration,
|
|
37
|
-
} = require('../../integrations/use-cases/remove-entities-from-integration');
|
|
38
32
|
const {
|
|
39
33
|
getModulesDefinitionFromIntegrationClasses,
|
|
40
34
|
} = require('../../integrations/utils/map-integration-dto');
|
|
@@ -46,11 +40,6 @@ const ERROR_CODE_MAP = {
|
|
|
46
40
|
EXTERNAL_ID_REQUIRED: 400,
|
|
47
41
|
TYPE_REQUIRED: 400,
|
|
48
42
|
INTEGRATION_RECORD_NOT_FOUND: 404,
|
|
49
|
-
INVALID_INTEGRATION_DATA: 400,
|
|
50
|
-
ENTITIES_NOT_FOUND: 404,
|
|
51
|
-
ENTITY_USER_MISMATCH: 403,
|
|
52
|
-
ENTITY_MODULE_MISMATCH: 400,
|
|
53
|
-
LAST_ENTITY_OF_MODULE: 409,
|
|
54
43
|
};
|
|
55
44
|
|
|
56
45
|
function mapErrorToResponse(error) {
|
|
@@ -174,17 +163,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
174
163
|
integrationRepository,
|
|
175
164
|
});
|
|
176
165
|
|
|
177
|
-
const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
|
|
178
|
-
integrationRepository,
|
|
179
|
-
moduleRepository,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const removeEntitiesFromIntegrationUseCase =
|
|
183
|
-
new RemoveEntitiesFromIntegration({
|
|
184
|
-
integrationRepository,
|
|
185
|
-
moduleRepository,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
166
|
return {
|
|
189
167
|
findIntegrationById,
|
|
190
168
|
listIntegrations,
|
|
@@ -329,47 +307,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
329
307
|
}
|
|
330
308
|
},
|
|
331
309
|
|
|
332
|
-
/**
|
|
333
|
-
* Attach entities to an existing integration. Every entity must
|
|
334
|
-
* exist, belong to the integration's user, and be of a module the
|
|
335
|
-
* integration already holds; entities that are already attached are
|
|
336
|
-
* left as they are.
|
|
337
|
-
* @param {Object} params
|
|
338
|
-
* @param {string} params.integrationId - Integration ID
|
|
339
|
-
* @param {Array<string>} params.entityIds - Entity IDs to attach
|
|
340
|
-
* @returns {Promise<Object>} Updated integration
|
|
341
|
-
*/
|
|
342
|
-
async addEntitiesToIntegration({ integrationId, entityIds }) {
|
|
343
|
-
try {
|
|
344
|
-
return await addEntitiesToIntegrationUseCase.execute(
|
|
345
|
-
integrationId,
|
|
346
|
-
entityIds
|
|
347
|
-
);
|
|
348
|
-
} catch (error) {
|
|
349
|
-
return mapErrorToResponse(error);
|
|
350
|
-
}
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Detach entities from an integration. Entities that are not
|
|
355
|
-
* attached are ignored; detached entities are not deleted. Each
|
|
356
|
-
* module must keep at least one entity.
|
|
357
|
-
* @param {Object} params
|
|
358
|
-
* @param {string} params.integrationId - Integration ID
|
|
359
|
-
* @param {Array<string>} params.entityIds - Entity IDs to detach
|
|
360
|
-
* @returns {Promise<Object>} Updated integration
|
|
361
|
-
*/
|
|
362
|
-
async removeEntitiesFromIntegration({ integrationId, entityIds }) {
|
|
363
|
-
try {
|
|
364
|
-
return await removeEntitiesFromIntegrationUseCase.execute(
|
|
365
|
-
integrationId,
|
|
366
|
-
entityIds
|
|
367
|
-
);
|
|
368
|
-
} catch (error) {
|
|
369
|
-
return mapErrorToResponse(error);
|
|
370
|
-
}
|
|
371
|
-
},
|
|
372
|
-
|
|
373
310
|
/**
|
|
374
311
|
* Delete an integration by ID
|
|
375
312
|
* @param {string} integrationId - Integration ID to delete
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
-
const { isEncryptedEnvelope } = require('./encryption/ciphertext-format');
|
|
3
2
|
const {
|
|
4
3
|
getEncryptedFields,
|
|
5
4
|
getFieldsToEncryptOnWrite,
|
|
@@ -296,7 +295,39 @@ class DocumentDBEncryptionService {
|
|
|
296
295
|
* _isEncryptedValue({}) // false
|
|
297
296
|
*/
|
|
298
297
|
_isEncryptedValue(value) {
|
|
299
|
-
|
|
298
|
+
// Must be string
|
|
299
|
+
if (typeof value !== 'string') {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Must have exactly 4 colon-separated parts
|
|
304
|
+
const parts = value.split(':');
|
|
305
|
+
if (parts.length !== 4) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Enhanced validation: check for base64 pattern
|
|
310
|
+
// This prevents false positives on URLs, connection strings, etc.
|
|
311
|
+
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
312
|
+
|
|
313
|
+
// All parts should be base64-encoded
|
|
314
|
+
if (!parts.every(part => base64Pattern.test(part))) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Encrypted values should be sufficiently long to be valid
|
|
319
|
+
// Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
|
|
320
|
+
// - keyId (base64): ~12 chars minimum
|
|
321
|
+
// - iv (base64): ~24 chars for 16-byte IV
|
|
322
|
+
// - ciphertext (base64): varies, minimum ~16 chars for small values
|
|
323
|
+
// - encryptedKey (base64): ~44 chars for 32-byte data key
|
|
324
|
+
// Total minimum: ~96 chars, so 50 is a safe lower bound
|
|
325
|
+
// This prevents false positives on non-encrypted strings that happen to have 4 colons
|
|
326
|
+
if (value.length < 50) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return true;
|
|
300
331
|
}
|
|
301
332
|
}
|
|
302
333
|
|
|
@@ -508,9 +508,9 @@ return entities.map((e) => ({
|
|
|
508
508
|
|
|
509
509
|
**Verified:**
|
|
510
510
|
|
|
511
|
-
- ✅ `postgres-relation-decryption.
|
|
512
|
-
- ✅ `postgres-decryption-fix-verification.
|
|
513
|
-
- ✅ `mongo-decryption-fix-verification.
|
|
511
|
+
- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
|
|
512
|
+
- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
|
|
513
|
+
- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
|
|
514
514
|
|
|
515
515
|
**Implementation Examples:**
|
|
516
516
|
|
|
@@ -146,16 +146,6 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
for (const field of credentialFields) {
|
|
149
|
-
// Validate entry by entry: one bad entry must not drop the
|
|
150
|
-
// module's (or any other module's) valid fields.
|
|
151
|
-
if (typeof field !== 'string' || field.trim() === '') {
|
|
152
|
-
logger.warn(
|
|
153
|
-
`Ignoring invalid encryption.credentialFields entry ${JSON.stringify(field)} ` +
|
|
154
|
-
`in module "${moduleDef.moduleName || moduleDef.name || 'unknown'}" ` +
|
|
155
|
-
'(expected a non-empty string)'
|
|
156
|
-
);
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
149
|
const prefixedField = field.startsWith('data.') ? field : `data.${field}`;
|
|
160
150
|
fields.push(prefixedField);
|
|
161
151
|
}
|
|
@@ -184,46 +174,26 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
184
174
|
return;
|
|
185
175
|
}
|
|
186
176
|
|
|
187
|
-
const { getModulesDefinitionFromIntegrationClasses } = require('
|
|
188
|
-
|
|
189
|
-
// Integrations without modules contribute no credential fields.
|
|
190
|
-
const integrationsWithModules = integrations.filter(
|
|
191
|
-
(integration) => integration?.Definition?.modules
|
|
192
|
-
);
|
|
193
|
-
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(
|
|
194
|
-
integrationsWithModules
|
|
195
|
-
);
|
|
177
|
+
const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
|
|
196
178
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// other module field with them.
|
|
200
|
-
const coreCredentialFields = CORE_ENCRYPTION_SCHEMA.Credential.fields;
|
|
201
|
-
const credentialFields = extractCredentialFieldsFromModules(
|
|
202
|
-
moduleDefinitions
|
|
203
|
-
).filter((field) => !coreCredentialFields.includes(field));
|
|
179
|
+
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
|
|
180
|
+
const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
|
|
204
181
|
|
|
205
182
|
if (credentialFields.length === 0) {
|
|
206
183
|
return;
|
|
207
184
|
}
|
|
208
185
|
|
|
209
|
-
|
|
210
|
-
// than replacing it (registerCustomSchema overwrites).
|
|
211
|
-
const existingCredentialFields = customSchema.Credential?.fields || [];
|
|
212
|
-
const mergedSchema = {
|
|
213
|
-
...customSchema,
|
|
186
|
+
const moduleSchema = {
|
|
214
187
|
Credential: {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
...new Set([...existingCredentialFields, ...credentialFields]),
|
|
218
|
-
],
|
|
219
|
-
},
|
|
188
|
+
fields: credentialFields
|
|
189
|
+
}
|
|
220
190
|
};
|
|
221
191
|
|
|
222
192
|
logger.info(
|
|
223
193
|
`Registering module-level encryption for ${credentialFields.length} credential fields`
|
|
224
194
|
);
|
|
225
195
|
|
|
226
|
-
registerCustomSchema(
|
|
196
|
+
registerCustomSchema(moduleSchema);
|
|
227
197
|
}
|
|
228
198
|
|
|
229
199
|
/**
|
|
@@ -235,40 +205,7 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
235
205
|
*
|
|
236
206
|
* Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services.
|
|
237
207
|
*/
|
|
238
|
-
/**
|
|
239
|
-
* Raised when an app's encryption configuration is invalid. In deployed
|
|
240
|
-
* stages this fails startup instead of silently writing plaintext.
|
|
241
|
-
*/
|
|
242
|
-
class EncryptionSchemaError extends Error {
|
|
243
|
-
constructor(message, { cause } = {}) {
|
|
244
|
-
super(message, { cause });
|
|
245
|
-
this.name = 'EncryptionSchemaError';
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Stages where field-level encryption is bypassed (see prisma.js). */
|
|
250
|
-
function isLocalStage() {
|
|
251
|
-
const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
|
|
252
|
-
return ['dev', 'test', 'local'].includes(stage.toLowerCase());
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* An app definition was found but part of its encryption configuration
|
|
257
|
-
* could not be applied. Deployed stages fail closed; local stages warn and
|
|
258
|
-
* carry on so development isn't blocked.
|
|
259
|
-
*/
|
|
260
|
-
function reportInvalidEncryptionConfig(what, error) {
|
|
261
|
-
const message = `Invalid ${what}: ${error.message}`;
|
|
262
|
-
if (isLocalStage()) {
|
|
263
|
-
logger.warn(`${message} (ignored in local stage)`);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
logger.error(message);
|
|
267
|
-
throw new EncryptionSchemaError(message, { cause: error });
|
|
268
|
-
}
|
|
269
|
-
|
|
270
208
|
function loadCustomEncryptionSchema() {
|
|
271
|
-
let appDefinition;
|
|
272
209
|
try {
|
|
273
210
|
// Lazy require to avoid circular dependency issues
|
|
274
211
|
const path = require('node:path');
|
|
@@ -283,54 +220,38 @@ function loadCustomEncryptionSchema() {
|
|
|
283
220
|
const backendIndexPath = path.join(backendDir, 'index.js');
|
|
284
221
|
|
|
285
222
|
const backendModule = require(backendIndexPath);
|
|
286
|
-
appDefinition = backendModule?.Definition;
|
|
287
|
-
} catch (error) {
|
|
288
|
-
// No loadable backend (tests, standalone usage): custom schema is
|
|
289
|
-
// optional, so this stays at debug level.
|
|
290
|
-
logger.debug('Could not load custom encryption schema:', error.message);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
223
|
+
const appDefinition = backendModule?.Definition;
|
|
293
224
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// From here an app definition exists, so failures are configuration
|
|
299
|
-
// errors and must be visible.
|
|
225
|
+
if (!appDefinition) {
|
|
226
|
+
return; // No app definition found
|
|
227
|
+
}
|
|
300
228
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
try {
|
|
229
|
+
// Load app-level custom schema
|
|
230
|
+
const customSchema = appDefinition.encryption?.schema;
|
|
231
|
+
if (customSchema && Object.keys(customSchema).length > 0) {
|
|
305
232
|
registerCustomSchema(customSchema);
|
|
306
|
-
} catch (error) {
|
|
307
|
-
reportInvalidEncryptionConfig('app encryption.schema', error);
|
|
308
233
|
}
|
|
309
|
-
}
|
|
310
234
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
try {
|
|
235
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
236
|
+
// don't want encrypted on write (decryption on read still works,
|
|
237
|
+
// so legacy data remains readable).
|
|
238
|
+
const disable = appDefinition.encryption?.disable;
|
|
239
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
317
240
|
registerEncryptionOptOut(disable);
|
|
318
|
-
} catch (error) {
|
|
319
|
-
reportInvalidEncryptionConfig('app encryption.disable', error);
|
|
320
241
|
}
|
|
321
|
-
}
|
|
322
242
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
try {
|
|
243
|
+
// Load module-level encryption schemas from integrations
|
|
244
|
+
const integrations = appDefinition.integrations;
|
|
245
|
+
if (integrations && Array.isArray(integrations)) {
|
|
327
246
|
loadModuleEncryptionSchemas(integrations);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
reportInvalidEncryptionConfig(
|
|
330
|
-
'module encryption.credentialFields',
|
|
331
|
-
error
|
|
332
|
-
);
|
|
333
247
|
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// Silently ignore errors - custom schema is optional
|
|
250
|
+
// This handles cases like:
|
|
251
|
+
// - Backend package.json not found (tests, standalone usage)
|
|
252
|
+
// - No appDefinition defined
|
|
253
|
+
// - No custom encryption schema specified
|
|
254
|
+
logger.debug('Could not load custom encryption schema:', error.message);
|
|
334
255
|
}
|
|
335
256
|
}
|
|
336
257
|
|
|
@@ -465,7 +386,6 @@ function resetCustomSchema() {
|
|
|
465
386
|
}
|
|
466
387
|
|
|
467
388
|
module.exports = {
|
|
468
|
-
EncryptionSchemaError,
|
|
469
389
|
CORE_ENCRYPTION_SCHEMA,
|
|
470
390
|
getEncryptedFields,
|
|
471
391
|
getFieldsToEncryptOnWrite,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const { isEncryptedEnvelope } = require('./ciphertext-format');
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Field Encryption Service
|
|
5
3
|
*
|
|
@@ -149,7 +147,12 @@ class FieldEncryptionService {
|
|
|
149
147
|
}
|
|
150
148
|
|
|
151
149
|
_isEncrypted(value) {
|
|
152
|
-
|
|
150
|
+
if (typeof value !== 'string') {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parts = value.split(':');
|
|
155
|
+
return parts.length >= 4;
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
_getNestedValue(obj, path) {
|
|
@@ -215,22 +218,22 @@ class FieldEncryptionService {
|
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
|
218
|
-
* Serialize a value for encryption
|
|
219
|
-
*
|
|
220
|
-
* the round trip: '123456' is stored as "\"123456\"" and reads back as a
|
|
221
|
-
* string, 1234 reads back as a number.
|
|
221
|
+
* Serialize a value for encryption
|
|
222
|
+
* Objects/arrays are JSON stringified, primitives are converted to strings
|
|
222
223
|
* @private
|
|
223
224
|
*/
|
|
224
225
|
_serializeForEncryption(value) {
|
|
225
|
-
|
|
226
|
+
if (typeof value === 'object' && value !== null) {
|
|
227
|
+
// JSON.stringify for objects and arrays
|
|
228
|
+
return JSON.stringify(value);
|
|
229
|
+
}
|
|
230
|
+
// For primitives (string, number, boolean), convert to string
|
|
231
|
+
return String(value);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
/**
|
|
229
|
-
* Deserialize a value after decryption
|
|
230
|
-
*
|
|
231
|
-
* earlier versions stored strings raw: those that aren't valid JSON come
|
|
232
|
-
* back unchanged; a raw legacy string that is also valid JSON (e.g.
|
|
233
|
-
* '123456') still parses as it always did.
|
|
235
|
+
* Deserialize a value after decryption
|
|
236
|
+
* Attempts to parse as JSON, returns string if parsing fails
|
|
234
237
|
* @private
|
|
235
238
|
*/
|
|
236
239
|
_deserializeAfterDecryption(value) {
|
package/database/prisma.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createEncryptionExtension,
|
|
3
3
|
} = require('./encryption/prisma-encryption-extension');
|
|
4
|
-
const {
|
|
5
|
-
loadCustomEncryptionSchema,
|
|
6
|
-
EncryptionSchemaError,
|
|
7
|
-
} = require('./encryption/encryption-schema-registry');
|
|
4
|
+
const { loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
|
|
8
5
|
const { logger } = require('./encryption/logger');
|
|
9
6
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
10
7
|
const config = require('./config');
|
|
@@ -128,12 +125,6 @@ const prismaClientSingleton = () => {
|
|
|
128
125
|
`Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}`
|
|
129
126
|
);
|
|
130
127
|
} catch (error) {
|
|
131
|
-
// An invalid encryption configuration must fail startup: carrying
|
|
132
|
-
// on without the extension would write encrypted fields in
|
|
133
|
-
// plaintext.
|
|
134
|
-
if (error instanceof EncryptionSchemaError) {
|
|
135
|
-
throw error;
|
|
136
|
-
}
|
|
137
128
|
logger.error(
|
|
138
129
|
'Failed to initialize encryption extension:',
|
|
139
130
|
error
|