@friggframework/core 2.0.0--canary.405.b87f8d8.0 → 2.0.0--canary.405.b81908d.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.
@@ -17,7 +17,6 @@ const findOneEvents = [
17
17
  const shouldBypassEncryption = (STAGE) => {
18
18
  const defaultBypassStages = ['dev', 'test', 'local'];
19
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
20
  const useEnv = !String(bypassStageEnv) || !!bypassStageEnv;
22
21
  const bypassStages = useEnv
23
22
  ? bypassStageEnv.split(',').map((stage) => stage.trim())
@@ -25,19 +24,15 @@ const shouldBypassEncryption = (STAGE) => {
25
24
  return bypassStages.includes(STAGE);
26
25
  };
27
26
 
28
- // The Mongoose plug-in function
29
- function Encrypt(schema, options) {
27
+ function Encrypt(schema) {
30
28
  const { STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
31
29
 
32
30
  if (shouldBypassEncryption(STAGE)) {
33
31
  return;
34
32
  }
35
33
 
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
- }
34
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
35
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
41
36
 
42
37
  const fields = Object.values(schema.paths)
43
38
  .map(({ path, options }) => (options.lhEncrypt === true ? path : ''))
@@ -48,25 +43,17 @@ function Encrypt(schema, options) {
48
43
  }
49
44
 
50
45
  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
46
+ shouldUseAws: hasKMS,
54
47
  fields: fields,
55
48
  });
56
49
 
57
- // ---------------------------------------------
58
- // ### Encrypt fields before save/update/insert.
59
- // ---------------------------------------------
60
-
61
50
  schema.pre('save', async function encryptionPreSave() {
62
- // `this` will be a doc
63
51
  await cryptor.encryptFieldsInDocuments([this]);
64
52
  });
65
53
 
66
54
  schema.pre(
67
55
  'insertMany',
68
56
  async function encryptionPreInsertMany(_, docs, options) {
69
- // `this` will be the model
70
57
  if (options?.rawResult) {
71
58
  throw new Error(
72
59
  'Raw result not supported for insertMany with Encrypt plugin'
@@ -78,17 +65,14 @@ function Encrypt(schema, options) {
78
65
  );
79
66
 
80
67
  schema.pre(updateOneEvents, async function encryptionPreUpdateOne() {
81
- // `this` will be a query
82
68
  await cryptor.encryptFieldsInQuery(this);
83
69
  });
84
70
 
85
71
  schema.pre('updateMany', async function encryptionPreUpdateMany() {
86
- // `this` will be a query
87
72
  cryptor.expectNotToUpdateManyEncrypted(this.getUpdate());
88
73
  });
89
74
 
90
75
  schema.pre('update', async function encryptionPreUpdate() {
91
- // `this` will be a query
92
76
  const { multiple } = this.getOptions();
93
77
 
94
78
  if (multiple) {
@@ -99,16 +83,11 @@ function Encrypt(schema, options) {
99
83
  await cryptor.encryptFieldsInQuery(this);
100
84
  });
101
85
 
102
- // --------------------------------------------
103
- // ### Decrypt documents after they are loaded.
104
- // --------------------------------------------
105
86
  schema.post('save', async function encryptionPreSave() {
106
- // `this` will be a doc
107
87
  await cryptor.decryptFieldsInDocuments([this]);
108
88
  });
109
89
 
110
90
  schema.post(findOneEvents, async function encryptionPostFindOne(doc) {
111
- // `this` will be a query
112
91
  const { rawResult } = this.getOptions();
113
92
 
114
93
  if (rawResult) {
@@ -119,12 +98,10 @@ function Encrypt(schema, options) {
119
98
  });
120
99
 
121
100
  schema.post('find', async function encryptionPostFind(docs) {
122
- // `this` will be a query
123
101
  await cryptor.decryptFieldsInDocuments(docs);
124
102
  });
125
103
 
126
104
  schema.post('insertMany', async function encryptionPostInsertMany(docs) {
127
- // `this` will be the model
128
105
  await cryptor.decryptFieldsInDocuments(docs);
129
106
  });
130
107
  }
@@ -71,147 +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,
91
126
  };
127
+ };
92
128
 
93
- try {
94
- const dbState = mongoose.connection.readyState;
95
- const dbStateMap = {
96
- 0: 'disconnected',
97
- 1: 'connected',
98
- 2: 'connecting',
99
- 3: 'disconnecting'
100
- };
101
-
102
- checks.checks.database = {
103
- status: dbState === 1 ? 'healthy' : 'unhealthy',
104
- state: dbStateMap[dbState]
105
- };
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
+ };
106
145
 
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';
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'
113
152
  }
114
- } catch (error) {
115
- checks.checks.database = {
153
+ };
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 {
116
206
  status: 'unhealthy',
117
- error: error.message
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'
118
215
  };
119
- checks.status = 'unhealthy';
120
216
  }
217
+
218
+ return {
219
+ status: 'unhealthy',
220
+ testResult: 'Decryption failed or data mismatch'
221
+ };
222
+ };
121
223
 
224
+ const testEncryption = async () => {
225
+ const TestModel = createTestEncryptionModel();
226
+ const { testDoc, testData } = await createTestDocument(TestModel);
227
+
122
228
  try {
123
- const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } =
124
- process.env;
125
- const defaultBypassStages = ['dev', 'test', 'local'];
126
- const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined;
127
- const bypassStages = useEnv
128
- ? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim())
129
- : defaultBypassStages;
130
- const bypassed = bypassStages.includes(STAGE);
131
- const mode = KMS_KEY_ARN ? 'kms' : AES_KEY_ID ? 'aes' : 'none';
132
-
133
- let status = 'disabled';
134
- // Having both KMS_KEY_ARN and AES_KEY_ID present is considered unhealthy,
135
- // as only one encryption method should be configured at a time to avoid ambiguity
136
- // and potential security misconfiguration.
137
- if (KMS_KEY_ARN && AES_KEY_ID) {
138
- status = 'unhealthy';
139
- } else if (!bypassed && mode !== 'none') {
140
- status = 'enabled';
141
- } else {
142
- status = 'disabled';
143
- }
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
238
+ };
239
+ } finally {
240
+ await TestModel.deleteOne({ _id: testDoc._id });
241
+ }
242
+ };
144
243
 
145
- checks.checks.encryption = {
146
- status,
147
- mode,
148
- bypassed,
149
- stage: STAGE || null,
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';
251
+
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
+ }
263
+ };
264
+ }
265
+
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
+ }
150
278
  };
