@friggframework/core 2.0.0-next.41 → 2.0.0-next.43
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 +693 -0
- package/README.md +931 -50
- package/application/commands/README.md +421 -0
- package/application/commands/credential-commands.js +224 -0
- package/application/commands/entity-commands.js +315 -0
- package/application/commands/integration-commands.js +160 -0
- package/application/commands/integration-commands.test.js +123 -0
- package/application/commands/user-commands.js +213 -0
- package/application/index.js +69 -0
- package/core/CLAUDE.md +690 -0
- package/core/create-handler.js +0 -6
- package/credential/repositories/credential-repository-factory.js +47 -0
- package/credential/repositories/credential-repository-interface.js +98 -0
- package/credential/repositories/credential-repository-mongo.js +301 -0
- package/credential/repositories/credential-repository-postgres.js +307 -0
- package/credential/repositories/credential-repository.js +307 -0
- package/credential/use-cases/get-credential-for-user.js +21 -0
- package/credential/use-cases/update-authentication-status.js +15 -0
- package/database/config.js +117 -0
- package/database/encryption/README.md +683 -0
- package/database/encryption/encryption-integration.test.js +553 -0
- package/database/encryption/encryption-schema-registry.js +141 -0
- package/database/encryption/encryption-schema-registry.test.js +392 -0
- package/database/encryption/field-encryption-service.js +226 -0
- package/database/encryption/field-encryption-service.test.js +525 -0
- package/database/encryption/logger.js +79 -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/encryption/prisma-encryption-extension.js +222 -0
- package/database/encryption/prisma-encryption-extension.test.js +439 -0
- package/database/index.js +25 -12
- package/database/models/readme.md +1 -0
- package/database/prisma.js +162 -0
- package/database/repositories/health-check-repository-factory.js +38 -0
- package/database/repositories/health-check-repository-interface.js +86 -0
- package/database/repositories/health-check-repository-mongodb.js +72 -0
- package/database/repositories/health-check-repository-postgres.js +75 -0
- package/database/repositories/health-check-repository.js +108 -0
- package/database/use-cases/check-database-health-use-case.js +34 -0
- package/database/use-cases/check-encryption-health-use-case.js +82 -0
- package/database/use-cases/test-encryption-use-case.js +252 -0
- package/encrypt/Cryptor.js +20 -152
- package/encrypt/index.js +1 -2
- package/encrypt/test-encrypt.js +0 -2
- package/handlers/app-definition-loader.js +38 -0
- package/handlers/app-handler-helpers.js +0 -3
- package/handlers/auth-flow.integration.test.js +147 -0
- package/handlers/backend-utils.js +25 -45
- package/handlers/integration-event-dispatcher.js +54 -0
- package/handlers/integration-event-dispatcher.test.js +141 -0
- package/handlers/routers/HEALTHCHECK.md +103 -1
- package/handlers/routers/auth.js +3 -14
- package/handlers/routers/health.js +63 -424
- package/handlers/routers/health.test.js +7 -0
- package/handlers/routers/integration-defined-routers.js +8 -5
- package/handlers/routers/user.js +27 -5
- package/handlers/routers/websocket.js +5 -3
- package/handlers/use-cases/check-external-apis-health-use-case.js +81 -0
- package/handlers/use-cases/check-integrations-health-use-case.js +32 -0
- package/handlers/workers/integration-defined-workers.js +6 -3
- package/index.js +45 -22
- package/integrations/index.js +12 -10
- package/integrations/integration-base.js +224 -53
- package/integrations/integration-router.js +386 -178
- package/integrations/options.js +1 -1
- package/integrations/repositories/integration-mapping-repository-factory.js +50 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +106 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +161 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +227 -0
- package/integrations/repositories/integration-mapping-repository.js +156 -0
- package/integrations/repositories/integration-repository-factory.js +44 -0
- package/integrations/repositories/integration-repository-interface.js +115 -0
- package/integrations/repositories/integration-repository-mongo.js +271 -0
- package/integrations/repositories/integration-repository-postgres.js +319 -0
- package/integrations/tests/doubles/dummy-integration-class.js +90 -0
- package/integrations/tests/doubles/test-integration-repository.js +99 -0
- package/integrations/tests/use-cases/create-integration.test.js +131 -0
- package/integrations/tests/use-cases/delete-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js +92 -0
- package/integrations/tests/use-cases/get-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/get-integration-instance.test.js +176 -0
- package/integrations/tests/use-cases/get-integrations-for-user.test.js +176 -0
- package/integrations/tests/use-cases/get-possible-integrations.test.js +188 -0
- package/integrations/tests/use-cases/update-integration-messages.test.js +142 -0
- package/integrations/tests/use-cases/update-integration-status.test.js +103 -0
- package/integrations/tests/use-cases/update-integration.test.js +141 -0
- package/integrations/use-cases/create-integration.js +83 -0
- package/integrations/use-cases/delete-integration-for-user.js +73 -0
- package/integrations/use-cases/find-integration-context-by-external-entity-id.js +72 -0
- package/integrations/use-cases/get-integration-for-user.js +78 -0
- package/integrations/use-cases/get-integration-instance-by-definition.js +67 -0
- package/integrations/use-cases/get-integration-instance.js +83 -0
- package/integrations/use-cases/get-integrations-for-user.js +87 -0
- package/integrations/use-cases/get-possible-integrations.js +27 -0
- package/integrations/use-cases/index.js +11 -0
- package/integrations/use-cases/load-integration-context-full.test.js +329 -0
- package/integrations/use-cases/load-integration-context.js +71 -0
- package/integrations/use-cases/load-integration-context.test.js +114 -0
- package/integrations/use-cases/update-integration-messages.js +44 -0
- package/integrations/use-cases/update-integration-status.js +32 -0
- package/integrations/use-cases/update-integration.js +93 -0
- package/integrations/utils/map-integration-dto.js +36 -0
- package/jest-global-setup-noop.js +3 -0
- package/jest-global-teardown-noop.js +3 -0
- package/{module-plugin → modules}/entity.js +1 -0
- package/{module-plugin → modules}/index.js +0 -8
- package/modules/module-factory.js +56 -0
- package/modules/module-hydration.test.js +205 -0
- package/modules/module.js +221 -0
- package/modules/repositories/module-repository-factory.js +33 -0
- package/modules/repositories/module-repository-interface.js +129 -0
- package/modules/repositories/module-repository-mongo.js +386 -0
- package/modules/repositories/module-repository-postgres.js +437 -0
- package/modules/repositories/module-repository.js +327 -0
- package/{module-plugin → modules}/test/mock-api/api.js +8 -3
- package/{module-plugin → modules}/test/mock-api/definition.js +12 -8
- package/modules/tests/doubles/test-module-factory.js +16 -0
- package/modules/tests/doubles/test-module-repository.js +39 -0
- package/modules/use-cases/get-entities-for-user.js +32 -0
- package/modules/use-cases/get-entity-options-by-id.js +59 -0
- package/modules/use-cases/get-entity-options-by-type.js +34 -0
- package/modules/use-cases/get-module-instance-from-type.js +31 -0
- package/modules/use-cases/get-module.js +56 -0
- package/modules/use-cases/process-authorization-callback.js +122 -0
- package/modules/use-cases/refresh-entity-options.js +59 -0
- package/modules/use-cases/test-module-auth.js +55 -0
- package/modules/utils/map-module-dto.js +18 -0
- package/package.json +14 -6
- package/prisma-mongodb/schema.prisma +318 -0
- package/prisma-postgresql/migrations/20250930193005_init/migration.sql +315 -0
- package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
- package/prisma-postgresql/migrations/20251010000000_remove_unused_entity_reference_map/migration.sql +3 -0
- package/prisma-postgresql/migrations/migration_lock.toml +3 -0
- package/prisma-postgresql/schema.prisma +300 -0
- package/syncs/manager.js +468 -443
- package/syncs/repositories/sync-repository-factory.js +38 -0
- package/syncs/repositories/sync-repository-interface.js +109 -0
- package/syncs/repositories/sync-repository-mongo.js +239 -0
- package/syncs/repositories/sync-repository-postgres.js +319 -0
- package/syncs/sync.js +0 -1
- package/token/repositories/token-repository-factory.js +33 -0
- package/token/repositories/token-repository-interface.js +131 -0
- package/token/repositories/token-repository-mongo.js +212 -0
- package/token/repositories/token-repository-postgres.js +257 -0
- package/token/repositories/token-repository.js +219 -0
- package/types/integrations/index.d.ts +2 -6
- package/types/module-plugin/index.d.ts +5 -57
- package/types/syncs/index.d.ts +0 -2
- package/user/repositories/user-repository-factory.js +46 -0
- package/user/repositories/user-repository-interface.js +198 -0
- package/user/repositories/user-repository-mongo.js +250 -0
- package/user/repositories/user-repository-postgres.js +311 -0
- package/user/tests/doubles/test-user-repository.js +72 -0
- package/user/tests/use-cases/create-individual-user.test.js +24 -0
- package/user/tests/use-cases/create-organization-user.test.js +28 -0
- package/user/tests/use-cases/create-token-for-user-id.test.js +19 -0
- package/user/tests/use-cases/get-user-from-bearer-token.test.js +64 -0
- package/user/tests/use-cases/login-user.test.js +140 -0
- package/user/use-cases/create-individual-user.js +61 -0
- package/user/use-cases/create-organization-user.js +47 -0
- package/user/use-cases/create-token-for-user-id.js +30 -0
- package/user/use-cases/get-user-from-bearer-token.js +77 -0
- package/user/use-cases/login-user.js +122 -0
- package/user/user.js +77 -0
- package/websocket/repositories/websocket-connection-repository-factory.js +37 -0
- package/websocket/repositories/websocket-connection-repository-interface.js +106 -0
- package/websocket/repositories/websocket-connection-repository-mongo.js +155 -0
- package/websocket/repositories/websocket-connection-repository-postgres.js +195 -0
- package/websocket/repositories/websocket-connection-repository.js +160 -0
- package/database/models/State.js +0 -9
- package/database/models/Token.js +0 -70
- package/database/mongo.js +0 -171
- package/encrypt/Cryptor.test.js +0 -32
- package/encrypt/encrypt.js +0 -104
- package/encrypt/encrypt.test.js +0 -1069
- package/handlers/routers/middleware/loadUser.js +0 -15
- package/handlers/routers/middleware/requireLoggedInUser.js +0 -12
- package/integrations/create-frigg-backend.js +0 -31
- package/integrations/integration-factory.js +0 -251
- package/integrations/integration-mapping.js +0 -43
- package/integrations/integration-model.js +0 -46
- package/integrations/integration-user.js +0 -144
- package/integrations/test/integration-base.test.js +0 -144
- package/module-plugin/auther.js +0 -393
- package/module-plugin/credential.js +0 -22
- package/module-plugin/entity-manager.js +0 -70
- package/module-plugin/manager.js +0 -169
- package/module-plugin/module-factory.js +0 -61
- package/module-plugin/test/auther.test.js +0 -97
- /package/{module-plugin → modules}/ModuleConstants.js +0 -0
- /package/{module-plugin → modules}/requester/api-key.js +0 -0
- /package/{module-plugin → modules}/requester/basic.js +0 -0
- /package/{module-plugin → modules}/requester/oauth-2.js +0 -0
- /package/{module-plugin → modules}/requester/requester.js +0 -0
- /package/{module-plugin → modules}/requester/requester.test.js +0 -0
- /package/{module-plugin → modules}/test/mock-api/mocks/hubspot.js +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use Case for testing encryption functionality.
|
|
3
|
+
* Contains business logic for verifying that encryption and decryption work correctly.
|
|
4
|
+
*
|
|
5
|
+
* Follows DDD/Hexagonal Architecture:
|
|
6
|
+
* - Application Layer (this use case)
|
|
7
|
+
* - Depends on Infrastructure Layer (HealthCheckRepository)
|
|
8
|
+
*/
|
|
9
|
+
class TestEncryptionUseCase {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} params
|
|
12
|
+
* @param {import('../health-check-repository-interface').HealthCheckRepositoryInterface} params.healthCheckRepository
|
|
13
|
+
*/
|
|
14
|
+
constructor({ healthCheckRepository }) {
|
|
15
|
+
this.repository = healthCheckRepository;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Execute encryption test
|
|
20
|
+
* Orchestrates the full encryption test workflow using Prisma
|
|
21
|
+
* @returns {Promise<Object>} Test results with status and details
|
|
22
|
+
*/
|
|
23
|
+
async execute() {
|
|
24
|
+
const testData = {
|
|
25
|
+
testSecret: 'This is a secret value that should be encrypted',
|
|
26
|
+
normalField: 'This is a normal field that should not be encrypted',
|
|
27
|
+
nestedSecret: {
|
|
28
|
+
value: 'This is a nested secret that should be encrypted',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const credentialData = this._mapTestDataToCredential(testData);
|
|
33
|
+
|
|
34
|
+
const credential = await this._withTimeout(
|
|
35
|
+
this.repository.createCredential(credentialData),
|
|
36
|
+
5000,
|
|
37
|
+
'Save operation timed out'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const retrievedCredential = await this._withTimeout(
|
|
42
|
+
this.repository.findCredentialById(credential.id),
|
|
43
|
+
5000,
|
|
44
|
+
'Find operation timed out'
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const retrievedTestData =
|
|
48
|
+
this._mapCredentialToTestData(retrievedCredential);
|
|
49
|
+
const decryptionWorks = this._verifyDecryption(
|
|
50
|
+
retrievedTestData,
|
|
51
|
+
testData
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const rawCredential = await this._withTimeout(
|
|
55
|
+
this.repository.getRawCredentialById(credential.id),
|
|
56
|
+
5000,
|
|
57
|
+
'Database verification timed out'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const rawTestData = this._mapRawCredentialToTestData(rawCredential);
|
|
61
|
+
const encryptionResults = this._verifyEncryptionInDatabase(
|
|
62
|
+
rawTestData,
|
|
63
|
+
testData
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return this._evaluateEncryptionResults(
|
|
67
|
+
decryptionWorks,
|
|
68
|
+
encryptionResults
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await this._withTimeout(
|
|
72
|
+
this.repository.deleteCredential(credential.id),
|
|
73
|
+
5000,
|
|
74
|
+
'Delete operation timed out'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Map test data format to Credential model format
|
|
81
|
+
* @param {Object} testData - Test data with testSecret, normalField, nestedSecret
|
|
82
|
+
* @returns {Object} Credential data structure
|
|
83
|
+
* @private
|
|
84
|
+
*/
|
|
85
|
+
_mapTestDataToCredential(testData) {
|
|
86
|
+
return {
|
|
87
|
+
user_id: 'test-encryption-user',
|
|
88
|
+
entity_id: 'test-encryption-entity',
|
|
89
|
+
data: {
|
|
90
|
+
access_token: testData.testSecret,
|
|
91
|
+
refresh_token: testData.nestedSecret?.value,
|
|
92
|
+
domain: testData.normalField,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Map Credential model format to test data format
|
|
99
|
+
* @param {Object} credential - Credential from database
|
|
100
|
+
* @returns {Object} Test data format
|
|
101
|
+
* @private
|
|
102
|
+
*/
|
|
103
|
+
_mapCredentialToTestData(credential) {
|
|
104
|
+
if (!credential) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
id: credential.id,
|
|
110
|
+
testSecret: credential.data.access_token,
|
|
111
|
+
normalField: credential.data.domain,
|
|
112
|
+
nestedSecret: {
|
|
113
|
+
value: credential.data.refresh_token,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Map raw Credential data to test data format
|
|
120
|
+
* @param {Object} rawCredential - Raw credential from database
|
|
121
|
+
* @returns {Object} Test data format with raw encrypted values
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
_mapRawCredentialToTestData(rawCredential) {
|
|
125
|
+
if (!rawCredential) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
testSecret: rawCredential.data?.access_token,
|
|
131
|
+
normalField: rawCredential.data?.domain,
|
|
132
|
+
nestedSecret: {
|
|
133
|
+
value: rawCredential.data?.refresh_token,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Verify that a document was decrypted correctly
|
|
140
|
+
* @param {Object} retrievedDoc - Document retrieved from database
|
|
141
|
+
* @param {Object} originalData - Original unencrypted data
|
|
142
|
+
* @returns {boolean} True if decryption worked correctly
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_verifyDecryption(retrievedDoc, originalData) {
|
|
146
|
+
return (
|
|
147
|
+
retrievedDoc &&
|
|
148
|
+
retrievedDoc.testSecret === originalData.testSecret &&
|
|
149
|
+
retrievedDoc.normalField === originalData.normalField &&
|
|
150
|
+
retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Verify that data was encrypted in the database
|
|
156
|
+
* Business rule: Encrypted fields should contain ':' and differ from original
|
|
157
|
+
* @param {Object} rawDoc - Raw document from database
|
|
158
|
+
* @param {Object} originalData - Original unencrypted data
|
|
159
|
+
* @returns {Object} Encryption verification results
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_verifyEncryptionInDatabase(rawDoc, originalData) {
|
|
163
|
+
const secretIsEncrypted =
|
|
164
|
+
rawDoc &&
|
|
165
|
+
typeof rawDoc.testSecret === 'string' &&
|
|
166
|
+
rawDoc.testSecret.includes(':') &&
|
|
167
|
+
rawDoc.testSecret !== originalData.testSecret;
|
|
168
|
+
|
|
169
|
+
const nestedIsEncrypted =
|
|
170
|
+
rawDoc?.nestedSecret?.value &&
|
|
171
|
+
typeof rawDoc.nestedSecret.value === 'string' &&
|
|
172
|
+
rawDoc.nestedSecret.value.includes(':') &&
|
|
173
|
+
rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
|
|
174
|
+
|
|
175
|
+
const normalNotEncrypted =
|
|
176
|
+
rawDoc && rawDoc.normalField === originalData.normalField;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
secretIsEncrypted,
|
|
180
|
+
nestedIsEncrypted,
|
|
181
|
+
normalNotEncrypted,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Evaluate encryption test results
|
|
187
|
+
* Business logic for determining if encryption is healthy
|
|
188
|
+
* @param {boolean} decryptionWorks - Whether decryption succeeded
|
|
189
|
+
* @param {Object} encryptionResults - Encryption verification results
|
|
190
|
+
* @returns {Object} Test status and result message
|
|
191
|
+
* @private
|
|
192
|
+
*/
|
|
193
|
+
_evaluateEncryptionResults(decryptionWorks, encryptionResults) {
|
|
194
|
+
const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } =
|
|
195
|
+
encryptionResults;
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
decryptionWorks &&
|
|
199
|
+
secretIsEncrypted &&
|
|
200
|
+
nestedIsEncrypted &&
|
|
201
|
+
normalNotEncrypted
|
|
202
|
+
) {
|
|
203
|
+
return {
|
|
204
|
+
status: 'enabled',
|
|
205
|
+
testResult:
|
|
206
|
+
'Encryption and decryption verified successfully',
|
|
207
|
+
encryptionWorks: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
|
|
212
|
+
return {
|
|
213
|
+
status: 'unhealthy',
|
|
214
|
+
testResult: 'Fields are not being encrypted in database',
|
|
215
|
+
encryptionWorks: false,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (decryptionWorks && !normalNotEncrypted) {
|
|
220
|
+
return {
|
|
221
|
+
status: 'unhealthy',
|
|
222
|
+
testResult: 'Normal fields are being incorrectly encrypted',
|
|
223
|
+
encryptionWorks: false,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
status: 'unhealthy',
|
|
229
|
+
testResult: 'Decryption failed or data mismatch',
|
|
230
|
+
encryptionWorks: false,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Execute promise with timeout
|
|
236
|
+
* @param {Promise} promise - Promise to execute
|
|
237
|
+
* @param {number} ms - Timeout in milliseconds
|
|
238
|
+
* @param {string} errorMessage - Error message for timeout
|
|
239
|
+
* @returns {Promise} Promise that rejects on timeout
|
|
240
|
+
* @private
|
|
241
|
+
*/
|
|
242
|
+
_withTimeout(promise, ms, errorMessage) {
|
|
243
|
+
return Promise.race([
|
|
244
|
+
promise,
|
|
245
|
+
new Promise((_, reject) =>
|
|
246
|
+
setTimeout(() => reject(new Error(errorMessage)), ms)
|
|
247
|
+
),
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { TestEncryptionUseCase };
|
package/encrypt/Cryptor.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptor - Encryption Service Adapter
|
|
3
|
+
*
|
|
4
|
+
* Infrastructure Layer adapter for AWS KMS and local AES encryption.
|
|
5
|
+
* Provides envelope encryption pattern for field-level encryption.
|
|
6
|
+
*
|
|
7
|
+
* Envelope Encryption Pattern:
|
|
8
|
+
* 1. Generate Data Encryption Key (DEK) via KMS or locally
|
|
9
|
+
* 2. Encrypt field value with DEK using AES-256-CTR
|
|
10
|
+
* 3. Encrypt DEK with Master Key (KMS CMK or AES_KEY)
|
|
11
|
+
* 4. Return format: "keyId:encryptedText:encryptedKey"
|
|
12
|
+
*
|
|
13
|
+
* Benefits:
|
|
14
|
+
* - Reduces KMS API calls (unique DEK per operation)
|
|
15
|
+
* - Master key never leaves KMS
|
|
16
|
+
* - Enables key rotation without re-encrypting data
|
|
17
|
+
*/
|
|
18
|
+
|
|
1
19
|
const crypto = require('crypto');
|
|
2
20
|
const AWS = require('aws-sdk');
|
|
3
|
-
const { get, set } = require('lodash');
|
|
4
21
|
const aes = require('./aes');
|
|
5
22
|
|
|
6
|
-
const hasValue = (a) => a !== undefined && a !== null && a !== '';
|
|
7
|
-
|
|
8
23
|
class Cryptor {
|
|
9
|
-
constructor({
|
|
24
|
+
constructor({ shouldUseAws }) {
|
|
10
25
|
this.shouldUseAws = shouldUseAws;
|
|
11
|
-
this.fields = fields;
|
|
12
|
-
|
|
13
|
-
this.permutationsByField = {};
|
|
14
|
-
|
|
15
|
-
for (const field of fields) {
|
|
16
|
-
this.permutationsByField[field] = this.calculatePermutations(
|
|
17
|
-
field.split('.')
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
async generateDataKey() {
|
|
@@ -56,7 +62,7 @@ class Cryptor {
|
|
|
56
62
|
const key = availableKeys[keyId];
|
|
57
63
|
|
|
58
64
|
if (!key) {
|
|
59
|
-
throw new Error(
|
|
65
|
+
throw new Error('Encryption key not found');
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
return key;
|
|
@@ -79,146 +85,9 @@ class Cryptor {
|
|
|
79
85
|
return aes.decrypt(encryptedKey, key);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
// If the field has a value in the document, apply async function f to that field.
|
|
83
|
-
async setInDocument(doc, f) {
|
|
84
|
-
// Use the Mongoose document get/set when available (not for insertMany)
|
|
85
|
-
if (doc.get) {
|
|
86
|
-
for (const field of this.fields) {
|
|
87
|
-
const value = doc.get(field);
|
|
88
|
-
if (hasValue(value)) {
|
|
89
|
-
doc.set(field, await f(value));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Otherwise use permutations.
|
|
96
|
-
for (const field of this.fields) {
|
|
97
|
-
const updatedDoc = await this.applyAll(doc, field, f);
|
|
98
|
-
Object.assign(doc, updatedDoc);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Calculate all possible permutations for a nested field. For example a
|
|
103
|
-
// field "deeply.nested.field" might be referred to in a Mongo query as
|
|
104
|
-
// { deeply: { 'nested.field': {} } } or { 'deeply.nested.field': {} }
|
|
105
|
-
// etc. For a given path, this gives all path parts to check in a format
|
|
106
|
-
// that lodash understands when using get and set with an array of path
|
|
107
|
-
// parts e.g. get(o, ['deeply', 'nested.parts'])
|
|
108
|
-
calculatePermutations = (parts) => {
|
|
109
|
-
if (!parts.length) return [];
|
|
110
|
-
if (parts.length === 1) return [parts];
|
|
111
|
-
|
|
112
|
-
const combos = [];
|
|
113
|
-
|
|
114
|
-
for (let i = 0; i < parts.length; i += 1) {
|
|
115
|
-
const frontPath = parts.slice(0, i + 1).join('.');
|
|
116
|
-
const rest = parts.slice(i + 1);
|
|
117
|
-
|
|
118
|
-
if (rest.length) {
|
|
119
|
-
combos.push(
|
|
120
|
-
...this.calculatePermutations(rest).map((child) => [
|
|
121
|
-
frontPath,
|
|
122
|
-
...child,
|
|
123
|
-
])
|
|
124
|
-
);
|
|
125
|
-
} else {
|
|
126
|
-
combos.push([frontPath]);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return combos;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// Encrypt all possible permutations of a field (possibly nested), if there
|
|
134
|
-
// is a value at that path permutation.
|
|
135
|
-
async applyAll(o, field, f) {
|
|
136
|
-
const clone = { ...o };
|
|
137
|
-
const permutations = this.permutationsByField[field];
|
|
138
|
-
|
|
139
|
-
for (const path of permutations) {
|
|
140
|
-
const value = get(o, path);
|
|
141
|
-
if (hasValue(value)) {
|
|
142
|
-
set(clone, path, await f(value));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return clone;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async processFieldsInDocuments(docs, f) {
|
|
150
|
-
const promises = docs
|
|
151
|
-
.filter(Boolean)
|
|
152
|
-
.flatMap((doc) => this.setInDocument(doc, f));
|
|
153
|
-
|
|
154
|
-
return Promise.all(promises);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async encryptFieldsInDocuments(docs) {
|
|
158
|
-
await this.processFieldsInDocuments(docs, this.encrypt.bind(this));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async decryptFieldsInDocuments(docs) {
|
|
162
|
-
await this.processFieldsInDocuments(docs, this.decrypt.bind(this));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async encryptFieldsInQuery(query) {
|
|
166
|
-
for (const field of this.fields) {
|
|
167
|
-
const originalUpdate = query.getUpdate();
|
|
168
|
-
const updatedUpdate = await this.applyAll(
|
|
169
|
-
originalUpdate,
|
|
170
|
-
field,
|
|
171
|
-
this.encrypt.bind(this)
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
if (originalUpdate.$set) {
|
|
175
|
-
const updatedSetUpdate = await this.applyAll(
|
|
176
|
-
originalUpdate.$set,
|
|
177
|
-
field,
|
|
178
|
-
this.encrypt.bind(this)
|
|
179
|
-
);
|
|
180
|
-
updatedUpdate.$set = { ...updatedSetUpdate };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (originalUpdate.$setOnInsert) {
|
|
184
|
-
const updatedSetOnInsertUpdate = await this.applyAll(
|
|
185
|
-
originalUpdate.$setOnInsert,
|
|
186
|
-
field,
|
|
187
|
-
this.encrypt.bind(this)
|
|
188
|
-
);
|
|
189
|
-
updatedUpdate.$setOnInsert = { ...updatedSetOnInsertUpdate };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
query.setUpdate(updatedUpdate);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
expectNotToUpdateManyEncrypted(update) {
|
|
197
|
-
for (const field of this.fields) {
|
|
198
|
-
if (update.$set && hasValue(update.$set[field])) {
|
|
199
|
-
throw new Error(
|
|
200
|
-
'Attempted to update encrypted field of multiple documents'
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (update.$setOnInsert && hasValue(update.$setOnInsert[field])) {
|
|
205
|
-
throw new Error(
|
|
206
|
-
'Attempted to update encrypted field of multiple documents'
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (hasValue(update[field])) {
|
|
211
|
-
throw new Error(
|
|
212
|
-
'Attempted to update encrypted field of multiple documents'
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
88
|
async encrypt(text) {
|
|
219
89
|
const { keyId, encryptedKey, plaintext } = await this.generateDataKey();
|
|
220
90
|
const encryptedText = aes.encrypt(text, plaintext);
|
|
221
|
-
|
|
222
91
|
return `${keyId}:${encryptedText}:${encryptedKey}`;
|
|
223
92
|
}
|
|
224
93
|
|
|
@@ -228,7 +97,6 @@ class Cryptor {
|
|
|
228
97
|
const encryptedText = `${split[1]}:${split[2]}`;
|
|
229
98
|
const encryptedKey = Buffer.from(split[3], 'base64');
|
|
230
99
|
const plaintext = await this.decryptDataKey(keyId, encryptedKey);
|
|
231
|
-
|
|
232
100
|
return aes.decrypt(encryptedText, plaintext);
|
|
233
101
|
}
|
|
234
102
|
}
|
package/encrypt/index.js
CHANGED
package/encrypt/test-encrypt.js
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const { findNearestBackendPackageJson } = require('@friggframework/core/utils');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Loads the App definition from the nearest backend package
|
|
7
|
+
* @function loadAppDefinition
|
|
8
|
+
* @description Searches for the nearest backend package.json, loads the corresponding index.js file,
|
|
9
|
+
* and extracts the application definition containing integrations and user configuration.
|
|
10
|
+
* @returns {{integrations: Array<object>, userConfig: object | null}} An object containing the application definition.
|
|
11
|
+
* @throws {Error} Throws error if backend package.json cannot be found.
|
|
12
|
+
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
13
|
+
* @example
|
|
14
|
+
* const { integrations, userConfig } = loadAppDefinition();
|
|
15
|
+
* console.log(`Found ${integrations.length} integrations`);
|
|
16
|
+
*/
|
|
17
|
+
function loadAppDefinition() {
|
|
18
|
+
const backendPath = findNearestBackendPackageJson();
|
|
19
|
+
if (!backendPath) {
|
|
20
|
+
throw new Error('Could not find backend package.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const backendDir = path.dirname(backendPath);
|
|
24
|
+
const backendFilePath = path.join(backendDir, 'index.js');
|
|
25
|
+
if (!fs.existsSync(backendFilePath)) {
|
|
26
|
+
throw new Error('Could not find index.js');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const backendJsFile = require(backendFilePath);
|
|
30
|
+
const appDefinition = backendJsFile.Definition;
|
|
31
|
+
|
|
32
|
+
const { integrations = [], user: userConfig = null } = appDefinition;
|
|
33
|
+
return { integrations, userConfig };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
loadAppDefinition,
|
|
38
|
+
};
|
|
@@ -3,7 +3,6 @@ const express = require('express');
|
|
|
3
3
|
const bodyParser = require('body-parser');
|
|
4
4
|
const cors = require('cors');
|
|
5
5
|
const Boom = require('@hapi/boom');
|
|
6
|
-
const loadUserManager = require('./routers/middleware/loadUser');
|
|
7
6
|
const serverlessHttp = require('serverless-http');
|
|
8
7
|
|
|
9
8
|
const createApp = (applyMiddleware) => {
|
|
@@ -20,8 +19,6 @@ const createApp = (applyMiddleware) => {
|
|
|
20
19
|
})
|
|
21
20
|
);
|
|
22
21
|
|
|
23
|
-
app.use(loadUserManager);
|
|
24
|
-
|
|
25
22
|
if (applyMiddleware) applyMiddleware(app);
|
|
26
23
|
|
|
27
24
|
// Handle sending error response and logging server errors to console
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
jest.mock('../database/config', () => ({
|
|
2
|
+
DB_TYPE: 'mongodb',
|
|
3
|
+
getDatabaseType: jest.fn(() => 'mongodb'),
|
|
4
|
+
PRISMA_LOG_LEVEL: 'error,warn',
|
|
5
|
+
PRISMA_QUERY_LOGGING: false,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const { IntegrationEventDispatcher } = require('./integration-event-dispatcher');
|
|
9
|
+
const { IntegrationBase } = require('../integrations/integration-base');
|
|
10
|
+
|
|
11
|
+
class SimulatedAsanaIntegration extends IntegrationBase {
|
|
12
|
+
static Definition = {
|
|
13
|
+
name: 'asana',
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
modules: {},
|
|
16
|
+
routes: [
|
|
17
|
+
{ path: '/auth', method: 'GET', event: 'AUTH_REQUEST' },
|
|
18
|
+
{ path: '/auth/redirect/:provider', method: 'GET', event: 'AUTH_REDIRECT' },
|
|
19
|
+
{ path: '/form', method: 'GET', event: 'LOAD_FORM' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
constructor(params = {}) {
|
|
24
|
+
super(params);
|
|
25
|
+
this.events = {
|
|
26
|
+
AUTH_REQUEST: { handler: this.authRequest.bind(this) },
|
|
27
|
+
AUTH_REDIRECT: { handler: this.authRedirect.bind(this) },
|
|
28
|
+
LOAD_FORM: { handler: this.loadForm.bind(this) },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async authRequest() {
|
|
33
|
+
return {
|
|
34
|
+
success: true,
|
|
35
|
+
action: 'redirect',
|
|
36
|
+
hydrated: this.isHydrated,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async authRedirect({ req }) {
|
|
41
|
+
const { code } = req.query || {};
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
action: 'tokens_received',
|
|
45
|
+
receivedCode: code,
|
|
46
|
+
hydrated: this.isHydrated,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async loadForm() {
|
|
51
|
+
if (!this.isHydrated && SimulatedAsanaIntegration.testRecord) {
|
|
52
|
+
this.setIntegrationRecord({
|
|
53
|
+
record: SimulatedAsanaIntegration.testRecord.record,
|
|
54
|
+
modules: SimulatedAsanaIntegration.testRecord.modules,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.assertHydrated('Integration not found - must authenticate first');
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
success: true,
|
|
62
|
+
form: {
|
|
63
|
+
fields: ['field1', 'field2'],
|
|
64
|
+
},
|
|
65
|
+
integrationId: this.id,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('IntegrationEventDispatcher auth flow', () => {
|
|
71
|
+
const createDispatcher = () =>
|
|
72
|
+
new IntegrationEventDispatcher(new SimulatedAsanaIntegration());
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
SimulatedAsanaIntegration.testRecord = null;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles auth request without hydration', async () => {
|
|
79
|
+
const dispatcher = createDispatcher();
|
|
80
|
+
const result = await dispatcher.dispatchHttp({
|
|
81
|
+
event: 'AUTH_REQUEST',
|
|
82
|
+
req: { params: { provider: 'asana' }, query: {} },
|
|
83
|
+
res: {},
|
|
84
|
+
next: jest.fn(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({ success: true, action: 'redirect', hydrated: false });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles auth redirect without hydration', async () => {
|
|
91
|
+
const dispatcher = createDispatcher();
|
|
92
|
+
const result = await dispatcher.dispatchHttp({
|
|
93
|
+
event: 'AUTH_REDIRECT',
|
|
94
|
+
req: { params: { provider: 'asana' }, query: { code: 'abc123' } },
|
|
95
|
+
res: {},
|
|
96
|
+
next: jest.fn(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual({
|
|
100
|
+
success: true,
|
|
101
|
+
action: 'tokens_received',
|
|
102
|
+
receivedCode: 'abc123',
|
|
103
|
+
hydrated: false,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws for protected routes when no record is loaded', async () => {
|
|
108
|
+
const dispatcher = createDispatcher();
|
|
109
|
+
await expect(
|
|
110
|
+
dispatcher.dispatchHttp({
|
|
111
|
+
event: 'LOAD_FORM',
|
|
112
|
+
req: { query: {} },
|
|
113
|
+
res: {},
|
|
114
|
+
next: jest.fn(),
|
|
115
|
+
})
|
|
116
|
+
).rejects.toThrow('Integration not found - must authenticate first');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('allows handlers to hydrate explicitly before continuing', async () => {
|
|
120
|
+
SimulatedAsanaIntegration.testRecord = {
|
|
121
|
+
record: {
|
|
122
|
+
id: 'integration-123',
|
|
123
|
+
userId: 'user-456',
|
|
124
|
+
config: { type: 'asana' },
|
|
125
|
+
status: 'ENABLED',
|
|
126
|
+
version: '1.0.0',
|
|
127
|
+
messages: { errors: [], warnings: [] },
|
|
128
|
+
entities: [],
|
|
129
|
+
},
|
|
130
|
+
modules: [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const dispatcher = createDispatcher();
|
|
134
|
+
const result = await dispatcher.dispatchHttp({
|
|
135
|
+
event: 'LOAD_FORM',
|
|
136
|
+
req: { query: {} },
|
|
137
|
+
res: {},
|
|
138
|
+
next: jest.fn(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
success: true,
|
|
143
|
+
form: { fields: ['field1', 'field2'] },
|
|
144
|
+
integrationId: 'integration-123',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|