diff --git a/package-lock.json b/package-lock.json index f97969d42..272911546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@apollo/server": "4.0.0", "@apollo/subgraph": "2.5.2", + "@icgc-argo/ego-token-utils": "^8.2.0", "@overturebio-stack/lectern-client": "1.4.0", "@types/mongoose-paginate-v2": "^1.3.11", "adm-zip": "^0.4.16", @@ -982,6 +983,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@icgc-argo/ego-token-utils": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@icgc-argo/ego-token-utils/-/ego-token-utils-8.3.0.tgz", + "integrity": "sha512-11Qhuh7LbHsrgZJ6Z4ZxYLY4IINkarzu/WamNMggeDK9oY8R5n5CaimB86sRc1ceHnRpzbMY6S82JovTmAOxIw==", + "dependencies": { + "jsonwebtoken": "^8.5.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@josephg/resolvable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", @@ -9943,6 +9955,14 @@ "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", "requires": {} }, + "@icgc-argo/ego-token-utils": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@icgc-argo/ego-token-utils/-/ego-token-utils-8.3.0.tgz", + "integrity": "sha512-11Qhuh7LbHsrgZJ6Z4ZxYLY4IINkarzu/WamNMggeDK9oY8R5n5CaimB86sRc1ceHnRpzbMY6S82JovTmAOxIw==", + "requires": { + "jsonwebtoken": "^8.5.1" + } + }, "@josephg/resolvable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz", diff --git a/package.json b/package.json index ba06ef358..78458c403 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@apollo/server": "4.0.0", "@overturebio-stack/lectern-client": "1.4.0", "@types/mongoose-paginate-v2": "^1.3.11", + "@icgc-argo/ego-token-utils": "^8.2.0", "adm-zip": "^0.4.16", "async": "^3.0.1", "bcrypt-nodejs": "^0.0.3", diff --git a/src/app.ts b/src/app.ts index c8260d2f1..ae3716d0e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,6 +37,7 @@ import icgcImport from './routes/icgc-import'; import exceptionRouter from './routes/exception'; import responseTime from 'response-time'; import morgan from 'morgan'; +import { EgoJwtData } from '@icgc-argo/ego-token-utils/dist/common'; const L = loggerFor(__filename); @@ -44,6 +45,15 @@ process.title = 'clinical'; // Create Express server const app = express(); + +export type GlobalGqlContext = { + isUserRequest: boolean; + egoToken: string; + Authorization: string; + userJwtData: EgoJwtData | undefined; + dataLoaders: {}; +}; + app.set('port', process.env.PORT || 3000); app.set('graphqlPort', process.env.GRAPHQLPORT || 3001); app.use(bodyParser.json()); diff --git a/src/dictionary/api.ts b/src/dictionary/api.ts index cd12cdb2d..d6f780207 100644 --- a/src/dictionary/api.ts +++ b/src/dictionary/api.ts @@ -33,7 +33,7 @@ class SchemaController { async update(req: Request, res: Response) { const version: string = req.body.version; const sync: boolean = req.query.sync; - const initiator = ControllerUtils.getUserFromToken(req); + const initiator = ControllerUtils.getUserFromRequest(req); const migration = await manager.instance().updateSchemaVersion(version, initiator, sync); return res.status(200).send(migration); } @@ -49,7 +49,7 @@ class SchemaController { @HasFullWriteAccess() async dryRunUpdate(req: Request, res: Response) { const version: string = req.body.version; - const initiator = ControllerUtils.getUserFromToken(req); + const initiator = ControllerUtils.getUserFromRequest(req); const migration = await manager.instance().dryRunSchemaUpgrade(version, initiator); return res.status(200).send(migration); } diff --git a/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts b/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts new file mode 100644 index 000000000..b05519373 --- /dev/null +++ b/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { GlobalGqlContext } from '../../app'; +import submissionAPI from '../../submission/submission-api'; +import { convertClinicalSubmissionDataToGql } from '../utils'; + +const clearClinicalSubmissionResolver = { + clearClinicalSubmission: async ( + obj: unknown, + args: { programShortName: string; fileType: string; version: string }, + contextValue: any, + ) => { + const { programShortName, fileType, version } = args; + const response = await submissionAPI.clearFileDataFromActiveSubmission( + programShortName, + fileType, + version, + (contextValue).egoToken, + ); + return convertClinicalSubmissionDataToGql(programShortName, { + submission: response, + }); + }, +}; + +export default clearClinicalSubmissionResolver; diff --git a/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts b/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts index 1e5671c3c..420b44a17 100644 --- a/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts +++ b/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts @@ -21,7 +21,7 @@ import submissionAPI from '../../submission/submission-api'; import get from 'lodash/get'; import { ActiveClinicalSubmission } from '../../submission/submission-entities'; import { DeepReadonly } from 'deep-freeze'; -import { convertClinicalFileErrorToGql, convertClinicalSubmissionEntityToGql } from '../utils'; +import { convertClinicalSubmissionDataToGql } from '../utils'; import { getClinicalEntitiesData } from '../../dictionary/api'; const clinicalSubmissionResolver = { @@ -35,35 +35,4 @@ const clinicalSubmissionResolver = { }, }; -const convertClinicalSubmissionDataToGql = ( - programShortName: string, - data: { - submission: DeepReadonly | undefined; - batchErrors?: { message: string; batchNames: string[]; code: string }[]; - }, -) => { - const submission = get(data, 'submission', {} as Partial); - const fileErrors = get(data, 'batchErrors', [] as typeof data.batchErrors); - const clinicalEntities = get(submission, 'clinicalEntities'); - return { - id: submission?._id || undefined, - programShortName, - state: submission?.state || undefined, - version: submission?.version || undefined, - updatedBy: submission?.updatedBy || undefined, - updatedAt: submission?.updatedAt ? submission.updatedAt : undefined, - clinicalEntities: async () => { - const clinicalSubmissionTypeList = await getClinicalEntitiesData('false'); // to confirm for true or false - const filledClinicalEntities = clinicalSubmissionTypeList.map(clinicalType => ({ - clinicalType, - ...(clinicalEntities ? clinicalEntities[clinicalType.name] : {}), - })); - return filledClinicalEntities.map(clinicalEntity => - convertClinicalSubmissionEntityToGql(clinicalEntity?.clinicalType.name, clinicalEntity), - ); - }, - fileErrors: fileErrors?.map(convertClinicalFileErrorToGql), - }; -}; - export default clinicalSubmissionResolver; diff --git a/src/schemas/gqlTypeDefs.ts b/src/schemas/gqlTypeDefs.ts index 074fddb50..7663f465c 100644 --- a/src/schemas/gqlTypeDefs.ts +++ b/src/schemas/gqlTypeDefs.ts @@ -37,6 +37,18 @@ const typeDefs = gql` clinicalSubmissions(programShortName: String!): ClinicalSubmissionData! } + type Mutation { + """ + Clear Clinical Submission + fileType is optional, if it is not provided all fileTypes will be cleared. The values for fileType are the same as the file names from each template (ex. donor, specimen) + """ + clearClinicalSubmission( + programShortName: String! + version: String! + fileType: String + ): ClinicalSubmissionData! + } + scalar DateTime """ diff --git a/src/schemas/resolvers.ts b/src/schemas/resolvers.ts index 4584ff905..e8d89b0f8 100644 --- a/src/schemas/resolvers.ts +++ b/src/schemas/resolvers.ts @@ -1,6 +1,7 @@ import clinicalRegistrationResolver from './clinical-resolvers/clinicalRegistrationData'; import clinicalSearchResultResolver from './clinical-resolvers/clinicalSearchResults'; import clinicalSubmissionResolver from './clinical-resolvers/clinicalSubmissionDataResolver'; +import clearClinicalSubmissionResolver from './clinical-resolvers/clearClinicalSubmissionResolver'; const resolvers = { Query: { @@ -8,6 +9,9 @@ const resolvers = { ...clinicalSearchResultResolver, ...clinicalSubmissionResolver, }, + Mutation: { + ...clearClinicalSubmissionResolver, + }, }; export default resolvers; diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts index 3a1d40bb2..c4016a561 100644 --- a/src/schemas/utils.ts +++ b/src/schemas/utils.ts @@ -19,10 +19,12 @@ import get from 'lodash/get'; import { + ActiveClinicalSubmission, SubmissionValidationError, SubmissionValidationUpdate, } from '../submission/submission-entities'; import { DeepReadonly } from 'deep-freeze'; +import { getClinicalEntitiesData } from '../dictionary/api'; const ARRAY_DELIMITER_CHAR = '|'; @@ -209,6 +211,37 @@ const convertClinicalSubmissionUpdateToGql = (updateData: UpdateData) => { }; }; +const convertClinicalSubmissionDataToGql = ( + programShortName: string, + data: { + submission: DeepReadonly | undefined; + batchErrors?: { message: string; batchNames: string[]; code: string }[]; + }, +) => { + const submission = get(data, 'submission', {} as Partial); + const fileErrors = get(data, 'batchErrors', [] as typeof data.batchErrors); + const clinicalEntities = get(submission, 'clinicalEntities'); + return { + id: submission?._id || undefined, + programShortName, + state: submission?.state || undefined, + version: submission?.version || undefined, + updatedBy: submission?.updatedBy || undefined, + updatedAt: submission?.updatedAt ? submission.updatedAt : undefined, + clinicalEntities: async () => { + const clinicalSubmissionTypeList = await getClinicalEntitiesData('false'); // to confirm for true or false + const filledClinicalEntities = clinicalSubmissionTypeList.map(clinicalType => ({ + clinicalType, + ...(clinicalEntities ? clinicalEntities[clinicalType.name] : {}), + })); + return filledClinicalEntities.map(clinicalEntity => + convertClinicalSubmissionEntityToGql(clinicalEntity?.clinicalType.name, clinicalEntity), + ); + }, + fileErrors: fileErrors?.map(convertClinicalFileErrorToGql), + }; +}; + export { convertClinicalRecordToGql, convertRegistrationErrorToGql, @@ -216,4 +249,5 @@ export { convertRegistrationStatsToGql, RegistrationErrorData, convertClinicalSubmissionEntityToGql, + convertClinicalSubmissionDataToGql, }; diff --git a/src/server.ts b/src/server.ts index 36e3e0395..ac0d2c98e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,8 @@ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import { token } from 'morgan'; + console.time('boot time'); // Has to import config before any other import uses the configurations import { AppConfig, RxNormDbConfig, KafkaConfigurations } from './config'; @@ -29,11 +31,16 @@ import { Server } from 'http'; // we import here to allow configs to fully load import * as bootstrap from './bootstrap'; import app from './app'; +import { GlobalGqlContext } from './app'; import { database, up } from 'migrate-mongo'; -import { ApolloServer } from '@apollo/server'; -import { startStandaloneServer } from '@apollo/server/standalone'; +import { ApolloServer, ContextFunction } from '@apollo/server'; +import { + StandaloneServerContextFunctionArgument, + startStandaloneServer, +} from '@apollo/server/standalone'; import schema from './schemas/index'; +import { EgoJwtData } from '@icgc-argo/ego-token-utils/dist/common'; let secrets: any = {}; let server: Server; @@ -153,10 +160,37 @@ let server: Server; /** * Start Graphql server. */ - const apolloServer = new ApolloServer({ + + const context: ContextFunction< + [StandaloneServerContextFunctionArgument], + GlobalGqlContext + > = async ({ req, res }) => { + // Get the user token from the headers. + const authHeader = req.headers.authorization; + let userJwtData: EgoJwtData | undefined = undefined; + try { + if (authHeader) { + const jwt = authHeader.replace('Bearer ', ''); + } + } catch (err) { + userJwtData = undefined; + } + // Add the user to the context + return { + isUserRequest: true, + egoToken: (authHeader || '').split('Bearer ').join(''), + Authorization: `Bearer ${(authHeader || '').replace(/^Bearer[\s]*!/, '')}` || '', + userJwtData, + dataLoaders: {}, + }; + }; + + const apolloServer = new ApolloServer({ schema, }); + const { url } = await startStandaloneServer(apolloServer, { + context, listen: { port: app.get('graphqlPort') }, }); diff --git a/src/submission/submission-api.ts b/src/submission/submission-api.ts index 4e2a3e31c..f94845187 100644 --- a/src/submission/submission-api.ts +++ b/src/submission/submission-api.ts @@ -35,6 +35,7 @@ import { HasFullWriteAccess, HasProgramWriteAccess } from '../decorators'; import _ from 'lodash'; import { batchErrorMessage } from './submission-error-messages'; import * as fs from 'fs'; +import { GlobalGqlContext } from '../app'; const L = loggerFor(__filename); const fsPromises = fs.promises; @@ -62,7 +63,7 @@ class SubmissionController { return; } const programId = req.params.programId; - const creator = ControllerUtils.getUserFromToken(req); + const creator = ControllerUtils.getUserFromRequest(req); const file = req.file; let records: ReadonlyArray; try { @@ -136,7 +137,7 @@ class SubmissionController { return; } - const user = ControllerUtils.getUserFromToken(req); + const user = ControllerUtils.getUserFromRequest(req); const newClinicalData: NewClinicalEntity[] = []; const tsvParseErrors: SubmissionBatchError[] = []; const clinicalFiles = req.files as Express.Multer.File[]; @@ -191,7 +192,7 @@ class SubmissionController { async validateActiveSubmission(req: Request, res: Response) { if (await submissionSystemIsDisabled(res)) return; const { versionId, programId } = req.params; - const updater = ControllerUtils.getUserFromToken(req); + const updater = ControllerUtils.getUserFromRequest(req); const result = await submission.operations.validateMultipleClinical({ versionId, programId, @@ -207,7 +208,7 @@ class SubmissionController { async clearFileFromActiveSubmission(req: Request, res: Response) { if (await submissionSystemIsDisabled(res)) return; const { programId, versionId, fileType } = req.params; - const updater = ControllerUtils.getUserFromToken(req); + const updater = ControllerUtils.getUserFromRequest(req); L.debug(`Entering clearFileFromActiveSubmission: ${{ programId, versionId, fileType }}`); const updatedSubmission = await submission.operations.clearSubmissionData({ programId, @@ -219,11 +220,32 @@ class SubmissionController { return res.status(200).send(updatedSubmission || {}); } + // @HasProgramWriteAccess((programId: string) => programId) + async clearFileDataFromActiveSubmission( + programId: string, + fileType: string, + versionId: string, + token: string, + ) { + const submissionSystemDisabled = await persistedConfig.getSubmissionDisabledState(); + if (submissionSystemDisabled) return; + const updater = ControllerUtils.getUserFromToken(token); + L.debug(`Entering clearFileDataFromActiveSubmission: ${{ programId, versionId, fileType }}`); + const updatedSubmission = await submission.operations.clearSubmissionData({ + programId, + versionId, + fileType, + updater, + }); + // Handle case where submission was cleared and is now undefined + return updatedSubmission; + } + @HasProgramWriteAccess((req: Request) => req.params.programId) async commitActiveSubmission(req: Request, res: Response) { if (await submissionSystemIsDisabled(res)) return; const { versionId, programId } = req.params; - const updater = ControllerUtils.getUserFromToken(req); + const updater = ControllerUtils.getUserFromRequest(req); const activeSubmission = await submission2Clinical.commitClinicalSubmission({ versionId, programId, @@ -247,7 +269,7 @@ class SubmissionController { async reopenActiveSubmission(req: Request, res: Response) { if (await submissionSystemIsDisabled(res)) return; const { versionId, programId } = req.params; - const updater = ControllerUtils.getUserFromToken(req); + const updater = ControllerUtils.getUserFromRequest(req); const activeSubmission = await submission.operations.reopenClinicalSubmission({ versionId, programId, @@ -299,7 +321,7 @@ class SubmissionController { const samplesSubmitterIds = req.query.sampleSubmitterIds && req.query.sampleSubmitterIds.split(','); const dryRun = req.query.dryRun === 'false' ? false : true; - const updater = ControllerUtils.getUserFromToken(req); + const updater = ControllerUtils.getUserFromRequest(req); L.info( `Delete registered samples called, caller ${updater}, ids: ${samplesSubmitterIds}, programId: ${programId}`, ); diff --git a/src/utils.ts b/src/utils.ts index 6f0ad85d5..5c018ce83 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -136,7 +136,7 @@ export namespace ControllerUtils { }; // checks authHeader + decoded jwt and returns the user name - export const getUserFromToken = (req: Request): string => { + export const getUserFromRequest = (req: Request): string => { const authHeader = req.headers.authorization; if (!authHeader) { throw new Error("can't get here without auth header"); @@ -147,6 +147,15 @@ export namespace ControllerUtils { } return decoded.context.user.firstName + ' ' + decoded.context.user.lastName; }; + + // checks authHeader + decoded jwt and returns the user name + export const getUserFromToken = (token: string) => { + const decoded = jwt.decode(token) as any; + if (!decoded || !decoded.context || !decoded.context.user) { + throw new Error('invalid token structure'); + } + return decoded.context.user.firstName + ' ' + decoded.context.user.lastName; + }; } export namespace DonorUtils {