diff --git a/config/nginx.conf b/config/nginx.conf index c7f86e7e0..e3aaf0e8f 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -37,6 +37,7 @@ http { proxy_set_header X-Insecure "true"; proxy_set_header X-UserId $req_userid; proxy_set_header X-Email "user@example.com"; + proxy_set_header X-User '{"some":"data"}'; client_max_body_size 0; chunked_transfer_encoding on; diff --git a/data/model.ts b/data/model.ts index daab108e9..cf5195e8b 100644 --- a/data/model.ts +++ b/data/model.ts @@ -4,7 +4,7 @@ import qs from 'qs' import { fetcher } from '../utils/fetcher' import { Deployment, Model, Schema, Version } from '../types/interfaces' -export type ListModelType = 'favourites' | 'mine' | 'all' +export type ListModelType = 'favourites' | 'user' | 'all' export function useListModels(type: ListModelType, filter?: string) { const { data, error, mutate } = useSWR<{ models: Array diff --git a/data/requests.ts b/data/requests.ts index 929f83633..dc2a6f143 100644 --- a/data/requests.ts +++ b/data/requests.ts @@ -5,7 +5,7 @@ import qs from 'qs' import { fetcher } from '../utils/fetcher' export type RequestType = 'Upload' | 'Deployment' -export type ReviewFilterType = 'mine' | 'all' +export type ReviewFilterType = 'user' | 'all' export function useListRequests(type: RequestType, filter: ReviewFilterType) { const { data, error, mutate } = useSWR<{ requests: Array diff --git a/lib/node/index.ts b/lib/node/index.ts index 39ccd0374..683f6faeb 100644 --- a/lib/node/index.ts +++ b/lib/node/index.ts @@ -94,11 +94,11 @@ class Model { } } -type ModelsType = 'favourites' | 'mine' | 'all' +type ModelsType = 'favourites' | 'user' | 'all' type SchemaUse = 'UPLOAD' | 'DEPLOYMENT' type RequestUse = 'Upload' | 'Deployment' -type RequestFilter = 'all' | 'mine' +type RequestFilter = 'all' | 'user' interface File { stream: any diff --git a/pages/index.tsx b/pages/index.tsx index 2dea71407..ec3ef78a6 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -69,7 +69,7 @@ export default function ExploreModels() { - + diff --git a/pages/review.tsx b/pages/review.tsx index 81ebc8bfb..8bc9ab8c4 100644 --- a/pages/review.tsx +++ b/pages/review.tsx @@ -24,7 +24,7 @@ import MultipleErrorWrapper from '../src/errors/MultipleErrorWrapper' import { postEndpoint } from '../data/api' export default function Review() { - const [value, setValue] = useState('mine') + const [value, setValue] = useState('user') const handleChange = (_event: React.SyntheticEvent, newValue: ReviewFilterType) => { setValue(newValue) @@ -34,7 +34,7 @@ export default function Review() { <> - + diff --git a/server/external/Authorisation.ts b/server/external/Authorisation.ts new file mode 100644 index 000000000..924b024d3 --- /dev/null +++ b/server/external/Authorisation.ts @@ -0,0 +1,9 @@ +// This file is intended to be static. You may alter this file without +// worrying about merge conflicts later on. + +// TypeScript will ensure at build time that any updates made to the +// Authorisation layout are reflected in your class. + +import AuthorisationBase from '../utils/AuthorisationBase' + +export default AuthorisationBase diff --git a/server/models/User.ts b/server/models/User.ts index ad957dba7..bfe43e803 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -12,6 +12,9 @@ const UserSchema = new Schema( // uuidv4() is cryptographically safe token: { type: String, required: true, default: uuidv4(), select: false }, + + // mixed user information provided by authorisation + data: { type: Schema.Types.Mixed }, }, { timestamps: true, diff --git a/server/processors/processDeployments.ts b/server/processors/processDeployments.ts index f85dc6f91..a3d0860b9 100644 --- a/server/processors/processDeployments.ts +++ b/server/processors/processDeployments.ts @@ -1,11 +1,11 @@ -import DeploymentModel from '../models/Deployment' import { deploymentQueue } from '../utils/queues' import config from 'config' import prettyMs from 'pretty-ms' import https from 'https' import logger from '../utils/logger' import { getAccessToken } from '../routes/v1/registryAuth' -import UserModel from '../models/User' +import { getUserByInternalId } from '../services/user' +import { findDeploymentById, markDeploymentBuilt } from '../services/deployment' const httpsAgent = new https.Agent({ rejectUnauthorized: !config.get('registry.insecure'), @@ -17,23 +17,24 @@ export default function processDeployments() { try { const startTime = new Date() - const { deploymentId } = job.data - const deployment = await DeploymentModel.findById(deploymentId).populate('model') + const { deploymentId, userId } = job.data - const dlog = logger.child({ deploymentId: deployment._id }) + const user = await getUserByInternalId(userId) - if (!deployment) { - dlog.error('Unable to find deployment') - throw new Error('Unable to find deployment') + if (!user) { + logger.error('Unable to find deployment owner') + throw new Error('Unable to find deployment owner') } - const user = await UserModel.findById(deployment.owner) + const deployment = await findDeploymentById(user, deploymentId, { populate: true }) - if (!user) { - dlog.error('Unable to find deployment owner') - throw new Error('Unable to find deployment owner') + if (!deployment) { + logger.error('Unable to find deployment') + throw new Error('Unable to find deployment') } + const dlog = logger.child({ deploymentId: deployment._id }) + const { modelID, initialVersionRequested } = deployment.metadata.highLevelDetails const registry = `https://${config.get('registry.host')}/v2` @@ -119,7 +120,7 @@ export default function processDeployments() { deployment.log('info', 'Finalised new manifest') dlog.info('Marking build as successful') - await DeploymentModel.findOneAndUpdate({ _id: deployment._id }, { built: true }) + await markDeploymentBuilt(deployment._id) const time = prettyMs(new Date().getTime() - startTime.getTime()) await deployment.log('info', `Processed deployment with tag '${externalImage}' in ${time}`) diff --git a/server/processors/processUploads.ts b/server/processors/processUploads.ts index cd3c22ac9..7d9fcfb89 100644 --- a/server/processors/processUploads.ts +++ b/server/processors/processUploads.ts @@ -1,17 +1,18 @@ import { buildPython } from '../utils/build' import { uploadQueue } from '../utils/queues' import prettyMs from 'pretty-ms' -import VersionModel from '../models/Version' +import { findVersionById, markVersionBuilt } from '../services/version' import logger from '../utils/logger' +import { getUserById } from '../services/user' export default function processUploads() { uploadQueue.process(async (job) => { logger.info({ job: job.data }, 'Started processing upload') try { const startTime = new Date() - const version = await VersionModel.findOne({ - _id: job.data.versionId, - }).populate('model') + + const user = await getUserById(job.data.userId) + const version = await findVersionById(user, job.data.versionId, { populate: true }) const vlog = logger.child({ versionId: version._id }) @@ -20,7 +21,7 @@ export default function processUploads() { const tag = await buildPython(version, { binary, code }) vlog.info('Marking build as successful') - await VersionModel.findOneAndUpdate({ _id: version._id }, { built: true }) + await markVersionBuilt(version._id) const time = prettyMs(new Date().getTime() - startTime.getTime()) await version.log('info', `Processed job with tag ${tag} in ${time}`) @@ -28,9 +29,8 @@ export default function processUploads() { logger.error({ error: e, versionId: job.data.versionId }, 'Error occurred whilst processing upload') try { - const version = await VersionModel.findOne({ - _id: job.data.versionId, - }).populate('model') + const user = await getUserById(job.data.userId) + const version = await findVersionById(user, job.data.versionId, { populate: true }) await version.log('error', `Failed to process job due to error: '${e}'`) version.state.build = { diff --git a/server/routes/v1/deployment.ts b/server/routes/v1/deployment.ts index 08b51a230..fe5da92b0 100644 --- a/server/routes/v1/deployment.ts +++ b/server/routes/v1/deployment.ts @@ -1,14 +1,14 @@ import { Request, Response } from 'express' import bodyParser from 'body-parser' -import ModelModel from '../../models/Model' import SchemaModel from '../../models/Schema' import { validateSchema } from '../../utils/validateSchema' -import DeploymentModel from '../../models/Deployment' import { customAlphabet } from 'nanoid' import { ensureUserRole } from '../../utils/user' -import VersionModel from '../../models/Version' import { createDeploymentRequests } from '../../services/request' import { BadReq, NotFound, Forbidden } from '../../utils/result' +import { findModelByUuid } from '../../services/model' +import { findVersionByName } from '../../services/version' +import { createDeployment, findDeploymentByUuid, findDeployments } from '../../services/deployment' const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 6) @@ -17,7 +17,7 @@ export const getDeployment = [ async (req: Request, res: Response) => { const { uuid } = req.params - const deployment = await DeploymentModel.findOne({ uuid }) + const deployment = await findDeploymentByUuid(req.user!, uuid) if (!deployment) { throw NotFound({ uuid }, `Unable to find deployment '${uuid}'`) @@ -32,9 +32,9 @@ export const getCurrentUserDeployments = [ async (req: Request, res: Response) => { const { id } = req.params - const deployment = await DeploymentModel.find({ owner: id }) + const deployments = await findDeployments(req.user!, { owner: id }) - return res.json(deployment) + return res.json(deployments) }, ] @@ -62,9 +62,7 @@ export const postDeployment = [ throw NotFound({ errors: schemaIsInvalid }, 'Rejected due to invalid schema') } - const model = await ModelModel.findOne({ - uuid: body.highLevelDetails.modelID, - }) + const model = await findModelByUuid(req.user!, body.highLevelDetails.modelID) if (!model) { throw NotFound( @@ -81,23 +79,20 @@ export const postDeployment = [ const uuid = `${name}-${nanoid()}` req.log.info({ uuid }, `Named deployment '${uuid}'`) - const deployment = new DeploymentModel({ + const deployment = await createDeployment(req.user!, { schemaRef: body.schemaRef, uuid: uuid, model: model._id, metadata: body, - owner: req.user?._id, + owner: req.user!._id, }) req.log.info('Saving deployment model') await deployment.save() - const version = await VersionModel.findOne({ - model: model._id, - version: body.highLevelDetails.initialVersionRequested, - }) + const version = await findVersionByName(req.user!, model._id, body.highLevelDetails.initialVersionRequested) if (!version) { throw NotFound( @@ -121,17 +116,19 @@ export const resetDeploymentApprovals = [ async (req: Request, res: Response) => { const user = req.user const { uuid } = req.params - const deployment = await DeploymentModel.findOne({ uuid }) + const deployment = await findDeploymentByUuid(req.user!, uuid) if (!deployment) { throw BadReq({ uuid }, `Unabled to find version for requested deployment: '${uuid}'`) } if (user?.id !== deployment.metadata.contacts.requester) { throw Forbidden({}, 'You cannot reset the approvals for a deployment you do not own.') } - const version = await VersionModel.findOne({ - model: deployment.model, - version: deployment.metadata.highLevelDetails.initialVersionRequested, - }) + + const version = await findVersionByName( + user!, + deployment.model, + deployment.metadata.highLevelDetails.initialVersionRequested + ) if (!version) { throw BadReq({ uuid }, `Unabled to find version for requested deployment: '${uuid}'`) } diff --git a/server/routes/v1/model.ts b/server/routes/v1/model.ts index 158e90a7e..f3fa78a49 100644 --- a/server/routes/v1/model.ts +++ b/server/routes/v1/model.ts @@ -1,30 +1,25 @@ -import ModelModel from '../../models/Model' import { Request, Response } from 'express' import SchemaModel from '../../models/Schema' -import VersionModel from '../../models/Version' -import DeploymentModel from '../../models/Deployment' import { ensureUserRole } from '../../utils/user' -import { NotFound } from '../../utils/result' +import { BadReq, NotFound } from '../../utils/result' +import { findModelById, findModelByUuid, findModels, isValidFilter, isValidType } from '../../services/model' +import { findModelVersions, findVersionById, findVersionByName } from '../../services/version' +import { findDeployments } from '../../services/deployment' export const getModels = [ ensureUserRole('user'), async (req: Request, res: Response) => { const { type, filter } = req.query - const query: any = filter ? { $text: { $search: filter as string } } : {} - if (type === 'favourites') { - req.log.info('Limiting model requests to favourites') - query._id = { - $in: req.user?.favourites, - } + if (!isValidType(type)) { + throw BadReq({ type }, `Provided invalid type '${type}'`) } - if (type === 'mine') { - req.log.info('Limiting model requests to mine') - query.owner = req.user?._id + if (!isValidFilter(filter)) { + throw BadReq({ filter }, `Provided invalid filter '${filter}'`) } - const models = await ModelModel.find(query).sort({ updatedAt: -1 }) + const models = await findModels(req.user!, { filter: filter as string, type }) return res.json({ models, @@ -37,7 +32,7 @@ export const getModelByUuid = [ async (req: Request, res: Response) => { const { uuid } = req.params - const model = await ModelModel.findOne({ uuid }) + const model = await findModelByUuid(req.user!, uuid) if (!model) { throw NotFound({ uuid }, `Unable to find model '${uuid}'`) @@ -52,7 +47,7 @@ export const getModelById = [ async (req: Request, res: Response) => { const { id } = req.params - const model = await ModelModel.findOne({ _id: id }) + const model = await findModelById(req.user!, id) if (!model) { throw NotFound({ id }, `Unable to find model '${id}'`) @@ -67,15 +62,13 @@ export const getModelDeployments = [ async (req: Request, res: Response) => { const { uuid } = req.params - const model = await ModelModel.findOne({ uuid }) + const model = await findModelByUuid(req.user!, uuid) if (!model) { throw NotFound({ uuid }, `Unable to find model '${uuid}'`) } - const deployments = await DeploymentModel.find({ - model: model._id, - }) + const deployments = await findDeployments(req.user!, { model: model._id }) return res.json(deployments) }, @@ -86,7 +79,7 @@ export const getModelSchema = [ async (req: Request, res: Response) => { const { uuid } = req.params - const model = await ModelModel.findOne({ uuid }) + const model = await findModelByUuid(req.user!, uuid) if (!model) { throw NotFound({ uuid }, `Unable to find model '${uuid}'`) @@ -107,13 +100,13 @@ export const getModelVersions = [ async (req: Request, res: Response) => { const { uuid } = req.params - const model = await ModelModel.findOne({ uuid }) + const model = await findModelByUuid(req.user!, uuid) if (!model) { throw NotFound({ uuid }, `Unable to find model '${uuid}'`) } - const versions = await VersionModel.find({ model: model._id }, { state: 0, logs: 0, metadata: 0 }) + const versions = await findModelVersions(req.user!, model._id, { thin: true }) return res.json(versions) }, @@ -124,7 +117,7 @@ export const getModelVersion = [ async (req: Request, res: Response) => { const { uuid, version: versionName } = req.params - const model = await ModelModel.findOne({ uuid }) + const model = await findModelByUuid(req.user!, uuid) if (!model) { throw NotFound({ uuid }, `Unable to find model '${uuid}'`) @@ -132,9 +125,9 @@ export const getModelVersion = [ let version if (versionName === 'latest') { - version = await VersionModel.findOne({ _id: model.versions[model.versions.length - 1] }) + version = await findVersionById(req.user!, model.versions[model.versions.length - 1]) } else { - version = await VersionModel.findOne({ model: model._id, version: versionName }) + version = await findVersionByName(req.user!, model._id, versionName) } if (!version) { diff --git a/server/routes/v1/requests.ts b/server/routes/v1/requests.ts index 5f2445778..daadb2fb0 100644 --- a/server/routes/v1/requests.ts +++ b/server/routes/v1/requests.ts @@ -1,17 +1,17 @@ import { Request, Response } from 'express' import bodyParser from 'body-parser' -import { Document, ObjectId } from 'mongoose' +import { Document, Types } from 'mongoose' import { ensureUserRole, hasRole } from '../../utils/user' -import VersionModel from '../../models/Version' -import DeploymentModel from '../../models/Deployment' import { deploymentQueue } from '../../utils/queues' import { getRequest, readNumRequests, readRequests, RequestType } from '../../services/request' import { RequestStatusType } from '../../../types/interfaces' -import UserModel from '../../models/User' +import { getUserById, getUserByInternalId } from '../../services/user' import { BadReq, Unauthorised } from '../../utils/result' import { reviewedRequest } from '../../templates/reviewedRequest' import { sendEmail } from '../../utils/smtp' +import { findVersionById } from '../../services/version' +import { findDeploymentById } from '../../services/deployment' export const getRequests = [ ensureUserRole('user'), @@ -85,12 +85,12 @@ export const postRequestResponse = [ field = 'reviewerApproved' } - let userId: ObjectId + let userId: Types.ObjectId let requestType: RequestType let document: Document & { model: any; uuid: string } if (request.version) { - const version = await VersionModel.findById(request.version).populate('model') + const version = await findVersionById(req.user!, request.version._id, { populate: true }) userId = version.model.owner requestType = 'Upload' document = version @@ -98,7 +98,7 @@ export const postRequestResponse = [ version[field] = choice await version.save() } else if (request.deployment) { - const deployment = await DeploymentModel.findById(request.deployment).populate('model') + const deployment = await findDeploymentById(req.user!, request.deployment._id, { populate: true }) userId = deployment.model.owner requestType = 'Deployment' document = deployment @@ -113,6 +113,7 @@ export const postRequestResponse = [ await deploymentQueue .createJob({ deploymentId: deployment._id, + userId, }) .timeout(60000) .retries(2) @@ -122,7 +123,7 @@ export const postRequestResponse = [ throw BadReq({ requestId: request._id }, 'Unable to determine request type') } - const user = await UserModel.findById(userId) + const user = await getUserByInternalId(userId) if (user.email) { await sendEmail({ to: user.email, diff --git a/server/routes/v1/upload.ts b/server/routes/v1/upload.ts index 482c34623..bfa522daa 100644 --- a/server/routes/v1/upload.ts +++ b/server/routes/v1/upload.ts @@ -3,19 +3,19 @@ import config from 'config' import { v4 as uuidv4 } from 'uuid' import { customAlphabet } from 'nanoid' -import ModelModel from '../../models/Model' import { validateSchema } from '../../utils/validateSchema' import SchemaModel from '../../models/Schema' import { normalizeMulterFile } from '../../utils/multer' import MinioStore from '../../utils/MinioStore' import { uploadQueue } from '../../utils/queues' -import VersionModel from '../../models/Version' import { ensureUserRole } from '../../utils/user' import { Request, Response } from 'express' import mongoose from 'mongoose' import { createVersionRequests } from '../../services/request' import { BadReq } from '../../utils/result' +import { findModelByUuid, createModel } from '../../services/model' +import { createVersion } from '../../services/version' export interface MinioFile { [fieldname: string]: Array @@ -96,14 +96,12 @@ export const postUpload = [ }) } - // create a version instance - const version = new VersionModel({ - version: metadata.highLevelDetails.modelCardVersion, - metadata: metadata, - }) - + let version try { - await version.save() + version = await createVersion(req.user!, { + version: metadata.highLevelDetails.modelCardVersion, + metadata: metadata, + }) } catch (err: any) { if (err.toString().includes('duplicate key error')) { return res.status(409).json({ @@ -121,9 +119,7 @@ export const postUpload = [ let parentId if (req.body.parent) { req.log.info({ parent: req.body.parent }, 'Uploaded model has parent') - const parentModel = await ModelModel.findOne({ - uuid: req.body.parent, - }) + const parentModel = await findModelByUuid(req.user!, req.body.parent) if (!parentModel) { req.log.warn({ parent: req.body.parent }, 'Could not find parent') @@ -142,12 +138,13 @@ export const postUpload = [ if (mode === 'newVersion') { // Update an existing model's version array const modelUuid = req.query.modelUuid - model = await ModelModel.findOne({ uuid: modelUuid }) + + model = await findModelByUuid(req.user!, modelUuid as string) model.versions.push(version._id) model.currentMetadata = metadata } else { // Save a new model, and add the uploaded version to its array - model = new ModelModel({ + model = await createModel(req.user!, { schemaRef: metadata.schemaRef, uuid: `${name}-${nanoid()}`, @@ -155,7 +152,7 @@ export const postUpload = [ versions: [version._id], currentMetadata: metadata, - owner: req.user?._id, + owner: req.user!._id, }) } @@ -177,6 +174,7 @@ export const postUpload = [ const job = await uploadQueue .createJob({ versionId: version._id, + userId: req.user?._id, binary: normalizeMulterFile(files.binary[0]), code: normalizeMulterFile(files.code[0]), }) diff --git a/server/routes/v1/users.ts b/server/routes/v1/users.ts index d6b3ce5fd..c949809ee 100644 --- a/server/routes/v1/users.ts +++ b/server/routes/v1/users.ts @@ -1,15 +1,15 @@ import { ensureUserRole } from '../../utils/user' import { Request, Response } from 'express' import { v4 as uuidv4 } from 'uuid' -import UserModel from '../../models/User' import logger from '../../utils/logger' -import ModelModel from '../../models/Model' import { BadReq, NotFound } from '../../utils/result' +import { findModelById } from '../../services/model' +import { findUsers, getUserById, getUserByInternalId } from '../../services/user' export const getUsers = [ ensureUserRole('user'), async (_req: Request, res: Response) => { - const users = await UserModel.find({}).select('-token') + const users = await findUsers() return res.json({ users, }) @@ -20,7 +20,7 @@ export const getLoggedInUser = [ ensureUserRole('user'), async (req: Request, res: Response) => { const _id = req.user!._id - const user = await UserModel.findOne({ _id }) + const user = await getUserByInternalId(_id) return res.json(user) }, ] @@ -48,8 +48,8 @@ export const favouriteModel = [ throw BadReq({}, `Model ID must be a string`) } - const user = await UserModel.findOne({ id: req.user!.id }) - const model = await ModelModel.findById({ _id: modelId }) + const user = await getUserById(req.user!.id) + const model = await findModelById(req.user!, modelId) if (user.favourites.includes(modelId)) { // model already favourited @@ -75,8 +75,8 @@ export const unfavouriteModel = [ throw BadReq({}, `Model ID must be a string`) } - const user = await UserModel.findOne({ id: req.user!.id }) - const model = await ModelModel.findById({ _id: modelId }) + const user = await getUserById(req.user!.id) + const model = await findModelById(req.user!, modelId) if (!user.favourites.includes(modelId)) { // model not favourited diff --git a/server/routes/v1/version.ts b/server/routes/v1/version.ts index 82b6e30cd..68eaf617e 100644 --- a/server/routes/v1/version.ts +++ b/server/routes/v1/version.ts @@ -1,16 +1,16 @@ -import VersionModel from '../../models/Version' import { Request, Response } from 'express' import { ensureUserRole } from '../../utils/user' import bodyParser from 'body-parser' import { createVersionRequests } from '../../services/request' import { Forbidden, NotFound, BadReq } from '../../utils/result' +import { findVersionById } from '../../services/version' export const getVersion = [ ensureUserRole('user'), async (req: Request, res: Response) => { const { id } = req.params - const version = await VersionModel.findOne({ _id: id }) + const version = await findVersionById(req.user!, id) if (!version) { throw NotFound({ versionId: id }, 'Unable to find version') @@ -28,7 +28,7 @@ export const putVersion = [ const { id } = req.params const metadata = req.body - const version = await VersionModel.findOne({ _id: id }).populate('model') + const version = await findVersionById(req.user!, id, { populate: true }) if (!version) { throw NotFound({ id: id }, 'Unable to find version') @@ -56,7 +56,7 @@ export const resetVersionApprovals = [ async (req: Request, res: Response) => { const { id } = req.params const user = req.user - const version = await VersionModel.findOne({ _id: id }).populate('model') + const version = await findVersionById(req.user!, id, { populate: true }) if (!version) { throw BadReq({}, 'Unabled to find version for requested deployment') } diff --git a/server/scripts/addFakeData.ts b/server/scripts/addFakeData.ts index 8597234f4..e8c6b8c42 100644 --- a/server/scripts/addFakeData.ts +++ b/server/scripts/addFakeData.ts @@ -1,10 +1,16 @@ -import { findUser } from '../utils/user' +import { findAndUpdateUser } from '../services/user' import { connectToMongoose, disconnectFromMongoose } from '../utils/database' ;(async () => { await connectToMongoose() // create users - await findUser('user') + await findAndUpdateUser({ + userId: 'user', + email: 'user@example.com', + data: { + special: 'data', + }, + }) setTimeout(disconnectFromMongoose, 50) })() diff --git a/server/scripts/triggerDeployment.ts b/server/scripts/triggerDeployment.ts index 4748adc87..18d2df1c9 100644 --- a/server/scripts/triggerDeployment.ts +++ b/server/scripts/triggerDeployment.ts @@ -1,9 +1,9 @@ -import DeploymentModel from '../models/Deployment' import { deploymentQueue } from '../utils/queues' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' import logger from '../utils/logger' import { connectToMongoose, disconnectFromMongoose } from '../utils/database' +import DeploymentModel from '../models/Deployment' ;(async () => { await connectToMongoose() diff --git a/server/services/deployment.ts b/server/services/deployment.ts new file mode 100644 index 000000000..54d64c060 --- /dev/null +++ b/server/services/deployment.ts @@ -0,0 +1,78 @@ +import { castArray } from 'lodash' + +import { Forbidden } from '../utils/result' +import DeploymentModel from '../models/Deployment' +import { Deployment, User, ModelId } from '../../types/interfaces' +import AuthorisationBase from '../utils/AuthorisationBase' +import { asyncFilter } from '../utils/general' + +const authorisation = new AuthorisationBase() + +interface GetDeploymentOptions { + populate?: boolean +} + +export async function filterDeployment(user: User, unfiltered: T): Promise { + const deployments = castArray(unfiltered) + + const filtered = await asyncFilter(deployments, (deployment: Deployment) => + authorisation.canUserSeeDeployment(user, deployment) + ) + + return Array.isArray(unfiltered) ? (filtered as unknown as T) : filtered[0] +} + +export async function findDeploymentByUuid(user: User, uuid: string, opts?: GetDeploymentOptions) { + let deployment = await DeploymentModel.findOne({ uuid }) + if (opts?.populate) deployment = deployment.populate('model') + + return filterDeployment(user, deployment) +} + +export async function findDeploymentById(user: User, id: ModelId, opts?: GetDeploymentOptions) { + let deployment = await DeploymentModel.findById(id) + if (opts?.populate) deployment = deployment.populate('model') + + return filterDeployment(user, deployment) +} + +export interface DeploymentFilter { + owner?: ModelId + model?: ModelId +} + +export async function findDeployments(user: User, { owner, model }: DeploymentFilter) { + const query: any = {} + + if (owner) query.owner = owner + if (model) query.model = model + + const models = await DeploymentModel.find(query).sort({ updatedAt: -1 }) + return filterDeployment(user, models) +} + +export async function markDeploymentBuilt(_id: ModelId) { + return DeploymentModel.findByIdAndUpdate(_id, { built: true }) +} + +interface CreateDeployment { + schemaRef: string + uuid: string + + model: ModelId + metadata: any + + owner: ModelId +} + +export async function createDeployment(user: User, data: CreateDeployment) { + const deployment = new DeploymentModel(data) + + if (!authorisation.canUserSeeDeployment(user, deployment)) { + throw Forbidden({ data }, 'Unable to create deployment, failed permissions check.') + } + + await deployment.save() + + return deployment +} diff --git a/server/services/model.ts b/server/services/model.ts new file mode 100644 index 000000000..2aeadcf5e --- /dev/null +++ b/server/services/model.ts @@ -0,0 +1,68 @@ +import { Types } from 'mongoose' +import { castArray } from 'lodash' + +import { Forbidden } from '../utils/result' +import ModelModel from '../models/Model' +import { Model, User } from '../../types/interfaces' +import AuthorisationBase from '../utils/AuthorisationBase' +import { asyncFilter } from '../utils/general' + +const authorisation = new AuthorisationBase() + +export async function filterModel(user: User, unfiltered: T): Promise { + const models = castArray(unfiltered) + + const filtered = await asyncFilter(models, (model: Model) => authorisation.canUserSeeModel(user, model)) + + return Array.isArray(unfiltered) ? (filtered as unknown as T) : filtered[0] +} + +export async function findModelByUuid(user: User, uuid: string) { + const model = await ModelModel.findOne({ uuid }) + return filterModel(user, model) +} + +export async function findModelById(user: User, id: string | Types.ObjectId) { + const model = await ModelModel.findById(id) + return filterModel(user, model) +} + +export interface ModelFilter { + filter?: string + type: 'favourites' | 'user' | 'all' +} + +export function isValidType(type: any): type is 'favourites' | 'user' | 'all' { + return typeof type === 'string' && ['favourites', 'user', 'all'].includes(type) +} + +export function isValidFilter(filter: any): filter is string { + return typeof filter === 'string' +} + +export async function findModels(user: User, { filter, type }: ModelFilter) { + const query: any = {} + + if (filter) query.$text = { $search: filter as string } + + if (type === 'favourites') { + query._id = { $in: user.favourites } + } else if (type === 'user') { + query.owner = user._id + } + + const models = await ModelModel.find(query).sort({ updatedAt: -1 }) + return filterModel(user, models) +} + +export async function createModel(user: User, data: Model) { + const model = new ModelModel(data) + + if (!authorisation.canUserSeeModel(user, model)) { + throw Forbidden({ data }, 'Unable to create model, failed permissions check.') + } + + await model.save() + + return model +} diff --git a/server/services/request.ts b/server/services/request.ts index 5bcc1d724..d42b7d950 100644 --- a/server/services/request.ts +++ b/server/services/request.ts @@ -1,15 +1,13 @@ import { Document, Types } from 'mongoose' -import UserModel from '../models/User' import { Deployment, Request, User, Version } from '../../types/interfaces' import RequestModel from '../models/Request' import { BadReq } from '../utils/result' import { sendEmail } from '../utils/smtp' import { reviewRequest } from '../templates/reviewRequest' +import { getUserById } from './user' export async function createDeploymentRequests({ version, deployment }: { version: Version; deployment: Deployment }) { - const manager = await UserModel.findOne({ - id: version.metadata.contacts.manager, - }) + const manager = await getUserById(version.metadata.contacts.manager) if (!manager) { throw BadReq( @@ -27,12 +25,8 @@ export async function createDeploymentRequests({ version, deployment }: { versio export async function createVersionRequests({ version }: { version: Version }) { const [manager, reviewer] = await Promise.all([ - UserModel.findOne({ - id: version.metadata.contacts.manager, - }), - UserModel.findOne({ - id: version.metadata.contacts.reviewer, - }), + getUserById(version.metadata.contacts.manager), + getUserById(version.metadata.contacts.reviewer), ]) if (!manager) { diff --git a/server/services/user.ts b/server/services/user.ts new file mode 100644 index 000000000..f8c571407 --- /dev/null +++ b/server/services/user.ts @@ -0,0 +1,49 @@ +import { Types } from 'mongoose' +import memoize from 'memoizee' + +import UserModel from '../models/User' +import { ModelId } from '../../types/interfaces' + +interface GetUserOptions { + includeToken?: boolean +} + +export async function getUserById(id: ModelId, opts?: GetUserOptions) { + let user = UserModel.findOne({ id }) + if (opts?.includeToken) user = user.select('+token') + + return user +} + +export async function getUserByInternalId(_id: string | Types.ObjectId, opts?: GetUserOptions) { + let user = UserModel.findById(_id) + if (opts?.includeToken) user = user.select('+token') + + return user +} + +export async function findUsers() { + return UserModel.find({}) +} + +interface FindAndUpdateUserArgs { + userId: string + email?: string + data?: any +} + +export async function findAndUpdateUser({ userId, email, data }: FindAndUpdateUserArgs) { + // findOneAndUpdate is atomic, so we don't need to worry about + // multiple threads calling this simultaneously. + return await UserModel.findOneAndUpdate( + { $or: [{ id: userId }, { email }] }, + { id: userId, email, data }, // upsert docs + { new: true, upsert: true } + ) +} + +export const findUserCached = memoize(findAndUpdateUser, { + promise: true, + maxAge: 5000, + normalizer: (args: [FindAndUpdateUserArgs]) => JSON.stringify(args), +}) diff --git a/server/services/version.ts b/server/services/version.ts new file mode 100644 index 000000000..ca5beb1d4 --- /dev/null +++ b/server/services/version.ts @@ -0,0 +1,83 @@ +import { castArray } from 'lodash' +import VersionModel from '../models/Version' + +import { Version, User, ModelId } from '../../types/interfaces' +import AuthorisationBase from '../utils/AuthorisationBase' +import { asyncFilter } from '../utils/general' +import { Forbidden } from '../utils/result' + +const authorisation = new AuthorisationBase() + +interface GetVersionOptions { + thin?: boolean + populate?: boolean +} + +export async function filterVersion(user: User, unfiltered: T): Promise { + const versions = castArray(unfiltered) + + const filtered = await asyncFilter(versions, (version: Version) => authorisation.canUserSeeVersion(user, version)) + + return Array.isArray(unfiltered) ? (filtered as unknown as T) : filtered[0] +} + +export async function findVersionById(user: User, id: ModelId, opts?: GetVersionOptions) { + let version = VersionModel.findById(id) + if (opts?.thin) version = version.select({ state: 0, logs: 0, metadata: 0 }) + if (opts?.populate) version = version.populate('model') + + return filterVersion(user, version) +} + +export async function findVersionByName(user: User, model: ModelId, name: string, opts?: GetVersionOptions) { + let version = VersionModel.findOne({ model, version: name }) + if (opts?.thin) version = version.select({ state: 0, logs: 0, metadata: 0 }) + if (opts?.populate) version = version.populate('model') + + return filterVersion(user, version) +} + +export async function findModelVersions(user: User, model: ModelId, opts?: GetVersionOptions) { + let versions = VersionModel.find({ model }) + if (opts?.thin) versions = versions.select({ state: 0, logs: 0, metadata: 0 }) + if (opts?.populate) versions = versions.populate('model') + + return filterVersion(user, versions) +} + +export async function markVersionBuilt(_id: ModelId) { + return VersionModel.findByIdAndUpdate(_id, { built: true }) +} + +export async function markVersionState(user: User, _id: ModelId, state: string) { + const version = await findVersionById(user, _id) + + version.state.build = { + ...(version.state.build || {}), + state, + } + + if (state === 'succeeded') { + version.state.build.reason = undefined + } + + version.markModified('state') + await version.save() +} + +interface CreateVersion { + version: string + metadata: any +} + +export async function createVersion(user: User, data: CreateVersion) { + const version = new VersionModel(data) + + if (!authorisation.canUserSeeVersion(user, version)) { + throw Forbidden({ data }, 'Unable to create version, failed permissions check.') + } + + await version.save() + + return version +} diff --git a/server/utils/AuthorisationBase.ts b/server/utils/AuthorisationBase.ts new file mode 100644 index 000000000..b34609078 --- /dev/null +++ b/server/utils/AuthorisationBase.ts @@ -0,0 +1,30 @@ +import { User, Model, Deployment, Version } from '../../types/interfaces' +import { Request } from 'express' + +export default class AuthorisationBase { + constructor() {} + + async getUserFromReq(req: Request) { + const userId = req.get('x-userid') + const email = req.get('x-email') + const data = JSON.parse(req.get('x-user') ?? '{}') + + return { + userId, + email, + data, + } + } + + async canUserSeeModel(_user: User, _model: Model) { + return true + } + + async canUserSeeVersion(_user: User, _model: Version) { + return true + } + + async canUserSeeDeployment(_user: User, _deployment: Deployment) { + return true + } +} diff --git a/server/utils/general.ts b/server/utils/general.ts new file mode 100644 index 000000000..baecdf532 --- /dev/null +++ b/server/utils/general.ts @@ -0,0 +1,3 @@ +export async function asyncFilter(arr: Array, predicate: any): Promise> { + return Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])) +} diff --git a/server/utils/queues.ts b/server/utils/queues.ts index ea33a5cb4..053ea4e18 100644 --- a/server/utils/queues.ts +++ b/server/utils/queues.ts @@ -1,9 +1,10 @@ import Queue from 'bee-queue' import config from 'config' -import DeploymentModel from '../models/Deployment' -import VersionModel from '../models/Version' import { simpleEmail } from '../templates/simpleEmail' import { sendEmail } from './smtp' +import { findVersionById, markVersionState } from '../services/version' +import { getUserByInternalId } from '../services/user' +import { findDeploymentById } from '../services/deployment' export const uploadQueue = new Queue('UPLOAD_QUEUE', { redis: config.get('redis'), @@ -18,22 +19,11 @@ export const deploymentQueue = new Queue('DEPLOYMENT_QUEUE', { async function setUploadState(jobId: string, state: string) { const job = await uploadQueue.getJob(jobId) - const version = await VersionModel.findById(job.data.versionId).populate({ - path: 'model', - populate: { path: 'owner' }, - }) - version.state.build = { - ...(version.state.build || {}), - state, - } + const user = await getUserByInternalId(job.data.userId) + const version = await findVersionById(user, job.data.versionId, { populate: true }) - if (state === 'succeeded') { - version.state.build.reason = undefined - } - - version.markModified('state') - await version.save() + await markVersionState(user, job.data.versionId, state) if (!version.model.owner.email) { return @@ -71,9 +61,11 @@ uploadQueue.on('job failed', async (jobId) => { async function sendDeploymentEmail(jobId: string, state: string) { const job = await deploymentQueue.getJob(jobId) - const deployment = await DeploymentModel.findById(job.data.deploymentId).populate('owner').populate('model') - if (!deployment.owner.email) { + const user = await getUserByInternalId(job.data.userId) + const deployment = await findDeploymentById(user, job.data.deploymentId, { populate: true }) + + if (!user.email) { return } @@ -81,7 +73,7 @@ async function sendDeploymentEmail(jobId: string, state: string) { const base = `${config.get('app.protocol')}://${config.get('app.host')}:${config.get('app.port')}` await sendEmail({ - to: deployment.owner.email, + to: user.email, ...simpleEmail({ text: `Your deployment for '${deployment.model.currentMetadata.highLevelDetails.name}' has ${message}`, columns: [ diff --git a/server/utils/user.ts b/server/utils/user.ts index 5ecc42654..bea86d5c8 100644 --- a/server/utils/user.ts +++ b/server/utils/user.ts @@ -1,20 +1,12 @@ import { Request, Response, NextFunction } from 'express' -import UserModel from '../models/User' -import memoize from 'memoizee' import { User } from '../../types/interfaces' import { timingSafeEqual } from 'crypto' import { getAdminToken } from '../routes/v1/registryAuth' import { Forbidden, Unauthorised } from './result' +import Authorisation from '../external/Authorisation' +import { findAndUpdateUser, findUserCached, getUserById } from '../services/user' -export async function findUser(userId: string, email?: string) { - // findOneAndUpdate is atomic, so we don't need to worry about - // multiple threads calling this simultaneously. - return await UserModel.findOneAndUpdate( - { $or: [{ id: userId }, { email }] }, - { id: userId, email }, // upsert docs - { new: true, upsert: true } - ) -} +const authorisation = new Authorisation() function safelyCompareTokens(expected, actual) { // This is not constant time, which will allow a user to calculate the length @@ -51,9 +43,7 @@ export async function getUserFromAuthHeader(header: string): Promise<{ error?: s return { user: { _id: '', id: '' }, admin: true } } - const user = await UserModel.findOne({ - id: username, - }).select('+token') + const user = await getUserById(username, { includeToken: true }) if (!user) { return { error: 'User not found' } @@ -68,20 +58,15 @@ export async function getUserFromAuthHeader(header: string): Promise<{ error?: s return { user } } -// cache user status for -const findUserCached = memoize(findUser, { - promise: true, - primitive: true, - maxAge: 5000, -}) - export async function getUser(req: Request, _res: Response, next: NextFunction) { - const userId = req.get('x-userid') - const email = req.get('x-email') + // this function must never fail to call next, even when + // no user is found. + + const userInfo = await authorisation.getUserFromReq(req) - if (!userId || !email) return next() + if (!userInfo.userId || !userInfo.email) return next() - const user = await findUserCached(userId, email) + const user = await findUserCached(userInfo) req.user = user next() diff --git a/types/interfaces.ts b/types/interfaces.ts index 462c66923..3d23e41f0 100644 --- a/types/interfaces.ts +++ b/types/interfaces.ts @@ -184,3 +184,5 @@ export interface SplitSchema { steps: Array } + +export type ModelId = string | Types.ObjectId