@friggframework/core 2.0.0--canary.702.d5e6de1.0 → 2.0.0--canary.666.89f6545.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 +41 -1
- package/application/commands/README.md +17 -11
- package/application/commands/entity-commands.js +18 -60
- package/application/commands/integration-commands.js +63 -0
- package/database/documentdb-encryption-service.js +2 -33
- package/database/encryption/README.md +3 -3
- package/database/encryption/ciphertext-format.js +24 -0
- package/database/encryption/encryption-schema-registry.js +109 -29
- package/database/encryption/field-encryption-service.js +13 -16
- package/database/prisma.js +10 -1
- package/integrations/integration-router.js +18 -22
- package/integrations/repositories/integration-repository-documentdb.js +32 -0
- package/integrations/repositories/integration-repository-interface.js +30 -0
- package/integrations/repositories/integration-repository-mongo.js +52 -0
- package/integrations/repositories/integration-repository-postgres.js +58 -0
- package/integrations/use-cases/add-entities-to-integration.js +92 -0
- package/integrations/use-cases/create-integration.js +57 -19
- package/integrations/use-cases/remove-entities-from-integration.js +70 -0
- package/integrations/utils/assert-entity-ids-input.js +42 -0
- package/jest-setup.js +13 -2
- package/jest-teardown.js +5 -2
- package/jest.config.js +4 -0
- package/jest.db.config.js +23 -0
- package/modules/use-cases/create-entity-from-credential.js +105 -0
- package/modules/use-cases/get-entity-options-by-id.js +7 -5
- package/modules/use-cases/get-module.js +29 -8
- package/modules/use-cases/refresh-entity-options.js +7 -5
- package/modules/use-cases/test-module-auth.js +7 -5
- package/modules/utils/find-module-definition.js +23 -0
- package/modules/utils/map-module-dto.js +5 -3
- package/modules/utils/to-credential-id.js +14 -0
- package/package.json +9 -8
- package/queues/queuer-util.js +6 -5
- package/user/tests/doubles/test-user-repository.js +1 -1
- package/user/use-cases/get-user-from-bearer-token.js +7 -2
package/CLAUDE.md
CHANGED
|
@@ -206,11 +206,51 @@ 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
|
|
209
210
|
|
|
210
211
|
**Repositories**:
|
|
211
212
|
- `integration-repository-factory.js` - Creates database-specific repositories
|
|
212
213
|
- `integration-repository-mongo.js` - MongoDB implementation
|
|
213
214
|
- `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.
|
|
214
254
|
- `integration-mapping-repository-*.js` - Mapping data persistence.
|
|
215
255
|
`queryMappings(integrationId, { where, orderBy, skip, take, omit })`
|
|
216
256
|
returns `{ mappings, total }`: one filtered, ordered page of an
|
|
@@ -226,7 +266,7 @@ packages/core/
|
|
|
226
266
|
mapping keys to leave out of the rows; never write such rows back. Path
|
|
227
267
|
segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
|
|
228
268
|
DocumentDB adapters give the same pages and totals;
|
|
229
|
-
`integration-mapping-repository-query-parity.test.js` checks that against
|
|
269
|
+
`integration-mapping-repository-query-parity.db.test.js` checks that against
|
|
230
270
|
real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
|
|
231
271
|
`QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
|
|
232
272
|
strings compare by the database collation on PostgreSQL and by code point
|
|
@@ -67,9 +67,10 @@ 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
|
-
externalUserId
|
|
72
|
-
|
|
70
|
+
await this.commands.findIntegrationContextByExternalEntityId({
|
|
71
|
+
externalId: externalUserId,
|
|
72
|
+
type: 'my-integration', // integration config.type to match
|
|
73
|
+
});
|
|
73
74
|
|
|
74
75
|
if (result.error) {
|
|
75
76
|
return { error: result.error };
|
|
@@ -231,9 +232,10 @@ Manage integration records and load full integration contexts.
|
|
|
231
232
|
```javascript
|
|
232
233
|
// Find integration context by external entity ID
|
|
233
234
|
// Returns { context, error } where context includes record + hydrated modules
|
|
234
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
235
|
-
'external-user-or-workspace-id'
|
|
236
|
-
|
|
235
|
+
const result = await commands.findIntegrationContextByExternalEntityId({
|
|
236
|
+
externalId: 'external-user-or-workspace-id',
|
|
237
|
+
type: 'my-integration', // integration config.type to match
|
|
238
|
+
});
|
|
237
239
|
|
|
238
240
|
if (!result.error) {
|
|
239
241
|
integration.setIntegrationRecord(result.context);
|
|
@@ -299,7 +301,10 @@ try {
|
|
|
299
301
|
For integration context operations, errors are returned in the result:
|
|
300
302
|
|
|
301
303
|
```javascript
|
|
302
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
304
|
+
const result = await commands.findIntegrationContextByExternalEntityId({
|
|
305
|
+
externalId: userId,
|
|
306
|
+
type: 'my-integration',
|
|
307
|
+
});
|
|
303
308
|
|
|
304
309
|
if (result.error) {
|
|
305
310
|
return { error: result.error };
|
|
@@ -349,9 +354,10 @@ const result =
|
|
|
349
354
|
const { createFriggCommands } = require('@friggframework/core');
|
|
350
355
|
|
|
351
356
|
this.commands = createFriggCommands({ integrationClass: MyIntegration });
|
|
352
|
-
const result = await this.commands.findIntegrationContextByExternalEntityId(
|
|
353
|
-
userId
|
|
354
|
-
|
|
357
|
+
const result = await this.commands.findIntegrationContextByExternalEntityId({
|
|
358
|
+
externalId: userId,
|
|
359
|
+
type: 'my-integration',
|
|
360
|
+
});
|
|
355
361
|
```
|
|
356
362
|
|
|
357
363
|
## Best Practices
|
|
@@ -433,7 +439,7 @@ if (!user) {
|
|
|
433
439
|
| | `findEntitiesByIds(ids)` | Find multiple by IDs |
|
|
434
440
|
| | `updateEntity(id, updates)` | Update entity properties |
|
|
435
441
|
| | `deleteEntity(id)` | Delete entity |
|
|
436
|
-
| **Integration** | `findIntegrationContextByExternalEntityId(externalId)`
|
|
442
|
+
| **Integration** | `findIntegrationContextByExternalEntityId({ externalId, type })` | Load integration + modules by external ID |
|
|
437
443
|
| | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID |
|
|
438
444
|
|
|
439
445
|
## Support
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createModuleRepository,
|
|
3
3
|
} = require('../../modules/repositories/module-repository-factory');
|
|
4
|
+
const { toCredentialId } = require('../../modules/utils/to-credential-id');
|
|
4
5
|
|
|
5
6
|
const ERROR_CODE_MAP = {
|
|
6
7
|
ENTITY_NOT_FOUND: 404,
|
|
7
8
|
INVALID_ENTITY_DATA: 400,
|
|
8
9
|
};
|
|
9
10
|
|
|
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
|
+
|
|
10
22
|
function mapErrorToResponse(error) {
|
|
11
23
|
const status = ERROR_CODE_MAP[error?.code] || 500;
|
|
12
24
|
return {
|
|
@@ -66,16 +78,7 @@ function createEntityCommands() {
|
|
|
66
78
|
|
|
67
79
|
const entity = await moduleRepo.createEntity(entityData);
|
|
68
80
|
|
|
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
|
-
};
|
|
81
|
+
return toEntityDto(entity);
|
|
79
82
|
} catch (error) {
|
|
80
83
|
return mapErrorToResponse(error);
|
|
81
84
|
}
|
|
@@ -109,16 +112,7 @@ function createEntityCommands() {
|
|
|
109
112
|
return null;
|
|
110
113
|
}
|
|
111
114
|
|
|
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
|
-
};
|
|
115
|
+
return toEntityDto(entity);
|
|
122
116
|
} catch (error) {
|
|
123
117
|
return mapErrorToResponse(error);
|
|
124
118
|
}
|
|
@@ -139,16 +133,7 @@ function createEntityCommands() {
|
|
|
139
133
|
|
|
140
134
|
const entities = await moduleRepo.findEntitiesByUserId(userId);
|
|
141
135
|
|
|
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
|
-
}));
|
|
136
|
+
return entities.map((entity) => toEntityDto(entity));
|
|
152
137
|
} catch (error) {
|
|
153
138
|
if (error.code) {
|
|
154
139
|
return mapErrorToResponse(error);
|
|
@@ -180,16 +165,7 @@ function createEntityCommands() {
|
|
|
180
165
|
moduleName
|
|
181
166
|
);
|
|
182
167
|
|
|
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
|
-
}));
|
|
168
|
+
return entities.map((entity) => toEntityDto(entity));
|
|
193
169
|
} catch (error) {
|
|
194
170
|
if (error.code) {
|
|
195
171
|
return mapErrorToResponse(error);
|
|
@@ -213,16 +189,7 @@ function createEntityCommands() {
|
|
|
213
189
|
|
|
214
190
|
const entity = await moduleRepo.findEntityById(entityId);
|
|
215
191
|
|
|
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
|
-
};
|
|
192
|
+
return toEntityDto(entity);
|
|
226
193
|
} catch (error) {
|
|
227
194
|
return mapErrorToResponse(error);
|
|
228
195
|
}
|
|
@@ -250,16 +217,7 @@ function createEntityCommands() {
|
|
|
250
217
|
throw error;
|
|
251
218
|
}
|
|
252
219
|
|
|
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
|
-
};
|
|
220
|
+
return toEntityDto(entity);
|
|
263
221
|
} catch (error) {
|
|
264
222
|
return mapErrorToResponse(error);
|
|
265
223
|
}
|
|
@@ -29,6 +29,12 @@ 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');
|
|
32
38
|
const {
|
|
33
39
|
getModulesDefinitionFromIntegrationClasses,
|
|
34
40
|
} = require('../../integrations/utils/map-integration-dto');
|
|
@@ -40,6 +46,11 @@ const ERROR_CODE_MAP = {
|
|
|
40
46
|
EXTERNAL_ID_REQUIRED: 400,
|
|
41
47
|
TYPE_REQUIRED: 400,
|
|
42
48
|
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,
|
|
43
54
|
};
|
|
44
55
|
|
|
45
56
|
function mapErrorToResponse(error) {
|
|
@@ -163,6 +174,17 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
163
174
|
integrationRepository,
|
|
164
175
|
});
|
|
165
176
|
|
|
177
|
+
const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
|
|
178
|
+
integrationRepository,
|
|
179
|
+
moduleRepository,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const removeEntitiesFromIntegrationUseCase =
|
|
183
|
+
new RemoveEntitiesFromIntegration({
|
|
184
|
+
integrationRepository,
|
|
185
|
+
moduleRepository,
|
|
186
|
+
});
|
|
187
|
+
|
|
166
188
|
return {
|
|
167
189
|
findIntegrationById,
|
|
168
190
|
listIntegrations,
|
|
@@ -307,6 +329,47 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
307
329
|
}
|
|
308
330
|
},
|
|
309
331
|
|
|
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
|
+
|
|
310
373
|
/**
|
|
311
374
|
* Delete an integration by ID
|
|
312
375
|
* @param {string} integrationId - Integration ID to delete
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
+
const { isEncryptedEnvelope } = require('./encryption/ciphertext-format');
|
|
2
3
|
const {
|
|
3
4
|
getEncryptedFields,
|
|
4
5
|
getFieldsToEncryptOnWrite,
|
|
@@ -295,39 +296,7 @@ class DocumentDBEncryptionService {
|
|
|
295
296
|
* _isEncryptedValue({}) // false
|
|
296
297
|
*/
|
|
297
298
|
_isEncryptedValue(value) {
|
|
298
|
-
|
|
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;
|
|
299
|
+
return isEncryptedEnvelope(value);
|
|
331
300
|
}
|
|
332
301
|
}
|
|
333
302
|
|
|
@@ -508,9 +508,9 @@ return entities.map((e) => ({
|
|
|
508
508
|
|
|
509
509
|
**Verified:**
|
|
510
510
|
|
|
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
|
|
511
|
+
- ✅ `postgres-relation-decryption.db.test.js` - Proves the bug exists
|
|
512
|
+
- ✅ `postgres-decryption-fix-verification.db.test.js` - Verifies separate queries work
|
|
513
|
+
- ✅ `mongo-decryption-fix-verification.db.test.js` - Verifies fix for MongoDB
|
|
514
514
|
|
|
515
515
|
**Implementation Examples:**
|
|
516
516
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognises values produced by Cryptor.encrypt():
|
|
3
|
+
* keyId:iv:ciphertext:encryptedKey
|
|
4
|
+
* keyId and encryptedKey are base64, iv and ciphertext are hex, so all four
|
|
5
|
+
* parts match the base64 alphabet. Real envelopes are well over 50
|
|
6
|
+
* characters (the encrypted data key alone is longer).
|
|
7
|
+
*
|
|
8
|
+
* Shared by the Prisma (FieldEncryptionService) and DocumentDB paths so a
|
|
9
|
+
* plaintext value that happens to contain colons (`a:b:c:d`, a URL with
|
|
10
|
+
* userinfo and a port) is never mistaken for ciphertext: on write it gets
|
|
11
|
+
* encrypted, on read it is returned as plaintext.
|
|
12
|
+
*/
|
|
13
|
+
const BASE64_PART = /^[A-Za-z0-9+/=]+$/;
|
|
14
|
+
const MIN_ENVELOPE_LENGTH = 50;
|
|
15
|
+
|
|
16
|
+
function isEncryptedEnvelope(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length < MIN_ENVELOPE_LENGTH) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const parts = value.split(':');
|
|
21
|
+
return parts.length === 4 && parts.every((part) => BASE64_PART.test(part));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { isEncryptedEnvelope };
|
|
@@ -146,6 +146,16 @@ 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
|
+
}
|
|
149
159
|
const prefixedField = field.startsWith('data.') ? field : `data.${field}`;
|
|
150
160
|
fields.push(prefixedField);
|
|
151
161
|
}
|
|
@@ -174,26 +184,46 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
174
184
|
return;
|
|
175
185
|
}
|
|
176
186
|
|
|
177
|
-
const { getModulesDefinitionFromIntegrationClasses } = require('
|
|
187
|
+
const { getModulesDefinitionFromIntegrationClasses } = require('../../integrations/utils/map-integration-dto');
|
|
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
|
+
);
|
|
178
196
|
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
// Fields the core schema already encrypts (e.g. data.api_key) are
|
|
198
|
+
// covered; registering them again would fail validation and drop every
|
|
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));
|
|
181
204
|
|
|
182
205
|
if (credentialFields.length === 0) {
|
|
183
206
|
return;
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
|
|
209
|
+
// Merge with whatever custom schema the app already registered rather
|
|
210
|
+
// than replacing it (registerCustomSchema overwrites).
|
|
211
|
+
const existingCredentialFields = customSchema.Credential?.fields || [];
|
|
212
|
+
const mergedSchema = {
|
|
213
|
+
...customSchema,
|
|
187
214
|
Credential: {
|
|
188
|
-
|
|
189
|
-
|
|
215
|
+
...customSchema.Credential,
|
|
216
|
+
fields: [
|
|
217
|
+
...new Set([...existingCredentialFields, ...credentialFields]),
|
|
218
|
+
],
|
|
219
|
+
},
|
|
190
220
|
};
|
|
191
221
|
|
|
192
222
|
logger.info(
|
|
193
223
|
`Registering module-level encryption for ${credentialFields.length} credential fields`
|
|
194
224
|
);
|
|
195
225
|
|
|
196
|
-
registerCustomSchema(
|
|
226
|
+
registerCustomSchema(mergedSchema);
|
|
197
227
|
}
|
|
198
228
|
|
|
199
229
|
/**
|
|
@@ -205,7 +235,40 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
205
235
|
*
|
|
206
236
|
* Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services.
|
|
207
237
|
*/
|
|
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
|
+
|
|
208
270
|
function loadCustomEncryptionSchema() {
|
|
271
|
+
let appDefinition;
|
|
209
272
|
try {
|
|
210
273
|
// Lazy require to avoid circular dependency issues
|
|
211
274
|
const path = require('node:path');
|
|
@@ -220,38 +283,54 @@ function loadCustomEncryptionSchema() {
|
|
|
220
283
|
const backendIndexPath = path.join(backendDir, 'index.js');
|
|
221
284
|
|
|
222
285
|
const backendModule = require(backendIndexPath);
|
|
223
|
-
|
|
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
|
+
}
|
|
224
293
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
294
|
+
if (!appDefinition) {
|
|
295
|
+
return; // No app definition found
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// From here an app definition exists, so failures are configuration
|
|
299
|
+
// errors and must be visible.
|
|
228
300
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
301
|
+
// Load app-level custom schema
|
|
302
|
+
const customSchema = appDefinition.encryption?.schema;
|
|
303
|
+
if (customSchema && Object.keys(customSchema).length > 0) {
|
|
304
|
+
try {
|
|
232
305
|
registerCustomSchema(customSchema);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
reportInvalidEncryptionConfig('app encryption.schema', error);
|
|
233
308
|
}
|
|
309
|
+
}
|
|
234
310
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
311
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
312
|
+
// don't want encrypted on write (decryption on read still works,
|
|
313
|
+
// so legacy data remains readable).
|
|
314
|
+
const disable = appDefinition.encryption?.disable;
|
|
315
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
316
|
+
try {
|
|
240
317
|
registerEncryptionOptOut(disable);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
reportInvalidEncryptionConfig('app encryption.disable', error);
|
|
241
320
|
}
|
|
321
|
+
}
|
|
242
322
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
323
|
+
// Load module-level encryption schemas from integrations
|
|
324
|
+
const integrations = appDefinition.integrations;
|
|
325
|
+
if (integrations && Array.isArray(integrations)) {
|
|
326
|
+
try {
|
|
246
327
|
loadModuleEncryptionSchemas(integrations);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
reportInvalidEncryptionConfig(
|
|
330
|
+
'module encryption.credentialFields',
|
|
331
|
+
error
|
|
332
|
+
);
|
|
247
333
|
}
|
|
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);
|
|
255
334
|
}
|
|
256
335
|
}
|
|
257
336
|
|
|
@@ -386,6 +465,7 @@ function resetCustomSchema() {
|
|
|
386
465
|
}
|
|
387
466
|
|
|
388
467
|
module.exports = {
|
|
468
|
+
EncryptionSchemaError,
|
|
389
469
|
CORE_ENCRYPTION_SCHEMA,
|
|
390
470
|
getEncryptedFields,
|
|
391
471
|
getFieldsToEncryptOnWrite,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { isEncryptedEnvelope } = require('./ciphertext-format');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Field Encryption Service
|
|
3
5
|
*
|
|
@@ -147,12 +149,7 @@ class FieldEncryptionService {
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
_isEncrypted(value) {
|
|
150
|
-
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const parts = value.split(':');
|
|
155
|
-
return parts.length >= 4;
|
|
152
|
+
return isEncryptedEnvelope(value);
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
_getNestedValue(obj, path) {
|
|
@@ -218,22 +215,22 @@ class FieldEncryptionService {
|
|
|
218
215
|
}
|
|
219
216
|
|
|
220
217
|
/**
|
|
221
|
-
* Serialize a value for encryption
|
|
222
|
-
*
|
|
218
|
+
* Serialize a value for encryption.
|
|
219
|
+
* Every value is JSON-encoded, strings included, so the type survives
|
|
220
|
+
* the round trip: '123456' is stored as "\"123456\"" and reads back as a
|
|
221
|
+
* string, 1234 reads back as a number.
|
|
223
222
|
* @private
|
|
224
223
|
*/
|
|
225
224
|
_serializeForEncryption(value) {
|
|
226
|
-
|
|
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);
|
|
225
|
+
return JSON.stringify(value);
|
|
232
226
|
}
|
|
233
227
|
|
|
234
228
|
/**
|
|
235
|
-
* Deserialize a value after decryption
|
|
236
|
-
*
|
|
229
|
+
* Deserialize a value after decryption.
|
|
230
|
+
* Parses the JSON written by _serializeForEncryption. Values encrypted by
|
|
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.
|
|
237
234
|
* @private
|
|
238
235
|
*/
|
|
239
236
|
_deserializeAfterDecryption(value) {
|