From 35d23cf55c197b27b43966e25db242021f27d844 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 9 Jan 2023 01:03:40 +0700 Subject: [PATCH] Finish preliminary /v2/secrets routes for batch/single CRUD secrets endpoints --- backend/src/app.ts | 10 +- backend/src/controllers/v2/index.ts | 4 +- .../src/controllers/v2/secretController.ts | 232 ++++----- .../src/controllers/v2/secretsController.ts | 472 ++++++++++++++++++ backend/src/ee/models/secretVersion.ts | 6 +- backend/src/helpers/secret.ts | 47 +- backend/src/middleware/index.ts | 2 + backend/src/middleware/requireSecretAuth.ts | 5 +- backend/src/middleware/requireSecretsAuth.ts | 49 ++ backend/src/models/secret.ts | 6 +- backend/src/routes/v2/index.ts | 4 +- backend/src/routes/v2/secret.ts | 71 ++- backend/src/routes/v2/secrets.ts | 177 +++++++ backend/src/types/express/index.d.ts | 4 +- 14 files changed, 918 insertions(+), 171 deletions(-) create mode 100644 backend/src/controllers/v2/secretsController.ts create mode 100644 backend/src/middleware/requireSecretsAuth.ts create mode 100644 backend/src/routes/v2/secrets.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 000fa647d..06f0c7a0b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -38,6 +38,7 @@ import { } from './routes/v1'; import { secret as v2SecretRouter, + secrets as v2SecretsRouter, workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, @@ -91,16 +92,17 @@ app.use('/api/v1/membership', v1MembershipRouter); app.use('/api/v1/key', v1KeyRouter); app.use('/api/v1/invite-org', v1InviteOrgRouter); app.use('/api/v1/secret', v1SecretRouter); -app.use('/api/v1/service-token', v1ServiceTokenRouter); // deprecate +app.use('/api/v1/service-token', v1ServiceTokenRouter); // stop supporting app.use('/api/v1/password', v1PasswordRouter); app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes -app.use('/api/v2/workspace', v2WorkspaceRouter); -app.use('/api/v2/secret', v2SecretRouter); -app.use('/api/v2/service-token', v2ServiceTokenDataRouter); +app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route +app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise +app.use('/api/v2/secrets', v2SecretsRouter); +app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key-data', v2APIKeyDataRouter); diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 2c5cce660..1651c09ee 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -2,10 +2,12 @@ import * as workspaceController from './workspaceController'; import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; +import * as secretsController from './secretsController'; export { workspaceController, serviceTokenDataController, apiKeyDataController, - secretController + secretController, + secretsController } diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index f291dd1e7..b993d3811 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -7,13 +7,16 @@ const { ValidationError } = mongoose.Error; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; -import { validateMembership } from "../../helpers/membership"; -import { ADMIN, MEMBER } from '../../variables'; import { postHogClient } from '../../services'; -export const createSingleSecret = async (req: Request, res: Response) => { +/** + * Create secret for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecret = async (req: Request, res: Response) => { const secretToCreate: CreateSecretRequestBody = req.body.secret; - const { workspaceId, environmentName } = req.params + const { workspaceId, environment } = req.params const sanitizedSecret: SanitizedSecretForCreate = { secretKeyCiphertext: secretToCreate.secretKeyCiphertext, secretKeyIV: secretToCreate.secretKeyIV, @@ -28,13 +31,13 @@ export const createSingleSecret = async (req: Request, res: Response) => { secretCommentTag: secretToCreate.secretCommentTag, secretCommentHash: secretToCreate.secretCommentHash, workspace: new Types.ObjectId(workspaceId), - environment: environmentName, + environment, type: secretToCreate.type, user: new Types.ObjectId(req.user._id) } - const [error, newlyCreatedSecret] = await to(Secret.create(sanitizedSecret).then()) + const [error, secret] = await to(Secret.create(sanitizedSecret).then()) if (error instanceof ValidationError) { throw RouteValidationError({ message: error.message, stack: error.stack }) } @@ -45,20 +48,27 @@ export const createSingleSecret = async (req: Request, res: Response) => { distinctId: req.user.email, properties: { numberOfSecrets: 1, - environment: environmentName, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); - } + } - res.status(200).send() + res.status(200).send({ + secret + }) } -export const batchCreateSecrets = async (req: Request, res: Response) => { +/** + * Create many secrets for workspace wiht id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecrets = async (req: Request, res: Response) => { const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets; - const { workspaceId, environmentName } = req.params + const { workspaceId, environment } = req.params const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = [] secretsToCreate.forEach(rawSecret => { @@ -76,7 +86,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { secretCommentTag: rawSecret.secretCommentTag, secretCommentHash: rawSecret.secretCommentHash, workspace: new Types.ObjectId(workspaceId), - environment: environmentName, + environment, type: rawSecret.type, user: new Types.ObjectId(req.user._id) } @@ -84,7 +94,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { sanitizedSecretesToCreate.push(safeUpdateFields) }) - const [bulkCreateError, newlyCreatedSecrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then()) + const [bulkCreateError, secrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then()) if (bulkCreateError) { if (bulkCreateError instanceof ValidationError) { throw RouteValidationError({ message: bulkCreateError.message, stack: bulkCreateError.stack }) @@ -99,18 +109,25 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { distinctId: req.user.email, properties: { numberOfSecrets: (secretsToCreate ?? []).length, - environment: environmentName, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); } - res.status(200).send() + res.status(200).send({ + secrets + }) } -export const batchDeleteSecrets = async (req: Request, res: Response) => { +/** + * Delete secrets in workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const deleteSecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretIdsToDelete: string[] = req.body.secretIds @@ -149,8 +166,8 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { numberOfSecrets: numSecretsDeleted, environment: environmentName, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); } @@ -158,49 +175,40 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { res.status(200).send() } -export const deleteSingleSecret = async (req: Request, res: Response) => { - const { secretId } = req.params; +/** + * Delete secret with id [secretId] + * @param req + * @param res + */ +export const deleteSecret = async (req: Request, res: Response) => { + await Secret.findByIdAndDelete(req._secret._id) - const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) - if (error instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + workspaceId: req._secret.workspace.toString(), + environment: req._secret.environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); } - if (singleSecretRetrieved) { - const [membershipValidationError, membership] = await to(validateMembership({ - userId: req.user._id, - workspaceId: singleSecretRetrieved.workspace._id.toString(), - acceptedRoles: [ADMIN, MEMBER] - })) - - if (membershipValidationError || !membership) { - throw UnauthorizedRequestError() - } - - await Secret.findByIdAndDelete(secretId) - - if (postHogClient) { - postHogClient.capture({ - event: 'secrets deleted', - distinctId: req.user.email, - properties: { - numberOfSecrets: 1, - // #TODO: how do we get env name ans project id? - // environment: environmentName, - // workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' - } - }); - } - - res.status(200).send() - } else { - throw BadRequestError() - } + res.status(200).send({ + secret: req._secret + }) } -export const batchModifySecrets = async (req: Request, res: Response) => { +/** + * Update secrets for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + * @returns + */ +export const updateSecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; const [secretIdsUserCanModifyError, secretIdsUserCanModify] = await to(Secret.find({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then()) @@ -252,8 +260,8 @@ export const batchModifySecrets = async (req: Request, res: Response) => { numberOfSecrets: (secretsModificationsRequested ?? []).length, environment: environmentName, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); } @@ -261,8 +269,13 @@ export const batchModifySecrets = async (req: Request, res: Response) => { return res.status(200).send() } -// #TODO: I assume this should be '...Secret'? -export const modifySingleSecrets = async (req: Request, res: Response) => { +/** + * Update a secret within workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + * @returns + */ +export const updateSecret = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretModificationsRequested: ModifySecretRequestBody = req.body.secret; @@ -299,8 +312,8 @@ export const modifySingleSecrets = async (req: Request, res: Response) => { numberOfSecrets: 1, environment: environmentName, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); } @@ -308,11 +321,18 @@ export const modifySingleSecrets = async (req: Request, res: Response) => { return res.status(200).send(singleModificationUpdate) } -export const fetchAllSecrets = async (req: Request, res: Response) => { +/** + * Return secrets for workspace with id [workspaceId], environment [environment] and user + * with id [req.user._id] + * @param req + * @param res + * @returns + */ +export const getSecrets = async (req: Request, res: Response) => { const { environment } = req.query; const { workspaceId } = req.params; - let userId: string | undefined = undefined // Used for choosing the personal secrets to fetch in + let userId: string | undefined = undefined // used for getting personal secrets for user if (req.user) { userId = req.user._id.toString(); } @@ -321,7 +341,7 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { userId = req.serviceTokenData.user._id } - const [retriveAllSecretsError, allSecrets] = await to(Secret.find( + const [err, secrets] = await to(Secret.find( { workspace: workspaceId, environment, @@ -330,8 +350,8 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { } ).then()) - if (retriveAllSecretsError instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secrets, please try again", stack: retriveAllSecretsError.stack }) + if (err) { + throw RouteValidationError({ message: "Failed to get secrets, please try again", stack: err.stack }) } if (postHogClient) { @@ -339,56 +359,40 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { event: 'secrets pulled', distinctId: req.user.email, properties: { - numberOfSecrets: (allSecrets ?? []).length, + numberOfSecrets: (secrets ?? []).length, environment, workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] } }); } - return res.json(allSecrets) + return res.json(secrets) } -export const fetchSingleSecret = async (req: Request, res: Response) => { - const { secretId } = req.params; - - const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) - - if (error instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) - } - - if (singleSecretRetrieved) { - const [membershipValidationError, membership] = await to(validateMembership({ - userId: req.user._id, - workspaceId: singleSecretRetrieved.workspace._id.toString(), - acceptedRoles: [ADMIN, MEMBER] - })) - - if (membershipValidationError || !membership) { - throw UnauthorizedRequestError() - } - - res.json(singleSecretRetrieved) - - if (postHogClient) { - postHogClient.capture({ - event: 'secrets pulled', - distinctId: req.user.email, - properties: { - numberOfSecrets: 1, - // #TODO: how do we get environment and workspace here? Do we need that? When is this route used? - // environment, - // workspaceId, - // #TODO: why does this route have no channel? - // channel: channel ? channel : 'cli' - } - }); - } - - } else { - throw BadRequestError() +/** + * Return secret with id [secretId] + * @param req + * @param res + * @returns + */ +export const getSecret = async (req: Request, res: Response) => { + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pulled', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + workspaceId: req._secret.workspace.toString(), + environment: req._secret.environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); } + + return res.status(200).send({ + secret: req._secret + }); } \ No newline at end of file diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts new file mode 100644 index 000000000..ac8301570 --- /dev/null +++ b/backend/src/controllers/v2/secretsController.ts @@ -0,0 +1,472 @@ +import to from 'await-to-js'; +import { Types } from 'mongoose'; +import { Request, Response } from 'express'; +import { ISecret, Secret } from '../../models'; +import { + SECRET_PERSONAL, + SECRET_SHARED, + ACTION_ADD_SECRETS, + ACTION_READ_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS +} from '../../variables'; +import { ValidationError } from '../../utils/errors'; +import { EESecretService, EELogService } from '../../ee/services'; +import { postHogClient } from '../../services'; + +/** + * Create secret(s) for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + const { workspaceId, environment } = req.body; + + let toAdd; + if (Array.isArray(req.body.secrets)) { + // case: create multiple secrets + toAdd = req.body.secrets; + } else if (typeof req.body.secrets === 'object') { + // case: create 1 secret + toAdd = [req.body.secrets]; + } + + const newSecrets = await Secret.insertMany( + toAdd.map(({ + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + }: { + type: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + type, + user: type === SECRET_PERSONAL ? req.user : undefined, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag + })) + ); + + // (EE) add secret versions for new secrets + EESecretService.addSecretVersions({ + secretVersions: newSecrets.map(({ + _id, + version, + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) => ({ + _id: new Types.ObjectId(), + secret: _id, + version, + workspace, + type, + user, + environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + })) + }); + + const addAction = await EELogService.createActionSecret({ + name: ACTION_ADD_SECRETS, + userId: req.user._id.toString(), + workspaceId, + secretIds: newSecrets.map((n) => n._id) + }); + + // (EE) create (audit) log + addAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId, + actions: [addAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: toAdd.length, + environment, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + return res.status(200).send({ + secrets: newSecrets + }); +} + +/** + * Return secret(s) for workspace with id [workspaceId], environment [environment] and user + * with id [req.user._id] + * @param req + * @param res + * @returns + */ +export const getSecrets = async (req: Request, res: Response) => { + const { workspaceId, environment } = req.query; + + let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user + if (req.user) { + userId = req.user._id; + } + + if (req.serviceTokenData) { + userId = req.serviceTokenData.user._id + } + + const [err, secrets] = await to(Secret.find( + { + workspace: workspaceId, + environment, + $or: [ + { user: userId }, + { user: { $exists: false } } + ], + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + ).then()) + + if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); + + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + + const readAction = await EELogService.createActionSecret({ + name: ACTION_READ_SECRETS, + userId: req.user._id.toString(), + workspaceId: workspaceId as string, + secretIds: secrets.map((n: any) => n._id) + }); + + readAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: workspaceId as string, + actions: [readAction], + channel, + ipAddress: req.ip + }); + + return res.status(200).send({ + secrets + }); +} + +/** + * Update secret(s) in workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const updateSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + const { workspaceId, environment } = req.body; + + // TODO: move type + interface PatchSecret { + id: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + } + + const ops = req.body.secrets.map((secret: PatchSecret) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } = secret; + return ({ + updateOne: { + filter: { _id: new Types.ObjectId(secret.id) }, + update: { + $inc: { + version: 1 + }, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + ...(( + secretCommentCiphertext && + secretCommentIV && + secretCommentTag + ) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : {}), + } + } + }); + }); + const b = await Secret.bulkWrite(ops); + + let newSecretsObj: { [key: string]: PatchSecret } = {}; + req.body.secrets.forEach((secret: PatchSecret) => { + newSecretsObj[secret.id] = secret; + }); + + await EESecretService.addSecretVersions({ + secretVersions: req.secrets.map((secret: ISecret) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } = newSecretsObj[secret._id.toString()] + return ({ + secret: secret._id, + version: secret.version + 1, + workspace: secret.workspace, + type: secret.type, + environment: secret.environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + ...(( + secretCommentCiphertext && + secretCommentIV && + secretCommentTag + ) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : { + secretCommentCiphertext: '', + secretCommentIV: '', + secretCommentTag: '' + }) + }); + }) + }); + + // group secrets into workspaces so updated secrets can + // be logged and snapshotted separately for each workspace + let workspaceSecretObj: any = {}; + req.secrets.forEach((s: any) => { + if (s.workspace.toString() in workspaceSecretObj) { + workspaceSecretObj[s.workspace.toString()].push(s); + } else { + workspaceSecretObj[s.workspace.toString()] = [s] + } + }); + + Object.keys(workspaceSecretObj).forEach(async (key) => { + const updateAction = await EELogService.createActionSecret({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id.toString(), + workspaceId: key, + secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) + }); + + // (EE) create (audit) log + updateAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: key, + actions: [updateAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId: key + }) + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: workspaceSecretObj[key].length, + environment: workspaceSecretObj[key][0].environment, + workspaceId: key, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + }); + + const updateAction = await EELogService.createActionSecret({ + name: ACTION_UPDATE_SECRETS, + userId: req.user._id.toString(), + workspaceId, + secretIds: req.secrets.map((secret: ISecret) => secret._id) + }); + + // (EE) create (audit) log + updateAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId, + actions: [updateAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: req.secrets.length, + environment, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + return res.status(200).send({ + secrets: await Secret.find({ + _id: { + $in: req.secrets.map((secret: ISecret) => secret._id) + } + }) + }); +} + +/** + * Delete secret(s) in workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const deleteSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + const toDelete = req.secrets.map((s: any) => s._id); + + await Secret.deleteMany({ + _id: { + $in: toDelete + } + }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: toDelete + }); + + // group secrets into workspaces so deleted secrets can + // be logged and snapshotted separately for each workspace + let workspaceSecretObj: any = {}; + req.secrets.forEach((s: any) => { + if (s.workspace.toString() in workspaceSecretObj) { + workspaceSecretObj[s.workspace.toString()].push(s); + } else { + workspaceSecretObj[s.workspace.toString()] = [s] + } + }); + + Object.keys(workspaceSecretObj).forEach(async (key) => { + const deleteAction = await EELogService.createActionSecret({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id.toString(), + workspaceId: key, + secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) + }); + + // (EE) create (audit) log + deleteAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: key, + actions: [deleteAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId: key + }) + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: workspaceSecretObj[key].length, + environment: workspaceSecretObj[key][0].environment, + workspaceId: key, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + }); + + return res.status(400).send({ + secrets: req.secrets + }); +} \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 0197c3a25..eb5bdd7e6 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -86,8 +86,7 @@ const secretVersionSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -102,8 +101,7 @@ const secretVersionSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String } }, { diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 920e8dc1d..b8f9ffc9b 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { Secret, ISecret, + Membership } from '../models'; import { EESecretService, @@ -20,6 +21,47 @@ import { ACTION_READ_SECRETS } from '../variables'; +/** + * Validate that user with id [userId] can modify secrets with ids [secretIds] + * @param {Object} obj + * @param {Object} obj.userId - id of user to validate + * @param {Object} obj.secretIds - secret ids + * @returns {Secret[]} secrets + */ +const validateSecrets = async ({ + userId, + secretIds +}: { + userId: string; + secretIds: string[]; +}) =>{ + let secrets; + try { + secrets = await Secret.find({ + _id: { + $in: secretIds + } + }); + + const workspaceIdsSet = new Set((await Membership.find({ + user: userId + }, 'workspace')) + .map((m) => m.workspace.toString())); + + secrets.forEach((secret: ISecret) => { + if (!workspaceIdsSet.has(secret.workspace.toString())) { + throw new Error('Failed to validate secret'); + } + }); + + } catch (err) { + console.error(err); + throw new Error('Failed to validate secrets'); + } + + return secrets; +} + interface V1PushSecret { ciphertextKey: string; ivKey: string; @@ -258,6 +300,7 @@ const v1PushSecrets = async ({ secretValueTag, secretValueHash }) => ({ + _id: new Types.ObjectId(), secret: _id, version, workspace, @@ -280,7 +323,7 @@ const v1PushSecrets = async ({ // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ workspaceId - }) + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -527,6 +570,7 @@ const v1PushSecrets = async ({ environment: string; }): Promise => { let secrets: any; // TODO: FIX any + try { // get shared workspace secrets const sharedSecrets = await Secret.find({ @@ -655,6 +699,7 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => { }; export { + validateSecrets, v1PushSecrets, v2PushSecrets, pullSecrets, diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 119a86abe..6a3537076 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -8,6 +8,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireSecretAuth from './requireSecretAuth'; +import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; export { @@ -21,5 +22,6 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireSecretAuth, + requireSecretsAuth, validateRequest }; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index c6a291200..36e47247e 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -5,6 +5,9 @@ import { validateMembership } from '../helpers/membership'; +// note: used for old /v1/secret and /v2/secret routes. +// newer /v2/secrets routes use [requireSecretsAuth] middleware + /** * Validate if user on request has proper membership to modify secret. * @param {Object} obj @@ -34,7 +37,7 @@ const requireSecretAuth = ({ acceptedRoles }); - req.secret = secret as any; + req._secret = secret; next(); } catch (err) { diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts new file mode 100644 index 000000000..c8b89a74c --- /dev/null +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { UnauthorizedRequestError } from '../utils/errors'; +import { Secret, Membership } from '../models'; +import { validateSecrets } from '../helpers/secret'; + +// TODO: make this work for delete route + +const requireSecretsAuth = ({ + acceptedRoles +}: { + acceptedRoles: string[]; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + let secrets; + try { + if (Array.isArray(req.body.secrets)) { + // case: validate multiple secrets + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secrets.map((s: any) => s.id) + }); + } else if (typeof req.body.secrets === 'object') { // change this to check for object + // case: validate 1 secret + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secrets.id + }); + } else if (Array.isArray(req.body.secretIds)) { + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secretIds + }); + } else if (typeof req.body.secretIds === 'string') { + // case: validate secretIds + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: [req.body.secretIds] + }); + } + + req.secrets = secrets; + return next(); + } catch (err) { + return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret(s)' })); + } + } +} + +export default requireSecretsAuth; \ No newline at end of file diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index bbaaff8c3..a01b92d80 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -69,8 +69,7 @@ const secretSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -85,8 +84,7 @@ const secretSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String }, secretCommentCiphertext: { type: String, diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index d0f3833ba..8bea42620 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,10 +1,12 @@ -import secret from './secret'; +import secret from './secret'; // stop-supporting +import secrets from './secrets'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; export { secret, + secrets, workspace, serviceTokenData, apiKeyData diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index 893af8683..b0c0f8f36 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -1,18 +1,21 @@ -import express, { Request, Response } from 'express'; -import { requireAuth, requireWorkspaceAuth, validateRequest } from '../../middleware'; +import express from 'express'; +import { + requireAuth, + requireWorkspaceAuth, + requireSecretAuth, + validateRequest +} from '../../middleware'; import { body, param, query } from 'express-validator'; import { ADMIN, MEMBER } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; -import { fetchAllSecrets, fetchSingleSecret } from '../../controllers/v2/secretController'; + +// note to devs: stop supporting const router = express.Router(); -/** - * Create many secrets for a given workspace and environmentName - */ router.post( - '/batch-create/workspace/:workspaceId/environment/:environmentName', + '/batch-create/workspace/:workspaceId/environment/:environment', requireAuth({ acceptedAuthModes: ['jwt'] }), @@ -20,17 +23,15 @@ router.post( acceptedRoles: [ADMIN, MEMBER] }), param('workspaceId').exists().isMongoId().trim(), - param('environmentName').exists().trim(), + param('environment').exists().trim(), body('secrets').exists().isArray().custom((value) => value.every((item: CreateSecretRequestBody) => typeof item === 'object')), + body('channel'), validateRequest, - secretController.batchCreateSecrets + secretController.createSecrets ); -/** - * Create single secret for a given workspace and environmentName - */ router.post( - '/workspace/:workspaceId/environment/:environmentName', + '/workspace/:workspaceId/environment/:environment', requireAuth({ acceptedAuthModes: ['jwt'] }), @@ -38,15 +39,13 @@ router.post( acceptedRoles: [ADMIN, MEMBER] }), param('workspaceId').exists().isMongoId().trim(), - param('environmentName').exists().trim(), + param('environment').exists().trim(), body('secret').exists().isObject(), + body('channel'), validateRequest, - secretController.createSingleSecret + secretController.createSecret ); -/** - * Get all secrets for a given environment and workspace id - */ router.get( '/workspace/:workspaceId', param('workspaceId').exists().trim(), @@ -57,25 +56,23 @@ router.get( requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER] }), + query('channel'), validateRequest, - fetchAllSecrets + secretController.getSecrets ); -/** - * Get single secret by id - */ router.get( '/:secretId', requireAuth({ acceptedAuthModes: ['jwt', 'serviceToken'] }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), validateRequest, - fetchSingleSecret + secretController.getSecret ); -/** - * Batch delete secrets in a given workspace and environment name - */ router.delete( '/batch/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -88,26 +85,22 @@ router.delete( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.batchDeleteSecrets - + secretController.deleteSecrets ); -/** - * delete single secret by id - */ router.delete( '/:secretId', requireAuth({ acceptedAuthModes: ['jwt'] }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), param('secretId').isMongoId(), validateRequest, - secretController.deleteSingleSecret + secretController.deleteSecret ); -/** - * Apply modifications to many existing secrets in a given workspace and environment - */ router.patch( '/batch-modify/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -120,12 +113,10 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.batchModifySecrets + secretController.updateSecrets ); -/** - * Apply modifications to single existing secret in a given workspace and environment - */ + router.patch( '/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -138,7 +129,7 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.modifySingleSecrets + secretController.updateSecret ); export default router; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts new file mode 100644 index 000000000..a4c62bbc1 --- /dev/null +++ b/backend/src/routes/v2/secrets.ts @@ -0,0 +1,177 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireWorkspaceAuth, + requireSecretsAuth, + validateRequest +} from '../../middleware'; +import { query, check, body } from 'express-validator'; +import { secretsController } from '../../controllers/v2'; +import { + ADMIN, + MEMBER, + SECRET_PERSONAL, + SECRET_SHARED +} from '../../variables'; + +router.post( + '/', + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim().isIn(['dev', 'staging', 'prod', 'test']), + body('secrets') + .exists() + .custom((value) => { + if (Array.isArray(value)) { + // case: create multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array') + for (const secret of value) { + if ( + !secret.type || + !(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) || + !secret.secretKeyCiphertext || + !secret.secretKeyIV || + !secret.secretKeyTag || + !secret.secretValueCiphertext || + !secret.secretValueIV || + !secret.secretValueTag + ) { + throw new Error('secrets array must contain objects that conform to the Secret interface'); + } + } + } else if (typeof value === 'object') { + // case: update 1 secret + if ( + !value.type || + !(value.type === SECRET_PERSONAL || value.type === SECRET_SHARED) || + !value.secretKeyCiphertext || + !value.secretKeyIV || + !value.secretKeyTag || + !value.secretValueCiphertext || + !value.secretValueIV || + !value.secretValueTag + ) { + throw new Error('secrets array must contain objects that conform to the Secret interface'); + } + } else { + throw new Error('secrets must be an object or an array of objects') + } + + return true; + }), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + secretsController.createSecrets +); + +router.get( + '/', + query('workspaceId').exists().trim(), + query('environment').exists().trim().isIn(['dev', 'staging', 'prod', 'test']), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'serviceToken'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'query' + }), + secretsController.getSecrets +); + +router.patch( + '/', + body('workspaceId').exists().trim(), + body('environment').exists().trim().isIn(['dev', 'staging', 'prod', 'test']), + body('secrets') + .exists() + .custom((value) => { + if (Array.isArray(value)) { + // case: update multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array') + for (const secret of value) { + if ( + !secret.id || + !secret.secretKeyCiphertext || + !secret.secretKeyIV || + !secret.secretKeyTag || + !secret.secretValueCiphertext || + !secret.secretValueIV || + !secret.secretValueTag + ) { + throw new Error('secrets array must contain objects that conform to the Secret interface'); + } + } + } else if (typeof value === 'object') { + // case: update 1 secret + if ( + !value.id || + !value.secretKeyCiphertext || + !value.secretKeyIV || + !value.secretKeyTag || + !value.secretValueCiphertext || + !value.secretValueIV || + !value.secretValueTag + ) { + throw new Error('secrets array must contain objects that conform to the Secret interface'); + } + } else { + throw new Error('secrets must be an object or an array of objects') + } + + return true; + }), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + requireSecretsAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + secretsController.updateSecrets +); + +router.delete( + '/', + [ + check('secretIds') + .exists() + .custom((value) => { + // case: delete 1 secret + if (typeof value === 'string') return true; + + if (Array.isArray(value)) { + // case: delete multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array'); + return value.every((id: string) => typeof id === 'string') + } + + throw new Error('secretIds must be a string or an array of strings'); + }) + .not() + .isEmpty() + ], + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireSecretsAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + secretsController.deleteSecrets +); + +export default router; + + + diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index f43b5fa79..ae9edb4c5 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,4 +1,5 @@ import * as express from 'express'; +import { ISecret } from '../../models'; // TODO: fix (any) types declare global { @@ -12,7 +13,8 @@ declare global { integration: any; integrationAuth: any; bot: any; - secret: any; + _secret: any; + secrets: any; secretSnapshot: any; serviceToken: any; accessToken: any;