From 07c34c490f68d9d948ecf89171cc2a59b40d594d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 26 Dec 2022 21:45:26 -0500 Subject: [PATCH 1/2] Begin moving /secret/workspaceId routes to /workspace/workspaceId --- backend/src/app.ts | 10 +- backend/src/controllers/v2/index.ts | 5 + .../src/controllers/v2/secretController.ts | 0 .../src/controllers/v2/workspaceController.ts | 560 ++++++++++++++++++ backend/src/routes/v2/index.ts | 7 + backend/src/routes/v2/secret.ts | 4 + backend/src/routes/v2/workspace.ts | 176 ++++++ 7 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/v2/index.ts create mode 100644 backend/src/controllers/v2/secretController.ts create mode 100644 backend/src/controllers/v2/workspaceController.ts create mode 100644 backend/src/routes/v2/index.ts create mode 100644 backend/src/routes/v2/secret.ts create mode 100644 backend/src/routes/v2/workspace.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index dcad66f5d..f4271a34b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,7 +15,6 @@ import { workspace as eeWorkspaceRouter, secret as eeSecretRouter } from './ee/routes/v1'; - import { signup as v1SignupRouter, auth as v1AuthRouter, @@ -35,6 +34,10 @@ import { integration as v1IntegrationRouter, integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; +import { + secret as v2SecretRouter, + workspace as v2WorkspaceRouter +} from './routes/v2'; import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; @@ -86,6 +89,11 @@ app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); +// v2 routes (new) +app.use('/api/v1/workspace', v2WorkspaceRouter); +app.use('/api/v1/secret', v2SecretRouter); + + //* Handle unrouted requests and respond with proper error message as well as status code app.use((req, res, next)=>{ if(res.headersSent) return next(); diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts new file mode 100644 index 000000000..dc6977c91 --- /dev/null +++ b/backend/src/controllers/v2/index.ts @@ -0,0 +1,5 @@ +import * as workspaceController from './workspaceController'; + +export { + workspaceController +} diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts new file mode 100644 index 000000000..3e784f22c --- /dev/null +++ b/backend/src/controllers/v2/workspaceController.ts @@ -0,0 +1,560 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { + Workspace, + Membership, + MembershipOrg, + Integration, + IntegrationAuth, + Key, + IUser, + ServiceToken, +} from '../../models'; +import { + createWorkspace as create, + deleteWorkspace as deleteWork +} from '../../helpers/workspace'; +import { + pushSecrets as push, + pullSecrets as pull, + reformatPullSecrets +} from '../../helpers/secret'; +import { pushKeys } from '../../helpers/key'; +import { addMemberships } from '../../helpers/membership'; +import { postHogClient, EventService } from '../../services'; +import { eventPushSecrets } from '../../events'; +import { ADMIN, COMPLETED, GRANTED, ENV_SET } from '../../variables'; + +interface PushSecret { + ciphertextKey: string; + ivKey: string; + tagKey: string; + hashKey: string; + ciphertextValue: string; + ivValue: string; + tagValue: string; + hashValue: string; + type: 'shared' | 'personal'; +} + +/** + * Return public keys of members of workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const getWorkspacePublicKeys = async (req: Request, res: Response) => { + let publicKeys; + try { + const { workspaceId } = req.params; + + publicKeys = ( + await Membership.find({ + workspace: workspaceId + }).populate<{ user: IUser }>('user', 'publicKey') + ) + .filter((m) => m.status === COMPLETED || m.status === GRANTED) + .map((member) => { + return { + publicKey: member.user.publicKey, + userId: member.user._id + }; + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace member public keys' + }); + } + + return res.status(200).send({ + publicKeys + }); +}; + +/** + * Return memberships for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const getWorkspaceMemberships = async (req: Request, res: Response) => { + let users; + try { + const { workspaceId } = req.params; + + users = await Membership.find({ + workspace: workspaceId + }).populate('user', '+publicKey'); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace members' + }); + } + + return res.status(200).send({ + users + }); +}; + +/** + * Return workspaces that user is part of + * @param req + * @param res + * @returns + */ +export const getWorkspaces = async (req: Request, res: Response) => { + let workspaces; + try { + workspaces = ( + await Membership.find({ + user: req.user._id + }).populate('workspace') + ).map((m) => m.workspace); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspaces' + }); + } + + return res.status(200).send({ + workspaces + }); +}; + +/** + * Return workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const getWorkspace = async (req: Request, res: Response) => { + let workspace; + try { + const { workspaceId } = req.params; + + workspace = await Workspace.findOne({ + _id: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace' + }); + } + + return res.status(200).send({ + workspace + }); +}; + +/** + * Create new workspace named [workspaceName] under organization with id + * [organizationId] and add user as admin + * @param req + * @param res + * @returns + */ +export const createWorkspace = async (req: Request, res: Response) => { + let workspace; + try { + const { workspaceName, organizationId } = req.body; + + // validate organization membership + const membershipOrg = await MembershipOrg.findOne({ + user: req.user._id, + organization: organizationId + }); + + if (!membershipOrg) { + throw new Error('Failed to validate organization membership'); + } + + if (workspaceName.length < 1) { + throw new Error('Workspace names must be at least 1-character long'); + } + + // create workspace and add user as member + workspace = await create({ + name: workspaceName, + organizationId + }); + + await addMemberships({ + userIds: [req.user._id], + workspaceId: workspace._id.toString(), + roles: [ADMIN], + statuses: [GRANTED] + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to create workspace' + }); + } + + return res.status(200).send({ + workspace + }); +}; + +/** + * Delete workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const deleteWorkspace = async (req: Request, res: Response) => { + try { + const { workspaceId } = req.params; + + // delete workspace + await deleteWork({ + id: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete workspace' + }); + } + + return res.status(200).send({ + message: 'Successfully deleted workspace' + }); +}; + +/** + * Change name of workspace with id [workspaceId] to [name] + * @param req + * @param res + * @returns + */ +export const changeWorkspaceName = async (req: Request, res: Response) => { + let workspace; + try { + const { workspaceId } = req.params; + const { name } = req.body; + + workspace = await Workspace.findOneAndUpdate( + { + _id: workspaceId + }, + { + name + }, + { + new: true + } + ); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to change workspace name' + }); + } + + return res.status(200).send({ + message: 'Successfully changed workspace name', + workspace + }); +}; + +/** + * Return integrations for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const getWorkspaceIntegrations = async (req: Request, res: Response) => { + let integrations; + try { + const { workspaceId } = req.params; + + integrations = await Integration.find({ + workspace: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace integrations' + }); + } + + return res.status(200).send({ + integrations + }); +}; + +/** + * Return (integration) authorizations for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ +export const getWorkspaceIntegrationAuthorizations = async ( + req: Request, + res: Response +) => { + let authorizations; + try { + const { workspaceId } = req.params; + + authorizations = await IntegrationAuth.find({ + workspace: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace integration authorizations' + }); + } + + return res.status(200).send({ + authorizations + }); +}; + +/** + * Return service service tokens for workspace [workspaceId] belonging to user + * @param req + * @param res + * @returns + */ +export const getWorkspaceServiceTokens = async ( + req: Request, + res: Response +) => { + let serviceTokens; + try { + const { workspaceId } = req.params; + + serviceTokens = await ServiceToken.find({ + user: req.user._id, + workspace: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace service tokens' + }); + } + + return res.status(200).send({ + serviceTokens + }); +} + +/** + * Upload (encrypted) secrets to workspace with id [workspaceId] + * for environment [environment] + * @param req + * @param res + * @returns + */ +export const pushSecrets = async (req: Request, res: Response) => { + // upload (encrypted) secrets to workspace with id [workspaceId] + + try { + let { secrets }: { secrets: PushSecret[] } = req.body; + const { keys, environment, channel } = req.body; + const { workspaceId } = req.params; + + // validate environment + if (!ENV_SET.has(environment)) { + throw new Error('Failed to validate environment'); + } + + // sanitize secrets + secrets = secrets.filter( + (s: PushSecret) => s.ciphertextKey !== '' && s.ciphertextValue !== '' + ); + + await push({ + userId: req.user._id, + workspaceId, + environment, + secrets + }); + + await pushKeys({ + userId: req.user._id, + workspaceId, + keys + }); + + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pushed', + distinctId: req.user.email, + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel: channel ? channel : 'cli' + } + }); + } + + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to upload workspace secrets' + }); + } + + return res.status(200).send({ + message: 'Successfully uploaded workspace secrets' + }); +}; + +/** + * Return (encrypted) secrets for workspace with id [workspaceId] + * for environment [environment] and (encrypted) workspace key + * @param req + * @param res + * @returns + */ +export const pullSecrets = async (req: Request, res: Response) => { + let secrets; + let key; + try { + const environment: string = req.query.environment as string; + const channel: string = req.query.channel as string; + const { workspaceId } = req.params; + + // validate environment + if (!ENV_SET.has(environment)) { + throw new Error('Failed to validate environment'); + } + + secrets = await pull({ + userId: req.user._id.toString(), + workspaceId, + environment + }); + + key = await Key.findOne({ + workspace: workspaceId, + receiver: req.user._id + }) + .sort({ createdAt: -1 }) + .populate('sender', '+publicKey'); + + if (channel !== 'cli') { + secrets = reformatPullSecrets({ secrets }); + } + + if (postHogClient) { + // capture secrets pushed event in production + postHogClient.capture({ + distinctId: req.user.email, + event: 'secrets pulled', + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel: channel ? channel : 'cli' + } + }); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to pull workspace secrets' + }); + } + + return res.status(200).send({ + secrets, + key + }); +}; + +// TODO: modify based on upcoming serviceTokenData changes + +/** + * Return (encrypted) secrets for workspace with id [workspaceId] + * for environment [environment] and (encrypted) workspace key + * via service token + * @param req + * @param res + * @returns + */ + export const pullSecretsServiceToken = async (req: Request, res: Response) => { + let secrets; + let key; + try { + const environment: string = req.query.environment as string; + const channel: string = req.query.channel as string; + const { workspaceId } = req.params; + + // validate environment + if (!ENV_SET.has(environment)) { + throw new Error('Failed to validate environment'); + } + + secrets = await pull({ + userId: req.serviceToken.user._id.toString(), + workspaceId, + environment + }); + + key = { + encryptedKey: req.serviceToken.encryptedKey, + nonce: req.serviceToken.nonce, + sender: { + publicKey: req.serviceToken.publicKey + }, + receiver: req.serviceToken.user, + workspace: req.serviceToken.workspace + }; + + if (postHogClient) { + // capture secrets pulled event in production + postHogClient.capture({ + distinctId: req.serviceToken.user.email, + event: 'secrets pulled', + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel: channel ? channel : 'cli' + } + }); + } + } catch (err) { + Sentry.setUser({ email: req.serviceToken.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to pull workspace secrets' + }); + } + + return res.status(200).send({ + secrets: reformatPullSecrets({ secrets }), + key + }); +}; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts new file mode 100644 index 000000000..6e6758753 --- /dev/null +++ b/backend/src/routes/v2/index.ts @@ -0,0 +1,7 @@ +import secret from './secret'; +import workspace from './workspace'; + +export { + secret, + workspace +} diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts new file mode 100644 index 000000000..17a91d39c --- /dev/null +++ b/backend/src/routes/v2/secret.ts @@ -0,0 +1,4 @@ +import express from 'express'; +const router = express.Router(); + +export default router; diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts new file mode 100644 index 000000000..4e954c75d --- /dev/null +++ b/backend/src/routes/v2/workspace.ts @@ -0,0 +1,176 @@ +import express from 'express'; +const router = express.Router(); +import { body, param, query } from 'express-validator'; +import { + requireAuth, + requireWorkspaceAuth, + requireServiceTokenAuth, + validateRequest +} from '../../middleware'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../variables'; +import { membershipController } from '../../controllers/v1'; +import { workspaceController } from '../../controllers/v2'; + +router.get( + '/:workspaceId/keys', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspacePublicKeys +); + +router.get( + '/:workspaceId/users', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspaceMemberships +); + +router.get('/', requireAuth, workspaceController.getWorkspaces); + +router.get( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspace +); + +router.post( + '/', + requireAuth, + body('workspaceName').exists().trim().notEmpty(), + body('organizationId').exists().trim().notEmpty(), + validateRequest, + workspaceController.createWorkspace +); + +router.delete( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.deleteWorkspace +); + +router.post( + '/:workspaceId/name', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('workspaceId').exists().trim(), + body('name').exists().trim().notEmpty(), + validateRequest, + workspaceController.changeWorkspaceName +); + +router.post( + '/:workspaceId/invite-signup', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + body('email').exists().trim().notEmpty(), + validateRequest, + membershipController.inviteUserToWorkspace +); + +router.get( + '/:workspaceId/integrations', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspaceIntegrations +); + +router.get( + '/:workspaceId/authorizations', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspaceIntegrationAuthorizations +); + +router.get( + '/:workspaceId/service-tokens', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspaceServiceTokens +); + +router.post( + '/:workspaceId/secrets', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + body('secrets').exists(), + body('keys').exists(), + body('environment').exists().trim().notEmpty(), + body('channel'), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.pushSecrets +); + +router.get( + '/:workspaceId/secrets', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + query('environment').exists().trim(), + query('channel'), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.pullSecrets +); + +router.get( // TODO: modify based on upcoming serviceTokenData changes + '/:workspaceId/secrets-service-token', + requireServiceTokenAuth, + query('environment').exists().trim(), + query('channel'), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.pullSecretsServiceToken +); + + +export default router; From f93594b62faa45ca67c07f6937bc303fe392ac1e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 26 Dec 2022 22:50:59 -0500 Subject: [PATCH 2/2] Migrate POST /v1/secret/:workspaceId to /v2/workspace/:workspaceId/secrets and cleared room for /v2 secret routes --- backend/src/app.ts | 8 +- .../src/controllers/v1/secretController.ts | 2 +- .../src/controllers/v2/workspaceController.ts | 35 +- backend/src/helpers/bot.ts | 1 - backend/src/helpers/secret.ts | 338 ++++++++++++++---- backend/src/routes/v2/workspace.ts | 4 +- .../components/utilities/secrets/pushKeys.ts | 42 +-- frontend/pages/api/files/UploadSecrets.ts | 2 +- 8 files changed, 315 insertions(+), 117 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index f4271a34b..8320a7d76 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -66,7 +66,7 @@ if (NODE_ENV === 'production') { app.use(helmet()); } -// (EE) routers +// (EE) routes app.use('/api/v1/secret', eeSecretRouter); app.use('/api/v1/workspace', eeWorkspaceRouter); @@ -89,9 +89,9 @@ app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); -// v2 routes (new) -app.use('/api/v1/workspace', v2WorkspaceRouter); -app.use('/api/v1/secret', v2SecretRouter); +// v2 routes +app.use('/api/v2/workspace', v2WorkspaceRouter); +app.use('/api/v2/secret', v2SecretRouter); //* Handle unrouted requests and respond with proper error message as well as status code diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 5d4b4da73..238b38ced 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { Key, Secret } from '../../models'; import { - pushSecrets as push, + v1PushSecrets as push, pullSecrets as pull, reformatPullSecrets } from '../../helpers/secret'; diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 3e784f22c..86693b6c4 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -15,7 +15,7 @@ import { deleteWorkspace as deleteWork } from '../../helpers/workspace'; import { - pushSecrets as push, + v2PushSecrets as push, pullSecrets as pull, reformatPullSecrets } from '../../helpers/secret'; @@ -24,17 +24,20 @@ import { addMemberships } from '../../helpers/membership'; import { postHogClient, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; import { ADMIN, COMPLETED, GRANTED, ENV_SET } from '../../variables'; - -interface PushSecret { - ciphertextKey: string; - ivKey: string; - tagKey: string; - hashKey: string; - ciphertextValue: string; - ivValue: string; - tagValue: string; - hashValue: string; - type: 'shared' | 'personal'; +interface V2PushSecret { + type: string; // personal or shared + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretKeyHash: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretValueHash: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + secretCommentHash?: string; } /** @@ -364,11 +367,11 @@ export const getWorkspaceServiceTokens = async ( * @param res * @returns */ -export const pushSecrets = async (req: Request, res: Response) => { +export const pushWorkspaceSecrets = async (req: Request, res: Response) => { // upload (encrypted) secrets to workspace with id [workspaceId] try { - let { secrets }: { secrets: PushSecret[] } = req.body; + let { secrets }: { secrets: V2PushSecret[] } = req.body; const { keys, environment, channel } = req.body; const { workspaceId } = req.params; @@ -379,7 +382,7 @@ export const pushSecrets = async (req: Request, res: Response) => { // sanitize secrets secrets = secrets.filter( - (s: PushSecret) => s.ciphertextKey !== '' && s.ciphertextValue !== '' + (s: V2PushSecret) => s.secretKeyCiphertext !== '' && s.secretValueCiphertext !== '' ); await push({ @@ -437,6 +440,8 @@ export const pushSecrets = async (req: Request, res: Response) => { * @returns */ export const pullSecrets = async (req: Request, res: Response) => { + // TODO: only return secrets, do not return workspace key + let secrets; let key; try { diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index abaf73af4..b3f276b53 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -12,7 +12,6 @@ import { decryptSymmetric, decryptAsymmetric } from '../utils/crypto'; -import { decryptSecrets } from '../helpers/secret'; import { ENCRYPTION_KEY } from '../config'; import { SECRET_SHARED } from '../variables'; diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 0e3d82749..f055971ae 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -14,9 +14,8 @@ import { } from '../ee/helpers/secret'; import { decryptSymmetric } from '../utils/crypto'; import { SECRET_SHARED, SECRET_PERSONAL } from '../variables'; -import { LICENSE_KEY } from '../config'; -interface PushSecret { +interface V1PushSecret { ciphertextKey: string; ivKey: string; tagKey: string; @@ -32,6 +31,22 @@ interface PushSecret { type: 'shared' | 'personal'; } +interface V2PushSecret { + type: string; // personal or shared + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretKeyHash: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretValueHash: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + secretCommentHash?: string; +} + interface Update { [index: string]: any; } @@ -49,7 +64,7 @@ type DecryptSecretType = 'text' | 'object' | 'expanded'; * @param {String} obj.environment - environment for secrets * @param {Object[]} obj.secrets - secrets to push */ -const pushSecrets = async ({ +const v1PushSecrets = async ({ userId, workspaceId, environment, @@ -58,7 +73,7 @@ const pushSecrets = async ({ userId: string; workspaceId: string; environment: string; - secrets: PushSecret[]; + secrets: V1PushSecret[]; }): Promise => { // TODO: clean up function and fix up types try { @@ -99,7 +114,7 @@ const pushSecrets = async ({ if (`${s.type}-${s.secretKeyHash}` in newSecretsObj) { if (s.secretValueHash !== newSecretsObj[`${s.type}-${s.secretKeyHash}`].hashValue || s.secretCommentHash !== newSecretsObj[`${s.type}-${s.secretKeyHash}`].hashComment) { - // case: filter secrets where value changed + // case: filter secrets where value or comment changed return true; } @@ -259,6 +274,249 @@ const pushSecrets = async ({ } }; +/** + * Push secrets for user with id [userId] to workspace + * with id [workspaceId] with environment [environment]. Follow steps: + * 1. Handle shared secrets (insert, delete) + * 2. handle personal secrets (insert, delete) + * @param {Object} obj + * @param {String} obj.userId - id of user to push secrets for + * @param {String} obj.workspaceId - id of workspace to push to + * @param {String} obj.environment - environment for secrets + * @param {Object[]} obj.secrets - secrets to push + */ + const v2PushSecrets = async ({ + userId, + workspaceId, + environment, + secrets +}: { + userId: string; + workspaceId: string; + environment: string; + secrets: V2PushSecret[]; +}): Promise => { + // TODO: clean up function and fix up types + try { + // construct useful data structures + const oldSecrets = await pullSecrets({ + userId, + workspaceId, + environment + }); + + const oldSecretsObj: any = oldSecrets.reduce((accumulator, s: any) => + ({ ...accumulator, [`${s.type}-${s.secretKeyHash}`]: s }) + , {}); + const newSecretsObj: any = secrets.reduce((accumulator, s) => + ({ ...accumulator, [`${s.type}-${s.secretKeyHash}`]: s }) + , {}); + + // handle deleting secrets + const toDelete = oldSecrets + .filter( + (s: ISecret) => !(`${s.type}-${s.secretKeyHash}` in newSecretsObj) + ) + .map((s) => s._id); + if (toDelete.length > 0) { + await Secret.deleteMany({ + _id: { $in: toDelete } + }); + + await SecretVersion.updateMany({ + secret: { $in: toDelete } + }, { + isDeleted: true + }); + } + + const toUpdate = oldSecrets + .filter((s) => { + if (`${s.type}-${s.secretKeyHash}` in newSecretsObj) { + if (s.secretValueHash !== newSecretsObj[`${s.type}-${s.secretKeyHash}`].secretValueHash + || s.secretCommentHash !== newSecretsObj[`${s.type}-${s.secretKeyHash}`].secretCommentHash) { + // case: filter secrets where value or comment changed + return true; + } + + if (!s.version) { + // case: filter (legacy) secrets that were not versioned + return true; + } + } + + return false; + }); + + const operations = toUpdate + .map((s) => { + const { + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; + + const update: Update = { + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + } + + if (!s.version) { + // case: (legacy) secret was not versioned + update.version = 1; + } else { + update['$inc'] = { + version: 1 + } + } + + if (s.type === SECRET_PERSONAL) { + // attach user associated with the personal secret + update['user'] = userId; + } + + return { + updateOne: { + filter: { + _id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id + }, + update + } + }; + }); + await Secret.bulkWrite(operations as any); + + // (EE) add secret versions for updated secrets + await EESecretService.addSecretVersions({ + secretVersions: toUpdate.map((s) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; + + return ({ + secret: s._id, + version: s.version ? s.version + 1 : 1, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) + }) + }); + + // handle adding new secrets + const toAdd = secrets.filter((s) => !(`${s.type}-${s.secretKeyHash}` in oldSecretsObj)); + + if (toAdd.length > 0) { + // add secrets + const newSecrets = await Secret.insertMany( + toAdd.map(({ + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + }, idx) => { + const obj: any = { + version: 1, + workspace: workspaceId, + type: toAdd[idx].type, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash + }; + + if (toAdd[idx].type === 'personal') { + obj['user' as keyof typeof obj] = userId; + } + + return obj; + }) + ); + + // (EE) add secret versions for new secrets + EESecretService.addSecretVersions({ + secretVersions: newSecrets.map(({ + _id, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) => ({ + secret: _id, + version: 1, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + })) + }); + } + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }) + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to push shared and personal secrets'); + } +}; + /** * Pull secrets for user with id [userId] for workspace * with id [workspaceId] with environment [environment] @@ -350,73 +608,9 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => { return reformatedSecrets; }; -/** - * Return decrypted secrets in format [format] - * @param {Object} obj - * @param {Object[]} obj.secrets - array of (encrypted) secret key-value pair objects - * @param {String} obj.key - symmetric key to decrypt secret key-value pairs - * @param {String} obj.format - desired return format that is either "text," "object," or "expanded" - * @return {String|Object} (decrypted) secrets also called the content - */ -const decryptSecrets = ({ - secrets, - key, - format -}: { - secrets: PushSecret[]; - key: string; - format: DecryptSecretType; -}) => { - // init content - let content: any = format === 'text' ? '' : {}; - - // decrypt secrets - secrets.forEach((s, idx) => { - const secretKey = decryptSymmetric({ - ciphertext: s.ciphertextKey, - iv: s.ivKey, - tag: s.tagKey, - key - }); - - const secretValue = decryptSymmetric({ - ciphertext: s.ciphertextValue, - iv: s.ivValue, - tag: s.tagValue, - key - }); - - switch (format) { - case 'text': - content += secretKey; - content += '='; - content += secretValue; - - if (idx < secrets.length) { - content += '\n'; - } - break; - case 'object': - content[secretKey] = secretValue; - break; - case 'expanded': - content[secretKey] = { - ...s, - plaintextKey: secretKey, - plaintextValue: secretValue - }; - break; - } - }); - - return content; -}; - - - export { - pushSecrets, + v1PushSecrets, + v2PushSecrets, pullSecrets, - reformatPullSecrets, - decryptSecrets + reformatPullSecrets }; diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 4e954c75d..98eacd9ea 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -120,7 +120,7 @@ router.get( workspaceController.getWorkspaceIntegrationAuthorizations ); -router.get( +router.get( // TODO: modify '/:workspaceId/service-tokens', requireAuth, requireWorkspaceAuth({ @@ -145,7 +145,7 @@ router.post( body('channel'), param('workspaceId').exists().trim(), validateRequest, - workspaceController.pushSecrets + workspaceController.pushWorkspaceSecrets ); router.get( diff --git a/frontend/components/utilities/secrets/pushKeys.ts b/frontend/components/utilities/secrets/pushKeys.ts index e8a303992..1bedf357c 100644 --- a/frontend/components/utilities/secrets/pushKeys.ts +++ b/frontend/components/utilities/secrets/pushKeys.ts @@ -47,9 +47,9 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st const secrets = Object.keys(obj).map((key) => { // encrypt key const { - ciphertext: ciphertextKey, - iv: ivKey, - tag: tagKey, + ciphertext: secretKeyCiphertext, + iv: secretKeyIV, + tag: secretKeyTag, } = encryptSymmetric({ plaintext: key.slice(1), key: randomBytes, @@ -57,9 +57,9 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st // encrypt value const { - ciphertext: ciphertextValue, - iv: ivValue, - tag: tagValue, + ciphertext: secretValueCiphertext, + iv: secretValueIV, + tag: secretValueTag, } = encryptSymmetric({ plaintext: obj[key as keyof typeof obj][0], key: randomBytes, @@ -67,9 +67,9 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st // encrypt comment const { - ciphertext: ciphertextComment, - iv: ivComment, - tag: tagComment, + ciphertext: secretCommentCiphertext, + iv: secretCommentIV, + tag: secretCommentTag, } = encryptSymmetric({ plaintext: obj[key as keyof typeof obj][1], key: randomBytes, @@ -78,18 +78,18 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st const visibility = key.charAt(0) == "p" ? "personal" : "shared"; return { - ciphertextKey, - ivKey, - tagKey, - hashKey: crypto.createHash("sha256").update(key.slice(1)).digest("hex"), - ciphertextValue, - ivValue, - tagValue, - hashValue: crypto.createHash("sha256").update(obj[key as keyof typeof obj][0]).digest("hex"), - ciphertextComment, - ivComment, - tagComment, - hashComment: crypto.createHash("sha256").update(obj[key as keyof typeof obj][1]).digest("hex"), + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash: crypto.createHash("sha256").update(key.slice(1)).digest("hex"), + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash: crypto.createHash("sha256").update(obj[key as keyof typeof obj][0]).digest("hex"), + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash: crypto.createHash("sha256").update(obj[key as keyof typeof obj][1]).digest("hex"), type: visibility, }; }); diff --git a/frontend/pages/api/files/UploadSecrets.ts b/frontend/pages/api/files/UploadSecrets.ts index 04fcb78b5..03a98d483 100644 --- a/frontend/pages/api/files/UploadSecrets.ts +++ b/frontend/pages/api/files/UploadSecrets.ts @@ -22,7 +22,7 @@ const uploadSecrets = async ({ keys, environment }: Props) => { - return SecurityClient.fetchCall('/api/v1/secret/' + workspaceId, { + return SecurityClient.fetchCall('/api/v2/workspace/' + workspaceId + '/secrets', { method: 'POST', headers: { 'Content-Type': 'application/json'