@friggframework/core 2.0.0-next.26 → 2.0.0-next.28

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.
@@ -16,28 +16,18 @@ const findOneEvents = [
16
16
 
17
17
  const shouldBypassEncryption = (STAGE) => {
18
18
  const defaultBypassStages = ['dev', 'test', 'local'];
19
- const bypassStageEnv = process.env.BYPASS_ENCRYPTION_STAGE;
20
- // If the env is set to anything or an empty string, use the env. Otherwise, use the default array
21
- const useEnv = !String(bypassStageEnv) || !!bypassStageEnv;
22
- const bypassStages = useEnv
23
- ? bypassStageEnv.split(',').map((stage) => stage.trim())
24
- : defaultBypassStages;
25
- return bypassStages.includes(STAGE);
19
+ return defaultBypassStages.includes(STAGE);
26
20
  };
27
21
 
28
- // The Mongoose plug-in function
29
- function Encrypt(schema, options) {
22
+ function Encrypt(schema) {
30
23
  const { STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
31
24
 
32
25
  if (shouldBypassEncryption(STAGE)) {
33
26
  return;
34
27
  }
35
28
 
36
- if (KMS_KEY_ARN && AES_KEY_ID) {
37
- throw new Error(
38
- 'Local and AWS encryption keys are both set in the environment.'
39
- );
40
- }
29
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
30
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
41
31
 
42
32
  const fields = Object.values(schema.paths)
43
33
  .map(({ path, options }) => (options.lhEncrypt === true ? path : ''))
@@ -48,25 +38,17 @@ function Encrypt(schema, options) {
48
38
  }
49
39
 
50
40
  const cryptor = new Cryptor({
51
- // Use AWS if the CMK is present
52
- shouldUseAws: !!KMS_KEY_ARN,
53
- // Find all the fields in the schema with lhEncrypt === true
41
+ shouldUseAws: hasKMS,
54
42
  fields: fields,
55
43
  });
56
44
 
57
- // ---------------------------------------------
58
- // ### Encrypt fields before save/update/insert.
59
- // ---------------------------------------------
60
-
61
45
  schema.pre('save', async function encryptionPreSave() {
62
- // `this` will be a doc
63
46
  await cryptor.encryptFieldsInDocuments([this]);
64
47
  });
65
48
 
66
49
  schema.pre(
67
50
  'insertMany',
68
51
  async function encryptionPreInsertMany(_, docs, options) {
69
- // `this` will be the model
70
52
  if (options?.rawResult) {
71
53
  throw new Error(
72
54
  'Raw result not supported for insertMany with Encrypt plugin'
@@ -78,17 +60,14 @@ function Encrypt(schema, options) {
78
60
  );
79
61
 
80
62
  schema.pre(updateOneEvents, async function encryptionPreUpdateOne() {
81
- // `this` will be a query
82
63
  await cryptor.encryptFieldsInQuery(this);
83
64
  });
84
65
 
85
66
  schema.pre('updateMany', async function encryptionPreUpdateMany() {
86
- // `this` will be a query
87
67
  cryptor.expectNotToUpdateManyEncrypted(this.getUpdate());
88
68
  });
89
69
 
90
70
  schema.pre('update', async function encryptionPreUpdate() {
91
- // `this` will be a query
92
71
  const { multiple } = this.getOptions();
93
72
 
94
73
  if (multiple) {
@@ -99,16 +78,11 @@ function Encrypt(schema, options) {
99
78
  await cryptor.encryptFieldsInQuery(this);
100
79
  });
101
80
 
102
- // --------------------------------------------
103
- // ### Decrypt documents after they are loaded.
104
- // --------------------------------------------
105
81
  schema.post('save', async function encryptionPreSave() {
106
- // `this` will be a doc
107
82
  await cryptor.decryptFieldsInDocuments([this]);
108
83
  });
109
84
 
110
85
  schema.post(findOneEvents, async function encryptionPostFindOne(doc) {
111
- // `this` will be a query
112
86
  const { rawResult } = this.getOptions();
113
87
 
114
88
  if (rawResult) {
@@ -119,12 +93,10 @@ function Encrypt(schema, options) {
119
93
  });
120
94
 
121
95
  schema.post('find', async function encryptionPostFind(docs) {
122
- // `this` will be a query
123
96
  await cryptor.decryptFieldsInDocuments(docs);
124
97
  });
125
98
 
126
99
  schema.post('insertMany', async function encryptionPostInsertMany(docs) {
127
- // `this` will be the model
128
100
  await cryptor.decryptFieldsInDocuments(docs);
129
101
  });
130
102
  }
@@ -71,103 +71,346 @@ const checkExternalAPI = (url, timeout = 5000) => {
71
71
  });
72
72
  };
73
73
 
74
- router.get('/health', async (_req, res) => {
75
- const status = {
76
- status: 'ok',
77
- timestamp: new Date().toISOString(),
78
- service: 'frigg-core-api'
74
+ const getDatabaseState = () => {
75
+ const stateMap = {
76
+ 0: 'disconnected',
77
+ 1: 'connected',
78
+ 2: 'connecting',
79
+ 3: 'disconnecting'
79
80
  };
81
+ const readyState = mongoose.connection.readyState;
82
+
83
+ return {
84
+ readyState,
85
+ stateName: stateMap[readyState],
86
+ isConnected: readyState === 1
87
+ };
88
+ };
80
89
 
81
- res.status(200).json(status);
82
- });
90
+ const checkDatabaseHealth = async () => {
91
+ const { stateName, isConnected } = getDatabaseState();
92
+ const result = {
93
+ status: isConnected ? 'healthy' : 'unhealthy',
94
+ state: stateName
95
+ };
83
96
 
84
- router.get('/health/detailed', async (_req, res) => {
85
- const startTime = Date.now();
86
- const checks = {
87
- service: 'frigg-core-api',
88
- status: 'healthy',
89
- timestamp: new Date().toISOString(),
90
- checks: {}
97
+ if (isConnected) {
98
+ const pingStart = Date.now();
99
+ await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
100
+ result.responseTime = Date.now() - pingStart;
101
+ }
102
+
103
+ return result;
104
+ };
105
+
106
+ const getEncryptionConfiguration = () => {
107
+ const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
108
+
109
+ const defaultBypassStages = ['dev', 'test', 'local'];
110
+ const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined;
111
+ const bypassStages = useEnv
112
+ ? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim())
113
+ : defaultBypassStages;
114
+
115
+ const isBypassed = bypassStages.includes(STAGE);
116
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
117
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
118
+ const mode = hasAES ? 'aes' : hasKMS ? 'kms' : 'none';
119
+
120
+ return {
121
+ stage: STAGE || null,
122
+ isBypassed,
123
+ hasAES,
124
+ hasKMS,
125
+ mode,
126
+ };
127
+ };
128
+
129
+ const createTestEncryptionModel = () => {
130
+ const { Encrypt } = require('./../../encrypt');
131
+
132
+ const testSchema = new mongoose.Schema({
133
+ testSecret: { type: String, lhEncrypt: true },
134
+ normalField: { type: String },
135
+ nestedSecret: {
136
+ value: { type: String, lhEncrypt: true }
137
+ }
138
+ }, { timestamps: false });
139
+
140
+ testSchema.plugin(Encrypt);
141
+
142
+ return mongoose.models.TestEncryption ||
143
+ mongoose.model('TestEncryption', testSchema);
144
+ };
145
+
146
+ const createTestDocument = async (TestModel) => {
147
+ const testData = {
148
+ testSecret: 'This is a secret value that should be encrypted',
149
+ normalField: 'This is a normal field that should not be encrypted',
150
+ nestedSecret: {
151
+ value: 'This is a nested secret that should be encrypted'
152
+ }
91
153
  };
92
154
 
155
+ const testDoc = new TestModel(testData);
156
+ await testDoc.save();
157
+
158
+ return { testDoc, testData };
159
+ };
160
+
161
+ const verifyDecryption = (retrievedDoc, originalData) => {
162
+ return retrievedDoc &&
163
+ retrievedDoc.testSecret === originalData.testSecret &&
164
+ retrievedDoc.normalField === originalData.normalField &&
165
+ retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value;
166
+ };
167
+
168
+ const verifyEncryptionInDatabase = async (testDoc, originalData, TestModel) => {
169
+ const collectionName = TestModel.collection.name;
170
+ const rawDoc = await mongoose.connection.db
171
+ .collection(collectionName)
172
+ .findOne({ _id: testDoc._id });
173
+
174
+ const secretIsEncrypted = rawDoc &&
175
+ typeof rawDoc.testSecret === 'string' &&
176
+ rawDoc.testSecret.includes(':') &&
177
+ rawDoc.testSecret !== originalData.testSecret;
178
+
179
+ const nestedIsEncrypted = rawDoc?.nestedSecret?.value &&
180
+ typeof rawDoc.nestedSecret.value === 'string' &&
181
+ rawDoc.nestedSecret.value.includes(':') &&
182
+ rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
183
+
184
+ const normalNotEncrypted = rawDoc &&
185
+ rawDoc.normalField === originalData.normalField;
186
+
187
+ return {
188
+ secretIsEncrypted,
189
+ nestedIsEncrypted,
190
+ normalNotEncrypted
191
+ };
192
+ };
193
+
194
+ const evaluateEncryptionTestResults = (decryptionWorks, encryptionResults) => {
195
+ const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } = encryptionResults;
196
+
197
+ if (decryptionWorks && secretIsEncrypted && nestedIsEncrypted && normalNotEncrypted) {
198
+ return {
199
+ status: 'enabled',
200
+ testResult: 'Encryption and decryption verified successfully'
201
+ };
202
+ }
203
+
204
+ if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
205
+ return {
206
+ status: 'unhealthy',
207
+ testResult: 'Fields are not being encrypted in database'
208
+ };
209
+ }
210
+
211
+ if (decryptionWorks && !normalNotEncrypted) {
212
+ return {
213
+ status: 'unhealthy',
214
+ testResult: 'Normal fields are being incorrectly encrypted'
215
+ };
216
+ }
217
+
218
+ return {
219
+ status: 'unhealthy',
220
+ testResult: 'Decryption failed or data mismatch'
221
+ };
222
+ };
223
+
224
+ const testEncryption = async () => {
225
+ const TestModel = createTestEncryptionModel();
226
+ const { testDoc, testData } = await createTestDocument(TestModel);
227
+
93
228
  try {
94
- const dbState = mongoose.connection.readyState;
95
- const dbStateMap = {
96
- 0: 'disconnected',
97
- 1: 'connected',
98
- 2: 'connecting',
99
- 3: 'disconnecting'
229
+ const retrievedDoc = await TestModel.findById(testDoc._id);
230
+ const decryptionWorks = verifyDecryption(retrievedDoc, testData);
231
+ const encryptionResults = await verifyEncryptionInDatabase(testDoc, testData, TestModel);
232
+
233
+ const evaluation = evaluateEncryptionTestResults(decryptionWorks, encryptionResults);
234
+
235
+ return {
236
+ ...evaluation,
237
+ encryptionWorks: decryptionWorks
100
238
  };
239
+ } finally {
240
+ await TestModel.deleteOne({ _id: testDoc._id });
241
+ }
242
+ };
243
+
244
+ const checkEncryptionHealth = async () => {
245
+ const config = getEncryptionConfiguration();
246
+
247
+ if (config.isBypassed || config.mode === 'none') {
248
+ const testResult = config.isBypassed
249
+ ? 'Encryption bypassed for this stage'
250
+ : 'No encryption keys configured';
101
251
 
102
- checks.checks.database = {
103
- status: dbState === 1 ? 'healthy' : 'unhealthy',
104
- state: dbStateMap[dbState]
252
+ return {
253
+ status: 'disabled',
254
+ mode: config.mode,
255
+ bypassed: config.isBypassed,
256
+ stage: config.stage,
257
+ testResult,
258
+ encryptionWorks: false,
259
+ debug: {
260
+ hasKMS: config.hasKMS,
261
+ hasAES: config.hasAES
262
+ }
105
263
  };
264
+ }
106
265
 
107
- if (dbState === 1) {
108
- const pingStart = Date.now();
109
- await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
110
- checks.checks.database.responseTime = Date.now() - pingStart;
111
- } else {
112
- checks.status = 'unhealthy';
113
- }
266
+ try {
267
+ const testResults = await testEncryption();
268
+
269
+ return {
270
+ ...testResults,
271
+ mode: config.mode,
272
+ bypassed: config.isBypassed,
273
+ stage: config.stage,
274
+ debug: {
275
+ hasKMS: config.hasKMS,
276
+ hasAES: config.hasAES
277
+ }
278
+ };
114
279
  } catch (error) {
115
- checks.checks.database = {
280
+ return {
116
281
  status: 'unhealthy',
117
- error: error.message
282
+ mode: config.mode,
283
+ bypassed: config.isBypassed,
284
+ stage: config.stage,
285
+ testResult: `Encryption test failed: ${error.message}`,
286
+ encryptionWorks: false,
287
+ debug: {
288
+ hasKMS: config.hasKMS,
289
+ hasAES: config.hasAES
290
+ }
118
291
  };
119
- checks.status = 'unhealthy';
120
292
  }
293
+ };
121
294
 
122
- const externalAPIs = [
295
+ const checkExternalAPIs = async () => {
296
+ const apis = [
123
297
  { name: 'github', url: 'https://api.github.com/status' },
124
298
  { name: 'npm', url: 'https://registry.npmjs.org' }
125
299
  ];
126
300
 
127
- checks.checks.externalApis = {};
128
-
129
- const apiChecks = await Promise.all(
130
- externalAPIs.map(api =>
301
+ const results = await Promise.all(
302
+ apis.map(api =>
131
303
  checkExternalAPI(api.url).then(result => ({ name: api.name, ...result }))
132
304
  )
133
305
  );
134
306
 
135
- apiChecks.forEach(result => {
136
- const { name, ...checkResult } = result;
137
- checks.checks.externalApis[name] = checkResult;
307
+ const apiStatuses = {};
308
+ let allReachable = true;
309
+
310
+ results.forEach(({ name, ...checkResult }) => {
311
+ apiStatuses[name] = checkResult;
138
312
  if (!checkResult.reachable) {
139
- checks.status = 'unhealthy';
313
+ allReachable = false;
140
314
  }
141
315
  });
316
+
317
+ return { apiStatuses, allReachable };
318
+ };
319
+
320
+ const checkIntegrations = () => {
321
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
322
+ ? moduleFactory.moduleTypes
323
+ : [];
324
+
325
+ const integrationTypes = Array.isArray(integrationFactory.integrationTypes)
326
+ ? integrationFactory.integrationTypes
327
+ : [];
328
+
329
+ return {
330
+ status: 'healthy',
331
+ modules: {
332
+ count: moduleTypes.length,
333
+ available: moduleTypes,
334
+ },
335
+ integrations: {
336
+ count: integrationTypes.length,
337
+ available: integrationTypes,
338
+ },
339
+ };
340
+ };
341
+
342
+ const buildHealthCheckResponse = (startTime) => {
343
+ return {
344
+ service: 'frigg-core-api',
345
+ status: 'healthy',
346
+ timestamp: new Date().toISOString(),
347
+ checks: {},
348
+ calculateResponseTime: () => Date.now() - startTime
349
+ };
350
+ };
351
+
352
+ router.get('/health', async (_req, res) => {
353
+ const status = {
354
+ status: 'ok',
355
+ timestamp: new Date().toISOString(),
356
+ service: 'frigg-core-api'
357
+ };
358
+
359
+ res.status(200).json(status);
360
+ });
361
+
362
+ router.get('/health/detailed', async (_req, res) => {
363
+ const startTime = Date.now();
364
+ const response = buildHealthCheckResponse(startTime);
142
365
 
143
366
  try {
144
- const availableModules = moduleFactory.getAll();
145
- const availableIntegrations = integrationFactory.getAll();
146
-
147
- checks.checks.integrations = {
148
- status: 'healthy',
149
- modules: {
150
- count: Object.keys(availableModules).length,
151
- available: Object.keys(availableModules)
152
- },
153
- integrations: {
154
- count: Object.keys(availableIntegrations).length,
155
- available: Object.keys(availableIntegrations)
156
- }
367
+ response.checks.database = await checkDatabaseHealth();
368
+ const dbState = getDatabaseState();
369
+ if (!dbState.isConnected) {
370
+ response.status = 'unhealthy';
371
+ }
372
+ } catch (error) {
373
+ response.checks.database = {
374
+ status: 'unhealthy',
375
+ error: error.message
157
376
  };
377
+ response.status = 'unhealthy';
378
+ }
379
+
380
+ try {
381
+ response.checks.encryption = await checkEncryptionHealth();
382
+ if (response.checks.encryption.status === 'unhealthy') {
383
+ response.status = 'unhealthy';
384
+ }
158
385
  } catch (error) {
159
- checks.checks.integrations = {
386
+ response.checks.encryption = {
160
387
  status: 'unhealthy',
161
388
  error: error.message
162
389
  };
163
- checks.status = 'unhealthy';
390
+ response.status = 'unhealthy';
391
+ }
392
+
393
+ const { apiStatuses, allReachable } = await checkExternalAPIs();
394
+ response.checks.externalApis = apiStatuses;
395
+ if (!allReachable) {
396
+ response.status = 'unhealthy';
164
397
  }
165
398
 
166
- checks.responseTime = Date.now() - startTime;
399
+ try {
400
+ response.checks.integrations = checkIntegrations();
401
+ } catch (error) {
402
+ response.checks.integrations = {
403
+ status: 'unhealthy',
404
+ error: error.message
405
+ };
406
+ response.status = 'unhealthy';
407
+ }
167
408
 
168
- const statusCode = checks.status === 'healthy' ? 200 : 503;
409
+ response.responseTime = response.calculateResponseTime();
410
+ delete response.calculateResponseTime;
169
411
 
170
- res.status(statusCode).json(checks);
412
+ const statusCode = response.status === 'healthy' ? 200 : 503;
413
+ res.status(statusCode).json(response);
171
414
  });
172
415
 
173
416
  router.get('/health/live', (_req, res) => {
@@ -178,26 +421,29 @@ router.get('/health/live', (_req, res) => {
178
421
  });
179
422
 
180
423
  router.get('/health/ready', async (_req, res) => {
181
- const checks = {
182
- ready: true,
183
- timestamp: new Date().toISOString(),
184
- checks: {}
185
- };
186
-
187
- const dbState = mongoose.connection.readyState;
188
- checks.checks.database = dbState === 1;
424
+ const dbState = getDatabaseState();
425
+ const isDbReady = dbState.isConnected;
189
426
 
427
+ let areModulesReady = false;
190
428
  try {
191
- const modules = moduleFactory.getAll();
192
- checks.checks.modules = Object.keys(modules).length > 0;
429
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
430
+ ? moduleFactory.moduleTypes
431
+ : [];
432
+ areModulesReady = moduleTypes.length > 0;
193
433
  } catch (error) {
194
- checks.checks.modules = false;
434
+ areModulesReady = false;
195
435
  }
196
436
 
197
- checks.ready = checks.checks.database && checks.checks.modules;
437
+ const isReady = isDbReady && areModulesReady;
198
438
 
199
- const statusCode = checks.ready ? 200 : 503;
200
- res.status(statusCode).json(checks);
439
+ res.status(isReady ? 200 : 503).json({
440
+ ready: isReady,
441
+ timestamp: new Date().toISOString(),
442
+ checks: {
443
+ database: isDbReady,
444
+ modules: areModulesReady
445
+ }
446
+ });
201
447
  });
202
448
 
203
449
  const handler = createAppHandler('HTTP Event: Health', router);
@@ -14,16 +14,10 @@ jest.mock('mongoose', () => ({
14
14
 
15
15
  jest.mock('./../backend-utils', () => ({
16
16
  moduleFactory: {
17
- getAll: () => ({
18
- 'test-module': {},
19
- 'another-module': {}
20
- })
17
+ moduleTypes: ['test-module', 'another-module']
21
18
  },
22
19
  integrationFactory: {
23
- getAll: () => ({
24
- 'test-integration': {},
25
- 'another-integration': {}
26
- })
20
+ integrationTypes: ['test-integration', 'another-integration']
27
21
  }
28
22
  }));
29
23
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.26",
4
+ "version": "2.0.0-next.28",
5
5
  "dependencies": {
6
6
  "@hapi/boom": "^10.0.1",
7
7
  "aws-sdk": "^2.1200.0",
@@ -22,9 +22,9 @@
22
22
  "uuid": "^9.0.1"
23
23
  },
24
24
  "devDependencies": {
25
- "@friggframework/eslint-config": "2.0.0-next.26",
26
- "@friggframework/prettier-config": "2.0.0-next.26",
27
- "@friggframework/test": "2.0.0-next.26",
25
+ "@friggframework/eslint-config": "2.0.0-next.28",
26
+ "@friggframework/prettier-config": "2.0.0-next.28",
27
+ "@friggframework/test": "2.0.0-next.28",
28
28
  "@types/lodash": "4.17.15",
29
29
  "@typescript-eslint/eslint-plugin": "^8.0.0",
30
30
  "chai": "^4.3.6",
@@ -53,5 +53,5 @@
53
53
  },
54
54
  "homepage": "https://github.com/friggframework/frigg#readme",
55
55
  "description": "",
56
- "gitHead": "9b9a6cf25e458ec0033c7f4e4ee1f2128b81599e"
56
+ "gitHead": "3c830c9e559a1ed9b8a2b2de885cd5984649e2fb"
57
57
  }