Skip to content

Commit

Permalink
Allow multiple CHEFS forms to be configured
Browse files Browse the repository at this point in the history
FE can pull from multiple data sources. BE has dynamic basic auth based on the requested form ID
  • Loading branch information
kyle1morel committed Nov 29, 2023
1 parent 7620356 commit 01e7998
Show file tree
Hide file tree
Showing 16 changed files with 183 additions and 264 deletions.
2 changes: 1 addition & 1 deletion app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ appRouter.get('/config', (_req: Request, res: Response, next: (err: unknown) =>
...config.get('frontend'),
gitRev: state.gitRev,
idpList: readIdpList(),
version: process.env.npm_package_version
version: appVersion
});
} catch (err) {
next(err);
Expand Down
14 changes: 14 additions & 0 deletions app/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
"server": {
"apiPath": "SERVER_APIPATH",
"bodyLimit": "SERVER_BODYLIMIT",
"chefs": {
"forms": {
"form1": {
"name": "FORM_1_NAME",
"formId": "FORM_1_ID",
"formApiKey": "FORM_1_APIKEY"
},
"form2": {
"name": "FORM_2_NAME",
"formId": "FORM_2_ID",
"formApiKey": "FORM_2_APIKEY"
}
}
},
"oidc": {
"enabled": "SERVER_OIDC_ENABLED",
"clientId": "SERVER_OIDC_CLIENTID",
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import config from 'config';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';

import { getLogger } from './log';
import { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig';
const log = getLogger(module.filename);

/**
* @function getChefsApiKey
* Search for a CHEFS form Api Key
* @returns {string | undefined} The CHEFS form Api Key if it exists
*/
export function getChefsApiKey(formId: string): string | undefined {
const cfg = config.get('server.chefs.forms') as ChefsFormConfig;
return Object.values<ChefsFormConfigData>(cfg).find((o: ChefsFormConfigData) => o.formId === formId)?.formApiKey;
}

/**
* @function getGitRevision
* Gets the current git revision hash
Expand Down Expand Up @@ -74,3 +86,19 @@ export function readIdpList(): object[] {

return idpList;
}

/**
* @function redactSecrets
* Sanitizes objects by replacing sensitive data with a REDACTED string value
* @param {object} data An arbitrary object
* @param {string[]} fields An array of field strings to sanitize on
* @returns {object} An arbitrary object with specified secret fields marked as redacted
*/
export function redactSecrets(data: { [key: string]: unknown }, fields: Array<string>): unknown {
if (fields && Array.isArray(fields) && fields.length) {
fields.forEach((field) => {
if (data[field]) data[field] = 'REDACTED';
});
}
return data;
}
97 changes: 34 additions & 63 deletions app/src/controllers/chefs.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,55 @@
import config from 'config';

import { chefsService } from '../services';
import { isTruthy } from '../components/utils';
import { IdentityProvider } from '../components/constants';

import type { NextFunction, Request, Response } from 'express';
import type { JwtPayload } from 'jsonwebtoken';
import type { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig';
import type { ChefsSubmissionDataSource } from '../types/ChefsSubmissionDataSource';

const controller = {
exportSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.exportSubmissions(req.params.formId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getFormSubmissions: async (req: Request, res: Response, next: NextFunction) => {
getSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getFormSubmissions(req.params.formId);

// IDIR users should be able to see all submissions
const filterToUser = (req.currentUser?.tokenPayload as JwtPayload).identity_provider !== IdentityProvider.IDIR;

if (isTruthy(filterToUser)) {
res
.status(200)
.send(
response.filter(
(x: { createdBy: string }) =>
x.createdBy.toUpperCase().substring(0, x.createdBy.indexOf('@idir')) ===
(req.currentUser?.tokenPayload as JwtPayload).idir_username.toUpperCase()
)
const cfg = config.get('server.chefs.forms') as ChefsFormConfig;
let formData = new Array<ChefsSubmissionDataSource>();

await Promise.all(
Object.values<ChefsFormConfigData>(cfg).map(async (x: ChefsFormConfigData) => {
const data = await chefsService.getFormSubmissions(x.formId);
formData = formData.concat(data);
})
);

/*
* Filter Data source
* IDIR users should be able to see all submissions
* BCeID/Business should only see their own submissions
*/
const filterData = (data: Array<ChefsSubmissionDataSource>) => {
const filterToUser = (req.currentUser?.tokenPayload as JwtPayload).identity_provider !== IdentityProvider.IDIR;

if (isTruthy(filterToUser)) {
return data.filter(
(x: { createdBy: string }) =>
x.createdBy.toUpperCase().substring(0, x.createdBy.indexOf('@')) ===
(req.currentUser?.tokenPayload as JwtPayload).bceid_username.toUpperCase()
);
} else {
res.status(200).send(response);
}
} catch (e: unknown) {
next(e);
}
},
} else {
return data;
}
};

getPublishedVersion: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getPublishedVersion(req.params.formId);
res.status(200).send(response);
res.status(200).send(filterData(formData));
} catch (e: unknown) {
next(e);
}
},

getSubmission: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getSubmission(req.params.formSubmissionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersion: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersion(req.params.formId, req.params.versionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersionFields: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersionFields(req.params.formId, req.params.versionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersionSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersionSubmissions(req.params.formId, req.params.versionId);
const response = await chefsService.getSubmission(req.query.formId as string, req.params.formSubmissionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
Expand Down
36 changes: 36 additions & 0 deletions app/src/middleware/requireChefsFormConfigData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @ts-expect-error api-problem lacks a defined interface; code still works fine
import Problem from 'api-problem';

import { getChefsApiKey } from '../components/utils';

import type { NextFunction, Request, Response } from 'express';

/**
* @function requireChefsFormConfigData
* Rejects the request if there is no form ID present in the request
* or if the given Form ID/Api Key is not configured
* @param {object} req Express request object
* @param {object} _res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
* @throws The error encountered upon failure
*/
export const requireChefsFormConfigData = (req: Request, _res: Response, next: NextFunction) => {
const params = { ...req.query, ...req.params };

if (!params.formId) {
throw new Problem(400, {
detail: 'Form ID not present in request.',
instance: req.originalUrl
});
}

if (!getChefsApiKey(params.formId as string)) {
throw new Problem(412, {
detail: 'Form not present or misconfigured.',
instance: req.originalUrl
});
}

next();
};
37 changes: 7 additions & 30 deletions app/src/routes/v1/chefs.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,24 @@
import express from 'express';
import { chefsController } from '../../controllers';
import { requireChefsFormConfigData } from '../../middleware/requireChefsFormConfigData';
import { requireSomeAuth } from '../../middleware/requireSomeAuth';

import type { NextFunction, Request, Response } from 'express';

const router = express.Router();
router.use(requireSomeAuth);

// Export submissions endpoint
router.get('/forms/:formId/export', (req: Request, res: Response, next: NextFunction): void => {
chefsController.exportSubmissions(req, res, next);
});

// Form submissions endpoint
router.get('/forms/:formId/submissions', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getFormSubmissions(req, res, next);
});

// Published version endpoint
router.get('/forms/:formId/version', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getPublishedVersion(req, res, next);
});

// Submission endpoint
router.get('/submission/:formSubmissionId', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getSubmission(req, res, next);
router.get('/submissions', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getSubmissions(req, res, next);
});

// Version endpoint
router.get('/forms/:formId/versions/:versionId', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersion(req, res, next);
});

// Version fields endpoint
router.get('/forms/:formId/versions/:versionId/fields', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersionFields(req, res, next);
});

// Version submissions endpoint
// Submission endpoint
router.get(
'/forms/:formId/versions/:versionId/submissions',
'/submission/:formSubmissionId',
requireChefsFormConfigData,
(req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersionSubmissions(req, res, next);
chefsController.getSubmission(req, res, next);
}
);

Expand Down
74 changes: 10 additions & 64 deletions app/src/services/chefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,38 @@
import axios from 'axios';
import config from 'config';

import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { getChefsApiKey } from '../components/utils';

import type { AxiosInstance, AxiosRequestConfig } from 'axios';

/**
* @function chefsAxios
* Returns an Axios instance for the CHEFS API
* @param {AxiosRequestConfig} options Axios request config options
* @returns {AxiosInstance} An axios instance
*/
function chefsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
const instance = axios.create({
function chefsAxios(formId: string, options: AxiosRequestConfig = {}): AxiosInstance {
return axios.create({
baseURL: config.get('frontend.chefs.apiPath'),
timeout: 10000,
...options
...options,
auth: { username: formId, password: getChefsApiKey(formId) ?? '' }
});

instance.interceptors.request.use(
async (cfg: InternalAxiosRequestConfig) => {
cfg.auth = { username: config.get('frontend.chefs.formId'), password: config.get('frontend.chefs.formApiKey') };
return Promise.resolve(cfg);
},
(error: Error) => {
return Promise.reject(error);
}
);

return instance;
}

const service = {
exportSubmissions: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/export`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getFormSubmissions: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/submissions`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getPublishedVersion: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/version`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getSubmission: async (formSubmissionId: string) => {
try {
const response = await chefsAxios().get(`submissions/${formSubmissionId}`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersion: async (formId: string, versionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersionFields: async (formId: string, versionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}/fields`);
const response = await chefsAxios(formId).get(`forms/${formId}/submissions`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersionSubmissions: async (formId: string, versionId: string) => {
getSubmission: async (formId: string, formSubmissionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}/submissions`);
const response = await chefsAxios(formId).get(`submissions/${formSubmissionId}`);
return response.data;
} catch (e: unknown) {
throw e;
Expand Down
10 changes: 10 additions & 0 deletions app/src/types/ChefsFormConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type ChefsFormConfig = {
form1: ChefsFormConfigData;
form2: ChefsFormConfigData;
};

export type ChefsFormConfigData = {
name: string;
formId: string;
formApiKey: string;
};
Loading

0 comments on commit 01e7998

Please sign in to comment.