151
- if (status === 'unhealthy') checks.status = 'unhealthy';
152
279
  } catch (error) {
153
- checks.checks.encryption = {
280
+ return {
154
281
  status: 'unhealthy',
155
- 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
+ }
156
291
  };
157
- checks.status = 'unhealthy';
158
292
  }
293
+ };
159
294
 
160
- const externalAPIs = [
295
+ const checkExternalAPIs = async () => {
296
+ const apis = [
161
297
  { name: 'github', url: 'https://api.github.com/status' },
162
298
  { name: 'npm', url: 'https://registry.npmjs.org' }
163
299
  ];
164
300
 
165
- checks.checks.externalApis = {};
166
-
167
- const apiChecks = await Promise.all(
168
- externalAPIs.map(api =>
301
+ const results = await Promise.all(
302
+ apis.map(api =>
169
303
  checkExternalAPI(api.url).then(result => ({ name: api.name, ...result }))
170
304
  )
171
305
  );
172
306
 
173
- apiChecks.forEach(result => {
174
- const { name, ...checkResult } = result;
175
- checks.checks.externalApis[name] = checkResult;
307
+ const apiStatuses = {};
308
+ let allReachable = true;
309
+
310
+ results.forEach(({ name, ...checkResult }) => {
311
+ apiStatuses[name] = checkResult;
176
312
  if (!checkResult.reachable) {
177
- checks.status = 'unhealthy';
313
+ allReachable = false;
178
314
  }
179
315
  });
316
+
317
+ return { apiStatuses, allReachable };
318
+ };
180
319
 
181
- try {
182
- const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
183
- ? moduleFactory.moduleTypes
184
- : [];
185
- const integrationTypes = Array.isArray(
186
- integrationFactory.integrationTypes
187
- )
188
- ? integrationFactory.integrationTypes
189
- : [];
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
+ };
190
358
 
191
- checks.checks.integrations = {
192
- status: 'healthy',
193
- modules: {
194
- count: moduleTypes.length,
195
- available: moduleTypes,
196
- },
197
- integrations: {
198
- count: integrationTypes.length,
199
- available: integrationTypes,
200
- },
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);
365
+
366
+ try {
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
201
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
+ }
202
385
  } catch (error) {
203
- checks.checks.integrations = {
386
+ response.checks.encryption = {
204
387
  status: 'unhealthy',
205
388
  error: error.message
206
389
  };
207
- 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';
208
397
  }
209
398
 
210
- 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
+ }
211
408
 
212
- const statusCode = checks.status === 'healthy' ? 200 : 503;
409
+ response.responseTime = response.calculateResponseTime();
410
+ delete response.calculateResponseTime;
213
411
 
214
- res.status(statusCode).json(checks);
412
+ const statusCode = response.status === 'healthy' ? 200 : 503;
413
+ res.status(statusCode).json(response);
215
414
  });
216
415
 
217
416
  router.get('/health/live', (_req, res) => {
@@ -222,28 +421,29 @@ router.get('/health/live', (_req, res) => {
222
421
  });
223
422
 
224
423
  router.get('/health/ready', async (_req, res) => {
225
- const checks = {
226
- ready: true,
227
- timestamp: new Date().toISOString(),
228
- checks: {}
229
- };
230
-
231
- const dbState = mongoose.connection.readyState;
232
- checks.checks.database = dbState === 1;
424
+ const dbState = getDatabaseState();
425
+ const isDbReady = dbState.isConnected;
233
426
 
427
+ let areModulesReady = false;
234
428
  try {
235
429
  const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
236
430
  ? moduleFactory.moduleTypes
237
431
  : [];
238
- checks.checks.modules = moduleTypes.length > 0;
432
+ areModulesReady = moduleTypes.length > 0;
239
433
  } catch (error) {
240
- checks.checks.modules = false;
434
+ areModulesReady = false;
241
435
  }
242
436
 
243
- checks.ready = checks.checks.database && checks.checks.modules;
437
+ const isReady = isDbReady && areModulesReady;
244
438
 
245
- const statusCode = checks.ready ? 200 : 503;
246
- 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
+ });
247
447
  });
248
448
 
249
449
  const handler = createAppHandler('HTTP Event: Health', router);
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--canary.405.b87f8d8.0",
4
+ "version": "2.0.0--canary.405.b81908d.0",
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--canary.405.b87f8d8.0",
26
- "@friggframework/prettier-config": "2.0.0--canary.405.b87f8d8.0",
27
- "@friggframework/test": "2.0.0--canary.405.b87f8d8.0",
25
+ "@friggframework/eslint-config": "2.0.0--canary.405.b81908d.0",
26
+ "@friggframework/prettier-config": "2.0.0--canary.405.b81908d.0",
27
+ "@friggframework/test": "2.0.0--canary.405.b81908d.0",
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": "b87f8d874639f6fbb52c8a7efc7841c879a1286f"
56
+ "gitHead": "b81908d5f795baf937e4b382dff63dedd40ddceb"
57
57
  }